As-built: recessed thumb-roller for tempo (no protruding knob); USB-C bus power

- Tempo control: replace the protruding knob with a recessed side-mount
  thumb-roller — a detented encoder (EC11/PEC12) with a ribbed wheel exposed
  only at the edge through a slot, like a mouse scroll wheel, so there's
  nothing to snap off. Scroll/drag tempo interaction is unchanged; the ribs
  scroll for roll feedback (--rib) instead of a knob pointer.
- Power: no battery — the device is USB-C bus-powered from the same port that
  carries config. Dropped the LiPo + TP4056 charger + 5 V boost from the BOM
  (total ≈ $49) and marked USB-C as 5 V in + config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-26 08:03:15 -05:00
parent 8c94749e4f
commit a40ff04fd1

View file

@ -12,9 +12,10 @@
the upgrade from the cramped 128×64 mono OLED — full colour, smooth type, 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 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; 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 • controls arranged for use: a recessed thumb-roller for tempo — a detented
never hides the readout) with arcade pushbuttons spread below — PREV far encoder with a side-mount wheel, so nothing sticks out to snap off — below
left, NEXT far right, a big central PLAY — so you don't hit the wrong one; the screen, with arcade pushbuttons spread below: PREV far left, NEXT far
right, a big central PLAY, so you don't hit the wrong one;
• rear I/O: external trigger in (footswitch), a 1/4" instrument pass-through • rear I/O: external trigger in (footswitch), a 1/4" instrument pass-through
with the click mixed in the ANALOG domain (DAC → summing op-amp → balanced with the click mixed in the ANALOG domain (DAC → summing op-amp → balanced
line driver), a shared 1/4" balanced-TRS main out, plus an analog monitor line driver), a shared 1/4" balanced-TRS main out, plus an analog monitor
@ -101,12 +102,14 @@
wheel never hides the readout · PREV far-left / NEXT far-right · big central PLAY */ 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 } .controls{ display:flex; flex-direction:column; align-items:center; gap:13px; margin:14px 0 2px }
.enc-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px } .enc-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px }
.enc{ width:52px; height:52px; border-radius:50%; cursor:ns-resize; position:relative; touch-action:none; /* recessed thumb-roller (side-mount encoder wheel) — only the edge is exposed */
background:repeating-conic-gradient(from 0deg, #424b57 0 7deg, #2c333d 7deg 14deg); .roller{ width:28px; height:50px; border-radius:7px; cursor:ns-resize; position:relative; touch-action:none; overflow:hidden;
border:2px solid #565f6c; box-shadow:0 3px 8px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.12) } background:#0a0d12; border:1px solid #04060a; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 2px 4px rgba(0,0,0,.5) }
.enc::before{ content:""; position:absolute; inset:9px; border-radius:50%; background:radial-gradient(circle at 38% 32%,#3b434f,#181c22 75%) } .roller::before{ content:""; position:absolute; inset:2px 4px; border-radius:4px; /* ribbed wheel surface, scrolls via --rib */
.enc::after{ content:""; position:absolute; left:50%; top:7px; width:3px; height:12px; background:var(--cyan); border-radius:2px; background:repeating-linear-gradient(0deg, rgba(255,255,255,.11) 0 1px, rgba(0,0,0,.5) 1px 4px);
transform-origin:50% 19px; transform:translateX(-50%) rotate(var(--a,0deg)); box-shadow:0 0 5px var(--cyan) } background-position:0 var(--rib,0px) }
.roller::after{ content:""; position:absolute; inset:0; border-radius:7px; pointer-events:none; /* cylinder sheen: bright centre, curving dark at top/bottom */
background:linear-gradient(180deg, rgba(0,0,0,.72) 0%, rgba(255,255,255,.12) 48%, rgba(255,255,255,.12) 52%, rgba(0,0,0,.72) 100%) }
.enc-wrap small{ font-size:8px; color:var(--silk); letter-spacing:.12em; opacity:.85 } .enc-wrap small{ font-size:8px; color:var(--silk); letter-spacing:.12em; opacity:.85 }
.keys{ display:flex; align-items:flex-end; justify-content:space-between; width:100%; padding:0 2px } .keys{ display:flex; align-items:flex-end; justify-content:space-between; width:100%; padding:0 2px }
.key-mid{ display:flex; align-items:flex-end; gap:20px } .key-mid{ display:flex; align-items:flex-end; gap:20px }
@ -192,7 +195,7 @@
<div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789) — beat &amp; subdivisions onscreen</div> <div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789) — beat &amp; subdivisions onscreen</div>
<div class="controls"> <div class="controls">
<div class="enc-wrap"><div class="enc" id="enc" title="Tempo — scroll or drag to turn"></div><small>TEMPO</small></div> <div class="enc-wrap"><div class="roller" id="enc" title="Tempo — roll it (scroll or drag)"></div><small>TEMPO</small></div>
<div class="keys"> <div class="keys">
<div class="key"><button class="abtn nav" id="bPrev" title="previous item"></button><small>Prev</small></div> <div class="key"><button class="abtn nav" id="bPrev" title="previous item"></button><small>Prev</small></div>
<div class="key-mid"> <div class="key-mid">
@ -233,7 +236,7 @@
<!-- ===================== BILL OF MATERIALS ===================== --> <!-- ===================== BILL OF MATERIALS ===================== -->
<div class="panel bom-panel"> <div class="panel bom-panel">
<h2>Bill of materials</h2> <h2>Bill of materials</h2>
<p class="sub">Rough parts list for the device above — an RP2040 build with analog click injection. <p class="sub">Rough parts list for the device above — a USBCpowered RP2040 build with analog click injection.
Ballpark one-off prices (USD); cheaper at volume.</p> Ballpark one-off prices (USD); cheaper at volume.</p>
<table class="bom"> <table class="bom">
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead> <thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
@ -244,7 +247,7 @@
<tr class="grp"><td colspan="3">Controls</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, 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> <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>
<tr><td class="part">EC11 rotary encoder + knurled knob <span class="spec">— tempo</span></td><td class="q">1</td><td class="c">2</td></tr> <tr><td class="part">Detented encoder (EC11 / PEC12) + sidemount thumbroller <span class="spec">— recessed; nothing to snap off</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr class="grp"><td colspan="3">Audio — analog click injection</td></tr> <tr class="grp"><td colspan="3">Audio — analog click injection</td></tr>
<tr><td class="part">PCM5102A I²S DAC <span class="spec">— linelevel click</span></td><td class="q">1</td><td class="c">3</td></tr> <tr><td class="part">PCM5102A I²S DAC <span class="spec">— linelevel click</span></td><td class="q">1</td><td class="c">3</td></tr>
<tr><td class="part">Dual opamp, NE5532 / OPA2134 <span class="spec">— hiZ instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr> <tr><td class="part">Dual opamp, NE5532 / OPA2134 <span class="spec">— hiZ instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr>
@ -252,13 +255,12 @@
<tr><td class="part">PAM8302A mono ClassD + 8 Ω 2 W speaker <span class="spec">— monitor</span></td><td class="q">1</td><td class="c">4</td></tr> <tr><td class="part">PAM8302A mono ClassD + 8 Ω 2 W speaker <span class="spec">— monitor</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr class="grp"><td colspan="3">Connectors &amp; power</td></tr> <tr class="grp"><td colspan="3">Connectors &amp; power</td></tr>
<tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS) · Trig In (TS)</span></td><td class="q">3</td><td class="c">3</td></tr> <tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS) · Trig In (TS)</span></td><td class="q">3</td><td class="c">3</td></tr>
<tr><td class="part">LiPo 1200 mAh + TP4056 charger + 5 V boost</td><td class="q">1</td><td class="c">6</td></tr> <tr><td class="part">USBC bus power (5 V) + PWR LED <span class="spec">— same port carries config; no battery</span></td><td class="q">1</td><td class="c">1</td></tr>
<tr><td class="part">Power slide switch + PWR LED</td><td class="q">1</td><td class="c">1</td></tr>
<tr class="grp"><td colspan="3">Build</td></tr> <tr class="grp"><td colspan="3">Build</td></tr>
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">5</td></tr> <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">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><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">≈ $55</td></tr> <tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $49</td></tr>
</tbody> </tbody>
</table> </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 <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
@ -346,8 +348,8 @@ function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running;
let taps=[]; let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now); function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } } if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
let knobAngle=0; let rollPos=0;
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); knobAngle+=d*9; $("enc").style.setProperty("--a",knobAngle+"deg"); renderAll(); } function nudge(d){ ramp.on=false; setBpm(state.bpm+d); rollPos+=d*2.5; $("enc").style.setProperty("--rib",rollPos+"px"); renderAll(); }
/* ===================== RENDER: 320×240 colour IPS TFT (ST7789) =============== */ /* ===================== RENDER: 320×240 colour IPS TFT (ST7789) =============== */
const TFT_W=320, TFT_H=240; const TFT_W=320, TFT_H=240;
@ -408,7 +410,7 @@ function drawTFT(){
} }
function renderAll(){ drawTFT(); $("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"); } $("enc").style.setProperty("--rib", rollPos+"px"); }
function draw(){ function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime; 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++; } } } 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++; } } }