- Remove the VCSL sample kit entirely (editor 351K → 141K). All voices are synthesized; the friendly GM names now alias to the punchier 808/909 renders (KIT_ALIAS). build.sh drops the @BUILD:samples inlining; assets/samples.json gone. - Conventions (backward-compatible): GM note-number aliases (36=kick…), '-'/'_' rest aliases in step patterns, Euclidean (k,n[,rot]) shorthand. - Per-lane gain in dB (@<db> in the grammar) applied as a velocity multiplier at schedule time — no stutter; threaded through every host's buildMeters + the editor's lanes (knob UI comes in Phase B). - 15/15 engine round-trip tests pass; pages console-clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
358 lines
23 KiB
HTML
358 lines
23 KiB
HTML
<!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‑µ — micro (home practice)</title>
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||
<script>
|
||
/* ?embed=1 → strip site chrome (base.css [data-embed]) + 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>
|
||
<!--
|
||
"Micro" — a long, narrow INLINE practice bar on the same RP2040 firmware.
|
||
Patch your instrument through it: 1/4" TRS in on one end; USB-C + 1/4" TRS out
|
||
on the other; powered over USB-C (wall adapter or power bank — no internal
|
||
battery). The click is summed into your signal in the ANALOG domain (and a
|
||
small speaker). Display is a 4-char amber 14-segment
|
||
(shows BPM *and* short track names). One control — a clickable thumb-ROLLER:
|
||
• roll → tempo
|
||
• press (click) → start / stop
|
||
• hold + roll → switch track (the display shows the track name)
|
||
Built-in tracks are the editor's seed grooves, flattened. Shares src/engine.js.
|
||
-->
|
||
<script>
|
||
// Set theme before first paint (shared "metronome.theme" with the other pages).
|
||
(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; --dmuted:#5a626c;
|
||
--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:30px 14px 44px;
|
||
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) }
|
||
.topbar{ width:100%; max-width:330px; display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; color:var(--muted); flex-wrap:wrap }
|
||
.topbar b{ color:var(--txt) }
|
||
.topbar-right{ display:flex; align-items:center; gap:10px }
|
||
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px; padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
|
||
.tbtn:hover{ color:var(--txt) }
|
||
|
||
/* ---- the micro device: a long, narrow brushed-aluminium bar ----
|
||
The main body and the two end caps are SEPARATE pieces with a gap between
|
||
them, so the jacks read as being on the ENDS of the bar (not the front). */
|
||
.device{ width:100%; max-width:660px; display:flex; align-items:center; gap:15px; position:relative }
|
||
|
||
/* end caps — the extrusion's end faces, stood apart from the body; jacks exit here.
|
||
A touch shorter than the body (align-self:stretch + margin) so they look set-back. */
|
||
.endcap{ flex:0 0 auto; width:66px; align-self:stretch; margin:9px 0;
|
||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:11px;
|
||
padding:12px 7px; border-radius:10px; border:1px solid #292b30;
|
||
background:linear-gradient(180deg,#1b1d21,#0a0b0e); /* darker end-grain */
|
||
box-shadow:0 12px 24px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04), inset 0 -2px 6px rgba(0,0,0,.55) }
|
||
.endcap.left{ border-right-color:#3b3e45 } /* chamfer highlight on the edge facing the body */
|
||
.endcap.right{ border-left-color:#3b3e45 }
|
||
.endlbl{ font-size:7px; color:var(--silk); text-transform:uppercase; letter-spacing:.1em; text-align:center; line-height:1.35; opacity:.8 }
|
||
.jk{ display:flex; flex-direction:column; align-items:center; gap:4px }
|
||
.jk i{ width:23px; height:23px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
|
||
border:2px solid #5b6470; box-shadow:inset 0 0 5px #000 }
|
||
.jk.usb i{ width:25px; height:10px; border-radius:4px; border:2px solid #5b6470; background:#07090c }
|
||
.jk b{ font-size:7px; font-weight:700; color:var(--silk); letter-spacing:.05em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.25 }
|
||
|
||
/* the main body / top face (between the end caps) */
|
||
.face{ flex:1; min-width:0; display:flex; flex-direction:column; padding:11px 16px; gap:8px;
|
||
border-radius:16px; border:1px solid var(--device-bd);
|
||
background:
|
||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px, /* bead-blast micro-texture */
|
||
linear-gradient(180deg, #2b2d33, #161719); /* matte anodised graphite */
|
||
box-shadow:0 24px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.5) }
|
||
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:0 }
|
||
.brand-logo{ height:13px; width:auto; display:block }
|
||
.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 }
|
||
.meta{ display:flex; align-items:center; gap:12px }
|
||
.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 }
|
||
|
||
.facemain{ display:flex; align-items:center; gap:14px }
|
||
/* ---- amber 14-segment alphanumeric display ---- */
|
||
.led-win{ flex:1; min-width:0; background:#140a02; border:2px solid #050100; border-radius:8px; padding:6px 12px;
|
||
box-shadow:inset 0 0 16px rgba(0,0,0,.85), inset 0 0 8px rgba(255,150,30,.10), 0 1px 0 rgba(255,255,255,.25) }
|
||
#led{ display:block; width:100%; max-width:236px; height:58px; margin:0 auto }
|
||
/* ---- recessed clickable thumb-roller (tempo) ---- */
|
||
.rollwrap{ display:flex; flex-direction:column; align-items:center; gap:3px }
|
||
.roller{ width:92px; height:46px; border-radius:9px; position:relative; cursor:ew-resize; overflow:hidden; touch-action:none;
|
||
background:#0a0d12; border:1px solid #04060a; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 2px 5px rgba(0,0,0,.5) }
|
||
.roller::before{ content:""; position:absolute; inset:4px 3px; border-radius:5px; /* ribbed cylinder, scrolls via --rib */
|
||
background:repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 1px, rgba(0,0,0,.5) 1px 5px); background-position:var(--rib,0px) 0 }
|
||
.roller::after{ content:""; position:absolute; inset:0; border-radius:9px; pointer-events:none; /* cylinder sheen: bright centre, dark edges */
|
||
background:linear-gradient(90deg, rgba(0,0,0,.72) 0%, rgba(255,255,255,.13) 49%, rgba(255,255,255,.13) 51%, rgba(0,0,0,.72) 100%) }
|
||
.roller.press{ filter:brightness(.9); box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 1px 2px rgba(0,0,0,.6) }
|
||
.roll-cap{ font-size:7px; color:var(--silk); letter-spacing:.16em; text-transform:uppercase; opacity:.8 }
|
||
|
||
/* speaker grille + status indicators along the bottom of the face */
|
||
.facebot{ display:flex; align-items:center; gap:12px }
|
||
.grille{ flex:1; height:9px; border-radius:5px; background:radial-gradient(circle, #000 1px, transparent 1.3px) 0 0/7px 7px; opacity:.45 }
|
||
.inds{ display:flex; gap:11px }
|
||
.ind{ display:flex; align-items:center; gap:4px; font-size:7.5px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
|
||
.ind .d{ width:6px; height:6px; border-radius:50%; background:#3a2306; box-shadow:inset 0 0 2px #000; transition:background .08s, box-shadow .08s }
|
||
.ind.on .d{ background:#ff8a1e; box-shadow:0 0 6px #ff8a1e }
|
||
.ind.play.on .d{ background:#2fe07a; box-shadow:0 0 6px #2fe07a }
|
||
|
||
.hint{ max-width:560px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||
/* embed mode: just the device */
|
||
[data-embed] .hint { display:none !important; }
|
||
/* stack the bar's end caps under the face on very narrow screens */
|
||
@media (max-width:460px){ .device{ flex-wrap:wrap; gap:10px }
|
||
.endcap{ width:calc(50% - 5px); align-self:auto; margin:0; flex-direction:row; gap:18px; justify-content:center }
|
||
.face{ flex-basis:100%; order:-1 } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
/*@BUILD:include:src/header.html@*/
|
||
|
||
<div class="device">
|
||
<!-- LEFT END: instrument / aux in -->
|
||
<div class="endcap left">
|
||
<div class="jk" title="1/4" TRS input — plug your instrument (or an aux source) in; the click is mixed into it"><i></i><b>TRS In</b></div>
|
||
<div class="endlbl">Inst /<br>aux in</div>
|
||
</div>
|
||
|
||
<!-- TOP FACE: display + roller + speaker -->
|
||
<div class="face">
|
||
<div class="brandrow">
|
||
<div class="silk"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="model">PM‑µ Micro</span></div>
|
||
<div class="meta">
|
||
<div class="pwr" title="Powered over USB‑C — wall adapter or power bank"><span class="dot"></span>USB‑C PWR</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="facemain">
|
||
<div class="led-win"><canvas id="led" width="236" height="58" aria-label="14-segment tempo / track display"></canvas></div>
|
||
<div class="rollwrap">
|
||
<div class="roller" id="enc" title="Roll = tempo · press = start/stop · hold + roll = switch track"></div>
|
||
<div class="roll-cap">Tempo</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="facebot">
|
||
<div class="grille" title="monitor speaker"></div>
|
||
<div class="inds">
|
||
<div class="ind on" id="indBpm"><span class="d"></span>BPM</div>
|
||
<div class="ind" id="indTrk"><span class="d"></span>Trk</div>
|
||
<div class="ind play" id="indPlay"><span class="d"></span>▶</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT END: power + output -->
|
||
<div class="endcap right">
|
||
<div class="jk usb" title="USB‑C — power (5 V) & set-list transfer"><i></i><b>USB‑C</b></div>
|
||
<div class="jk" title="1/4" TRS output — instrument + click, to your amp, headphones or the desk"><i></i><b>TRS Out</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hint">Roll = <b>tempo</b> · press = <b>start / stop</b> · hold & roll = <b>switch track</b>.
|
||
Instrument in one end, amp/headphones out the other — the click is mixed into your signal in the analog domain.</div>
|
||
|
||
<script>
|
||
const APP_VERSION = "v0.0.1-dev";
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||
const SAMPLES = {}; // synth-only
|
||
/*@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))); }
|
||
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; }
|
||
muteWindows=[]; schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); render();
|
||
}
|
||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; render(); }
|
||
function toggle(){ state.running ? stopAudio() : startAudio(); }
|
||
|
||
/* ========================= TRACKS (built-in seed grooves, flattened) ========= */
|
||
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||
let trackIdx=0, previewIdx=0;
|
||
// preload from a share link / embed config string (#p=<patch> or #sl=<set-list code>)
|
||
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; previewIdx=trackIdx;
|
||
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes); // ramps/bars ignored — a steady practice loop
|
||
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||
if(was) startAudio(); else render();
|
||
}
|
||
|
||
/* ========================= 14-SEGMENT DISPLAY ================================ */
|
||
const led=$("led"), lc=led.getContext("2d"), NCH=4, LW=236, LH=58;
|
||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); led.width=LW*dpr; led.height=LH*dpr; lc.scale(dpr,dpr); })();
|
||
const LED_ON="#ff8a1e", LED_OFF="#1d1004", LED_BG="#120802"; // OFF kept very dim so the lit digits read clearly
|
||
// 14-seg font (Adafruit bit order): 0=A 1=B 2=C 3=D 4=E 5=F 6=G1 7=G2 8=H 9=I 10=J 11=K 12=L 13=M
|
||
const SEG14={ " ":0x0000,"-":0x00C0,
|
||
"0":0x0C3F,"1":0x0006,"2":0x00DB,"3":0x008F,"4":0x00E6,"5":0x2069,"6":0x00FD,"7":0x0007,"8":0x00FF,"9":0x00EF,
|
||
"A":0x00F7,"B":0x128F,"C":0x0039,"D":0x120F,"E":0x00F9,"F":0x0071,"G":0x00BD,"H":0x00F6,"I":0x1209,"J":0x001E,
|
||
"K":0x2470,"L":0x0038,"M":0x0536,"N":0x2136,"O":0x003F,"P":0x00F3,"Q":0x203F,"R":0x20F3,"S":0x00ED,"T":0x1201,
|
||
"U":0x003E,"V":0x0C30,"W":0x2836,"X":0x2D00,"Y":0x1500,"Z":0x0C09 };
|
||
let displayMode="bpm";
|
||
function trackName(i){ const raw=(tracks[i]&&tracks[i].name)||("TR"+(i+1));
|
||
return (raw.replace(/[^A-Za-z0-9]/g,"").toUpperCase().slice(0,NCH)) || ("T"+(i+1)); }
|
||
function ledText(){ return (displayMode==="track" ? trackName(previewIdx) : String(state.bpm)).padStart(NCH," "); }
|
||
function drawChar(dx,dy,w,h,ch){
|
||
const m=SEG14[ch]!=null?SEG14[ch]:0, t=Math.max(2.5,w*0.13), g=Math.max(1.5,t*0.5),
|
||
cx=dx+w/2, midY=dy+h/2, vH=h/2-t-g;
|
||
const bar=(b,x,y,ww,hh)=>{ if((m>>b)&1){ lc.fillStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=6; } else { lc.fillStyle=LED_OFF; lc.shadowBlur=0; }
|
||
lc.fillRect(x,y,ww,hh); lc.shadowBlur=0; };
|
||
const diag=(b,x1,y1,x2,y2)=>{ lc.lineCap="round"; lc.lineWidth=t*0.82;
|
||
if((m>>b)&1){ lc.strokeStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=6; } else { lc.strokeStyle=LED_OFF; lc.shadowBlur=0; }
|
||
lc.beginPath(); lc.moveTo(x1,y1); lc.lineTo(x2,y2); lc.stroke(); lc.shadowBlur=0; };
|
||
bar(0, dx+t, dy, w-2*t, t); // A top
|
||
bar(5, dx, dy+t, t, vH); // F upper-left
|
||
bar(1, dx+w-t, dy+t, t, vH); // B upper-right
|
||
bar(9, cx-t/2, dy+t, t, vH); // I centre-upper
|
||
bar(6, dx+t, midY-t/2, w/2-t-g, t); // G1 mid-left
|
||
bar(7, cx+g, midY-t/2, w/2-t-g, t); // G2 mid-right
|
||
bar(4, dx, midY+g, t, vH); // E lower-left
|
||
bar(2, dx+w-t, midY+g, t, vH); // C lower-right
|
||
bar(12, cx-t/2, midY+g, t, vH); // L centre-lower
|
||
bar(3, dx+t, dy+h-t, w-2*t, t); // D bottom
|
||
diag(8, dx+t+1, dy+t+1, cx-t*0.6, midY-t*0.6); // H top-left
|
||
diag(10, dx+w-t-1, dy+t+1, cx+t*0.6, midY-t*0.6); // J top-right
|
||
diag(11, dx+t+1, dy+h-t-1, cx-t*0.6, midY+t*0.6); // K bottom-left
|
||
diag(13, dx+w-t-1, dy+h-t-1, cx+t*0.6, midY+t*0.6); // M bottom-right
|
||
}
|
||
function drawLED(){
|
||
lc.fillStyle=LED_BG; lc.fillRect(0,0,LW,LH);
|
||
const txt=ledText(), pad=10, gap=9, n=NCH, dw=(LW-2*pad-(n-1)*gap)/n, dh=LH-2*pad;
|
||
for(let i=0;i<n;i++) drawChar(pad+i*(dw+gap), pad, dw, dh, txt[i]||" ");
|
||
}
|
||
function render(){
|
||
drawLED();
|
||
$("indBpm").classList.toggle("on", displayMode==="bpm");
|
||
$("indTrk").classList.toggle("on", displayMode==="track");
|
||
$("indPlay").classList.toggle("on", state.running);
|
||
}
|
||
|
||
/* ========================= ROLLER (the only control) ========================= */
|
||
let rollPos=0;
|
||
function nudge(d){ setBpm(state.bpm+d); displayMode="bpm"; rollPos+=d*5; $("enc").style.setProperty("--rib", rollPos+"px"); render(); }
|
||
let revertT=null;
|
||
function previewTrack(d){ previewIdx=((previewIdx+d)%tracks.length+tracks.length)%tracks.length; displayMode="track"; render(); }
|
||
function commitTrack(){ loadTrack(previewIdx); displayMode="track"; render(); clearTimeout(revertT); revertT=setTimeout(()=>{ displayMode="bpm"; render(); }, 1100); }
|
||
|
||
/* roll = tempo · quick press = start/stop · hold (~350ms) then roll = switch track */
|
||
(function(){
|
||
const k=$("enc"); let down=false, moved=false, held=false, lastX=0, acc=0, holdT=null;
|
||
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
|
||
k.addEventListener("pointerdown",(e)=>{ down=true; moved=false; held=false; lastX=e.clientX; acc=0; previewIdx=trackIdx;
|
||
k.classList.add("press"); k.setPointerCapture(e.pointerId);
|
||
holdT=setTimeout(()=>{ if(down && !moved){ held=true; displayMode="track"; render(); } }, 350); });
|
||
k.addEventListener("pointermove",(e)=>{ if(!down) return; acc += e.clientX - lastX; lastX=e.clientX;
|
||
if(held){ while(Math.abs(acc)>=12){ const d=acc>0?1:-1; acc-=d*12; moved=true; previewTrack(d); } } // hold + roll → track
|
||
else { while(Math.abs(acc)>=6){ const d=acc>0?1:-1; acc-=d*6; moved=true; clearTimeout(holdT); nudge(d); } } }); // roll → tempo
|
||
k.addEventListener("pointerup",()=>{ if(!down) return; down=false; clearTimeout(holdT); k.classList.remove("press");
|
||
if(held){ if(moved) commitTrack(); else { displayMode="bpm"; render(); } }
|
||
else if(!moved){ toggle(); } }); // quick press → start/stop
|
||
k.addEventListener("pointercancel",()=>{ down=false; clearTimeout(holdT); k.classList.remove("press"); });
|
||
})();
|
||
|
||
/* theme toggle — cycles system → light → dark; shares the "metronome.theme" key */
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
|
||
addEventListener("keydown",(e)=>{
|
||
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
|
||
const k=e.key;
|
||
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
|
||
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
|
||
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
|
||
else if(k==="ArrowRight"){ e.preventDefault(); previewIdx=trackIdx+1; commitTrack(); }
|
||
else if(k==="ArrowLeft"){ e.preventDefault(); previewIdx=trackIdx-1; commitTrack(); }
|
||
});
|
||
|
||
/* ========================= INIT ============================================== */
|
||
{ const ht=tracksFromHash(); if(ht) tracks=ht; } // a #p=/#sl= link (or embed config) overrides the built-ins
|
||
loadTrack(0);
|
||
render();
|
||
</script>
|
||
|
||
<section class="about pageonly">
|
||
<h2>PM‑µ — Micro</h2>
|
||
<div class="ff-tags"><span class="hw">Hardware</span><span>Inline practice bar</span><span>~$35 one‑off</span></div>
|
||
<p>A long, narrow practice bar you patch <i>into</i> your signal: instrument in one end, amp or headphones out
|
||
the other, the click mixed in. One clickable thumb‑roller does everything (roll = tempo, press = start/stop,
|
||
hold + roll = switch track), and an amber 14‑segment display shows tempo and track names.</p>
|
||
<p>The click is summed into your signal in the <b>analog domain</b> (plus a small monitor speaker). Powered over
|
||
USB‑C — a wall adapter for a permanent practice‑space install, or a pocket power bank when you're mobile (no
|
||
internal battery to wear out); ships with the editor's grooves built in.</p>
|
||
</section>
|
||
|
||
<details class="spec pageonly">
|
||
<summary>Spec & bill of materials</summary>
|
||
<div class="spec-body">
|
||
<p class="sub">Rough parts list — a USB‑C‑powered RP2040 inline bar with analog click injection.
|
||
Ballpark one‑off 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 & display</td></tr>
|
||
<tr><td class="part">RP2040 board, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||
<tr><td class="part">4‑char 14‑segment alphanumeric LED + I²C driver <span class="spec">— amber; HT16K33. Shows BPM & track names</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||
<tr class="grp"><td colspan="3">Control</td></tr>
|
||
<tr><td class="part">Clickable thumb‑roller <span class="spec">— EC11 encoder + roller wheel · roll / press / hold‑roll</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><td class="part">PCM5102A I²S DAC <span class="spec">— line‑level click</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||
<tr><td class="part">Dual op‑amp, NE5532 / OPA2134 <span class="spec">— hi‑Z instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr>
|
||
<tr><td class="part">PAM8302A mono Class‑D + 8 Ω 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 & power</td></tr>
|
||
<tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS)</span></td><td class="q">2</td><td class="c">2</td></tr>
|
||
<tr><td class="part">USB‑C bus power (5 V) + PWR LED <span class="spec">— wall adapter or power bank; also carries config</span></td><td class="q">1</td><td class="c">1</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">4</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">2</td></tr>
|
||
<tr><td class="part">Extruded aluminium bar enclosure + end caps <span class="spec">— bead‑blasted, matte‑black anodised</span></td><td class="q">1</td><td class="c">8</td></tr>
|
||
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $35</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
|
||
/*@BUILD:include:src/footer.html@*/
|
||
</body>
|
||
</html>
|