- Rename player-asbuilt.html → stage.html (the pedalboard build). Update build.sh + deploy.sh (deploy now also removes the old player-asbuilt.html from the web root) and the cross-links in player.html / stage.html. - New /micro.html — a stripped-down home-practice metronome on the same RP2040 firmware. Hardware is just: ONE depressable scroll/rotary encoder, a red 7-segment LED display, a speaker, and USB-C for power. The encoder does everything: spin = tempo, press = start/stop, hold + spin = switch track (the LED shows the track number, with BPM / TRACK / ▶ indicators). Tracks = the editor's seed grooves flattened (23). Shares src/engine.js, setlists.js, base.css; synth-only; steady practice loop (ramps/bars ignored). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
14 KiB
HTML
241 lines
14 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@">
|
||
<!--
|
||
"Micro" — a stripped-down home-practice metronome on the same RP2040 firmware.
|
||
Hardware is just: ONE depressable scroll/rotary encoder (tempo), a red 7-seg
|
||
LED BPM display, a small speaker, and a USB-C port for power. No screen, no
|
||
buttons. Interaction lives entirely in the encoder:
|
||
• spin → tempo
|
||
• press (click) → start / stop
|
||
• hold + spin → switch track (the LED shows the track number)
|
||
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:#5a616b; --silk:#2f343b; --dmuted:#3f444b;
|
||
--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: small brushed-aluminium box ---- */
|
||
.device{ width:100%; max-width:330px; position:relative; border-radius:13px; padding:16px 16px 14px;
|
||
background:
|
||
repeating-linear-gradient(90deg, rgba(255,255,255,.07) 0 1px, rgba(0,0,0,.05) 1px 3px),
|
||
linear-gradient(158deg, #bcc2ca 0%, #969da7 46%, #aeb4bd 60%, #848b95 100%);
|
||
border:1px solid var(--device-bd);
|
||
box-shadow:0 22px 46px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.55), inset 0 -2px 8px rgba(0,0,0,.28) }
|
||
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:0 2px 14px }
|
||
.brand-logo{ height:14px; width:auto; display:block }
|
||
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
|
||
.silk .model{ font-size:9px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
|
||
.pwr{ display:flex; align-items:center; gap:6px; font-size:8px; 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 }
|
||
|
||
/* ---- red 7-segment LED display ---- */
|
||
.led-win{ background:#160403; border:2px solid #050100; border-radius:8px; padding:8px 14px; margin:0 2px;
|
||
box-shadow:inset 0 0 16px rgba(0,0,0,.85), inset 0 0 7px rgba(255,40,30,.14), 0 1px 0 rgba(255,255,255,.3) }
|
||
#led{ display:block; width:100%; max-width:240px; height:84px; margin:0 auto }
|
||
.inds{ display:flex; justify-content:center; gap:16px; margin:9px 0 2px }
|
||
.ind{ display:flex; align-items:center; gap:5px; font-size:8.5px; color:var(--silk); letter-spacing:.12em; text-transform:uppercase; opacity:.85 }
|
||
.ind .d{ width:7px; height:7px; border-radius:50%; background:#4a0b09; box-shadow:inset 0 0 2px #000; transition:background .08s, box-shadow .08s }
|
||
.ind.on .d{ background:#ff3b30; box-shadow:0 0 7px #ff3b30 }
|
||
.ind.play.on .d{ background:#2fe07a; box-shadow:0 0 7px #2fe07a }
|
||
|
||
/* ---- single push encoder ---- */
|
||
.knob-wrap{ display:flex; justify-content:center; margin:16px 0 6px }
|
||
.knob{ width:96px; height:96px; border-radius:50%; cursor:pointer; position:relative; touch-action:none;
|
||
background:repeating-conic-gradient(from 0deg, #3c444f 0 6deg, #2a313b 6deg 12deg);
|
||
border:2px solid #565f6c; box-shadow:0 6px 14px rgba(0,0,0,.5), inset 0 1px 2px rgba(255,255,255,.12) }
|
||
.knob::before{ content:""; position:absolute; inset:13px; border-radius:50%;
|
||
background:radial-gradient(circle at 38% 32%, #4a525e, #1b212a 76%); box-shadow:inset 0 1px 2px rgba(255,255,255,.1), inset 0 -2px 4px rgba(0,0,0,.5) }
|
||
.knob::after{ content:""; position:absolute; left:50%; top:9px; width:4px; height:17px; background:#ff3b30; border-radius:2px;
|
||
transform:translateX(-50%) rotate(var(--a,0deg)); transform-origin:50% 39px; box-shadow:0 0 6px #ff3b30 }
|
||
.knob.press{ box-shadow:0 2px 6px rgba(0,0,0,.5), inset 0 1px 2px rgba(255,255,255,.1); transform:translateY(2px) }
|
||
|
||
/* ---- speaker grille + USB-C ---- */
|
||
.grille{ height:11px; margin:16px 8px 9px; border-radius:5px;
|
||
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/8px 8px; opacity:.5 }
|
||
.usb{ display:flex; align-items:center; justify-content:center; gap:6px; font-size:8px; color:var(--silk); letter-spacing:.12em; text-transform:uppercase; opacity:.8 }
|
||
.usb .port{ width:24px; height:9px; border-radius:4px; background:#0a0c0f; border:2px solid #5b6470; box-shadow:inset 0 0 2px #000 }
|
||
|
||
.hint{ max-width:330px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<span><b>VARASYS PM‑µ</b> · micro (home practice)</span>
|
||
<span class="topbar-right">
|
||
<button id="themeBtn" class="tbtn" title="toggle light / dark theme">☀</button>
|
||
<a href="/stage.html">Stage ↗</a>
|
||
<a href="/index.html">Editor ↗</a>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="device">
|
||
<div class="brandrow">
|
||
<div class="silk"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" /><span class="model">PM‑µ Micro</span></div>
|
||
<div class="pwr"><span class="dot"></span>PWR</div>
|
||
</div>
|
||
|
||
<div class="led-win"><canvas id="led" width="240" height="84" aria-label="LED tempo display"></canvas></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>Track</div>
|
||
<div class="ind play" id="indPlay"><span class="d"></span>▶</div>
|
||
</div>
|
||
|
||
<div class="knob-wrap"><div class="knob" id="enc" title="Spin = tempo · Press = start/stop · Hold + spin = switch track"></div></div>
|
||
|
||
<div class="grille"></div>
|
||
<div class="usb"><span class="port"></span>USB‑C (power)</div>
|
||
</div>
|
||
|
||
<div class="hint">Spin the dial = <b>tempo</b> · press = <b>start / stop</b> · hold & spin = <b>switch track</b></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,
|
||
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) ========= */
|
||
const tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||
let trackIdx=0, previewIdx=0;
|
||
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();
|
||
}
|
||
|
||
/* ========================= 7-SEGMENT LED ===================================== */
|
||
const led=$("led"), lc=led.getContext("2d"), LW=240, LH=84;
|
||
(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="#ff3b30", LED_OFF="#330807", LED_BG="#160403";
|
||
const SEG7={ // a,b,c,d,e,f,g
|
||
"0":[1,1,1,1,1,1,0],"1":[0,1,1,0,0,0,0],"2":[1,1,0,1,1,0,1],"3":[1,1,1,1,0,0,1],"4":[0,1,1,0,0,1,1],
|
||
"5":[1,0,1,1,0,1,1],"6":[1,0,1,1,1,1,1],"7":[1,1,1,0,0,0,0],"8":[1,1,1,1,1,1,1],"9":[1,1,1,1,0,1,1],
|
||
" ":[0,0,0,0,0,0,0],"-":[0,0,0,0,0,0,1],"P":[1,1,0,0,1,1,1] };
|
||
let displayMode="bpm";
|
||
function ledText(){ return displayMode==="track" ? String(previewIdx+1).padStart(3," ") : String(state.bpm).padStart(3," "); }
|
||
function drawDigit(dx,dy,dw,dh,ch){
|
||
const segs=SEG7[ch]||SEG7[" "], t=Math.max(3,Math.round(dw*0.17)), vh=(dh-3*t)/2;
|
||
const put=(on,x,y,w,h)=>{ if(on){ lc.fillStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=7; } else { lc.fillStyle=LED_OFF; lc.shadowBlur=0; }
|
||
lc.fillRect(x,y,w,h); lc.shadowBlur=0; };
|
||
put(segs[0], dx+t, dy, dw-2*t, t); // a
|
||
put(segs[5], dx, dy+t, t, vh); // f
|
||
put(segs[1], dx+dw-t, dy+t, t, vh); // b
|
||
put(segs[6], dx+t, dy+t+vh, dw-2*t, t); // g
|
||
put(segs[4], dx, dy+2*t+vh, t, vh); // e
|
||
put(segs[2], dx+dw-t, dy+2*t+vh, t, vh); // c
|
||
put(segs[3], dx+t, dy+dh-t, dw-2*t, t); // d
|
||
}
|
||
function drawLED(){
|
||
lc.fillStyle=LED_BG; lc.fillRect(0,0,LW,LH);
|
||
const txt=ledText(), pad=12, gap=12, n=3, dw=(LW-2*pad-(n-1)*gap)/n, dh=LH-2*pad;
|
||
for(let i=0;i<n;i++) drawDigit(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);
|
||
}
|
||
|
||
/* ========================= ENCODER (the only control) ======================== */
|
||
let knobAngle=0;
|
||
function nudge(d){ setBpm(state.bpm+d); displayMode="bpm"; knobAngle+=d*10; $("enc").style.setProperty("--a",knobAngle+"deg"); 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(); }, 1000); }
|
||
|
||
(function(){
|
||
const k=$("enc"); let down=false, moved=false, lastY=0, acc=0;
|
||
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; lastY=e.clientY; acc=0; previewIdx=trackIdx; k.classList.add("press"); k.setPointerCapture(e.pointerId); });
|
||
k.addEventListener("pointermove",(e)=>{ if(!down) return; acc += lastY - e.clientY; lastY=e.clientY;
|
||
while(Math.abs(acc) >= 9){ const d = acc>0?1:-1; acc -= d*9; moved=true; previewTrack(d); } });
|
||
k.addEventListener("pointerup",()=>{ if(!down) return; down=false; k.classList.remove("press");
|
||
if(moved) commitTrack(); else toggle(); }); // hold+spin → track ; quick press → start/stop
|
||
k.addEventListener("pointercancel",()=>{ down=false; k.classList.remove("press"); });
|
||
})();
|
||
|
||
/* theme toggle — cycles system → light → dark; shares the "metronome.theme" key */
|
||
const THEMES=["system","light","dark"];
|
||
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
|
||
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
|
||
function applyTheme(p){ try{ localStorage.setItem("metronome.theme",p); }catch(e){}
|
||
document.documentElement.dataset.theme = effectiveTheme(p);
|
||
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p+" (system → light → dark)"; }
|
||
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
|
||
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
|
||
applyTheme(themePref());
|
||
|
||
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 ============================================== */
|
||
loadTrack(0);
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|