As-built: stack the beat bar into a 4×16 matrix (beat + 3 subdivision rows)

Replace the single 16-px strip with a 4×16 WS2812 matrix (four 16-px strips,
still PIO-driven on the RP2040):

- bottom row = the beat (cyan downbeats / amber group-starts, current beat
  bright, the rest a dim grid) — separated from the rows above by a divider;
- the three rows above stack the CURRENT beat's subdivisions as they pass:
  a column climbs row-by-row with each subdivision and resets on the next beat,
  with faint "slots" showing the ladder it will climb.

Subdivisions are driven by the finest lane that shares lane 1's beat grid
(non-poly, same beatsPerBar, max stepsPerBeat) — so an 8th-note hat shows one
row, 16ths show three, straight quarters show none. Also fixed a stray
"#05measure" typo left in the old .npx border rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-26 07:06:28 -05:00
parent 649501b51c
commit fcf58d9c1f

View file

@ -11,8 +11,9 @@
• a 128×64 MONOCHROME OLED (SSD1306/SH1106 class): rendered as a true 1-bit
framebuffer (drawn, then thresholded to crisp on/off pixels, scaled with
image-rendering:pixelated) so the cramped real layout is honest;
• a fixed 16-pixel WS2812 ("NeoPixel") RGB beat bar (PIO-driven) — lights the
first beatsPerBar slots, cyan downbeats / amber group-starts / dim others;
• 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);
• an EC11 rotary encoder (turn it: wheel or drag) for tempo, tactile buttons,
a MAX98357A-style speaker, USB-C and a PWR LED in a matte 3D-printed case.
Compare with the idealized /player.html. One file, no deps; shares src/engine.js.
@ -86,13 +87,15 @@
background:#000; border-radius:2px }
.oled-cap{ text-align:center; font-size:10px; color:var(--muted); margin-top:6px; letter-spacing:.02em }
/* ---- WS2812 RGB beat bar (fixed 16 px) ---- */
.ledbar{ display:flex; gap:5px; justify-content:center; align-items:center; margin:16px auto 4px; width:max-content;
background:linear-gradient(180deg,#10221c,var(--pcb)); border:1px solid #07140f; border-radius:5px; padding:7px 9px;
/* ---- 4×16 WS2812 RGB matrix: bottom row = beat, 3 rows above = subdivisions ---- */
.ledgrid{ display:flex; flex-direction:column; gap:5px; width:max-content; margin:16px auto 4px;
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) }
.npx{ width:16px; height:16px; border-radius:3px; background:#0c0e10; border:1px solid #05measure;
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,.06) } /* the 5050 die */
.ledrow{ display:flex; gap:5px }
.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 */
.ledbar-cap{ text-align:center; font-size:10px; color:var(--muted); margin:2px 0 0; letter-spacing:.02em }
/* ---- controls: encoder + tactile buttons ---- */
@ -165,8 +168,8 @@
</div>
<div class="oled-cap">0.96″ 128×64 mono OLED (SSD1306)</div>
<div class="ledbar" id="leds"></div>
<div class="ledbar-cap">16px WS2812 RGB beat bar</div>
<div class="ledgrid" id="leds"></div>
<div class="ledbar-cap">4×16 WS2812 — beat (bottom) + 3 subdivision rows</div>
<div class="controls">
<div class="keys">
@ -320,19 +323,36 @@ function drawOLED(){
oledThreshold();
}
/* ========================= RENDER: 16-px WS2812 beat bar ===================== */
const LED_COUNT=16, CYAN="#0AB3F7", AMBER="#ffd166";
function buildBar(){ const box=$("leds"); box.innerHTML=""; for(let i=0;i<LED_COUNT;i++){ const d=document.createElement("div"); d.className="npx"; box.appendChild(d); } }
/* ========== 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 m=meters[0], bpb=m?m.beatsPerBar:0;
const cur=(state.running&&m)? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
const els=$("leds").children;
for(let i=0;i<els.length;i++){
const led=els[i];
if(i>=bpb){ led.style.background="#0c0e10"; led.style.boxShadow="none"; continue; } // unused pixel on the fixed strip
const grp=m.groupStarts.has(i);
if(i===cur){ const c=grp?AMBER:CYAN; led.style.background=c; led.style.boxShadow="0 0 10px "+c+", inset 0 0 4px #fff"; }
else { led.style.background = grp?"rgba(255,209,102,.30)":"rgba(10,179,247,.20)"; led.style.boxShadow="none"; }
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)
}
}