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:
parent
b98c37ff68
commit
1f5fdeaba9
1 changed files with 49 additions and 21 deletions
70
mobile.html
70
mobile.html
|
|
@ -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){
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue