As-built: upgrade the mono OLED to a 320×240 colour IPS TFT (ST7789)

The 128×64 mono OLED was too pixelated, so step up to the realistic next
tier: a 2.0″ 320×240 colour IPS TFT (ST7789 — e.g. Pimoroni Pico Display 2.0),
~5× the resolution and full colour.

- Drop the 1-bit threshold + image-rendering:pixelated; render on a hi-DPI
  canvas (backing = 320×240 × devicePixelRatio) with smooth anti-aliased type.
- Richer colour layout: dim header (position + green ▶ PLAY / grey ■ STOP),
  a big cyan tempo with "BPM ♩ <grouping>", the centred item name (ellipsised),
  and a bottom strip with bar·beat + an amber bars countdown.

The screen stays a fixed dark UI (a TFT shows whatever firmware draws); the page
chrome still follows light/dark/system. Beat matrix, encoder, buttons unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-26 07:11:48 -05:00
parent 00cb245331
commit b434520505

View file

@ -8,9 +8,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 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 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);
@ -77,15 +77,13 @@
.pwr{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--silk); text-transform:uppercase; letter-spacing:.14em; opacity:.85 }
.pwr .dot{ width:7px; height:7px; border-radius:50%; background:#2fe07a; box-shadow:0 0 7px #2fe07a }
/* ---- 128×64 monochrome OLED module ---- */
.oled-wrap{ display:flex; justify-content:center; margin:0 4px }
.oled-mod{ background:#0a0c0f; border:1px solid #000; border-radius:6px; padding:10px 12px;
box-shadow:inset 0 0 0 2px #1a1d22, 0 2px 4px rgba(0,0,0,.5); position:relative }
.oled-mod::before{ content:""; position:absolute; top:6px; right:6px; width:26px; height:5px; border-radius:2px;
background:repeating-linear-gradient(90deg,#2a2f36 0 2px,transparent 2px 4px); opacity:.6 } /* tiny pin header detail */
#oled{ display:block; width:336px; height:168px; image-rendering:pixelated; image-rendering:crisp-edges;
background:#000; border-radius:2px }
.oled-cap{ text-align:center; font-size:10px; color:var(--muted); margin-top:6px; letter-spacing:.02em }
/* ---- 2.0″ 320×240 colour IPS TFT (the OLED upgrade) ---- */
.tft-wrap{ display:flex; justify-content:center; margin:0 4px }
.tft-mod{ background:#000; border-radius:10px; padding:9px;
box-shadow:inset 0 0 0 2px #15181d, inset 0 0 0 3px #000, 0 3px 9px rgba(0,0,0,.55) }
#tft{ display:block; width:320px; height:240px; max-width:100%; border-radius:4px; background:#06080c;
box-shadow:0 0 0 1px #000, inset 0 0 18px rgba(0,0,0,.45) }
.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:5px; width:max-content; margin:16px auto 4px;
@ -161,12 +159,12 @@
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
<div class="oled-wrap">
<div class="oled-mod">
<canvas id="oled" width="128" height="64" aria-label="128 by 64 monochrome OLED"></canvas>
<div class="tft-wrap">
<div class="tft-mod">
<canvas id="tft" width="320" height="240" aria-label="2 inch 320 by 240 colour IPS display"></canvas>
</div>
</div>
<div class="oled-cap">0.96″ 128×64 mono OLED (SSD1306)</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>
@ -285,42 +283,41 @@ function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000
let knobAngle=0;
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); knobAngle+=d*9; $("enc").style.setProperty("--a",knobAngle+"deg"); renderAll(); }
/* ========================= RENDER: 128×64 mono OLED ========================== */
const oled=$("oled"), oc=oled.getContext("2d");
const OLED_ON=[150,220,255]; // cool-white/blue monochrome panel
let nameScroll=0;
function oledThreshold(){ // posterize the framebuffer to 1-bit, recolour to the panel
const img=oc.getImageData(0,0,128,64), d=img.data, r=OLED_ON[0], g=OLED_ON[1], b=OLED_ON[2];
for(let i=0;i<d.length;i+=4){ const on=d[i]>110; d[i]=on?r:0; d[i+1]=on?g:0; d[i+2]=on?b:0; d[i+3]=255; }
oc.putImageData(img,0,0);
}
function drawOLED(){
/* ===================== RENDER: 320×240 colour IPS TFT (ST7789) =============== */
const TFT_W=320, TFT_H=240;
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];
oc.fillStyle="#000"; oc.fillRect(0,0,128,64);
oc.fillStyle="#fff"; oc.textBaseline="top";
// top status row
oc.font='7px "Courier New",monospace'; oc.textAlign="left";
oc.fillText(setlist?((idx+1)+"/"+setlist.items.length):"-/-", 0, 0);
oc.textAlign="right"; oc.fillText(state.running?"PLAY":"STOP", 128, 0);
// big BPM
oc.textAlign="left"; oc.font='bold 27px Arial,sans-serif';
const bpmStr=String(state.bpm); oc.fillText(bpmStr, 0, 8);
const bw=Math.min(oc.measureText(bpmStr).width+5, 96);
oc.font='8px "Courier New",monospace'; oc.fillText("BPM", bw, 12);
if(m){ oc.fillText((m.groupsStr.replace(/[^0-9+]/g,"")||String(m.beatsPerBar)), bw, 24); } // grouping e.g. 2+2+3
oc.fillRect(0,37,128,1); // separator
// item name (marquee if too wide)
oc.font='8px "Courier New",monospace';
const name=setlist?(setlist.items[idx].name||"-"):"-";
const nw=oc.measureText(name).width;
let nx=0;
if(nw>128){ const period=nw+24; nx=128-(nameScroll%period); }
oc.fillText(name, nx, 41);
// bottom row
oc.font='7px "Courier New",monospace'; oc.textAlign="left";
oc.fillText(state.running&&m ? ("bar "+(m.currentBar+1)+" beat "+(Math.floor(m.currentStep/m.stepsPerBeat)+1||"-")) : "ready", 0, 53);
if(segBars>0){ oc.textAlign="right"; const rem=Math.max(0,segBars-(m?m.currentBar:0)); oc.fillText((state.running?rem:segBars)+"b", 128, 53); }
oledThreshold();
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.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);
// 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);
// item name — centred, ellipsised
tc.textAlign="center"; tc.fillStyle="#e9eff7"; tc.font='600 23px "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);
// 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.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); }
}
/* ========== RENDER: 4×16 WS2812 matrix — beat row + 3 subdivision rows ========= */
@ -356,12 +353,12 @@ function renderBar(){
}
}
function renderAll(){ drawOLED(); renderBar(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running);
function renderAll(){ drawTFT(); renderBar(); $("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++; } } }
nameScroll+=0.6; drawOLED(); renderBar();
drawTFT(); renderBar();
requestAnimationFrame(draw);
}