As-built: side-by-side player + BOM; move the beat display onto the TFT

- Layout: wrap the device + loader in a left column and the BOM in a right
  column (flex row, wraps to stacked on narrow screens) so you can see the
  player and the parts list at once. Top bar widened to span both.
- Display: the colour TFT now draws the beat indicator itself — a centred row
  of beat dots (current beat bright, group-starts amber) with the current
  beat's subdivisions as pips below — so the separate 4×16 WS2812 matrix is
  gone. Subdivisions still come from the finest lane sharing lane 1's grid.
- BOM: dropped the WS2812 beat-bar line (−$6 → ≈ $55) — fewer parts, and one
  fewer thing to drive in firmware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-26 07:55:17 -05:00
parent 7b14f861a8
commit 43495777c9

View file

@ -9,11 +9,9 @@
"As-built" variant of the PM-1 player: the same firmware/engine, drawn with the
parts you'd actually solder for an RP2040 build —
• a 2.0″ 320×240 colour IPS TFT (ST7789, e.g. Pimoroni Pico Display 2.0):
the upgrade from the cramped 128×64 mono OLED — full colour, ~5× the
resolution, smooth anti-aliased type (rendered hi-DPI on a canvas);
• a 4×16 WS2812 ("NeoPixel") RGB matrix (PIO-driven): the bottom row is the
beat (cyan downbeats / amber group-starts), and the three rows above stack
the current beat's subdivisions as they pass (driven by the finest lane);
the upgrade from the cramped 128×64 mono OLED — full colour, smooth type,
hi-DPI. It also draws the beat indicator itself — a row of beat dots with
the current beat's subdivisions below — so there's no separate LED bar;
• controls arranged for use: an EC11 encoder below the screen (turning it
never hides the readout) with arcade pushbuttons spread below — PREV far
left, NEXT far right, a big central PLAY — so you don't hit the wrong one;
@ -55,7 +53,7 @@
display:flex; flex-direction:column; align-items:center; gap:14px;
}
a{color:var(--link)}
.topbar{width:100%; max-width:380px; display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap}
.topbar{width:100%; max-width:778px; display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap}
.topbar b{color:var(--txt)}
.topbar-right{ display:flex; align-items:center; gap:12px }
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
@ -92,16 +90,14 @@
.tft-cap{ text-align:center; font-size:10px; color:var(--muted); margin-top:7px; letter-spacing:.02em }
/* ---- 4×16 WS2812 RGB matrix: bottom row = beat, 3 rows above = subdivisions ---- */
.ledgrid{ display:flex; flex-direction:column; gap:4px; width:max-content; margin:12px auto 3px;
background:linear-gradient(180deg,#10221c,var(--pcb)); border:1px solid #07140f; border-radius:5px; padding:8px 9px;
box-shadow:inset 0 1px 2px rgba(0,0,0,.6) }
.ledrow{ display:flex; gap:4px }
.ledrow.beatrow{ margin-top:4px; padding-top:6px; border-top:1px solid rgba(255,255,255,.07) }
.npx{ width:15px; height:15px; border-radius:3px; background:#0c0e10; border:1px solid #060708;
position:relative; transition:background .05s, box-shadow .05s }
.npx::after{ content:""; position:absolute; inset:4px; border-radius:1px; background:rgba(255,255,255,.05) } /* the 5050 die */
/* the beat indicator lives on the TFT now (no LED matrix) */
.ledbar-cap{ text-align:center; font-size:10px; color:var(--muted); margin:2px 0 0; letter-spacing:.02em }
/* side-by-side: player on the left, BOM on the right (stacks when narrow) */
.cols{ display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:center; gap:18px; width:100% }
.col-left{ display:flex; flex-direction:column; align-items:center; gap:14px; width:380px; max-width:100% }
.bom-panel{ width:380px; max-width:100%; align-self:flex-start }
/* ---- controls: encoder above (under the screen), arcade buttons spread below ----
wheel never hides the readout · PREV far-left / NEXT far-right · big central PLAY */
.controls{ display:flex; flex-direction:column; align-items:center; gap:13px; margin:14px 0 2px }
@ -177,6 +173,9 @@
</span>
</div>
<div class="cols">
<div class="col-left">
<!-- ===================== THE DEVICE ===================== -->
<div class="device">
<span class="screw bl"></span><span class="screw br"></span>
@ -191,10 +190,7 @@
<canvas id="tft" width="320" height="240" aria-label="2 inch 320 by 240 colour IPS display"></canvas>
</div>
</div>
<div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789)</div>
<div class="ledgrid" id="leds"></div>
<div class="ledbar-cap">4×16 WS2812 — beat (bottom) + 3 subdivision rows</div>
<div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789) — beat &amp; subdivisions onscreen</div>
<div class="controls">
<div class="enc-wrap"><div class="enc" id="enc" title="Tempo — scroll or drag to turn"></div><small>TEMPO</small></div>
@ -233,9 +229,10 @@
</div>
<div class="status" id="status"></div>
</div>
</div><!-- /col-left -->
<!-- ===================== BILL OF MATERIALS ===================== -->
<div class="panel">
<div class="panel bom-panel">
<h2>Bill of materials</h2>
<p class="sub">Rough parts list for the device above — an RP2040 build with analog click injection.
Ballpark one-off prices (USD); cheaper at volume.</p>
@ -245,8 +242,6 @@
<tr class="grp"><td colspan="3">Brain &amp; display</td></tr>
<tr><td class="part">RP2040 board, USBC <span class="spec">— e.g. Waveshare RP2040Zero / Picoclone</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr><td class="part">2.0″ 320×240 IPS TFT, ST7789 <span class="spec">— SPI</span></td><td class="q">1</td><td class="c">8</td></tr>
<tr class="grp"><td colspan="3">Beat bar</td></tr>
<tr><td class="part">WS2812B / SK6812 RGB LED <span class="spec">— 4×16 matrix (or 4× 16px strips), PIOdriven</span></td><td class="q">64</td><td class="c">6</td></tr>
<tr class="grp"><td colspan="3">Controls</td></tr>
<tr><td class="part">Arcade pushbutton, 24 mm <span class="spec">— Prev · Next · Tap</span></td><td class="q">3</td><td class="c">4</td></tr>
<tr><td class="part">Arcade pushbutton, 30 mm <span class="spec">— Play</span></td><td class="q">1</td><td class="c">2</td></tr>
@ -264,13 +259,14 @@
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">5</td></tr>
<tr><td class="part">Passives, headers, wire <span class="spec">— R/C for the analog stage + decoupling</span></td><td class="q"></td><td class="c">3</td></tr>
<tr><td class="part">3Dprinted enclosure + screws / standoffs</td><td class="q">1</td><td class="c">5</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $61</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $55</td></tr>
</tbody>
</table>
<p class="sub" style="margin-top:12px">Audio is summed in the <b>analog domain</b>: the DAC's click is mixed with a highimpedance
buffer of the 1/4″ instrument input, then fed to the balanced line driver (1/4″ TRS out) and the monitor amp —
so your instrument is never redigitised (no added latency).</p>
</div>
</div><!-- /cols -->
<script>
const APP_VERSION = "v0.0.1-dev";
@ -326,7 +322,7 @@ function loadSetup(s){
segBars=s.bars||0; segBarCount=0;
setBpm(s.bpm||120);
meters=buildMeters(s.lanes);
renderBar();
drawTFT();
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
@ -360,76 +356,64 @@ const tft=$("tft"), tc=tft.getContext("2d");
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1));
tft.width=TFT_W*dpr; tft.height=TFT_H*dpr; tc.scale(dpr,dpr); })(); // hi-DPI backing → crisp type
function drawTFT(){
const m=meters[0];
const ref=meters[0], bpb=ref?ref.beatsPerBar:0;
// beats from lane 1; subdivisions from the finest lane sharing that beat grid
let disp=ref; if(ref){ for(const x of meters){ if(!x.poly && x.beatsPerBar===ref.beatsPerBar && x.stepsPerBeat>disp.stepsPerBeat) disp=x; } }
const spb=disp?disp.stepsPerBeat:1;
let curBeat=-1, curSub=-1;
if(state.running&&disp&&disp.currentStep>=0){ curBeat=Math.floor(disp.currentStep/spb); curSub=disp.currentStep%spb; }
const g=tc.createLinearGradient(0,0,0,TFT_H); g.addColorStop(0,"#0b0f18"); g.addColorStop(1,"#05070c");
tc.fillStyle=g; tc.fillRect(0,0,TFT_W,TFT_H);
// header
tc.textBaseline="middle"; tc.textAlign="left";
tc.font='600 13px "Segoe UI",system-ui,sans-serif'; tc.fillStyle="#5b7a93";
tc.fillText("♪ "+(setlist?((idx+1)+"/"+setlist.items.length):"/"), 14, 18);
tc.fillText("♪ "+(setlist?((idx+1)+"/"+setlist.items.length):"/"), 14, 17);
tc.textAlign="right"; tc.fillStyle = state.running ? "#2fe07a" : "#6b7787";
tc.fillText(state.running?"▶ PLAY":"■ STOP", TFT_W-14, 18);
tc.fillStyle="#13283c"; tc.fillRect(14,32,TFT_W-28,1);
tc.fillText(state.running?"▶ PLAY":"■ STOP", TFT_W-14, 17);
tc.fillStyle="#13283c"; tc.fillRect(14,30,TFT_W-28,1);
// tempo — the hero number
tc.textBaseline="alphabetic"; tc.textAlign="left";
tc.fillStyle="#1fb6f0"; tc.font='800 86px "Segoe UI",system-ui,sans-serif';
const bpm=String(state.bpm); tc.fillText(bpm, 18, 134);
const bx=18+tc.measureText(bpm).width+9;
tc.fillStyle="#5b7a93"; tc.font='700 17px "Segoe UI",system-ui,sans-serif'; tc.fillText("BPM", bx, 96);
tc.font='600 18px "Segoe UI",system-ui,sans-serif'; tc.fillText("♩ "+(m?(m.groupsStr.replace(/[^0-9+]/g,"")||m.beatsPerBar):""), bx, 122);
tc.fillStyle="#1fb6f0"; tc.font='800 78px "Segoe UI",system-ui,sans-serif';
const bpm=String(state.bpm); tc.fillText(bpm, 16, 110);
const bx=16+tc.measureText(bpm).width+9;
tc.fillStyle="#5b7a93"; tc.font='700 16px "Segoe UI",system-ui,sans-serif'; tc.fillText("BPM", bx, 74);
tc.font='600 17px "Segoe UI",system-ui,sans-serif'; tc.fillText("♩ "+(ref?(ref.groupsStr.replace(/[^0-9+]/g,"")||ref.beatsPerBar):""), bx, 98);
// item name — centred, ellipsised
tc.textAlign="center"; tc.fillStyle="#e9eff7"; tc.font='600 23px "Segoe UI",system-ui,sans-serif';
tc.textAlign="center"; tc.fillStyle="#e9eff7"; tc.font='600 22px "Segoe UI",system-ui,sans-serif';
let nm=setlist?(setlist.items[idx].name||"—"):"—";
if(tc.measureText(nm).width>TFT_W-28){ while(nm.length>1 && tc.measureText(nm+"…").width>TFT_W-28) nm=nm.slice(0,-1); nm+="…"; }
tc.fillText(nm, TFT_W/2, 180);
tc.fillText(nm, TFT_W/2, 138);
// beat dots + the current beat's subdivisions — the on-screen replacement for the LED matrix
if(bpb>0){
const gap=Math.min(30,(TFT_W-44)/bpb), x0=TFT_W/2 - gap*(bpb-1)/2, by=166;
for(let i=0;i<bpb;i++){
const x=x0+i*gap, grp=ref.groupStarts.has(i), on=(i===curBeat), col=grp?"#ffd166":"#1fb6f0";
tc.beginPath(); tc.arc(x,by, on?7:4, 0, Math.PI*2);
if(on){ tc.fillStyle=col; tc.shadowColor=col; tc.shadowBlur=11; tc.fill(); tc.shadowBlur=0; }
else { tc.fillStyle=grp?"rgba(255,209,102,.34)":"rgba(31,182,240,.26)"; tc.fill(); }
}
if(spb>1){
const sgap=8, sx0=TFT_W/2 - sgap*(spb-1)/2, sy=188;
for(let s=0;s<spb;s++){ tc.beginPath(); tc.arc(sx0+s*sgap, sy, 2.4, 0, Math.PI*2);
tc.fillStyle=(curBeat>=0&&s<=curSub)?"#2fe0a0":"rgba(47,224,160,.16)"; tc.fill(); }
}
}
// bottom strip
tc.fillStyle="#13283c"; tc.fillRect(14,200,TFT_W-28,1);
tc.textAlign="left"; tc.fillStyle="#7f8b9a"; tc.font='600 14px "Segoe UI",system-ui,sans-serif';
tc.fillText(state.running&&m ? ("BAR "+(m.currentBar+1)+" BEAT "+(Math.floor(m.currentStep/m.stepsPerBeat)+1||"")) : "READY", 14, 224);
if(segBars>0){ const rem=Math.max(0,segBars-(m?m.currentBar:0));
tc.fillStyle="#13283c"; tc.fillRect(14,206,TFT_W-28,1);
tc.textBaseline="alphabetic"; tc.textAlign="left"; tc.fillStyle="#7f8b9a"; tc.font='600 14px "Segoe UI",system-ui,sans-serif';
tc.fillText(state.running&&ref ? ("BAR "+(ref.currentBar+1)) : "READY", 14, 226);
if(segBars>0){ const rem=Math.max(0,segBars-(ref?ref.currentBar:0));
tc.textAlign="right"; tc.fillStyle="#ffd166"; tc.font='700 15px "Segoe UI",system-ui,sans-serif';
tc.fillText((state.running?rem:segBars)+" BARS", TFT_W-14, 224); }
tc.fillText((state.running?rem:segBars)+" BARS", TFT_W-14, 226); }
}
/* ========== RENDER: 4×16 WS2812 matrix — beat row + 3 subdivision rows ========= */
const LED_COLS=16, LED_ROWS=4, CYAN="#0AB3F7", AMBER="#ffd166", SUB="#2fe0a0";
let gridLeds=[];
function buildBar(){
const box=$("leds"); box.innerHTML=""; gridLeds=[[],[],[],[]];
for(let r=LED_ROWS-1;r>=0;r--){ // top row built first → the beat row (r=0) sits at the bottom
const row=document.createElement("div"); row.className="ledrow"+(r===0?" beatrow":"");
for(let c=0;c<LED_COLS;c++){ const d=document.createElement("div"); d.className="npx"; row.appendChild(d); gridLeds[r][c]=d; }
box.appendChild(row);
}
}
function setPx(led,bg,sh){ led.style.background=bg; led.style.boxShadow=sh||"none"; }
function renderBar(){
const ref=meters[0], bpb=ref?ref.beatsPerBar:0;
// beats come from lane 1; subdivisions from the finest lane sharing that beat grid
let disp=ref; if(ref){ for(const m of meters){ if(!m.poly && m.beatsPerBar===ref.beatsPerBar && m.stepsPerBeat>disp.stepsPerBeat) disp=m; } }
const spb=disp?disp.stepsPerBeat:1;
let curBeat=-1, curSub=-1;
if(state.running&&disp&&disp.currentStep>=0){ curBeat=Math.floor(disp.currentStep/spb); curSub=disp.currentStep%spb; }
for(let r=0;r<LED_ROWS;r++) for(let c=0;c<LED_COLS;c++){
const led=gridLeds[r][c];
if(c>=bpb){ setPx(led,"#0c0e10"); continue; } // unused column on the fixed matrix
const grp=ref.groupStarts.has(c);
if(r===0){ // BEAT row (bottom)
if(c===curBeat){ const col=grp?AMBER:CYAN; setPx(led,col,"0 0 9px "+col+", inset 0 0 3px #fff"); }
else setPx(led, grp?"rgba(255,209,102,.26)":"rgba(10,179,247,.15)");
} else if(r<spb && c===curBeat){ // SUBDIVISION rows climb in the current beat's column
if(curSub>=r) setPx(led,SUB,"0 0 7px "+SUB); // reached this subdivision
else setPx(led,"rgba(47,224,160,.10)"); // ahead: faint slot showing the ladder this beat will climb
} else setPx(led,"#0c0e10"); // off (no such subdivision, or not the current beat)
}
}
function renderAll(){ drawTFT(); renderBar(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running);
function renderAll(){ drawTFT(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running);
$("enc").style.setProperty("--a", knobAngle+"deg"); }
function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime;
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
drawTFT(); renderBar();
drawTFT();
requestAnimationFrame(draw);
}
@ -510,7 +494,6 @@ addEventListener("keydown",(e)=>{
});
/* ========================= INIT ============================================== */
buildBar();
loadStored();
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }