New Stage (foot-pedal) + Showcase (RGB pendulum); fix audio/visual sync

Sync: the visual playhead now advances on a latency-compensated clock
(currentTime − outputLatency||baseLatency) so the on-screen pulse lands when the
click is HEARD, not when it's queued — previously the visual could lead the audio
by the output buffer / Bluetooth latency (up to ~a subdivision). Applied to
editor, player, teacher, and the new pages; also bound the visual queue (vq trim).
No data races: single-threaded; only the rAF draw touches vqPtr/currentStep, and
each vq entry carries the exact scheduled time of its sound.

stage.html — foot-pedal stompbox: two heavy footswitches (Tap=tempo / hold=start-
stop, Next=item / hold=prev), 1/4" expression-pedal input → tempo sweep, big
floor-readable RGB beat light + angled TFT, analog instrument pass-through.
showcase.html — pyramid display piece: an RGB-light pendulum easing to each beat
plus per-lane segment rows showing subdivisions/accents/mutes (canvas).
Both: dual USB-C (data+power and power-thru) to daisy-chain off one source.

Wired into embed.js (stage, showcase variants), build.sh, deploy.sh, the
concepts gallery + landing cards, info-stage.html (~$52) + info-showcase.html
(~$39) with BOMs, and the README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 08:40:20 -05:00
parent e3031d9e80
commit 17053719f1
14 changed files with 899 additions and 16 deletions

View file

