metronome/stage.html
Me Here dc936fdc11 Proper VARASYS logo (wordmark + tagline) everywhere; embed defaults to the set lists
Logos: the brand is now a consistent lockup — wordmark image + a crisp CSS
"Simplifying Complexity" tagline — in the shared header, device silkscreens
(teacher/stage/micro), the player (was a CSS text box) and the showcase canvas
(was drawn text; now the real logo image + tagline). Cropped the baked tagline
out of logo-light.b64 so both themes render the tagline once. Renamed device
silk logos to .dev-logo so they no longer shrink the shared header logo.

Embeds: every form factor now loads its default set lists when embedded with no
config — and the Concepts landing embeds them that way (viewport loads
<device>?embed=1 with no forced #p=; the program box reflects what the device
reports and only overrides on explicit Load).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:48:44 -05:00

393 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PM_S1 — Stage (footpedal stompbox)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 → strip site chrome + auto-size to the host */
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
document.documentElement.dataset.embed="1";
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
})();
</script>
<!--
PM-1 "Stage" — the foot-operated live stompbox (the hands-free sibling of the
desktop /teacher.html). Same RP2040 firmware/engine. Floor-driven controls:
• LEFT footswitch = TAP tempo (tap to set BPM) ; hold = start / stop
• RIGHT footswitch = NEXT set-list item ; hold = previous
• 1/4" expression-pedal input = sweep tempo with your foot
A big floor-readable RGB BEAT light + a small angled TFT (BPM, item, beats).
Analog click injection (Inst in -> summed -> balanced TRS out) like the Teacher.
Power: TWO USB-C ports — one data+power, one power-thru, so pedals daisy-chain
off a single charger / power bank. Shares src/engine.js.
-->
<script>
(function(){ try{ var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bd:#2a313c; --device-bd:#33363c; --silk:#aab2bc; --cyan:#0AB3F7; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:26px 14px 46px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:16px }
a{ color:var(--link) }
/* ---- the stompbox ---- */
.device{ width:100%; max-width:340px; position:relative; border-radius:16px; padding:0 0 20px;
background:
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
linear-gradient(180deg, #2b2d33, #141518);
border:1px solid var(--device-bd);
box-shadow:0 26px 52px rgba(0,0,0,.62), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -3px 10px rgba(0,0,0,.55) }
/* top edge — all the jacks (cables exit upward off the board) */
.edge{ display:flex; align-items:flex-start; justify-content:space-between; gap:4px;
padding:11px 12px 12px; border-radius:16px 16px 0 0; background:linear-gradient(180deg,#1c1e22,#0d0e11);
border-bottom:1px solid #04060a; box-shadow:inset 0 -6px 12px rgba(0,0,0,.5) }
.jk{ flex:1; display:flex; flex-direction:column; align-items:center; gap:4px }
.jk i{ width:17px; height:17px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
border:2px solid #5b6470; box-shadow:inset 0 0 4px #000 }
.jk.usb i{ width:19px; height:8px; border-radius:3px; border:2px solid #5b6470; background:#07090c }
.jk b{ font-size:6.5px; font-weight:700; color:var(--silk); letter-spacing:.03em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.2 }
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:13px 16px 10px }
.dev-logo{ height:13px }
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
.pwr{ display:flex; align-items:center; gap:5px; font-size:7.5px; color:var(--silk); text-transform:uppercase; letter-spacing:.12em; opacity:.85 }
.pwr .dot{ width:6px; height:6px; border-radius:50%; background:#2fe07a; box-shadow:0 0 6px #2fe07a }
/* small angled TFT */
.tft-wrap{ margin:0 16px; padding:7px; border-radius:9px; background:linear-gradient(180deg,#0b0d11,#05070a);
border:1px solid #04060a; box-shadow:inset 0 2px 8px rgba(0,0,0,.7);
transform:perspective(440px) rotateX(7deg) }
#tft{ display:block; width:100%; height:96px; border-radius:5px; background:#06080c }
/* big floor-readable RGB beat light (dome LED) */
.beat-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px; margin:16px 0 4px }
.beatlight{ width:74px; height:74px; border-radius:50%; position:relative;
background:radial-gradient(circle at 42% 36%, #20242b, #0a0c10 72%);
border:3px solid #2a2f37; box-shadow:0 3px 8px rgba(0,0,0,.55), inset 0 2px 5px rgba(255,255,255,.06) }
.beatlight::after{ content:""; position:absolute; inset:11px; border-radius:50%;
background:var(--bc,#0c0f14); box-shadow:0 0 var(--bg-glow,0) var(--bc,#0c0f14); transition:none }
.beat-cap{ font-size:7.5px; color:var(--silk); letter-spacing:.16em; text-transform:uppercase; opacity:.8 }
/* expression-pedal stand-in: a rocker that sweeps tempo */
.exp{ margin:14px 18px 4px; display:flex; align-items:center; gap:10px }
.exp label{ font-size:7.5px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85; white-space:nowrap }
.exp input[type=range]{ flex:1; -webkit-appearance:none; appearance:none; height:8px; border-radius:5px; outline:none;
background:linear-gradient(90deg,#1b2733,#33424f); border:1px solid #04060a }
.exp input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:26px; height:18px; border-radius:4px; cursor:ew-resize;
background:linear-gradient(180deg,#444c57,#222730); border:1px solid #565f6c; box-shadow:0 2px 4px rgba(0,0,0,.5) }
.exp input[type=range]::-moz-range-thumb{ width:26px; height:18px; border-radius:4px; cursor:ew-resize;
background:linear-gradient(180deg,#444c57,#222730); border:1px solid #565f6c }
/* two heavy footswitches */
.switches{ display:flex; justify-content:space-around; gap:18px; margin:14px 14px 2px }
.fsw{ flex:1; display:flex; flex-direction:column; align-items:center; gap:8px }
.stomp{ width:78px; height:78px; border-radius:50%; cursor:pointer; position:relative; border:0; padding:0; touch-action:none;
background:radial-gradient(circle at 38% 30%, #eef2f6, #aab2bc 40%, #6c7480 70%, #3b424c 100%);
box-shadow:0 6px 10px rgba(0,0,0,.55), inset 0 -3px 6px rgba(0,0,0,.4), inset 0 3px 5px rgba(255,255,255,.5) }
.stomp::after{ content:""; position:absolute; inset:18px; border-radius:50%;
background:radial-gradient(circle at 40% 34%, #d7dde3, #8b939e 70%, #5a626c 100%);
box-shadow:inset 0 2px 4px rgba(255,255,255,.5), inset 0 -3px 6px rgba(0,0,0,.4) }
.stomp.down{ transform:translateY(3px); box-shadow:0 2px 4px rgba(0,0,0,.55), inset 0 -2px 4px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.4) }
.fsw b{ font-size:9px; font-weight:800; color:var(--silk); letter-spacing:.12em; text-transform:uppercase }
.fsw small{ font-size:7px; color:var(--muted); letter-spacing:.04em; text-align:center; line-height:1.3 }
.hint{ max-width:340px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
[data-embed] .hint{ display:none !important }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<h1 class="ff-title">PM_S1 Stage</h1>
<p class="ff-sum">Footpedal stompbox for the stage — handsfree with two footswitches and an expression pedal, a big floorreadable RGB beat light, instrument passthrough with the click mixed in.</p>
<div class="device">
<!-- top edge: all jacks, including dual USB-C daisy-chain power -->
<div class="edge">
<div class="jk" title="External trigger / aux footswitch in"><i></i><b>Trig</b></div>
<div class="jk" title="Instrument in — 1/4&quot; pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
<div class="jk" title="Main out — 1/4&quot; balanced TRS (instrument + click)"><i></i><b>Out TRS</b></div>
<div class="jk" title="Expression-pedal input — sweep tempo with your foot"><i></i><b>Exp</b></div>
<div class="jk usb" title="USB-C — power + data (config / firmware)"><i></i><b>USBC</b></div>
<div class="jk usb" title="USB-C — power thru (daisy-chain the next pedal)"><i></i><b>USBC thru</b></div>
</div>
<div class="brandrow">
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="dev-tag">Simplifying Complexity</span></span><span class="model">PM_S1 Stage</span></div>
<div class="pwr"><span class="dot"></span>USBC&nbsp;PWR</div>
</div>
<div class="tft-wrap"><canvas id="tft" width="300" height="96" aria-label="tempo / item display"></canvas></div>
<div class="beat-wrap">
<div class="beatlight" id="beat"></div>
<div class="beat-cap">Beat</div>
</div>
<div class="exp">
<label for="expPedal">Exp&nbsp;pedal<br>(tempo)</label>
<input type="range" id="expPedal" min="40" max="240" value="120" title="External expression pedal → tempo sweep">
</div>
<div class="switches">
<div class="fsw">
<button class="stomp" id="swTap" title="Tap to set tempo · hold to start/stop"></button>
<b>Tap</b><small>tap = tempo<br>hold = start/stop</small>
</div>
<div class="fsw">
<button class="stomp" id="swNext" title="Next item · hold for previous"></button>
<b>Next</b><small>tap = next<br>hold = previous</small>
</div>
</div>
</div>
<div class="hint">Stomp <b>Tap</b> to set tempo (hold = start/stop) · <b>Next</b> to change item (hold = previous) ·
an expression pedal sweeps tempo. Instrument passes through with the click mixed in (analog).</div>
/*@BUILD:include:src/progbox.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {};
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state = { bpm:120, volume:0.85, running:false };
let meters = [], muteWindows = [];
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
function scheduler(){
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
}
function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; }
beatCount=-1; lastBeatTime=t0; muteWindows=[];
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS (seed grooves, flattened) =================== */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
let trackIdx = 0;
function tracksFromHash(){
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
}catch(e){ return null; }
}
function loadTrack(i){
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
const t=tracks[trackIdx]; setBpm(t.bpm||120); $("expPedal").value=state.bpm; meters=buildMeters(t.lanes);
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio();
flashName=performance.now();
}
/* ========================= DISPLAY (TFT) + BEAT LIGHT ======================== */
const tft=$("tft"), tc=tft.getContext("2d"), TW=300, TH=96;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); tft.width=TW*dpr; tft.height=TH*dpr; tc.scale(dpr,dpr); })();
let beatCount=-1, lastBeatTime=0, flash=0, flashAccent=false, flashName=0;
function masterLane(){ return meters[0]; }
function drawTFT(){
tc.fillStyle="#06080c"; tc.fillRect(0,0,TW,TH);
// tempo
tc.fillStyle="#eaf6ff"; tc.font="700 40px 'Segoe UI',Roboto,Arial,sans-serif"; tc.textBaseline="alphabetic";
tc.fillText(String(state.bpm), 12, 50);
tc.fillStyle="#5b86a3"; tc.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif"; tc.fillText("BPM", 14, 64);
// running state
tc.fillStyle=state.running?"#2fe07a":"#7f8b9a"; tc.font="700 11px 'Segoe UI',Roboto,Arial,sans-serif";
tc.textAlign="right"; tc.fillText(state.running?"▶ RUN":"■ STOP", TW-12, 22); tc.textAlign="left";
// item name
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
tc.fillStyle="#c7d0db"; tc.font="600 13px 'Segoe UI',Roboto,Arial,sans-serif";
tc.fillText(nm.length>22?nm.slice(0,21)+"…":nm, 12, 84);
// beat dots (master lane)
const m=masterLane();
if(m){ const bpb=m.beatsPerBar, r=4, gap=13, x0=TW-12-(bpb-1)*gap, y=46;
const curBeat = m.currentStep>=0 ? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
for(let i=0;i<bpb;i++){ const on = state.running && i===curBeat;
tc.beginPath(); tc.arc(x0+i*gap, y, r, 0, 7);
tc.fillStyle = on ? (m.groupStarts.has(i)?"#ff9b2e":"#33d0ff") : "#243240"; tc.fill(); } }
}
function setBeatLight(){
const el=$("beat"), c=flashAccent?"#ff9b2e":"#33d0ff";
const lit = Math.max(0, flash);
el.style.setProperty("--bc", lit>0.02 ? c : "#0c0f14");
el.style.setProperty("--bg-glow", (10+lit*34).toFixed(0)+"px");
el.style.filter = "brightness("+(1+lit*1.3)+")";
}
// Visuals follow the SAME clock the audio is scheduled on, but compensated for
// output latency so the on-screen pulse lands when the click is *heard* (not when
// it's queued). Without this the visual leads the sound by the output buffer /
// Bluetooth latency — up to a full subdivision on high-latency outputs.
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
function draw(){
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
if(audioCtx && state.running){
for(const m of meters){ while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
if(m===meters[0] && e.step % m.stepsPerBeat === 0){ // a beat on the master lane
beatCount++; lastBeatTime=e.time;
const lvl=m.beatsOn[e.step]|0; flashAccent = lvl>=2 || m.groupStarts.has(e.step/m.stepsPerBeat);
if(lvl!==0) flash=1; // don't flash on a muted beat
}
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; } // bound the visual queue
}
}
flash = Math.max(0, flash - 0.085); // decay
drawTFT(); setBeatLight();
requestAnimationFrame(draw);
}
/* ========================= FOOTSWITCHES + EXP PEDAL ========================== */
function holdSwitch(el, onTap, onHold){
let t=null, held=false;
el.addEventListener("pointerdown",(e)=>{ e.preventDefault(); try{el.setPointerCapture(e.pointerId);}catch(_){}
held=false; el.classList.add("down"); t=setTimeout(()=>{ held=true; onHold&&onHold(); }, 480); });
const end=(fire)=>{ if(t){clearTimeout(t); t=null;} el.classList.remove("down"); if(fire && !held) onTap&&onTap(); held=false; };
el.addEventListener("pointerup",()=>end(true));
el.addEventListener("pointercancel",()=>end(false));
}
let taps=[];
function tapTempo(){ const now=performance.now(); taps.push(now); taps=taps.filter(x=>now-x<2400);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1];
const bpm=Math.round(60000/(s/(taps.length-1))); if(bpm>=30&&bpm<=300){ setBpm(bpm); $("expPedal").value=state.bpm; } } }
holdSwitch($("swTap"), ()=>tapTempo(), ()=>toggle());
holdSwitch($("swNext"), ()=>loadTrack(trackIdx+1), ()=>loadTrack(trackIdx-1));
$("expPedal").addEventListener("input", (e)=>{ setBpm(+e.target.value); });
/* theme toggle (shared "metronome.theme") */
/*@BUILD:include:src/chrome.js@*/
addEventListener("keydown",(e)=>{
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
if(e.key===" "){ e.preventDefault(); toggle(); }
else if(e.key==="t"||e.key==="T"){ tapTempo(); }
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
});
/* ========================= INIT ============================================== */
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
loadTrack(0);
requestAnimationFrame(draw);
window.currentProgramString = function(){ var t=tracks[trackIdx]||{}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
window.loadProgramString = function(plain){ var s=patchToSetup(plain); tracks=[{name:"Program", ...s}]; trackIdx=0; loadTrack(0); };
/*@BUILD:include:src/progbox.js@*/
</script>
<label class="info-toggle pageonly"><input type="checkbox" id="infoToggle"> Show technical info (dimensions, BOM, embedding)</label>
<div id="techinfo" class="pageonly" hidden>
<section class="about">
<h2>PM_S1 — Stage</h2>
<div class="ff-tags"><span class="hw">Hardware</span><span>Footpedal stompbox</span><span>~$52 oneoff</span></div>
<p>A footoperated polymeter stompbox for the stage: drive it handsfree with two heavy footswitches and an
expression pedal, read it off the floor from the big RGB beat light, and run your instrument through it with
the click mixed in. (For a desk/lesson unit with a full screen, see the <a href="/teacher.html">Teacher</a>.)</p>
<p>The controls are built for feet: the <b>left footswitch</b> taps tempo (hold to start/stop), the <b>right</b>
steps through your set list (hold for previous), and a <b>1/4″ expressionpedal input</b> sweeps tempo on the
fly. Your instrument passes through (1/4″ in) with the click summed in the <b>analog domain</b> and sent to a
balanced 1/4″ TRS out. Powered over USBC — with a second USBC <b>"thru"</b> port so several pedals
daisychain off one charger or power bank.</p>
</section>
<div class="dview">
<p class="cap">Dimensions &amp; layout — ≈ 4.7 × 3.7 × 1.5 in (120 × 93 × 38 mm), a 1590BBstyle stompbox</p>
<div class="drow">
<div class="dvy">↕ 3.7 in (93 mm)</div>
<div class="dschem" style="height:150px">
<span class="scap">Front</span>
<div class="scr" style="left:18%; right:18%; top:22px; height:34px"></div>
<div class="ctl" style="left:calc(50% - 15px); top:64px; width:30px; height:30px"></div>
<div class="ctl" style="left:20%; top:100px; width:34px; height:34px"></div>
<div class="ctl" style="left:calc(80% - 34px); top:100px; width:34px; height:34px"></div>
<div class="jl" style="left:0; right:0; bottom:5px">angled TFT · RGB beat light · Tap + Next footswitches</div>
</div>
</div>
<div class="dvx">↔ 4.7 in (120 mm) wide</div>
<div class="drow" style="margin-top:12px">
<div class="dvy">↕ 1.5 in (38 mm)</div>
<div class="dschem" style="height:56px">
<span class="scap">Top edge — I/O</span>
<div class="jk" style="left:7%; top:18px"></div><div class="jk" style="left:22%; top:18px"></div>
<div class="jk" style="left:37%; top:18px"></div><div class="jk" style="left:52%; top:18px"></div>
<div class="jk u" style="left:68%; top:21px"></div><div class="jk u" style="left:83%; top:21px"></div>
<div class="jl" style="left:0; right:0; bottom:4px">Trig · Inst In · Out TRS · Exp · USBC · USBC thru</div>
</div>
</div>
<div class="dvx">↔ 4.7 in (120 mm)</div>
</div>
<details class="spec pageonly">
<summary>Spec &amp; bill of materials</summary>
<div class="spec-body">
<p class="sub">Rough parts list — a footoperated RP2040 stompbox (USBC, dualport) with analog click injection.
Ballpark oneoff prices (USD); cheaper at volume.</p>
<table class="bom">
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
<tbody>
<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</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr><td class="part">1.3″ IPS TFT, ST7789 <span class="spec">— SPI; angled BPM / item readout</span></td><td class="q">1</td><td class="c">6</td></tr>
<tr><td class="part">Highbright diffused RGB beat indicator <span class="spec">— floorreadable</span></td><td class="q">1</td><td class="c">1</td></tr>
<tr class="grp"><td colspan="3">Controls</td></tr>
<tr><td class="part">Heavyduty momentary footswitch (softtouch) <span class="spec">— Tap · Next</span></td><td class="q">2</td><td class="c">6</td></tr>
<tr><td class="part">1/4″ expressionpedal input jack (TRS) <span class="spec">— tempo sweep</span></td><td class="q">1</td><td class="c">1</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">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">Balanced line driver, DRV134 <span class="spec">— → 1/4″ TRS out</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><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">2× USBC (data+power &amp; powerthru) + powerpath/protection + PWR LED <span class="spec">— daisychain pedals</span></td><td class="q">1</td><td class="c">3</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">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">Diecast aluminium stompbox (Hammond 1590BBstyle) <span class="spec">— beadblasted, matteblack Type II anodise, laseretched</span></td><td class="q">1</td><td class="c">12</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $52</td></tr>
</tbody>
</table>
<p class="sub" style="margin-top:12px">No builtin speaker — the Stage feeds your amp / PA. The click is summed in
the <b>analog domain</b> (hiZ instrument buffer + DAC → balanced line driver), so your instrument is never
redigitised (no added latency).</p>
</div>
</details>
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code>&lt;div&gt;</code> + a script —
see <a href="/embed.html">the embed docs</a>.</p>
</div><!-- /#techinfo -->
/*@BUILD:include:src/footer.html@*/
</body>
</html>