pm-mobile: beat-aligned pads + per-lane subdivision rhythm icons
- Pads now group into per-beat cells (one flex cell per beat), so beats line up in columns across lanes regardless of each lane's subdivision; the downbeat pad in each cell is full-height and the sub-beat pads are shorter/smaller. - Each lane label shows a small engraved rhythm figure for its subdivision, drawn as SVG (notehead + stem + beams + tuplet number): quarter, beamed eighths, triplet (3), sixteenths (double beam), sextuplet (6), etc. Engine untouched; conformance passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4b53f917f4
commit
b98c37ff68
1 changed files with 36 additions and 12 deletions
48
mobile.html
48
mobile.html
|
|
@ -104,12 +104,18 @@
|
||||||
#trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
|
#trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
|
||||||
.lane{ display:flex; align-items:center; gap:8px; }
|
.lane{ display:flex; align-items:center; gap:8px; }
|
||||||
.lane.off{ opacity:.5; }
|
.lane.off{ opacity:.5; }
|
||||||
.lmeta{ flex:0 0 auto; width:30%; max-width:130px; min-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left;
|
.lmeta{ flex:0 0 auto; width:34%; max-width:150px; min-width:92px; display:flex; align-items:center; gap:5px; text-align:left;
|
||||||
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:6px 8px;
|
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:5px 8px;
|
||||||
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
|
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
|
||||||
.lmeta .lg{ color:var(--muted); }
|
.lmeta .ln-name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.pads{ flex:1 1 auto; display:flex; gap:3px; overflow-x:auto; padding-bottom:2px; min-width:0; }
|
.lmeta .lg{ flex:0 0 auto; color:var(--muted); }
|
||||||
.pad{ flex:1 0 14px; min-width:14px; height:clamp(20px,3.6vmin,28px); border-radius:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
|
.lmeta .rhythm{ flex:0 0 auto; color:var(--txt); display:block; }
|
||||||
|
/* beats line up in columns across lanes; sub-beats sit inside the beat cell, smaller */
|
||||||
|
.pads{ flex:1 1 auto; display:flex; gap:7px; overflow-x:auto; padding-bottom:2px; min-width:0; align-items:center; }
|
||||||
|
.beatcell{ flex:1 1 0; min-width:0; display:flex; gap:2px; align-items:center; }
|
||||||
|
.pad{ flex:1 1 0; min-width:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
|
||||||
|
.pad.beat{ height:clamp(20px,3.8vmin,28px); border-radius:5px; }
|
||||||
|
.pad.sub{ height:clamp(11px,2.3vmin,16px); border-radius:3px; }
|
||||||
.pad.gs{ border-color:var(--amber); }
|
.pad.gs{ border-color:var(--amber); }
|
||||||
.pad.on{ background:var(--cyan); }
|
.pad.on{ background:var(--cyan); }
|
||||||
.pad.acc{ background:var(--amber); }
|
.pad.acc{ background:var(--amber); }
|
||||||
|
|
@ -443,25 +449,43 @@ function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
|
||||||
let laneSig=null, editLaneIdx=0;
|
let laneSig=null, editLaneIdx=0;
|
||||||
function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.stepsPerBeat+(m.swing?"s":"")+(m.enabled?"":"!")+(m.poly?"~":"")).join("|"); }
|
function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.stepsPerBeat+(m.swing?"s":"")+(m.enabled?"":"!")+(m.poly?"~":"")).join("|"); }
|
||||||
function lvlClass(l){ return l===2?"acc":l===3?"ghost":l===1?"on":""; }
|
function lvlClass(l){ return l===2?"acc":l===3?"ghost":l===1?"on":""; }
|
||||||
|
function padClass(m,k){ const spb=m.stepsPerBeat, isBeat=(k%spb===0), gs=isBeat&&m.groupStarts.has(k/spb), lvl=m.beatsOn[k]|0;
|
||||||
|
return "pad "+(isBeat?"beat":"sub")+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }
|
||||||
|
// Small engraved rhythm figure for a beat's subdivision (1=quarter, 2=8ths, 3=triplet,
|
||||||
|
// 4=16ths, 5/6/7=tuplets). Drawn as SVG so triplets/sextuplets render crisply.
|
||||||
|
function rhythmSVG(spb){
|
||||||
|
spb=Math.max(1,spb|0);
|
||||||
|
const n=spb, beams = n===1?0 : (n<=3?1:2), tup = (n===3||n>=5)?n:null;
|
||||||
|
const LEFT=3, GAP = n<=2?8 : n<=4?6.5 : 5.5, baseY=15, topY=6;
|
||||||
|
const W=Math.round(LEFT*2 + (n-1)*GAP + 4); let g="", first=0, last=0;
|
||||||
|
for(let i=0;i<n;i++){ const cx=LEFT+i*GAP, sx=cx+2.0; if(i===0) first=sx; last=sx;
|
||||||
|
g+='<ellipse cx="'+cx.toFixed(1)+'" cy="'+baseY+'" rx="2.4" ry="1.8" transform="rotate(-20 '+cx.toFixed(1)+' '+baseY+')"/>';
|
||||||
|
g+='<rect x="'+(sx-0.45).toFixed(2)+'" y="'+topY+'" width="0.9" height="'+(baseY-topY-0.5).toFixed(1)+'"/>'; }
|
||||||
|
if(beams>=1) g+='<rect x="'+(first-0.45).toFixed(2)+'" y="'+topY+'" width="'+(last-first+0.9).toFixed(2)+'" height="1.7"/>';
|
||||||
|
if(beams>=2) g+='<rect x="'+(first-0.45).toFixed(2)+'" y="'+(topY+2.6)+'" width="'+(last-first+0.9).toFixed(2)+'" height="1.7"/>';
|
||||||
|
if(tup) g+='<text x="'+(W/2).toFixed(1)+'" y="3.6" font-size="6" text-anchor="middle" font-style="italic" stroke="none">'+tup+'</text>';
|
||||||
|
return '<svg class="rhythm" viewBox="0 0 '+W+' 18" width="'+W+'" height="16" fill="currentColor" aria-hidden="true">'+g+'</svg>';
|
||||||
|
}
|
||||||
function buildLanes(){
|
function buildLanes(){
|
||||||
const box=$("lanes"); box.innerHTML="";
|
const box=$("lanes"); box.innerHTML="";
|
||||||
meters.forEach((m,i)=>{
|
meters.forEach((m,i)=>{
|
||||||
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off");
|
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off");
|
||||||
const meta=document.createElement("button"); meta.className="lmeta";
|
const meta=document.createElement("button"); meta.className="lmeta";
|
||||||
meta.innerHTML=esc(m.sound)+" <span class='lg'>"+esc(m.groupsStr)+(m.stepsPerBeat>1?"/"+m.stepsPerBeat:"")+(m.poly?"~":"")+"</span>";
|
meta.innerHTML="<span class='ln-name'>"+esc(m.sound)+"</span>"+rhythmSVG(m.stepsPerBeat)+"<span class='lg'>"+esc(m.groupsStr)+(m.swing?" sw":"")+(m.poly?"~":"")+"</span>";
|
||||||
meta.onclick=()=>openLaneSheet(i);
|
meta.onclick=()=>openLaneSheet(i);
|
||||||
const pads=document.createElement("div"); pads.className="pads";
|
const pads=document.createElement("div"); pads.className="pads";
|
||||||
const steps=m.beatsPerBar*m.stepsPerBeat; m._padEls=[]; m._lastPad=-1;
|
const spb=m.stepsPerBeat; m._padEls=new Array(m.beatsPerBar*spb); m._lastPad=-1;
|
||||||
for(let k=0;k<steps;k++){ const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); pads.appendChild(p); m._padEls.push(p); }
|
for(let b=0;b<m.beatsPerBar;b++){ const cell=document.createElement("div"); cell.className="beatcell";
|
||||||
|
for(let s=0;s<spb;s++){ const k=b*spb+s; const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); cell.appendChild(p); m._padEls[k]=p; }
|
||||||
|
pads.appendChild(cell); }
|
||||||
lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
|
lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
|
||||||
});
|
});
|
||||||
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
|
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
|
||||||
renderPadLevels();
|
renderPadLevels();
|
||||||
}
|
}
|
||||||
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return; const spb=m.stepsPerBeat;
|
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return;
|
||||||
m._padEls.forEach((p,k)=>{ const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k]|0; p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }); }); }
|
m._padEls.forEach((p,k)=>{ if(p) p.className=padClass(m,k); }); }); }
|
||||||
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; const spb=m.stepsPerBeat; const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k];
|
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; p.className=padClass(m,k); saveState(); }
|
||||||
p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); saveState(); }
|
|
||||||
function renderPadPlayheads(){ meters.forEach(m=>{ if(!m._padEls) return; const cur=state.running?m.currentStep:-1;
|
function renderPadPlayheads(){ meters.forEach(m=>{ if(!m._padEls) return; const cur=state.running?m.currentStep:-1;
|
||||||
if(m._lastPad!==cur){ if(m._lastPad>=0&&m._padEls[m._lastPad]) m._padEls[m._lastPad].classList.remove("cur"); if(cur>=0&&m._padEls[cur]) m._padEls[cur].classList.add("cur"); m._lastPad=cur; } }); }
|
if(m._lastPad!==cur){ if(m._lastPad>=0&&m._padEls[m._lastPad]) m._padEls[m._lastPad].classList.remove("cur"); if(cur>=0&&m._padEls[cur]) m._padEls[cur].classList.add("cur"); m._lastPad=cur; } }); }
|
||||||
function rebuildLane(i,cfg){
|
function rebuildLane(i,cfg){
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue