From 1f5fdeaba9cb1bed6543d4c0ca27e8667740a975 Mon Sep 17 00:00:00 2001 From: Me Here Date: Sun, 7 Jun 2026 12:29:56 -0500 Subject: [PATCH] pm-mobile: swing shuffle glyph, largest-note rhythm icons, polyrhythm indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- mobile.html | 70 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/mobile.html b/mobile.html index 2ed8b28..a5e7fe4 100644 --- a/mobile.html +++ b/mobile.html @@ -34,14 +34,14 @@ --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff; --panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36; --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; } :root[data-theme="light"]{ --bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4; --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; } html,body{ height:100%; } @@ -104,12 +104,14 @@ #trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; } .lane{ display:flex; align-items:center; gap:8px; } .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; 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 .lg{ flex:0 0 auto; color:var(--muted); } .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 */ .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; } @@ -120,6 +122,8 @@ .pad.on{ background:var(--cyan); } .pad.acc{ background:var(--amber); } .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); } .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; } @@ -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 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'; - g+=''; } - if(beams>=1) g+=''; - if(beams>=2) g+=''; - if(tup) g+=''+tup+''; - return ''; +// Effective note value a lane actually plays: reduce the subdivision grid to the +// largest note that lands on every active hit (so a triplet grid that only plays +// the beat shows a quarter, not a triplet). gcd(stepsPerBeat, all active offsets). +function gcd(a,b){ a=Math.abs(a); b=Math.abs(b); while(b){ const t=a%b; a=b; b=t; } return a; } +function laneNoteValue(m){ + const spb=m.stepsPerBeat; let g=spb, any=false; + for(let k=0;k0){ any=true; g=gcd(g,k); } } + if(!any) return 1; + return Math.max(1, spb/g); } +// 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)=>''; + const stem=(sx)=>''; + const beam=(x0,x1,y)=>''; + const wrap=(W,g)=>''; + 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) + +''; + 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=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+=''+tup+''; + 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?"↻"+m.beatsPerBar+":"+ref+"":""; + return ""+esc(m.sound)+""+rhythmSVG(eff,m.swing)+""+esc(m.groupsStr)+(m.swing&&!swGlyph?" sw":"")+""+poly; } function buildLanes(){ const box=$("lanes"); box.innerHTML=""; meters.forEach((m,i)=>{ - const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off"); - const meta=document.createElement("button"); meta.className="lmeta"; - meta.innerHTML=""+esc(m.sound)+""+rhythmSVG(m.stepsPerBeat)+""+esc(m.groupsStr)+(m.swing?" sw":"")+(m.poly?"~":"")+""; + const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off")+(m.poly?" poly":""); + const meta=document.createElement("button"); meta.className="lmeta"; meta.innerHTML=laneMetaHTML(m); m._meta=meta; meta.onclick=()=>openLaneSheet(i); const pads=document.createElement("div"); pads.className="pads"; 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; 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; 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){