@ -25,9 +25,11 @@ State (set lists, the practice log, theme and UI preferences) lives in `localSto
| `/concepts.html` | **PolyMeter Concepts** — the formfactor gallery (cards → live page + info) | | `/concepts.html` | **PolyMeter Concepts** — the formfactor gallery (cards → live page + info) |
| `/player.html` | **PM1 Initial** — idealized concept device (full display + setlist nav, theme, fullscreen "stage" view) | | `/player.html` | **PM1 Initial** — idealized concept device (full display + setlist nav, theme, fullscreen "stage" view) |
| `/teacher.html` | **PM1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument passthrough with analog click injection) | | `/teacher.html` | **PM1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument passthrough with analog click injection) |
| `/stage.html` | **PM1 Stage** — footpedal stompbox (two footswitches, expressionpedal in, RGB beat light, instrument passthrough) |
| `/micro.html` | **PMµ Micro** — inline practice bar (instrument in / out passthrough, clickable thumbroller, 14segment display) | | `/micro.html` | **PMµ Micro** — inline practice bar (instrument in / out passthrough, clickable thumbroller, 14segment display) |
| `/showcase.html` | **PMS Showcase** — pyramid display piece with an RGBlight pendulum + perlane subdivision/accent light rows |
| `/info-editor.html`, `/info-initial.html` | purpose pages (web app / concept — no BOM) | | `/info-editor.html`, `/info-initial.html` | purpose pages (web app / concept — no BOM) |
| `/info-teacher.html`, `/info-micro.html` | purpose **+ priced BOM** (buildable hardware only) | | `/info-teacher.html`, `/info-stage.html`, `/info-micro.html`, `/info-showcase.html` | purpose **+ priced BOM** (buildable hardware only) |
| `/embed.html` · `/embed.js` | embed docs and the dropin loader | | `/embed.html` · `/embed.js` | embed docs and the dropin loader |
Each page carries the same VARASYS header (logo + tagline, nav, theme toggle). The editor Each page carries the same VARASYS header (logo + tagline, nav, theme toggle). The editor
@ -151,7 +153,7 @@ container and the loader script — it builds an `<iframe>` to the chromestri
<script src="https://metronome.varasys.io/embed.js"></script> <script src="https://metronome.varasys.io/embed.js"></script>
``` ```
- `data-varasys-metronome` — variant: `editor` · `initial` · `teacher` · `micro`. - `data-varasys-metronome` — variant: `editor` · `initial` · `teacher` · `stage` · `micro` · `showcase`.
- `data-patch` — a [patch string](#patch-grammar) (maps to `#p=`); or `data-setlist` - `data-patch` — a [patch string](#patch-grammar) (maps to `#p=`); or `data-setlist`
for a setlist code (maps to `#sl=`). for a setlist code (maps to `#sl=`).
- `data-width` / `data-height` — optional initial size (default `100%` × `300px`; - `data-width` / `data-height` — optional initial size (default `100%` × `300px`;
@ -233,7 +235,7 @@ Push the tag, then deploy.
| `index.html` | the **landing page** (site front door) | | `index.html` | the **landing page** (site front door) |
| `editor.html` | the **PE1 editor** app (source, with `@BUILD:*` markers) | | `editor.html` | the **PE1 editor** app (source, with `@BUILD:*` markers) |
| `concepts.html` | the formfactor gallery | | `concepts.html` | the formfactor gallery |
| `player.html` · `teacher.html` · `micro.html` | the **PM1 Initial / Teacher** and **PMµ Micro** device mockups | | `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device mockups (PM1 Initial / Teacher / Stage, PMµ Micro, PMS Showcase) |
| `info-*.html` | performfactor info pages (purpose + priced BOM for buildable hardware) | | `info-*.html` | performfactor info pages (purpose + priced BOM for buildable hardware) |
| `embed.html` · `embed.js` | embed docs and the dropin widget loader | | `embed.html` · `embed.js` | embed docs and the dropin widget loader |
| `src/` | shared partials inlined into every page: `engine.js`, `setlists.js`, `base.css` | | `src/` | shared partials inlined into every page: `engine.js`, `setlists.js`, `base.css` |

View file

@ -31,8 +31,9 @@ def build(name):
out.write_text(src) out.write_text(src)
return out.stat().st_size return out.stat().st_size
for name in ("index.html","editor.html","player.html","teacher.html","micro.html","concepts.html","embed.html", for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html",
"info-editor.html","info-initial.html","info-teacher.html","info-micro.html"): "concepts.html","embed.html",
"info-editor.html","info-initial.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html"):
print("built %s (%dKB)" % (name, build(name) // 1024)) print("built %s (%dKB)" % (name, build(name) // 1024))
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
print("copied embed.js") print("copied embed.js")

View file

@ -90,6 +90,14 @@
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info &amp; BOM ⓘ</a></div> <div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info &amp; BOM ⓘ</a></div>
</div> </div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PM1 — Stage</h3>
<p>Footpedal stompbox for live use: two heavy footswitches (tap / next), an expressionpedal input, a
big floorreadable RGB beat light, instrument passthrough with analog click, dualUSBC daisychain.</p>
<div class="links"><a href="/stage.html">Open ↗</a><a href="/info-stage.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card"> <div class="card">
<span class="chip hw">Hardware</span> <span class="chip hw">Hardware</span>
<h3>PMµ — Micro</h3> <h3>PMµ — Micro</h3>
@ -98,6 +106,14 @@
<div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info &amp; BOM ⓘ</a></div> <div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info &amp; BOM ⓘ</a></div>
</div> </div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PMS — Showcase</h3>
<p>A display piece shaped like a classic pyramid windup metronome — the pendulum is RGB light easing to
the beat, with light rows showing every lane's subdivisions, accents &amp; mutes. USBC powered.</p>
<div class="links"><a href="/showcase.html">Open ↗</a><a href="/info-showcase.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card soon"> <div class="card soon">
<span class="chip">Coming</span> <span class="chip">Coming</span>
<h3>More form factors</h3> <h3>More form factors</h3>

View file

@ -40,14 +40,15 @@ fi
# stamp the version into the built copy only (source stays clean) # stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR" echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html player.html teacher.html micro.html concepts.html embed.html \ for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html \
info-editor.html info-initial.html info-teacher.html info-micro.html; do concepts.html embed.html \
info-editor.html info-initial.html info-teacher.html info-stage.html info-micro.html info-showcase.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f" sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)" echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done done
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)" cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
rm -f "$DEST_DIR/stage.html" "$DEST_DIR/info-stage.html" # "Stage" renamed to "Teacher" (new foot-pedal Stage is forthcoming) # (stage.html / info-stage.html are deployed again — now the foot-pedal Stage stompbox)
# If real audio samples are added later (see the plan's GM-sample note), # If real audio samples are added later (see the plan's GM-sample note),
# sync that directory too. # sync that directory too.

View file

@ -1072,12 +1072,14 @@ function applyHashShare() {
========================================================================= */ ========================================================================= */
function drawLoop() { function drawLoop() {
if (audioCtx) { if (audioCtx) {
const now = audioCtx.currentTime; const raw = audioCtx.currentTime;
// playhead follows when the click is HEARD (compensate output latency); timers keep the true clock
const now = raw - (audioCtx.outputLatency || audioCtx.baseLatency || 0);
for (const m of meters) { 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++; } 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++; }
if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; } if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; }
} }
updateStatus(now); updateStatus(raw);
} }
for (const m of meters) renderLaneStrip(m); for (const m of meters) renderLaneStrip(m);
tickTimers(); tickTimers();

View file

@ -5,7 +5,7 @@
* <script src="https://metronome.varasys.io/embed.js"></script> * <script src="https://metronome.varasys.io/embed.js"></script>
* *
* Attributes: * Attributes:
* data-varasys-metronome editor | initial | teacher | micro (which form factor) * data-varasys-metronome editor | initial | teacher | stage | micro | showcase (which form factor)
* data-patch a PolyMeter program/settings string (preloads it) * data-patch a PolyMeter program/settings string (preloads it)
* data-setlist a base64url set-list code (alternative to data-patch) * data-setlist a base64url set-list code (alternative to data-patch)
* data-width / data-height iframe size (default 100% × 300; height auto-grows) * data-width / data-height iframe size (default 100% × 300; height auto-grows)
@ -14,7 +14,8 @@
* own ?embed=1 mode strips the site chrome) and auto-resizes to the widget's content. * own ?embed=1 mode strips the site chrome) and auto-resizes to the widget's content.
*/ */
(function () { (function () {
var PAGES = { editor: "editor.html", initial: "player.html", teacher: "teacher.html", micro: "micro.html" }; var PAGES = { editor: "editor.html", initial: "player.html", teacher: "teacher.html",
stage: "stage.html", micro: "micro.html", showcase: "showcase.html" };
var me = document.currentScript; var me = document.currentScript;
var ORIGIN = me ? me.src.replace(/\/embed\.js(\?.*)?$/, "") : location.origin; var ORIGIN = me ? me.src.replace(/\/embed\.js(\?.*)?$/, "") : location.origin;

View file

@ -115,6 +115,14 @@
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info &amp; BOM ⓘ</a></div> <div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info &amp; BOM ⓘ</a></div>
</div> </div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PM1 — Stage</h3>
<p>Footpedal stompbox: two footswitches (tap / next), expressionpedal input, a big floorreadable RGB
beat light, instrument passthrough with analog click. DualUSBC daisychain.</p>
<div class="links"><a href="/stage.html">Open ↗</a><a href="/info-stage.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card"> <div class="card">
<span class="chip hw">Hardware</span> <span class="chip hw">Hardware</span>
<h3>PMµ — Micro</h3> <h3>PMµ — Micro</h3>
@ -123,6 +131,14 @@
<div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info &amp; BOM ⓘ</a></div> <div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info &amp; BOM ⓘ</a></div>
</div> </div>
<div class="card">
<span class="chip hw">Hardware</span>
<h3>PMS — Showcase</h3>
<p>A display piece shaped like a classic pyramid windup metronome — an RGBlight pendulum easing to the
beat, with light rows for every lane's subdivisions, accents &amp; mutes. USBC powered.</p>
<div class="links"><a href="/showcase.html">Open ↗</a><a href="/info-showcase.html">Info &amp; BOM ⓘ</a></div>
</div>
<div class="card"> <div class="card">
<span class="chip">Widget</span> <span class="chip">Widget</span>
<h3>Embed anywhere</h3> <h3>Embed anywhere</h3>

111
info-showcase.html Normal file
View file

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMS Showcase — info &amp; BOM — VARASYS</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<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-bg:#161b22;--panel-bd:#2a313c;--field-bg:#0e1116; }
:root[data-theme="light"]{ --bg1:#f5f8fc;--bg2:#dde4ec;--txt:#1e2630;--muted:#5c6776;--link:#1769c4;--panel-bg:#fff;--panel-bd:#d2dae4;--field-bg:#f1f4f8; }
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt); background:radial-gradient(circle at 50% -8%,var(--bg1),var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:760px; margin:26px auto 0; }
h1{ font-size:24px; margin:0 0 4px; } h2{ font-size:16px; margin:28px 0 8px; }
p{ color:var(--muted); font-size:14px; line-height:1.6; } p.lead{ max-width:62ch; color:var(--txt); }
.tags{ display:flex; gap:8px; flex-wrap:wrap; margin:8px 0 4px; }
.tag{ font-size:11px; color:var(--muted); border:1px solid var(--panel-bd); border-radius:999px; padding:2px 9px; }
.tag.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
.embed-wrap{ margin:16px 0 4px; display:flex; justify-content:center; }
.cap{ font-size:12px; color:var(--muted); text-align:center; }
.site-foot{ max-width:760px; margin:40px auto 0; font-size:12px; color:var(--muted); }
</style>
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PMS</b> · Showcase — info</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/showcase.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<main>
<h1>PMS — Showcase</h1>
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Display piece</span><span class="tag">~$39 oneoff</span></div>
<p class="lead">A metronome as an object: the silhouette of a classic pyramid windup unit, but the swinging
pendulum is pure <b>RGB light</b> — a glowing bob easing to the beat just like the mechanical original — with
rows of light beneath it showing every lane's subdivisions, accents and mutes.</p>
<div class="embed-wrap">
<div data-varasys-metronome="showcase"
data-patch="v1;t108;kick:4=X..x;snare:4=.X.X;hatClosed:4/4;tom:3~" data-height="560"></div>
</div>
<p class="cap">Live widget (embedded). <a href="/showcase.html">Open the full Showcase page ↗</a> · <a href="/embed.html">embed this</a></p>
<h2>Designed for</h2>
<p>The shelf, the studio, the shop window — a beautiful, glanceable tempo reference that's a pleasure to watch.
The RGB pendulum swings in perfect time (decelerating to each extreme exactly as a weighted rod would), and
the segment rows turn your polymeter pattern into a light show: accents glow amber, normal steps cyan, ghosts
soft violet, mutes stay dark, and the playhead sweeps each lane. It runs the same grooves as everything else
(load any program string), plays the click through a small speaker, and is powered over USBC with a second
"thru" port to daisychain. No instrument I/O — it's a showpiece, not a signalchain tool.</p>
<h2>Bill of materials</h2>
<p class="sub">Rough parts list — a USBCpowered RP2040 display piece driving addressable RGB light.
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</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 class="grp"><td colspan="3">RGB light</td></tr>
<tr><td class="part">Addressable RGB LEDs (WS2812B) <span class="spec">— pendulum rod + lane segment rows, ~50 px</span></td><td class="q">1</td><td class="c">5</td></tr>
<tr><td class="part">Frosted acrylic diffuser / lightguide <span class="spec">— the glowing "pendulum" face</span></td><td class="q">1</td><td class="c">3</td></tr>
<tr class="grp"><td colspan="3">Audio</td></tr>
<tr><td class="part">MAX98357A I²S amp + small speaker <span class="spec">— the click</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr class="grp"><td colspan="3">Power &amp; build</td></tr>
<tr><td class="part">2× USBC (data+power &amp; powerthru) + PWR LED <span class="spec">— daisychain</span></td><td class="q">1</td><td class="c">3</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, wire</td><td class="q"></td><td class="c">2</td></tr>
<tr><td class="part">Pyramid enclosure <span class="spec">— cast/CNC aluminium or hardwood, frosted front panel</span></td><td class="q">1</td><td class="c">14</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $39</td></tr>
</tbody>
</table>
</main>
<div class="site-foot">VARASYS · Simplifying Complexity — <span id="appVersion">v0.0.1-dev</span></div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id)=>document.getElementById(id);
try{ $("appVersion").textContent = "v"+APP_VERSION.replace(/^v/,""); }catch(e){}
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; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
</script>
<script src="/embed.js"></script>
</body>
</html>

121
info-stage.html Normal file
View file

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PM1 Stage — info &amp; BOM — VARASYS</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<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-bg:#161b22;--panel-bd:#2a313c;--field-bg:#0e1116; }
:root[data-theme="light"]{ --bg1:#f5f8fc;--bg2:#dde4ec;--txt:#1e2630;--muted:#5c6776;--link:#1769c4;--panel-bg:#fff;--panel-bd:#d2dae4;--field-bg:#f1f4f8; }
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt); background:radial-gradient(circle at 50% -8%,var(--bg1),var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:760px; margin:26px auto 0; }
h1{ font-size:24px; margin:0 0 4px; } h2{ font-size:16px; margin:28px 0 8px; }
p{ color:var(--muted); font-size:14px; line-height:1.6; } p.lead{ max-width:62ch; color:var(--txt); }
.tags{ display:flex; gap:8px; flex-wrap:wrap; margin:8px 0 4px; }
.tag{ font-size:11px; color:var(--muted); border:1px solid var(--panel-bd); border-radius:999px; padding:2px 9px; }
.tag.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
.embed-wrap{ margin:16px 0 4px; }
.cap{ font-size:12px; color:var(--muted); }
.site-foot{ max-width:760px; margin:40px auto 0; font-size:12px; color:var(--muted); }
</style>
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PM1</b> · Stage — info</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/stage.html">Open ↗</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<main>
<h1>PM1 — Stage</h1>
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Footpedal stompbox</span><span class="tag">~$52 oneoff</span></div>
<p class="lead">A footoperated polymeter stompbox for the stage: drive it handsfree with two heavy
footswitches and an expression pedal, read it off the floor from a 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>
<div class="embed-wrap">
<div data-varasys-metronome="stage"
data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="430"></div>
</div>
<p class="cap">Live widget (embedded). <a href="/stage.html">Open the full Stage page ↗</a> · <a href="/embed.html">embed this</a></p>
<h2>Designed for</h2>
<p>Live use, handsfree. 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. A large RGB <b>beat light</b> is readable from standing height, with a
small angled TFT for the BPM, item name and beat. 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 for the desk. Powered over USBC —
with a <b>second USBC "thru" port</b> so several pedals daisychain off one charger or power bank.</p>
<h2>Bill of materials</h2>
<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>
</main>
<div class="site-foot">VARASYS · Simplifying Complexity — <span id="appVersion">v0.0.1-dev</span></div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id)=>document.getElementById(id);
try{ $("appVersion").textContent = "v"+APP_VERSION.replace(/^v/,""); }catch(e){}
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; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
</script>
<script src="/embed.js"></script>
</body>
</html>

View file

@ -51,8 +51,8 @@
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Studio / lesson console</span><span class="tag">~$59 oneoff</span></div> <div class="tags"><span class="tag hw">Hardware</span><span class="tag">Studio / lesson console</span><span class="tag">~$59 oneoff</span></div>
<p class="lead">The fullfeature desktop console: a colour readout of every lane, fast setlist navigation, and <p class="lead">The fullfeature desktop console: a colour readout of every lane, fast setlist navigation, and
your instrument running straight through with the click mixed in — the handson unit for a studio desk or a your instrument running straight through with the click mixed in — the handson unit for a studio desk or a
teaching room, on a nonreflective matteblack case. (A handsfree, footoperated <b>Stage</b> stompbox is teaching room, on a nonreflective matteblack case. (For handsfree live use, see the footoperated
in the works for live use.)</p> <a href="/stage.html">Stage</a> stompbox.)</p>
<div class="embed-wrap"> <div class="embed-wrap">
<div data-varasys-metronome="teacher" <div data-varasys-metronome="teacher"

View file

@ -390,7 +390,9 @@ function renderScreen(){
} }
function renderAll(){ renderScreen(); renderLeds(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); } function renderAll(){ renderScreen(); renderLeds(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); }
function draw(){ function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime; // latency-compensated clock so the visual playhead lands when the click is HEARD
// (not when it's queued) — see the sync note; avoids the visual leading the audio.
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
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++; } } }
renderScreen(); renderLeds(); renderScreen(); renderLeds();
requestAnimationFrame(draw); requestAnimationFrame(draw);

284
showcase.html Normal file
View file

@ -0,0 +1,284 @@
<!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 PMS — Showcase (RGB pendulum metronome)</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-S "Showcase" — a display-piece metronome shaped like a classic pyramid wind-up
unit, but the swinging pendulum is simulated with RGB light (a glowing bob on a
rod, easing to the extremes on each beat exactly like the mechanical original),
and rows of RGB segment lights show every lane's subdivisions / accents / mutes.
Same RGB-everywhere RP2040 firmware/engine; USB-C powered (dual-port daisy-chain).
Visuals are latency-compensated so the swing lands when the click is HEARD.
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; --field-bg:#0e1116; --field-bd:#2a313c; --cyan:#0AB3F7; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:24px 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:14px }
a{ color:var(--link) }
.device{ width:100%; max-width:300px; filter:drop-shadow(0 22px 34px rgba(0,0,0,.6)); }
#stage{ display:block; width:100%; height:auto }
.ctrls{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; justify-content:center }
.ctrls button{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
padding:7px 12px; font-size:14px; line-height:1; cursor:pointer }
.ctrls button:hover{ border-color:var(--cyan) }
#play{ min-width:46px; font-size:15px }
.tempo, .trk{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted) }
.tempo b, .trk b{ color:var(--txt); min-width:34px; text-align:center; display:inline-block; font-variant-numeric:tabular-nums }
.u{ font-size:10px; letter-spacing:.08em; text-transform:uppercase }
.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>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PMS</b> · Showcase (RGB pendulum)</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-showcase.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<div class="device"><canvas id="stage" width="300" height="440" aria-label="RGB pendulum metronome"></canvas></div>
<div class="ctrls">
<button id="play" title="Start / stop (Space)"></button>
<div class="tempo"><button id="slower" title="Slower"></button><b id="bpmLbl">120</b><span class="u">bpm</span><button id="faster" title="Faster">+</button></div>
<div class="trk"><button id="prev" title="Previous"></button><b id="trkLbl"></b><button id="next" title="Next"></button></div>
</div>
<div class="hint">A showcase piece: the <b>RGB pendulum</b> swings in time (easing to the beat like a real windup
metronome); the light rows below show each lane's <b>subdivisions, accents &amp; mutes</b>. Click the piece to
start/stop · scroll it for tempo.</div>
<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))); }
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; m.currentStep=-1; }
beatCount=-1; lastBeatTime=t0; muteWindows=[];
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); syncBtns();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; syncBtns(); }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS ============================================ */
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); meters=buildMeters(t.lanes);
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio(); syncBtns();
}
function syncBtns(){ $("play").textContent = state.running ? "■" : "▶";
$("bpmLbl").textContent = state.bpm; $("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
/* ========================= RGB PENDULUM + LANE LIGHTS (canvas) =============== */
const cv=$("stage"), g=cv.getContext("2d"), CW=300, CH=440;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=CW*dpr; cv.height=CH*dpr; g.scale(dpr,dpr); })();
const MAXANG=0.42; // pendulum swing (rad) ~24°
let beatCount=-1, lastBeatTime=0, pend=0, flash=0, flashAccent=false;
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
const LEVELCOL = { 2:"#ff9b2e", 1:"#33d0ff", 3:"#9b7bff", 0:"#39424f" }; // accent / normal / ghost / mute
function roundRect(x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
function drawBody(){
// truncated-pyramid silhouette (classic metronome), matte graphite
g.clearRect(0,0,CW,CH);
const tlx=92, trx=208, blx=24, brx=276, topY=14, botY=420;
const grd=g.createLinearGradient(0,0,0,botY); grd.addColorStop(0,"#2c2e34"); grd.addColorStop(1,"#141518");
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(trx,topY); g.lineTo(brx,botY); g.lineTo(blx,botY); g.closePath();
g.fillStyle=grd; g.fill();
g.lineWidth=1.5; g.strokeStyle="rgba(255,255,255,.06)"; g.stroke();
// left-edge sheen
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(blx,botY); g.lineWidth=2; g.strokeStyle="rgba(255,255,255,.05)"; g.stroke();
// brand silk
g.textAlign="center"; g.fillStyle="#aab2bc";
g.font="700 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("V A R A S Y S", CW/2, 30);
g.font="600 7.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.globalAlpha=.8; g.fillText("PMS SHOWCASE", CW/2, 41); g.globalAlpha=1;
}
function drawWindow(){
// recessed dark window for the pendulum
roundRect(70,56,160,212,10); g.fillStyle="#05070a"; g.fill();
g.lineWidth=1; g.strokeStyle="rgba(0,0,0,.8)"; g.stroke();
// faint scale ticks (an arc the bob sweeps across)
const px=CW/2, py=258; // pivot near the bottom of the window
g.strokeStyle="rgba(255,255,255,.07)"; g.lineWidth=1;
for(let k=-3;k<=3;k++){ const a=(k/3)*MAXANG; const x1=px+Math.sin(a)*150, y1=py-Math.cos(a)*150, x2=px+Math.sin(a)*162, y2=py-Math.cos(a)*162;
g.beginPath(); g.moveTo(px+Math.sin(a)*70,py-Math.cos(a)*70); g.lineTo(px+Math.sin(a)*150,py-Math.cos(a)*150); g.globalAlpha=.05; g.stroke(); g.globalAlpha=1; }
return {px,py};
}
function drawPendulum(piv){
const px=piv.px, py=piv.py, L=176, a=pend;
const tipX=px+Math.sin(a)*L, tipY=py-Math.cos(a)*L;
const bobX=px+Math.sin(a)*L*0.66, bobY=py-Math.cos(a)*L*0.66;
// rod
g.strokeStyle="rgba(170,180,196,.5)"; g.lineWidth=3; g.lineCap="round";
g.beginPath(); g.moveTo(px,py); g.lineTo(tipX,tipY); g.stroke();
// pivot hub
g.beginPath(); g.arc(px,py,6,0,7); g.fillStyle="#2a2f37"; g.fill();
g.beginPath(); g.arc(px,py,2.5,0,7); g.fillStyle="#aab2bc"; g.fill();
// glowing RGB bob (brightens on the beat tick)
const col = flashAccent ? "#ff9b2e" : "#33d0ff", lit=0.4+0.6*Math.max(0,flash);
g.shadowColor=col; g.shadowBlur=18+26*Math.max(0,flash);
g.beginPath(); g.arc(bobX,bobY,12,0,7);
const bg=g.createRadialGradient(bobX-3,bobY-3,2,bobX,bobY,12); bg.addColorStop(0,"#ffffff"); bg.addColorStop(.4,col); bg.addColorStop(1,"rgba(0,0,0,.2)");
g.globalAlpha=lit; g.fillStyle=bg; g.fill(); g.globalAlpha=1; g.shadowBlur=0;
// tip glow dot
g.beginPath(); g.arc(tipX,tipY,3.5,0,7); g.fillStyle=col; g.shadowColor=col; g.shadowBlur=8*Math.max(.2,flash); g.fill(); g.shadowBlur=0;
}
function drawLanes(){
// RGB segment rows for each lane: subdivisions / accents / mutes, with the playhead
const lanes=meters.slice(0,4), x0=40, x1=260, top=294, rowH=26;
g.textAlign="left";
for(let li=0; li<lanes.length; li++){ const m=lanes[li], steps=m.beatsPerBar*m.stepsPerBeat, y=top+li*rowH;
const cw=(x1-x0)/Math.max(1,steps), pad=Math.min(3, cw*0.18);
for(let s=0;s<steps;s++){ const lvl=m.beatsOn[s]|0, x=x0+s*cw, isBeat=(s%m.stepsPerBeat===0);
const cur = state.running && s===m.currentStep;
const base = cur ? 1 : (lvl===0 ? 0.10 : 0.18);
g.globalAlpha = base; g.fillStyle = LEVELCOL[lvl]||LEVELCOL[0];
const h = isBeat ? 13 : 9, yy = y + (13-h)/2;
if(cur){ g.shadowColor=LEVELCOL[lvl]||"#33d0ff"; g.shadowBlur=12; }
roundRect(x+pad, yy, cw-2*pad, h, 2); g.fill(); g.shadowBlur=0; g.globalAlpha=1;
}
}
// BPM + item name on the base plinth
g.textAlign="center"; g.fillStyle="#c7d0db"; g.font="700 22px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText(String(state.bpm), CW/2-2, 410); g.font="600 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillStyle="#7f8b9a";
g.fillText("BPM", CW/2+44, 410);
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
g.fillStyle="#8f9aa6"; g.font="600 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(nm.length>30?nm.slice(0,29)+"…":nm, CW/2, 396);
}
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){
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;
}
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
}
// pendulum target angle (eases to extreme each beat, like a real metronome)
let tgt=0;
if(state.running && beatCount>=0){ let frac=(now-lastBeatTime)/(60/state.bpm); if(frac<0)frac=0; if(frac>1.2)frac=1.2;
tgt = MAXANG*Math.cos(Math.PI*(beatCount+frac)); }
pend += (tgt-pend)*(state.running?1:0.12);
flash = Math.max(0, flash-0.08);
drawBody(); const piv=drawWindow(); drawLanes(); drawPendulum(piv);
requestAnimationFrame(draw);
}
/* ========================= CONTROLS ========================================== */
cv.addEventListener("click", ()=>toggle());
cv.addEventListener("wheel", (e)=>{ e.preventDefault(); setBpm(state.bpm+(e.deltaY<0?(e.shiftKey?5:1):(e.shiftKey?-5:-1))); syncBtns(); }, {passive:false});
$("play").onclick = (e)=>{ e.stopPropagation(); toggle(); };
$("slower").onclick = ()=>{ setBpm(state.bpm-1); syncBtns(); };
$("faster").onclick = ()=>{ setBpm(state.bpm+1); syncBtns(); };
$("prev").onclick = ()=>loadTrack(trackIdx-1);
$("next").onclick = ()=>loadTrack(trackIdx+1);
/* theme toggle */
const THEMES=["system","light","dark"];
function effTheme(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 = effTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p; }
$("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;
if(e.key===" "){ e.preventDefault(); toggle(); }
else if(e.key==="ArrowUp"){ e.preventDefault(); setBpm(state.bpm+(e.shiftKey?10:1)); syncBtns(); }
else if(e.key==="ArrowDown"){ e.preventDefault(); setBpm(state.bpm-(e.shiftKey?10:1)); syncBtns(); }
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); syncBtns();
requestAnimationFrame(draw);
</script>
</body>
</html>

324
stage.html Normal file
View file

@ -0,0 +1,324 @@
<!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 PM1 — 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 }
.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 }
.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>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PM1</b> · Stage (footpedal stompbox)</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-stage.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<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"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="model">PM1 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>
<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))); }
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; 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") */
const THEMES=["system","light","dark"];
function effTheme(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 = effTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p; }
$("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;
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);
</script>
</body>
</html>

View file

@ -440,7 +440,9 @@ function drawTFT(){
function renderAll(){ drawTFT(); /* PLAY button is static hardware — transport state is shown on the screen */ function renderAll(){ drawTFT(); /* PLAY button is static hardware — transport state is shown on the screen */
$("enc").style.setProperty("--rib", rollPos+"px"); } $("enc").style.setProperty("--rib", rollPos+"px"); }
function draw(){ function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime; // latency-compensated clock so the visual playhead lands when the click is HEARD
// (not when it's queued) — avoids the visual leading the audio by the output buffer.
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
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++; } } }
drawTFT(); drawTFT();
requestAnimationFrame(draw); requestAnimationFrame(draw);