pm-mobile: swing shuffle glyph, largest-note rhythm icons, polyrhythm indicator

- Lane rhythm icon now reflects what's actually played: it reduces the
  subdivision grid to the largest note that lands on every active hit
  (gcd of stepsPerBeat + active offsets), so a triplet grid that only plays the
  beat shows a quarter, a 16th grid playing eighths shows eighths, etc. Updates
  live as pads are toggled.
- Swung eighths now render the dotted-eighth + sixteenth shuffle figure (full
  8th beam + partial 16th beam + augmentation dot) instead of plain eighths.
- Polyrhythm (poly ~) lanes are clearly marked on the main screen: a violet
  left-stripe, violet pads, and a ratio badge (e.g. ↻5:4 = lane beats : the
  reference lane's beats) — replaces the cryptic "~".

Engine untouched; conformance passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-07 12:29:56 -05:00
parent b98c37ff68
commit 1f5fdeaba9

View file

@ -34,14 +34,14 @@
--txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36; --panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
--cyan:#0AB3F7; --amber:#ffd166; --cyan:#0AB3F7; --amber:#ffd166;
--led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55); --led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55); --poly:#bb8cff;
--btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --chip-bg:#1b2230; --chip-bd:#2c3545; --btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --chip-bg:#1b2230; --chip-bd:#2c3545;
} }
:root[data-theme="light"]{ :root[data-theme="light"]{
--bg1:#eef3f9; --bg2:#cfd9e6; --bg1:#eef3f9; --bg2:#cfd9e6;
--txt:#10202f; --muted:#5c6776; --link:#1769c4; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
--led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45); --led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45); --poly:#7a3df0;
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5; --btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5;
} }
html,body{ height:100%; } html,body{ height:100%; }
@ -104,12 +104,14 @@
#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:34%; max-width:150px; min-width:92px; display:flex; align-items:center; gap:5px; text-align:left; .lmeta{ flex:0 0 auto; width:36%; max-width:168px; min-width:94px; 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:5px 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 .ln-name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .lmeta .ln-name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lmeta .lg{ flex:0 0 auto; color:var(--muted); } .lmeta .lg{ flex:0 0 auto; color:var(--muted); }
.lmeta .rhythm{ flex:0 0 auto; color:var(--txt); display:block; } .lmeta .rhythm{ flex:0 0 auto; color:var(--txt); display:block; }
.lmeta .polybadge{ flex:0 0 auto; color:var(--poly); font-weight:700; letter-spacing:.01em; }
.lane.poly .lmeta{ border-left:3px solid var(--poly); padding-left:6px; }
/* beats line up in columns across lanes; sub-beats sit inside the beat cell, smaller */ /* 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; } .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; } .beatcell{ flex:1 1 0; min-width:0; display:flex; gap:2px; align-items:center; }
@ -120,6 +122,8 @@
.pad.on{ background:var(--cyan); } .pad.on{ background:var(--cyan); }
.pad.acc{ background:var(--amber); } .pad.acc{ background:var(--amber); }
.pad.ghost{ background:var(--cyan); opacity:.42; } .pad.ghost{ background:var(--cyan); opacity:.42; }
.lane.poly .pad.on{ background:var(--poly); }
.lane.poly .pad.ghost{ background:var(--poly); opacity:.42; }
.pad.cur{ outline:2px solid var(--txt); outline-offset:-1px; box-shadow:0 0 8px var(--glow); } .pad.cur{ outline:2px solid var(--txt); outline-offset:-1px; box-shadow:0 0 8px var(--glow); }
.addlane{ align-self:flex-start; background:transparent; border:1px dashed var(--chip-bd); color:var(--muted); border-radius:8px; padding:5px 11px; font-size:12px; cursor:pointer; } .addlane{ align-self:flex-start; background:transparent; border:1px dashed var(--chip-bd); color:var(--muted); border-radius:8px; padding:5px 11px; font-size:12px; cursor:pointer; }
.chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; } .chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; }
@ -451,27 +455,49 @@ function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.ste
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; 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)):""); } 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, // Effective note value a lane actually plays: reduce the subdivision grid to the
// 4=16ths, 5/6/7=tuplets). Drawn as SVG so triplets/sextuplets render crisply. // largest note that lands on every active hit (so a triplet grid that only plays
function rhythmSVG(spb){ // the beat shows a quarter, not a triplet). gcd(stepsPerBeat, all active offsets).
spb=Math.max(1,spb|0); function gcd(a,b){ a=Math.abs(a); b=Math.abs(b); while(b){ const t=a%b; a=b; b=t; } return a; }
const n=spb, beams = n===1?0 : (n<=3?1:2), tup = (n===3||n>=5)?n:null; function laneNoteValue(m){
const LEFT=3, GAP = n<=2?8 : n<=4?6.5 : 5.5, baseY=15, topY=6; const spb=m.stepsPerBeat; let g=spb, any=false;
const W=Math.round(LEFT*2 + (n-1)*GAP + 4); let g="", first=0, last=0; for(let k=0;k<m.beatsOn.length;k++){ if((m.beatsOn[k]|0)>0){ any=true; g=gcd(g,k); } }
for(let i=0;i<n;i++){ const cx=LEFT+i*GAP, sx=cx+2.0; if(i===0) first=sx; last=sx; if(!any) return 1;
g+='<ellipse cx="'+cx.toFixed(1)+'" cy="'+baseY+'" rx="2.4" ry="1.8" transform="rotate(-20 '+cx.toFixed(1)+' '+baseY+')"/>'; return Math.max(1, spb/g);
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>';
} }
// Small engraved rhythm figure (1=quarter, 2=8ths, 3=triplet, 4=16ths, 5/6/7=tuplets);
// swung eighths render as the dotted-8th + 16th shuffle figure. Drawn as SVG.
function rhythmSVG(n, swing){
n=Math.max(1,n|0);
const baseY=15, topY=6, stemH=(baseY-topY-0.5).toFixed(1);
const head=(cx)=>'<ellipse cx="'+cx.toFixed(1)+'" cy="'+baseY+'" rx="2.4" ry="1.8" transform="rotate(-20 '+cx.toFixed(1)+' '+baseY+')"/>';
const stem=(sx)=>'<rect x="'+(sx-0.45).toFixed(2)+'" y="'+topY+'" width="0.9" height="'+stemH+'"/>';
const beam=(x0,x1,y)=>'<rect x="'+x0.toFixed(2)+'" y="'+y+'" width="'+(x1-x0).toFixed(2)+'" height="1.7"/>';
const wrap=(W,g)=>'<svg class="rhythm" viewBox="0 0 '+W+' 18" width="'+W+'" height="16" fill="currentColor" aria-hidden="true">'+g+'</svg>';
if(swing && n===2){ // dotted-eighth + sixteenth (shuffle)
const LEFT=3, U=4.6, cx0=LEFT, cx1=LEFT+2*U, s0=cx0+2, s1=cx1+2, W=Math.round(cx1+8);
let g=head(cx0)+head(cx1)+stem(s0)+stem(s1)+beam(s0-0.45,s1+0.45,topY)+beam(s1-3.4,s1+0.45,topY+2.6)
+'<circle cx="'+(cx0+3.7).toFixed(1)+'" cy="'+(baseY-0.6)+'" r="0.95"/>';
return wrap(W,g);
}
const 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, 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+=head(cx)+stem(sx); }
if(beams>=1) g+=beam(first-0.45,last+0.45,topY);
if(beams>=2) g+=beam(first-0.45,last+0.45,topY+2.6);
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 wrap(W,g);
}
function laneMetaHTML(m){ const eff=laneNoteValue(m), swGlyph=m.swing&&eff===2;
const ref=(meters[0]?meters[0].beatsPerBar:m.beatsPerBar);
const poly=m.poly?"<span class='polybadge' title='polyrhythm — "+m.beatsPerBar+" over "+ref+"'>↻"+m.beatsPerBar+":"+ref+"</span>":"";
return "<span class='ln-name'>"+esc(m.sound)+"</span>"+rhythmSVG(eff,m.swing)+"<span class='lg'>"+esc(m.groupsStr)+(m.swing&&!swGlyph?" sw":"")+"</span>"+poly; }
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")+(m.poly?" poly":"");
const meta=document.createElement("button"); meta.className="lmeta"; const meta=document.createElement("button"); meta.className="lmeta"; meta.innerHTML=laneMetaHTML(m); m._meta=meta;
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 spb=m.stepsPerBeat; m._padEls=new Array(m.beatsPerBar*spb); m._lastPad=-1; const spb=m.stepsPerBeat; m._padEls=new Array(m.beatsPerBar*spb); m._lastPad=-1;
@ -485,7 +511,9 @@ function buildLanes(){
} }
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return; function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return;
m._padEls.forEach((p,k)=>{ if(p) p.className=padClass(m,k); }); }); } 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; p.className=padClass(m,k); saveState(); } 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);
if(m._meta) m._meta.innerHTML=laneMetaHTML(m); // note value can change as hits are added/removed
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){