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:
parent
e3031d9e80
commit
17053719f1
14 changed files with 899 additions and 16 deletions
|
|
@ -25,9 +25,11 @@ State (set lists, the practice log, theme and UI preferences) lives in `localSto
|
||||||
| `/concepts.html` | **PolyMeter Concepts** — the form‑factor gallery (cards → live page + info) |
|
| `/concepts.html` | **PolyMeter Concepts** — the form‑factor gallery (cards → live page + info) |
|
||||||
| `/player.html` | **PM‑1 Initial** — idealized concept device (full display + set‑list nav, theme, fullscreen "stage" view) |
|
| `/player.html` | **PM‑1 Initial** — idealized concept device (full display + set‑list nav, theme, fullscreen "stage" view) |
|
||||||
| `/teacher.html` | **PM‑1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument pass‑through with analog click injection) |
|
| `/teacher.html` | **PM‑1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument pass‑through with analog click injection) |
|
||||||
|
| `/stage.html` | **PM‑1 Stage** — foot‑pedal stompbox (two footswitches, expression‑pedal in, RGB beat light, instrument pass‑through) |
|
||||||
| `/micro.html` | **PM‑µ Micro** — inline practice bar (instrument in / out pass‑through, clickable thumb‑roller, 14‑segment display) |
|
| `/micro.html` | **PM‑µ Micro** — inline practice bar (instrument in / out pass‑through, clickable thumb‑roller, 14‑segment display) |
|
||||||
|
| `/showcase.html` | **PM‑S Showcase** — pyramid display piece with an RGB‑light pendulum + per‑lane 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 drop‑in loader |
|
| `/embed.html` · `/embed.js` | embed docs and the drop‑in 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 chrome‑stri
|
||||||
<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 set‑list code (maps to `#sl=`).
|
for a set‑list 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 **PE‑1 editor** app (source, with `@BUILD:*` markers) |
|
| `editor.html` | the **PE‑1 editor** app (source, with `@BUILD:*` markers) |
|
||||||
| `concepts.html` | the form‑factor gallery |
|
| `concepts.html` | the form‑factor gallery |
|
||||||
| `player.html` · `teacher.html` · `micro.html` | the **PM‑1 Initial / Teacher** and **PM‑µ Micro** device mockups |
|
| `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device mockups (PM‑1 Initial / Teacher / Stage, PM‑µ Micro, PM‑S Showcase) |
|
||||||
| `info-*.html` | per‑form‑factor info pages (purpose + priced BOM for buildable hardware) |
|
| `info-*.html` | per‑form‑factor info pages (purpose + priced BOM for buildable hardware) |
|
||||||
| `embed.html` · `embed.js` | embed docs and the drop‑in widget loader |
|
| `embed.html` · `embed.js` | embed docs and the drop‑in 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` |
|
||||||
|
|
|
||||||
5
build.sh
5
build.sh
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,14 @@
|
||||||
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info & BOM ⓘ</a></div>
|
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info & BOM ⓘ</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<span class="chip hw">Hardware</span>
|
||||||
|
<h3>PM‑1 — Stage</h3>
|
||||||
|
<p>Foot‑pedal stompbox for live use: two heavy footswitches (tap / next), an expression‑pedal input, a
|
||||||
|
big floor‑readable RGB beat light, instrument pass‑through with analog click, dual‑USB‑C daisy‑chain.</p>
|
||||||
|
<div class="links"><a href="/stage.html">Open ↗</a><a href="/info-stage.html">Info & 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 & BOM ⓘ</a></div>
|
<div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info & BOM ⓘ</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<span class="chip hw">Hardware</span>
|
||||||
|
<h3>PM‑S — Showcase</h3>
|
||||||
|
<p>A display piece shaped like a classic pyramid wind‑up metronome — the pendulum is RGB light easing to
|
||||||
|
the beat, with light rows showing every lane's subdivisions, accents & mutes. USB‑C powered.</p>
|
||||||
|
<div class="links"><a href="/showcase.html">Open ↗</a><a href="/info-showcase.html">Info & 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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
5
embed.js
5
embed.js
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
16
index.html
16
index.html
|
|
@ -115,6 +115,14 @@
|
||||||
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info & BOM ⓘ</a></div>
|
<div class="links"><a href="/teacher.html">Open ↗</a><a href="/info-teacher.html">Info & BOM ⓘ</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<span class="chip hw">Hardware</span>
|
||||||
|
<h3>PM‑1 — Stage</h3>
|
||||||
|
<p>Foot‑pedal stompbox: two footswitches (tap / next), expression‑pedal input, a big floor‑readable RGB
|
||||||
|
beat light, instrument pass‑through with analog click. Dual‑USB‑C daisy‑chain.</p>
|
||||||
|
<div class="links"><a href="/stage.html">Open ↗</a><a href="/info-stage.html">Info & 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 & BOM ⓘ</a></div>
|
<div class="links"><a href="/micro.html">Open ↗</a><a href="/info-micro.html">Info & BOM ⓘ</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<span class="chip hw">Hardware</span>
|
||||||
|
<h3>PM‑S — Showcase</h3>
|
||||||
|
<p>A display piece shaped like a classic pyramid wind‑up metronome — an RGB‑light pendulum easing to the
|
||||||
|
beat, with light rows for every lane's subdivisions, accents & mutes. USB‑C powered.</p>
|
||||||
|
<div class="links"><a href="/showcase.html">Open ↗</a><a href="/info-showcase.html">Info & 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
111
info-showcase.html
Normal 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>PM‑S Showcase — info & 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>PM‑S</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>PM‑S — Showcase</h1>
|
||||||
|
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Display piece</span><span class="tag">~$39 one‑off</span></div>
|
||||||
|
<p class="lead">A metronome as an object: the silhouette of a classic pyramid wind‑up 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 USB‑C with a second
|
||||||
|
"thru" port to daisy‑chain. No instrument I/O — it's a showpiece, not a signal‑chain tool.</p>
|
||||||
|
|
||||||
|
<h2>Bill of materials</h2>
|
||||||
|
<p class="sub">Rough parts list — a USB‑C‑powered RP2040 display piece driving addressable RGB light.
|
||||||
|
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</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 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 / light‑guide <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 & build</td></tr>
|
||||||
|
<tr><td class="part">2× USB‑C (data+power & power‑thru) + PWR LED <span class="spec">— daisy‑chain</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 (one‑off)</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
121
info-stage.html
Normal 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>PM‑1 Stage — info & 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>PM‑1</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>PM‑1 — Stage</h1>
|
||||||
|
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Foot‑pedal stompbox</span><span class="tag">~$52 one‑off</span></div>
|
||||||
|
<p class="lead">A foot‑operated polymeter stompbox for the stage: drive it hands‑free 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, hands‑free. 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″ expression‑pedal
|
||||||
|
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 USB‑C —
|
||||||
|
with a <b>second USB‑C "thru" port</b> so several pedals daisy‑chain off one charger or power bank.</p>
|
||||||
|
|
||||||
|
<h2>Bill of materials</h2>
|
||||||
|
<p class="sub">Rough parts list — a foot‑operated RP2040 stompbox (USB‑C, dual‑port) 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">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">High‑bright diffused RGB beat indicator <span class="spec">— floor‑readable</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">Heavy‑duty momentary footswitch (soft‑touch) <span class="spec">— Tap · Next</span></td><td class="q">2</td><td class="c">6</td></tr>
|
||||||
|
<tr><td class="part">1/4″ expression‑pedal 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">— 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">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 & 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× USB‑C (data+power & power‑thru) + power‑path/protection + PWR LED <span class="spec">— daisy‑chain 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">Die‑cast aluminium stompbox (Hammond 1590BB‑style) <span class="spec">— bead‑blasted, matte‑black Type II anodise, laser‑etched</span></td><td class="q">1</td><td class="c">12</td></tr>
|
||||||
|
<tr class="total"><td>Total (one‑off)</td><td class="q"></td><td class="c">≈ $52</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="sub" style="margin-top:12px">No built‑in speaker — the Stage feeds your amp / PA. The click is summed in the
|
||||||
|
<b>analog domain</b> (hi‑Z instrument buffer + DAC → balanced line driver), so your instrument is never
|
||||||
|
re‑digitised (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>
|
||||||
|
|
@ -51,8 +51,8 @@
|
||||||
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Studio / lesson console</span><span class="tag">~$59 one‑off</span></div>
|
<div class="tags"><span class="tag hw">Hardware</span><span class="tag">Studio / lesson console</span><span class="tag">~$59 one‑off</span></div>
|
||||||
<p class="lead">The full‑feature desktop console: a colour readout of every lane, fast set‑list navigation, and
|
<p class="lead">The full‑feature desktop console: a colour readout of every lane, fast set‑list navigation, and
|
||||||
your instrument running straight through with the click mixed in — the hands‑on unit for a studio desk or a
|
your instrument running straight through with the click mixed in — the hands‑on unit for a studio desk or a
|
||||||
teaching room, on a non‑reflective matte‑black case. (A hands‑free, foot‑operated <b>Stage</b> stompbox is
|
teaching room, on a non‑reflective matte‑black case. (For hands‑free live use, see the foot‑operated
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -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
284
showcase.html
Normal 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 PM‑S — 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>PM‑S</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 wind‑up
|
||||||
|
metronome); the light rows below show each lane's <b>subdivisions, accents & 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("PM‑S 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
324
stage.html
Normal 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 PM‑1 — Stage (foot‑pedal 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>PM‑1</b> · Stage (foot‑pedal 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" pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
|
||||||
|
<div class="jk" title="Main out — 1/4" 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>USB‑C</b></div>
|
||||||
|
<div class="jk usb" title="USB-C — power thru (daisy-chain the next pedal)"><i></i><b>USB‑C 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">PM‑1 Stage</span></div>
|
||||||
|
<div class="pwr"><span class="dot"></span>USB‑C 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 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>
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue