Share engine, seed set lists & base CSS between editor and player
Both index.html (editor) and player.html (hardware-player mockup) now pull
their common code from src/ via a new build-time include marker
(/*@BUILD:include:src/…@*/), resolved by build.sh:
src/engine.js — audio voices (DRUMS×30), Web Audio scheduler primitives,
and the share-language codec (patch/set-list encode+decode)
src/setlists.js — SEED_SETLISTS, so the player ships the SAME default set
lists as the editor (player BUILTIN = SEED_SETLISTS)
src/base.css — reset + VARASYS brand palette + type stack
The editor inlines the CC0 acoustic samples; the player passes an empty
SAMPLES object and the shared playInstrument falls back to its synth voices,
so the device stays faithfully synth-only. Each app keeps its own state
globals, setBpm, advanceMaster/scheduler, and UI. ~400 lines of duplicated
engine code removed; the player's favicon is now the shared @BUILD:favicon@.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0ec49a0c3b
commit
b548c64d2a
7 changed files with 344 additions and 405 deletions
24
README.md
24
README.md
|
|
@ -158,11 +158,20 @@ disturbing what's playing, then commit on a musical boundary — no audible gap.
|
|||
|
||||
## Build
|
||||
|
||||
The source `index.html` keeps small `@BUILD:*` markers in place of the large
|
||||
base64 blobs (audio samples, brand logos, favicon) — those live in `assets/`.
|
||||
`./build.sh` inlines them into a self‑contained `dist/index.html` (and copies
|
||||
`player.html`). `dist/` is generated, git‑ignored — don't edit it by hand.
|
||||
`deploy.sh` runs the build first, so a deploy always serves a freshly assembled page.
|
||||
`index.html` (the editor) and `player.html` (the hardware‑player mockup) are both
|
||||
sources that share code through `@BUILD:*` markers, so the two stay in sync:
|
||||
|
||||
- `/*@BUILD:include:src/…@*/` inlines a **shared partial** — the audio/scheduler
|
||||
engine (`src/engine.js`), the seed set lists (`src/setlists.js`, so the player
|
||||
ships the **same default set lists** as the editor), and base styling
|
||||
(`src/base.css`: reset + brand palette + type).
|
||||
- `@BUILD:favicon@`, `@BUILD:logo-*@`, `/*@BUILD:samples@*/{}` inline the base64
|
||||
assets from `assets/`.
|
||||
|
||||
`./build.sh` resolves every marker into a self‑contained `dist/index.html` +
|
||||
`dist/player.html` (the editor inlines the CC0 samples; the player passes an empty
|
||||
`SAMPLES` for pure synth). `dist/` is generated, git‑ignored — don't edit it by hand.
|
||||
`deploy.sh` runs the build first, so a deploy always serves freshly assembled pages.
|
||||
|
||||
## Versioning
|
||||
|
||||
|
|
@ -179,10 +188,11 @@ Push the tag, then deploy.
|
|||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.html` | the whole app (source, with `@BUILD:*` asset markers) |
|
||||
| `index.html` | the editor app (source, with `@BUILD:*` markers) |
|
||||
| `player.html` | the play‑only hardware‑device mockup (`/player.html`) |
|
||||
| `src/` | shared partials inlined into both: `engine.js`, `setlists.js`, `base.css` |
|
||||
| `assets/` | base64 blobs inlined at build (samples, logos, favicon) |
|
||||
| `build.sh` | inline `assets/` into `dist/` (self‑contained pages) |
|
||||
| `build.sh` | resolve markers → self‑contained `dist/` pages |
|
||||
| `deploy.sh` | build, then publish to the Caddy web root |
|
||||
| `release.sh` | tag a formal version |
|
||||
| `VERSION` | formal version string |
|
||||
|
|
|
|||
37
build.sh
37
build.sh
|
|
@ -1,24 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
# Assemble the deployed single-file pages from source + assets/.
|
||||
# Assemble the deployed single-file pages from source + shared partials + assets/.
|
||||
#
|
||||
# The source index.html keeps small @BUILD:* markers instead of the large base64
|
||||
# blobs (audio samples, brand logos, favicon). This inlines those assets so the
|
||||
# built page in dist/ is one self-contained file (zero deps, works fully offline).
|
||||
# deploy.sh runs this first. dist/ is generated — don't edit or commit it.
|
||||
# Both index.html and player.html are sources that share code via markers:
|
||||
# /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS)
|
||||
# @BUILD:favicon@ / @BUILD:logo-*@ / /*@BUILD:samples@*/{} inline base64 assets
|
||||
# This resolves them so each built page in dist/ is one self-contained file
|
||||
# (zero deps, works fully offline). deploy.sh runs this first. dist/ is generated —
|
||||
# don't edit or commit it.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
mkdir -p dist
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
import json, os, pathlib, re
|
||||
A = pathlib.Path("assets")
|
||||
src = pathlib.Path("index.html").read_text()
|
||||
SAMPLES = json.dumps(json.load(open(A / "samples.json")))
|
||||
|
||||
def build(name):
|
||||
src = pathlib.Path(name).read_text()
|
||||
# 1) inline shared partials (function-replacement: no backslash/group interpretation)
|
||||
src = re.sub(r"/\*@BUILD:include:([^@]+)@\*/",
|
||||
lambda m: pathlib.Path(m.group(1)).read_text().rstrip("\n"), src)
|
||||
# 2) inline base64 assets
|
||||
src = src.replace("@BUILD:favicon@", (A / "favicon.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-dark@", (A / "logo-dark.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip())
|
||||
src = src.replace("/*@BUILD:samples@*/{}", json.dumps(json.load(open(A / "samples.json"))))
|
||||
assert "@BUILD:" not in src, "unresolved build marker(s) remain in index.html"
|
||||
pathlib.Path("dist/index.html").write_text(src)
|
||||
pathlib.Path("dist/player.html").write_text(pathlib.Path("player.html").read_text()) # no assets to inline
|
||||
print("built dist/index.html (%dKB) + dist/player.html (%dKB)" %
|
||||
(os.path.getsize("dist/index.html") // 1024, os.path.getsize("dist/player.html") // 1024))
|
||||
src = src.replace("/*@BUILD:samples@*/{}", SAMPLES)
|
||||
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
|
||||
out = pathlib.Path("dist") / name
|
||||
out.write_text(src)
|
||||
return out.stat().st_size
|
||||
|
||||
i = build("index.html"); p = build("player.html")
|
||||
print("built dist/index.html (%dKB) + dist/player.html (%dKB)" % (i // 1024, p // 1024))
|
||||
PY
|
||||
|
|
|
|||
251
index.html
251
index.html
|
|
@ -45,6 +45,7 @@
|
|||
Web Audio's look-ahead scheduler stands in for the hardware timer.
|
||||
-->
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root {
|
||||
--bg:#14171c; --bg2:#1b212b; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
|
||||
--txt:#c7d0db; --muted:#7f8b9a; --hot:#ffd166; --led-off:#2b323d; --ring:#ffffff;
|
||||
|
|
@ -53,12 +54,10 @@
|
|||
--bg:#e9edf2; --bg2:#f6f8fb; --panel:#ffffff; --panel-2:#eef2f7; --edge:#d2dae4;
|
||||
--txt:#1e2630; --muted:#5c6776; --hot:#a9760a; --led-off:#cdd6e0; --ring:#16202c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin:0; padding:24px; min-height:100vh;
|
||||
background: radial-gradient(circle at 50% -10%, var(--bg2), var(--bg));
|
||||
color: var(--txt); font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--txt);
|
||||
}
|
||||
h1 { font-size:18px; font-weight:600; letter-spacing:.5px; margin:0 0 2px; }
|
||||
.sub { color:var(--muted); font-size:12px; margin-bottom:18px; }
|
||||
|
|
@ -401,130 +400,11 @@ const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 };
|
|||
let meters = []; // array of meter-lane objects
|
||||
let meterSeq = 0; // id counter
|
||||
|
||||
/* =========================================================================
|
||||
GROUPING (PORTS TO FIRMWARE)
|
||||
"2+2+3" -> groups, beatsPerBar, and the beat indices that start a group.
|
||||
========================================================================= */
|
||||
function parseGroups(str) {
|
||||
const parts = String(str).split(/[^0-9]+/).map((s) => parseInt(s, 10)).filter((n) => n >= 1 && n <= 12);
|
||||
let total = 0; const groups = [];
|
||||
for (const p of parts) { if (total + p > 12) break; groups.push(p); total += p; }
|
||||
if (!groups.length) groups.push(4);
|
||||
const beatsPerBar = groups.reduce((a, b) => a + b, 0);
|
||||
const groupStarts = new Set(); let acc = 0;
|
||||
for (const g of groups) { groupStarts.add(acc); acc += g; }
|
||||
return { groups, beatsPerBar, groupStarts };
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
AUDIO (Web Audio look-ahead scheduler = stand-in for the RP2040 timer)
|
||||
========================================================================= */
|
||||
let audioCtx = null, masterGain = null, noiseBuf = null;
|
||||
const SAMPLES = /*@BUILD:samples@*/{};
|
||||
const sampleBuffers = {}; // decoded CC0 acoustic one-shots (VCSL, CC0); acoustic voices play these, electronic stay synth
|
||||
|
||||
let schedulerTimer = null;
|
||||
const LOOKAHEAD_MS = 25, SCHEDULE_AHEAD = 0.12;
|
||||
|
||||
function ensureAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = audioCtx.createGain();
|
||||
masterGain.gain.value = state.volume;
|
||||
masterGain.connect(audioCtx.destination);
|
||||
for (const k in SAMPLES) { // decode embedded one-shots once
|
||||
const bin = atob(SAMPLES[k]); const u8 = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||||
audioCtx.decodeAudioData(u8.buffer, (b) => { sampleBuffers[k] = b; }, () => {});
|
||||
}
|
||||
}
|
||||
function getNoise() {
|
||||
if (!noiseBuf) {
|
||||
const n = Math.floor(audioCtx.sampleRate * 1.0);
|
||||
noiseBuf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
|
||||
const d = noiseBuf.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return noiseBuf;
|
||||
}
|
||||
|
||||
// --- instrument voices (synthesized GM-style kit). On hardware these map 1:1 to
|
||||
// real CC0/GPL General-MIDI percussion samples played via the I2S DAC;
|
||||
// playInstrument() is the single swap point. level = velocity (accent/ghost). ---
|
||||
function ampEnv(time, peak, dur, attack) {
|
||||
const g = audioCtx.createGain();
|
||||
peak = Math.max(0.0003, peak);
|
||||
g.gain.setValueAtTime(0.0001, time);
|
||||
g.gain.exponentialRampToValueAtTime(peak, time + (attack || 0.001));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, time + dur);
|
||||
return g;
|
||||
}
|
||||
function tone(time, type, f0, f1, dur) {
|
||||
const o = audioCtx.createOscillator(); o.type = type;
|
||||
o.frequency.setValueAtTime(f0, time);
|
||||
if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), time + Math.min(dur, 0.09));
|
||||
o.start(time); o.stop(time + dur + 0.02); return o;
|
||||
}
|
||||
function noiseSrc(time, dur) { const s = audioCtx.createBufferSource(); s.buffer = getNoise(); s.start(time); s.stop(time + dur + 0.02); return s; }
|
||||
function filt(type, freq, q) { const f = audioCtx.createBiquadFilter(); f.type = type; f.frequency.value = freq; if (q) f.Q.value = q; return f; }
|
||||
function v_tone(time, level, type, f0, f1, dur, peak) { const o = tone(time, type, f0, f1, dur), g = ampEnv(time, peak * level, dur, 0.002); o.connect(g); g.connect(masterGain); }
|
||||
function v_noise(time, level, fType, freq, q, dur, peak, attack) { const n = noiseSrc(time, dur), f = filt(fType, freq, q), g = ampEnv(time, peak * level, dur, attack); n.connect(f); f.connect(g); g.connect(masterGain); }
|
||||
// 6 detuned square oscillators → bandpass + highpass = the classic 808/909 metallic hi-hat/cymbal timbre.
|
||||
function metalHat(time, level, dur, hpFreq, peak) {
|
||||
const fund = 40, ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
|
||||
const bp = filt("bandpass", 10000, 0.8), hp = filt("highpass", hpFreq, 0), g = ampEnv(time, peak * level, dur, 0.001);
|
||||
ratios.forEach((r) => { const o = audioCtx.createOscillator(); o.type = "square"; o.frequency.value = fund * r; o.start(time); o.stop(time + dur + 0.02); o.connect(bp); });
|
||||
bp.connect(hp); hp.connect(g); g.connect(masterGain);
|
||||
}
|
||||
|
||||
const DRUMS = {
|
||||
beep: (t, l) => v_tone(t, l, "square", l >= 1 ? 1600 : 1100, 0, 0.04, 0.5),
|
||||
kick: (t, l) => v_tone(t, l, "sine", 150, 50, 0.18, 1.0),
|
||||
snare: (t, l) => { v_tone(t, l, "triangle", 190, 140, 0.12, 0.45); v_noise(t, l, "highpass", 1500, 0, 0.2, 0.8); },
|
||||
rim: (t, l) => { const o = tone(t, "square", 1700, 0, 0.04), bp = filt("bandpass", 1700, 4), g = ampEnv(t, 0.6 * l, 0.04); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
clap: (t, l) => { const bp = filt("bandpass", 1200, 1.4); bp.connect(masterGain); [0, 0.012, 0.024].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 2 ? 0.5 : 0.85) * l, 0.06); n.connect(e); e.connect(bp); }); },
|
||||
hatClosed: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.045, 0.5),
|
||||
hatOpen: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.32, 0.45, 0.002),
|
||||
ride: (t, l) => { v_noise(t, l, "bandpass", 6000, 0.8, 0.4, 0.32, 0.002); v_tone(t, l, "square", 5200, 0, 0.1, 0.13); },
|
||||
crash: (t, l) => v_noise(t, l, "highpass", 4000, 0, 0.8, 0.5, 0.002),
|
||||
tomLow: (t, l) => v_tone(t, l, "sine", 150, 100, 0.25, 0.9),
|
||||
tomMid: (t, l) => v_tone(t, l, "sine", 220, 150, 0.23, 0.9),
|
||||
tomHigh: (t, l) => v_tone(t, l, "sine", 300, 210, 0.20, 0.9),
|
||||
tambourine:(t, l) => v_noise(t, l, "highpass", 8000, 0, 0.12, 0.5),
|
||||
cowbell: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
woodblock: (t, l) => v_tone(t, l, "triangle", 1800, 1500, 0.06, 0.8),
|
||||
claves: (t, l) => v_tone(t, l, "sine", 2500, 0, 0.045, 0.85),
|
||||
jamblock: (t, l) => { const o = tone(t, "square", 2600, 2000, 0.045), bp = filt("bandpass", 2000, 6), g = ampEnv(t, 0.8 * l, 0.045); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
// --- electronic drum-machine voices (synthesized — these machines ARE synths in reality) ---
|
||||
kick808: (t, l) => { v_tone(t, l, "sine", 120, 45, 0.7, 1.0); v_noise(t, l * 0.5, "highpass", 2000, 0, 0.008, 0.4, 0.001); }, // long boom + click
|
||||
snare808: (t, l) => { v_tone(t, l, "triangle", 178, 168, 0.16, 0.4); v_tone(t, l, "triangle", 331, 320, 0.12, 0.18); v_noise(t, l, "highpass", 1000, 0, 0.16, 0.7); },
|
||||
clap808: (t, l) => { const bp = filt("bandpass", 1100, 1.3); bp.connect(masterGain); [0, 0.01, 0.02, 0.032].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 3 ? 0.5 : 0.85) * l, 0.05); n.connect(e); e.connect(bp); }); },
|
||||
hat808: (t, l) => metalHat(t, l, 0.045, 7000, 0.4),
|
||||
openHat808: (t, l) => metalHat(t, l, 0.34, 7000, 0.38),
|
||||
cowbell808: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
tom808: (t, l) => v_tone(t, l, "sine", 120, 78, 0.34, 0.9),
|
||||
kick909: (t, l) => { v_tone(t, l, "sine", 110, 46, 0.26, 1.0); v_tone(t, l, "triangle", 280, 60, 0.035, 0.5); v_noise(t, l * 0.6, "highpass", 3000, 0, 0.01, 0.5, 0.001); }, // punchy + click
|
||||
snare909: (t, l) => { v_tone(t, l, "triangle", 190, 162, 0.09, 0.28); v_noise(t, l, "highpass", 1200, 0, 0.2, 0.85); },
|
||||
clap909: (t, l) => { const bp = filt("bandpass", 1000, 1.0); bp.connect(masterGain); [0, 0.009, 0.018].forEach((d) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, 0.6 * l, 0.05); n.connect(e); e.connect(bp); }); const tail = noiseSrc(t + 0.018, 0.2), te = ampEnv(t + 0.018, 0.35 * l, 0.2, 0.001); tail.connect(te); te.connect(bp); },
|
||||
hat909: (t, l) => metalHat(t, l, 0.05, 9000, 0.4),
|
||||
ride909: (t, l) => { metalHat(t, l, 0.5, 6000, 0.3); v_noise(t, l, "bandpass", 7000, 0.7, 0.18, 0.18, 0.002); },
|
||||
crash909: (t, l) => { metalHat(t, l, 0.9, 5000, 0.34); v_noise(t, l, "highpass", 4000, 0, 0.9, 0.4, 0.002); },
|
||||
};
|
||||
const VOICES = [
|
||||
["beep", "beep"], ["kick", "kick"], ["snare", "snare"], ["rim", "rim/stick"], ["clap", "clap"],
|
||||
["hatClosed", "hat closed"], ["hatOpen", "hat open"], ["ride", "ride"], ["crash", "crash"],
|
||||
["tomLow", "tom low"], ["tomMid", "tom mid"], ["tomHigh", "tom high"], ["tambourine", "tambourine"],
|
||||
["cowbell", "cowbell"], ["woodblock", "wood block"], ["claves", "claves"], ["jamblock", "jam block"],
|
||||
["kick808", "808 kick"], ["snare808", "808 snare"], ["clap808", "808 clap"], ["hat808", "808 hat"], ["openHat808", "808 open hat"], ["cowbell808", "808 cowbell"], ["tom808", "808 tom"],
|
||||
["kick909", "909 kick"], ["snare909", "909 snare"], ["clap909", "909 clap"], ["hat909", "909 hat"], ["ride909", "909 ride"], ["crash909", "909 crash"],
|
||||
];
|
||||
const SAMPLE_ALIAS = { tomMid: "tomLow" }; // VCSL has only 2 toms — mid = low tom pitched up
|
||||
const SAMPLE_RATE = { tomMid: 1.19 }; // ~3 semitones
|
||||
function playInstrument(type, time, level) {
|
||||
const buf = sampleBuffers[type] || sampleBuffers[SAMPLE_ALIAS[type]];
|
||||
if (buf) { const s = audioCtx.createBufferSource(); s.buffer = buf; s.playbackRate.value = SAMPLE_RATE[type] || 1; const g = audioCtx.createGain(); g.gain.value = level; s.connect(g); g.connect(masterGain); s.start(time); return; }
|
||||
(DRUMS[type] || DRUMS.beep)(time, level);
|
||||
}
|
||||
/* ---- shared engine: audio voices, scheduler primitives, share-language codec.
|
||||
Inlined from src/engine.js by build.sh; identical in player.html. ---- */
|
||||
const SAMPLES = /*@BUILD:samples@*/{}; // CC0 acoustic one-shots (inlined at build); empty ⇒ pure synth
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
|
||||
/* =========================================================================
|
||||
SCHEDULER (PORTS TO FIRMWARE)
|
||||
|
|
@ -533,7 +413,6 @@ function playInstrument(type, time, level) {
|
|||
let masterBeatTime = 0, masterBeat = 0;
|
||||
let muteWindows = []; // {start,end} time ranges silenced by the trainer
|
||||
|
||||
function masterBeatsPerBar() { return meters.length ? meters[0].beatsPerBar : 4; }
|
||||
|
||||
function advanceMaster(ahead) {
|
||||
const mbpb = masterBeatsPerBar();
|
||||
|
|
@ -558,31 +437,8 @@ function advanceMaster(ahead) {
|
|||
}
|
||||
if (audioCtx) muteWindows = muteWindows.filter((w) => w.end > audioCtx.currentTime - 1);
|
||||
}
|
||||
function isMutedAt(t) { return muteWindows.some((w) => t >= w.start && t < w.end); }
|
||||
|
||||
function scheduleMeterTick(m, time) {
|
||||
const spb = m.stepsPerBeat;
|
||||
const barLen = m.beatsPerBar * spb;
|
||||
const tickInBar = ((m.tick % barLen) + barLen) % barLen;
|
||||
m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
|
||||
if (!m.enabled || isMutedAt(time)) return;
|
||||
const lvl = m.beatsOn[tickInBar] | 0; // dynamics: 0 mute · 1 normal · 2 accent · 3 ghost
|
||||
if (!lvl) return;
|
||||
playInstrument(m.sound, time, lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6);
|
||||
}
|
||||
|
||||
// Reference bar = lane 1's bar; poly lanes fit their beats evenly into it.
|
||||
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||||
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
|
||||
function laneStepDur(m, tick) {
|
||||
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm (no swing)
|
||||
const beat = 60 / state.bpm;
|
||||
if (m.swing && m.stepsPerBeat % 2 === 0) { // swing even subdivisions (8ths, 16ths): long–short pairs
|
||||
const pairDur = beat / (m.stepsPerBeat / 2);
|
||||
return ((tick % m.stepsPerBeat) % 2) === 0 ? SWING_RATIO * pairDur : (1 - SWING_RATIO) * pairDur;
|
||||
}
|
||||
return beat / m.stepsPerBeat; // straight: shared even grid
|
||||
}
|
||||
|
||||
function scheduler() {
|
||||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||||
|
|
@ -1134,56 +990,6 @@ function importAll(file) {
|
|||
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
|
||||
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
|
||||
========================================================================= */
|
||||
function laneCfgToStr(c) {
|
||||
let s = c.sound + ":" + c.groupsStr;
|
||||
const spb = c.stepsPerBeat || 1;
|
||||
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||||
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / . mute)
|
||||
const isDefault = on.length && on.every((v, i) => (v | 0) === ((i % spb) === 0 ? 2 : 1));
|
||||
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
|
||||
if (c.poly) s += "~";
|
||||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||
return s;
|
||||
}
|
||||
function laneStrToCfg(tok) {
|
||||
let poly = false, disabled = false;
|
||||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
|
||||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
|
||||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||||
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||||
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
||||
const bpb = parseGroups(groupsStr).beatsPerBar;
|
||||
// pattern levels: X=accent(2), x/1=normal(1), . / anything else = mute(0); no pattern → default (first of each beat accented)
|
||||
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
|
||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1));
|
||||
if (!DRUMS[sound]) sound = "beep";
|
||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled };
|
||||
}
|
||||
function setupToPatch(s) {
|
||||
const parts = ["v1", "t" + s.bpm];
|
||||
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
||||
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
|
||||
if (s.bars > 0) parts.push("b" + s.bars);
|
||||
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
||||
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
||||
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||||
return parts.join(";");
|
||||
}
|
||||
function patchToSetup(str) {
|
||||
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
|
||||
for (let tok of String(str).split(";")) {
|
||||
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
||||
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } // lanes contain ":" → matched first
|
||||
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
||||
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
|
||||
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
|
||||
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
||||
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
||||
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp }); }
|
||||
function setVolume(pct) {
|
||||
state.volume = Math.max(0, Math.min(1, pct / 100));
|
||||
|
|
@ -1192,14 +998,7 @@ function setVolume(pct) {
|
|||
}
|
||||
function applyPatch(str) { const s = patchToSetup(str); if (s.volume != null) setVolume(s.volume * 100); applySetup(s); }
|
||||
|
||||
// base64url(JSON) for set lists — safely carries free-text titles/names
|
||||
function b64u(str) { return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
|
||||
function unb64u(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent(escape(atob(s))); }
|
||||
function setlistToCode(sl) { return b64u(JSON.stringify({ t: sl.title, d: sl.description, i: sl.items.map((it) => ({ n: it.name, p: setupToPatch(it) })) })); }
|
||||
function codeToSetlist(code) {
|
||||
const o = JSON.parse(unb64u(code));
|
||||
return { title: o.t || "Shared set list", description: o.d || "", items: (o.i || []).map((x) => ({ name: x.n || "Item", ...patchToSetup(x.p) })) };
|
||||
}
|
||||
|
||||
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
|
||||
function openShare(title, url, note) {
|
||||
|
|
@ -1231,43 +1030,7 @@ function applyHashShare() {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Demo set list (each item authored in the share language — also exercises the parser).
|
||||
const SEED_SETLISTS = [
|
||||
{ title: "🥁 Styles", description: "Grooves & feels — load one, press Space, and click pads to shape the accents.", items: [
|
||||
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
|
||||
["Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"],
|
||||
// Purdie half-time shuffle: triplet grid, backbeat on 3, snare ghosts (normal) around it
|
||||
["Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"],
|
||||
// Samba in 2/4 (16ths): surdo strong on beat 2, steady ganzá, tamborim teleco-teco
|
||||
["Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."],
|
||||
// Nañigo / 6/8 bembé bell over a 12/8 grid, low drum on the two main pulses
|
||||
["Nañigo (6/8 bembé)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"],
|
||||
["6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"],
|
||||
["7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"],
|
||||
["5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"],
|
||||
] },
|
||||
{ title: "🎯 Practice", description: "Polyrhythms, independence and tempo / gap tools.", items: [
|
||||
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||||
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
|
||||
["Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"],
|
||||
["Accents — cycle the pads", "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Tempo builder 80↑", "t80;woodblock:4;rmp80/4/4"],
|
||||
["Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"],
|
||||
] },
|
||||
// A continuous ~4:00 song: each item has a bar length (b<n>) so it auto-advances (with
|
||||
// Continue on) through tempo ramps and shifting styles. Durations ≈ bars × beats × 60/bpm.
|
||||
{ title: "🎵 Song — continuous (~4:00)", description: "A full song: turn on Continue, press ▶ on “Intro”, and it plays straight through (~4 min) — segments auto-advance on their bar counts, through tempo ramps and shifting styles.", items: [
|
||||
["Intro — hats & kick", "t88;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"],
|
||||
["Groove in — backbeat", "t88;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Half-time shuffle", "t92;b12;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"],
|
||||
["Build — ramp 92→120", "t92;b16;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Four-on-the-floor (909)", "t124;b18;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"],
|
||||
["Samba break (2/4)", "t116;b24;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."],
|
||||
["Peak — 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"],
|
||||
["Outro — ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"],
|
||||
] },
|
||||
];
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
|
||||
/* =========================================================================
|
||||
VISUALS
|
||||
|
|
|
|||
161
player.html
161
player.html
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VARASYS PM‑1 — hardware player (mockup)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNyIgZmlsbD0iIzFDMjgzRiIvPjxwYXRoIGQ9Ik0xMiA2aDhsNCAyMUg4eiIgZmlsbD0iIzBBQjNGNyIvPjxwYXRoIGQ9Ik0xNiAyNEwxOS42IDkiIHN0cm9rZT0iIzFDMjgzRiIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPjxyZWN0IHg9IjE2LjQiIHk9IjEzLjIiIHdpZHRoPSI0LjQiIGhlaWdodD0iMi44IiByeD0iMC42IiB0cmFuc2Zvcm09InJvdGF0ZSgtMTMgMTguNiAxNC42KSIgZmlsbD0iIzFDMjgzRiIvPjwvc3ZnPgo=">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||||
<!--
|
||||
Hardware-device MOCKUP / simulator for the Pi Pico (RP2040) build of the
|
||||
Stackable Metronome. The physical unit can't show the multi-lane editor or
|
||||
|
|
@ -17,17 +17,17 @@
|
|||
on hardware the voices map to CC0 samples on the I2S DAC). One file, no deps.
|
||||
-->
|
||||
<style>
|
||||
/*@BUILD:include:src/base.css@*/
|
||||
:root{
|
||||
--case:#1b1f27; --case2:#11141a; --edge:#0b0d11; --bezel:#0a0c10;
|
||||
--txt:#c7d0db; --muted:#7f8b9a; --cyan:#0AB3F7; --amber:#ffd166;
|
||||
--screen:#04140f; --phos:#34e0a0; --phos-dim:#1f6e54; --led-off:#1b2a23;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0; min-height:100vh; padding:28px 16px 48px;
|
||||
background:radial-gradient(circle at 50% -8%, #20242c, #0c0e12);
|
||||
color:var(--txt); font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
-webkit-font-smoothing:antialiased; display:flex; flex-direction:column; align-items:center; gap:20px;
|
||||
color:var(--txt);
|
||||
display:flex; flex-direction:column; align-items:center; gap:20px;
|
||||
}
|
||||
a{color:#6cb6ff}
|
||||
.topbar{width:100%; max-width:560px; display:flex; align-items:center; justify-content:space-between; font-size:13px; color:var(--muted)}
|
||||
|
|
@ -165,8 +165,8 @@
|
|||
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2/ …or a #sl=… link / base64 set-list code"></textarea>
|
||||
<div class="row">
|
||||
<button class="ld" id="bLoad">Load onto device</button>
|
||||
<span class="hint">or pick a set list you saved in the editor:</span>
|
||||
<select id="storedSel"><option value="">— your saved set lists —</option></select>
|
||||
<span class="hint">or pick a built-in or saved set list:</span>
|
||||
<select id="storedSel"><option value="">— choose a set list —</option></select>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
|
@ -176,84 +176,17 @@ const APP_VERSION = "v0.0.1-dev";
|
|||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
/* ========================= ENGINE (mirrors index.html; synth voices only) ===== */
|
||||
let audioCtx=null, masterGain=null, noiseBuf=null, schedulerTimer=null;
|
||||
const LOOKAHEAD_MS=25, SCHEDULE_AHEAD=0.12;
|
||||
const SAMPLES = {}; // synth-only device — no acoustic samples; the engine uses its synth voices
|
||||
/*@BUILD:include:src/engine.js@*/
|
||||
/*@BUILD:include:src/setlists.js@*/
|
||||
const state={ bpm:120, volume:0.85, running:false };
|
||||
let meters=[];
|
||||
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
|
||||
let segBars=0, segBarCount=0, pendingAdvance=false;
|
||||
let masterBeat=0, masterBeatTime=0, muteWindows=[];
|
||||
|
||||
function parseGroups(str){
|
||||
const parts=String(str).split(/[^0-9]+/).map(s=>parseInt(s,10)).filter(n=>n>=1&&n<=12);
|
||||
let total=0; const groups=[];
|
||||
for(const p of parts){ if(total+p>12) break; groups.push(p); total+=p; }
|
||||
if(!groups.length) groups.push(4);
|
||||
const beatsPerBar=groups.reduce((a,b)=>a+b,0);
|
||||
const groupStarts=new Set(); let acc=0;
|
||||
for(const g of groups){ groupStarts.add(acc); acc+=g; }
|
||||
return {groups,beatsPerBar,groupStarts};
|
||||
}
|
||||
function ensureAudio(){
|
||||
if(audioCtx) return;
|
||||
audioCtx=new (window.AudioContext||window.webkitAudioContext)();
|
||||
masterGain=audioCtx.createGain(); masterGain.gain.value=state.volume; masterGain.connect(audioCtx.destination);
|
||||
}
|
||||
function getNoise(){
|
||||
if(!noiseBuf){ const n=Math.floor(audioCtx.sampleRate*1.0); noiseBuf=audioCtx.createBuffer(1,n,audioCtx.sampleRate);
|
||||
const d=noiseBuf.getChannelData(0); for(let i=0;i<n;i++) d[i]=Math.random()*2-1; }
|
||||
return noiseBuf;
|
||||
}
|
||||
function ampEnv(time,peak,dur,attack){ const g=audioCtx.createGain(); peak=Math.max(0.0003,peak);
|
||||
g.gain.setValueAtTime(0.0001,time); g.gain.exponentialRampToValueAtTime(peak,time+(attack||0.001));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001,time+dur); return g; }
|
||||
function tone(time,type,f0,f1,dur){ const o=audioCtx.createOscillator(); o.type=type; o.frequency.setValueAtTime(f0,time);
|
||||
if(f1&&f1!==f0) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),time+Math.min(dur,0.09)); o.start(time); o.stop(time+dur+0.02); return o; }
|
||||
function noiseSrc(time,dur){ const s=audioCtx.createBufferSource(); s.buffer=getNoise(); s.start(time); s.stop(time+dur+0.02); return s; }
|
||||
function filt(type,freq,q){ const f=audioCtx.createBiquadFilter(); f.type=type; f.frequency.value=freq; if(q) f.Q.value=q; return f; }
|
||||
function v_tone(time,level,type,f0,f1,dur,peak){ const o=tone(time,type,f0,f1,dur),g=ampEnv(time,peak*level,dur,0.002); o.connect(g); g.connect(masterGain); }
|
||||
function v_noise(time,level,fType,freq,q,dur,peak,attack){ const n=noiseSrc(time,dur),f=filt(fType,freq,q),g=ampEnv(time,peak*level,dur,attack); n.connect(f); f.connect(g); g.connect(masterGain); }
|
||||
function metalHat(time,level,dur,hpFreq,peak){ const fund=40,ratios=[2,3,4.16,5.43,6.79,8.21];
|
||||
const bp=filt("bandpass",10000,0.8),hp=filt("highpass",hpFreq,0),g=ampEnv(time,peak*level,dur,0.001);
|
||||
ratios.forEach(r=>{const o=audioCtx.createOscillator();o.type="square";o.frequency.value=fund*r;o.start(time);o.stop(time+dur+0.02);o.connect(bp);});
|
||||
bp.connect(hp); hp.connect(g); g.connect(masterGain); }
|
||||
const DRUMS={
|
||||
beep:(t,l)=>v_tone(t,l,"square",l>=1?1600:1100,0,0.04,0.5),
|
||||
kick:(t,l)=>v_tone(t,l,"sine",150,50,0.18,1.0),
|
||||
snare:(t,l)=>{v_tone(t,l,"triangle",190,140,0.12,0.45);v_noise(t,l,"highpass",1500,0,0.2,0.8);},
|
||||
rim:(t,l)=>{const o=tone(t,"square",1700,0,0.04),bp=filt("bandpass",1700,4),g=ampEnv(t,0.6*l,0.04);o.connect(bp);bp.connect(g);g.connect(masterGain);},
|
||||
clap:(t,l)=>{const bp=filt("bandpass",1200,1.4);bp.connect(masterGain);[0,0.012,0.024].forEach((d,i)=>{const n=noiseSrc(t+d,0.05),e=ampEnv(t+d,(i<2?0.5:0.85)*l,0.06);n.connect(e);e.connect(bp);});},
|
||||
hatClosed:(t,l)=>v_noise(t,l,"highpass",7000,0,0.045,0.5),
|
||||
hatOpen:(t,l)=>v_noise(t,l,"highpass",7000,0,0.32,0.45,0.002),
|
||||
ride:(t,l)=>{v_noise(t,l,"bandpass",6000,0.8,0.4,0.32,0.002);v_tone(t,l,"square",5200,0,0.1,0.13);},
|
||||
crash:(t,l)=>v_noise(t,l,"highpass",4000,0,0.8,0.5,0.002),
|
||||
tomLow:(t,l)=>v_tone(t,l,"sine",150,100,0.25,0.9),
|
||||
tomMid:(t,l)=>v_tone(t,l,"sine",220,150,0.23,0.9),
|
||||
tomHigh:(t,l)=>v_tone(t,l,"sine",300,210,0.20,0.9),
|
||||
tambourine:(t,l)=>v_noise(t,l,"highpass",8000,0,0.12,0.5),
|
||||
cowbell:(t,l)=>{const sum=audioCtx.createGain(),bp=filt("bandpass",2640,1.2),g=ampEnv(t,0.8*l,0.3);[540,800].forEach(f=>tone(t,"square",f,0,0.3).connect(sum));sum.connect(bp);bp.connect(g);g.connect(masterGain);},
|
||||
woodblock:(t,l)=>v_tone(t,l,"triangle",1800,1500,0.06,0.8),
|
||||
claves:(t,l)=>v_tone(t,l,"sine",2500,0,0.045,0.85),
|
||||
jamblock:(t,l)=>{const o=tone(t,"square",2600,2000,0.045),bp=filt("bandpass",2000,6),g=ampEnv(t,0.8*l,0.045);o.connect(bp);bp.connect(g);g.connect(masterGain);},
|
||||
kick808:(t,l)=>{v_tone(t,l,"sine",120,45,0.7,1.0);v_noise(t,l*0.5,"highpass",2000,0,0.008,0.4,0.001);},
|
||||
snare808:(t,l)=>{v_tone(t,l,"triangle",178,168,0.16,0.4);v_tone(t,l,"triangle",331,320,0.12,0.18);v_noise(t,l,"highpass",1000,0,0.16,0.7);},
|
||||
clap808:(t,l)=>{const bp=filt("bandpass",1100,1.3);bp.connect(masterGain);[0,0.01,0.02,0.032].forEach((d,i)=>{const n=noiseSrc(t+d,0.05),e=ampEnv(t+d,(i<3?0.5:0.85)*l,0.05);n.connect(e);e.connect(bp);});},
|
||||
hat808:(t,l)=>metalHat(t,l,0.045,7000,0.4),
|
||||
openHat808:(t,l)=>metalHat(t,l,0.34,7000,0.38),
|
||||
cowbell808:(t,l)=>{const sum=audioCtx.createGain(),bp=filt("bandpass",2640,1.2),g=ampEnv(t,0.8*l,0.3);[540,800].forEach(f=>tone(t,"square",f,0,0.3).connect(sum));sum.connect(bp);bp.connect(g);g.connect(masterGain);},
|
||||
tom808:(t,l)=>v_tone(t,l,"sine",120,78,0.34,0.9),
|
||||
kick909:(t,l)=>{v_tone(t,l,"sine",110,46,0.26,1.0);v_tone(t,l,"triangle",280,60,0.035,0.5);v_noise(t,l*0.6,"highpass",3000,0,0.01,0.5,0.001);},
|
||||
snare909:(t,l)=>{v_tone(t,l,"triangle",190,162,0.09,0.28);v_noise(t,l,"highpass",1200,0,0.2,0.85);},
|
||||
clap909:(t,l)=>{const bp=filt("bandpass",1000,1.0);bp.connect(masterGain);[0,0.009,0.018].forEach(d=>{const n=noiseSrc(t+d,0.05),e=ampEnv(t+d,0.6*l,0.05);n.connect(e);e.connect(bp);});const tail=noiseSrc(t+0.018,0.2),te=ampEnv(t+0.018,0.35*l,0.2,0.001);tail.connect(te);te.connect(bp);},
|
||||
hat909:(t,l)=>metalHat(t,l,0.05,9000,0.4),
|
||||
ride909:(t,l)=>{metalHat(t,l,0.5,6000,0.3);v_noise(t,l,"bandpass",7000,0.7,0.18,0.18,0.002);},
|
||||
crash909:(t,l)=>{metalHat(t,l,0.9,5000,0.34);v_noise(t,l,"highpass",4000,0,0.9,0.4,0.002);},
|
||||
};
|
||||
function playInstrument(type,time,level){ (DRUMS[type]||DRUMS.beep)(time,level); }
|
||||
|
||||
function masterBeatsPerBar(){ return meters.length ? meters[0].beatsPerBar : 4; }
|
||||
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
|
||||
function isMutedAt(t){ return muteWindows.some(w=>t>=w.start&&t<w.end); }
|
||||
function advanceMaster(ahead){
|
||||
const mbpb=masterBeatsPerBar();
|
||||
while(masterBeatTime<ahead){
|
||||
|
|
@ -268,21 +201,6 @@ function advanceMaster(ahead){
|
|||
}
|
||||
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
|
||||
}
|
||||
function scheduleMeterTick(m,time){
|
||||
const spb=m.stepsPerBeat, barLen=m.beatsPerBar*spb, tickInBar=((m.tick%barLen)+barLen)%barLen;
|
||||
m.vq.push({time,step:tickInBar,bar:Math.floor(m.tick/barLen)});
|
||||
if(!m.enabled||isMutedAt(time)) return;
|
||||
const lvl=m.beatsOn[tickInBar]|0; if(!lvl) return;
|
||||
playInstrument(m.sound,time,lvl===2?1.0:lvl===3?0.25:0.6);
|
||||
}
|
||||
function refBarDur(){ return (meters.length?meters[0].beatsPerBar:4)*(60/state.bpm); }
|
||||
const SWING_RATIO=2/3;
|
||||
function laneStepDur(m,tick){
|
||||
if(m.poly) return refBarDur()/(m.beatsPerBar*m.stepsPerBeat);
|
||||
const beat=60/state.bpm;
|
||||
if(m.swing&&m.stepsPerBeat%2===0){ const pairDur=beat/(m.stepsPerBeat/2); return ((tick%m.stepsPerBeat)%2)===0?SWING_RATIO*pairDur:(1-SWING_RATIO)*pairDur; }
|
||||
return beat/m.stepsPerBeat;
|
||||
}
|
||||
function scheduler(){
|
||||
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
|
||||
advanceMaster(ahead);
|
||||
|
|
@ -290,50 +208,11 @@ function scheduler(){
|
|||
if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); } // loops at end (gotoItem wraps)
|
||||
}
|
||||
|
||||
/* ----- patch / set-list parsing (mirrors index.html) ----- */
|
||||
function laneStrToCfg(tok){
|
||||
let poly=false,disabled=false;
|
||||
while(/[~!]$/.test(tok)){ if(tok.endsWith("!")) disabled=true; else poly=true; tok=tok.slice(0,-1); }
|
||||
const ci=tok.indexOf(":"); if(ci<0) return null;
|
||||
let sound=tok.slice(0,ci),rest=tok.slice(ci+1),pattern=null;
|
||||
const eq=rest.indexOf("="); if(eq>=0){ pattern=rest.slice(eq+1); rest=rest.slice(0,eq); }
|
||||
let groupsStr=rest,sub=1,swing=false; const sl=rest.indexOf("/");
|
||||
if(sl>=0){ groupsStr=rest.slice(0,sl); const sp=rest.slice(sl+1); swing=/s$/i.test(sp); sub=parseInt(sp,10)||1; }
|
||||
const bpb=parseGroups(groupsStr).beatsPerBar;
|
||||
const beatsOn=pattern ? pattern.split("").map(ch=>ch==="X"?2:ch==="g"?3:(ch==="x"||ch==="1")?1:0)
|
||||
: Array.from({length:bpb*sub},(_,i)=>((i%sub)===0?2:1));
|
||||
if(!DRUMS[sound]) sound="beep";
|
||||
return {groupsStr,stepsPerBeat:sub,sound,beatsOn,poly,swing,enabled:!disabled};
|
||||
}
|
||||
function patchToSetup(str){
|
||||
const s={bpm:120,volume:null,countMs:0,bars:0,lanes:[],trainer:{on:false,playBars:2,muteBars:2},ramp:{on:false,startBpm:80,amount:5,everyBars:4}};
|
||||
for(let tok of String(str).split(";")){
|
||||
tok=tok.trim(); if(!tok||tok==="v1") continue;
|
||||
if(tok.includes(":")){ const c=laneStrToCfg(tok); if(c) s.lanes.push(c); }
|
||||
else if(tok.startsWith("vol")) s.volume=(parseInt(tok.slice(3),10)||0)/100;
|
||||
else if(tok.startsWith("cd")) s.countMs=(parseInt(tok.slice(2),10)||0)*1000;
|
||||
else if(tok.startsWith("b")) s.bars=parseInt(tok.slice(1),10)||0;
|
||||
else if(tok.startsWith("tr")){ const [p,m]=tok.slice(2).split("/"); s.trainer={on:true,playBars:+p||1,muteBars:+m||0}; }
|
||||
else if(tok.startsWith("rmp")){ const [a,b,c]=tok.slice(3).split("/"); s.ramp={on:true,startBpm:+a||80,amount:+b||0,everyBars:+c||1}; }
|
||||
else if(tok.startsWith("t")) s.bpm=parseInt(tok.slice(1),10)||120;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function unb64u(s){ s=s.replace(/-/g,"+").replace(/_/g,"/"); return decodeURIComponent(escape(atob(s))); }
|
||||
function codeToSetlist(code){
|
||||
const o=JSON.parse(unb64u(code));
|
||||
return { title:o.t||"Shared set list", description:o.d||"", items:(o.i||[]).map(x=>({name:x.n||"Item",...patchToSetup(x.p)})) };
|
||||
}
|
||||
|
||||
/* ========================= PLAYER ============================================= */
|
||||
let setlist=null, idx=0;
|
||||
function mkSetlist(title,arr){ return {title, items:arr.map(([n,p])=>({name:n,...patchToSetup(p)}))}; }
|
||||
const DEMO = mkSetlist("Demo song", [
|
||||
["Intro", "t96;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"],
|
||||
["Backbeat", "t96;b12;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Build ↑", "t96;b12;rmp96/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"],
|
||||
["909 floor", "t124;b16;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"],
|
||||
]);
|
||||
// Built-in set lists = the editor's seed lists (shared via src/setlists.js).
|
||||
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
|
||||
|
||||
function buildMeters(lanes){
|
||||
return (lanes||[]).map(c=>{
|
||||
|
|
@ -434,9 +313,14 @@ function loadConfig(text,quiet){
|
|||
}
|
||||
function loadStored(){
|
||||
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
|
||||
const sel=$("storedSel"); sel.innerHTML='<option value="">— your saved set lists —</option>';
|
||||
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value=i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; sel.appendChild(o); });
|
||||
sel._lists=lists;
|
||||
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
|
||||
const og1=document.createElement("optgroup"); og1.label="Built-in";
|
||||
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
|
||||
sel.appendChild(og1);
|
||||
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
|
||||
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
|
||||
sel.appendChild(og2); }
|
||||
sel._lists=lists; sel._builtin=BUILTIN;
|
||||
}
|
||||
|
||||
/* ========================= WIRING ============================================ */
|
||||
|
|
@ -446,8 +330,9 @@ $("bNext").onclick=()=>gotoItem(idx+1,state.running);
|
|||
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
|
||||
$("bTap").onclick=tapTempo;
|
||||
$("bLoad").onclick=()=>loadConfig($("cfg").value);
|
||||
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(v===""){return;} const sl=e.target._lists[+v]; if(!sl) return;
|
||||
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded saved set list “"+(sl.title||"set list")+"”.",true); };
|
||||
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
|
||||
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
|
||||
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded set list “"+(sl.title||"set list")+"”.",true); };
|
||||
addEventListener("keydown",(e)=>{
|
||||
const t=e.target, tag=t?t.tagName:""; if(tag==="TEXTAREA"||tag==="INPUT"||tag==="SELECT") return;
|
||||
const k=e.key;
|
||||
|
|
@ -462,7 +347,7 @@ addEventListener("keydown",(e)=>{
|
|||
/* ========================= INIT ============================================== */
|
||||
loadStored();
|
||||
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
|
||||
if(!setlist){ setlist=DEMO; idx=0; loadSetup(setlist.items[0]); }
|
||||
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
|
||||
renderAll();
|
||||
requestAnimationFrame(draw);
|
||||
</script>
|
||||
|
|
|
|||
12
src/base.css
Normal file
12
src/base.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/* Shared base — inlined into BOTH index.html and player.html by build.sh.
|
||||
Box-sizing reset, the VARASYS brand palette, and the common type stack.
|
||||
(Page-specific colours/layout live in each page's own <style>.) */
|
||||
* { box-sizing: border-box; }
|
||||
:root {
|
||||
--cyan: #0AB3F7; /* VARASYS brand cyan */
|
||||
--navy: #1C283F; /* VARASYS brand navy */
|
||||
}
|
||||
body {
|
||||
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
221
src/engine.js
Normal file
221
src/engine.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/* =========================================================================
|
||||
SHARED ENGINE — inlined into BOTH index.html and player.html by build.sh.
|
||||
Audio voices, the Web Audio look-ahead scheduler primitives (PORTS TO
|
||||
FIRMWARE), and the share-language codec. Each host supplies its own state
|
||||
globals (state, meters, ramp, trainer, segBars, masterBeat…), its own
|
||||
setBpm, advanceMaster and scheduler(), and its own SAMPLES object (the
|
||||
editor inlines CC0 one-shots; the player passes {} for pure synth).
|
||||
========================================================================= */
|
||||
const LOOKAHEAD_MS = 25, SCHEDULE_AHEAD = 0.12;
|
||||
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
|
||||
let audioCtx = null, masterGain = null, noiseBuf = null, schedulerTimer = null;
|
||||
const sampleBuffers = {}; // decoded CC0 acoustic one-shots (VCSL, CC0); acoustic voices play these, electronic stay synth
|
||||
|
||||
|
||||
// --- grouping: "2+2+3" → groups / beatsPerBar / group-start indices ---
|
||||
function parseGroups(str) {
|
||||
const parts = String(str).split(/[^0-9]+/).map((s) => parseInt(s, 10)).filter((n) => n >= 1 && n <= 12);
|
||||
let total = 0; const groups = [];
|
||||
for (const p of parts) { if (total + p > 12) break; groups.push(p); total += p; }
|
||||
if (!groups.length) groups.push(4);
|
||||
const beatsPerBar = groups.reduce((a, b) => a + b, 0);
|
||||
const groupStarts = new Set(); let acc = 0;
|
||||
for (const g of groups) { groupStarts.add(acc); acc += g; }
|
||||
return { groups, beatsPerBar, groupStarts };
|
||||
}
|
||||
|
||||
// --- audio context + embedded-sample decode ---
|
||||
function ensureAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = audioCtx.createGain();
|
||||
masterGain.gain.value = state.volume;
|
||||
masterGain.connect(audioCtx.destination);
|
||||
for (const k in SAMPLES) { // decode embedded one-shots once
|
||||
const bin = atob(SAMPLES[k]); const u8 = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||||
audioCtx.decodeAudioData(u8.buffer, (b) => { sampleBuffers[k] = b; }, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared noise buffer ---
|
||||
function getNoise() {
|
||||
if (!noiseBuf) {
|
||||
const n = Math.floor(audioCtx.sampleRate * 1.0);
|
||||
noiseBuf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
|
||||
const d = noiseBuf.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return noiseBuf;
|
||||
}
|
||||
|
||||
// --- synthesized GM-style voices (level = velocity) ---
|
||||
function ampEnv(time, peak, dur, attack) {
|
||||
const g = audioCtx.createGain();
|
||||
peak = Math.max(0.0003, peak);
|
||||
g.gain.setValueAtTime(0.0001, time);
|
||||
g.gain.exponentialRampToValueAtTime(peak, time + (attack || 0.001));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, time + dur);
|
||||
return g;
|
||||
}
|
||||
function tone(time, type, f0, f1, dur) {
|
||||
const o = audioCtx.createOscillator(); o.type = type;
|
||||
o.frequency.setValueAtTime(f0, time);
|
||||
if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), time + Math.min(dur, 0.09));
|
||||
o.start(time); o.stop(time + dur + 0.02); return o;
|
||||
}
|
||||
function noiseSrc(time, dur) { const s = audioCtx.createBufferSource(); s.buffer = getNoise(); s.start(time); s.stop(time + dur + 0.02); return s; }
|
||||
function filt(type, freq, q) { const f = audioCtx.createBiquadFilter(); f.type = type; f.frequency.value = freq; if (q) f.Q.value = q; return f; }
|
||||
function v_tone(time, level, type, f0, f1, dur, peak) { const o = tone(time, type, f0, f1, dur), g = ampEnv(time, peak * level, dur, 0.002); o.connect(g); g.connect(masterGain); }
|
||||
function v_noise(time, level, fType, freq, q, dur, peak, attack) { const n = noiseSrc(time, dur), f = filt(fType, freq, q), g = ampEnv(time, peak * level, dur, attack); n.connect(f); f.connect(g); g.connect(masterGain); }
|
||||
// 6 detuned square oscillators → bandpass + highpass = the classic 808/909 metallic hi-hat/cymbal timbre.
|
||||
function metalHat(time, level, dur, hpFreq, peak) {
|
||||
const fund = 40, ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
|
||||
const bp = filt("bandpass", 10000, 0.8), hp = filt("highpass", hpFreq, 0), g = ampEnv(time, peak * level, dur, 0.001);
|
||||
ratios.forEach((r) => { const o = audioCtx.createOscillator(); o.type = "square"; o.frequency.value = fund * r; o.start(time); o.stop(time + dur + 0.02); o.connect(bp); });
|
||||
bp.connect(hp); hp.connect(g); g.connect(masterGain);
|
||||
}
|
||||
|
||||
// --- voice table + sample aliases ---
|
||||
const DRUMS = {
|
||||
beep: (t, l) => v_tone(t, l, "square", l >= 1 ? 1600 : 1100, 0, 0.04, 0.5),
|
||||
kick: (t, l) => v_tone(t, l, "sine", 150, 50, 0.18, 1.0),
|
||||
snare: (t, l) => { v_tone(t, l, "triangle", 190, 140, 0.12, 0.45); v_noise(t, l, "highpass", 1500, 0, 0.2, 0.8); },
|
||||
rim: (t, l) => { const o = tone(t, "square", 1700, 0, 0.04), bp = filt("bandpass", 1700, 4), g = ampEnv(t, 0.6 * l, 0.04); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
clap: (t, l) => { const bp = filt("bandpass", 1200, 1.4); bp.connect(masterGain); [0, 0.012, 0.024].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 2 ? 0.5 : 0.85) * l, 0.06); n.connect(e); e.connect(bp); }); },
|
||||
hatClosed: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.045, 0.5),
|
||||
hatOpen: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.32, 0.45, 0.002),
|
||||
ride: (t, l) => { v_noise(t, l, "bandpass", 6000, 0.8, 0.4, 0.32, 0.002); v_tone(t, l, "square", 5200, 0, 0.1, 0.13); },
|
||||
crash: (t, l) => v_noise(t, l, "highpass", 4000, 0, 0.8, 0.5, 0.002),
|
||||
tomLow: (t, l) => v_tone(t, l, "sine", 150, 100, 0.25, 0.9),
|
||||
tomMid: (t, l) => v_tone(t, l, "sine", 220, 150, 0.23, 0.9),
|
||||
tomHigh: (t, l) => v_tone(t, l, "sine", 300, 210, 0.20, 0.9),
|
||||
tambourine:(t, l) => v_noise(t, l, "highpass", 8000, 0, 0.12, 0.5),
|
||||
cowbell: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
woodblock: (t, l) => v_tone(t, l, "triangle", 1800, 1500, 0.06, 0.8),
|
||||
claves: (t, l) => v_tone(t, l, "sine", 2500, 0, 0.045, 0.85),
|
||||
jamblock: (t, l) => { const o = tone(t, "square", 2600, 2000, 0.045), bp = filt("bandpass", 2000, 6), g = ampEnv(t, 0.8 * l, 0.045); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
// --- electronic drum-machine voices (synthesized — these machines ARE synths in reality) ---
|
||||
kick808: (t, l) => { v_tone(t, l, "sine", 120, 45, 0.7, 1.0); v_noise(t, l * 0.5, "highpass", 2000, 0, 0.008, 0.4, 0.001); }, // long boom + click
|
||||
snare808: (t, l) => { v_tone(t, l, "triangle", 178, 168, 0.16, 0.4); v_tone(t, l, "triangle", 331, 320, 0.12, 0.18); v_noise(t, l, "highpass", 1000, 0, 0.16, 0.7); },
|
||||
clap808: (t, l) => { const bp = filt("bandpass", 1100, 1.3); bp.connect(masterGain); [0, 0.01, 0.02, 0.032].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 3 ? 0.5 : 0.85) * l, 0.05); n.connect(e); e.connect(bp); }); },
|
||||
hat808: (t, l) => metalHat(t, l, 0.045, 7000, 0.4),
|
||||
openHat808: (t, l) => metalHat(t, l, 0.34, 7000, 0.38),
|
||||
cowbell808: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||||
tom808: (t, l) => v_tone(t, l, "sine", 120, 78, 0.34, 0.9),
|
||||
kick909: (t, l) => { v_tone(t, l, "sine", 110, 46, 0.26, 1.0); v_tone(t, l, "triangle", 280, 60, 0.035, 0.5); v_noise(t, l * 0.6, "highpass", 3000, 0, 0.01, 0.5, 0.001); }, // punchy + click
|
||||
snare909: (t, l) => { v_tone(t, l, "triangle", 190, 162, 0.09, 0.28); v_noise(t, l, "highpass", 1200, 0, 0.2, 0.85); },
|
||||
clap909: (t, l) => { const bp = filt("bandpass", 1000, 1.0); bp.connect(masterGain); [0, 0.009, 0.018].forEach((d) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, 0.6 * l, 0.05); n.connect(e); e.connect(bp); }); const tail = noiseSrc(t + 0.018, 0.2), te = ampEnv(t + 0.018, 0.35 * l, 0.2, 0.001); tail.connect(te); te.connect(bp); },
|
||||
hat909: (t, l) => metalHat(t, l, 0.05, 9000, 0.4),
|
||||
ride909: (t, l) => { metalHat(t, l, 0.5, 6000, 0.3); v_noise(t, l, "bandpass", 7000, 0.7, 0.18, 0.18, 0.002); },
|
||||
crash909: (t, l) => { metalHat(t, l, 0.9, 5000, 0.34); v_noise(t, l, "highpass", 4000, 0, 0.9, 0.4, 0.002); },
|
||||
};
|
||||
const VOICES = [
|
||||
["beep", "beep"], ["kick", "kick"], ["snare", "snare"], ["rim", "rim/stick"], ["clap", "clap"],
|
||||
["hatClosed", "hat closed"], ["hatOpen", "hat open"], ["ride", "ride"], ["crash", "crash"],
|
||||
["tomLow", "tom low"], ["tomMid", "tom mid"], ["tomHigh", "tom high"], ["tambourine", "tambourine"],
|
||||
["cowbell", "cowbell"], ["woodblock", "wood block"], ["claves", "claves"], ["jamblock", "jam block"],
|
||||
["kick808", "808 kick"], ["snare808", "808 snare"], ["clap808", "808 clap"], ["hat808", "808 hat"], ["openHat808", "808 open hat"], ["cowbell808", "808 cowbell"], ["tom808", "808 tom"],
|
||||
["kick909", "909 kick"], ["snare909", "909 snare"], ["clap909", "909 clap"], ["hat909", "909 hat"], ["ride909", "909 ride"], ["crash909", "909 crash"],
|
||||
];
|
||||
const SAMPLE_ALIAS = { tomMid: "tomLow" }; // VCSL has only 2 toms — mid = low tom pitched up
|
||||
const SAMPLE_RATE = { tomMid: 1.19 }; // ~3 semitones
|
||||
function playInstrument(type, time, level) {
|
||||
const buf = sampleBuffers[type] || sampleBuffers[SAMPLE_ALIAS[type]];
|
||||
if (buf) { const s = audioCtx.createBufferSource(); s.buffer = buf; s.playbackRate.value = SAMPLE_RATE[type] || 1; const g = audioCtx.createGain(); g.gain.value = level; s.connect(g); g.connect(masterGain); s.start(time); return; }
|
||||
(DRUMS[type] || DRUMS.beep)(time, level);
|
||||
}
|
||||
|
||||
// --- scheduler primitives (PORTS TO FIRMWARE) ---
|
||||
function masterBeatsPerBar() { return meters.length ? meters[0].beatsPerBar : 4; }
|
||||
|
||||
function isMutedAt(t) { return muteWindows.some((w) => t >= w.start && t < w.end); }
|
||||
|
||||
// --- per-step scheduling + dynamics ---
|
||||
function scheduleMeterTick(m, time) {
|
||||
const spb = m.stepsPerBeat;
|
||||
const barLen = m.beatsPerBar * spb;
|
||||
const tickInBar = ((m.tick % barLen) + barLen) % barLen;
|
||||
m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
|
||||
if (!m.enabled || isMutedAt(time)) return;
|
||||
const lvl = m.beatsOn[tickInBar] | 0; // dynamics: 0 mute · 1 normal · 2 accent · 3 ghost
|
||||
if (!lvl) return;
|
||||
playInstrument(m.sound, time, lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6);
|
||||
}
|
||||
|
||||
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||||
|
||||
// --- step duration (poly / swing / straight) ---
|
||||
function laneStepDur(m, tick) {
|
||||
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm (no swing)
|
||||
const beat = 60 / state.bpm;
|
||||
if (m.swing && m.stepsPerBeat % 2 === 0) { // swing even subdivisions (8ths, 16ths): long–short pairs
|
||||
const pairDur = beat / (m.stepsPerBeat / 2);
|
||||
return ((tick % m.stepsPerBeat) % 2) === 0 ? SWING_RATIO * pairDur : (1 - SWING_RATIO) * pairDur;
|
||||
}
|
||||
return beat / m.stepsPerBeat; // straight: shared even grid
|
||||
}
|
||||
|
||||
// --- share-language codec: config ⇄ lane token ---
|
||||
function laneCfgToStr(c) {
|
||||
let s = c.sound + ":" + c.groupsStr;
|
||||
const spb = c.stepsPerBeat || 1;
|
||||
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||||
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / . mute)
|
||||
const isDefault = on.length && on.every((v, i) => (v | 0) === ((i % spb) === 0 ? 2 : 1));
|
||||
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
|
||||
if (c.poly) s += "~";
|
||||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||
return s;
|
||||
}
|
||||
function laneStrToCfg(tok) {
|
||||
let poly = false, disabled = false;
|
||||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
|
||||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
|
||||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||||
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||||
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
||||
const bpb = parseGroups(groupsStr).beatsPerBar;
|
||||
// pattern levels: X=accent(2), x/1=normal(1), . / anything else = mute(0); no pattern → default (first of each beat accented)
|
||||
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
|
||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1));
|
||||
if (!DRUMS[sound]) sound = "beep";
|
||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled };
|
||||
}
|
||||
|
||||
// --- share-language codec: patch ⇄ setup ---
|
||||
function setupToPatch(s) {
|
||||
const parts = ["v1", "t" + s.bpm];
|
||||
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
||||
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
|
||||
if (s.bars > 0) parts.push("b" + s.bars);
|
||||
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
||||
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
||||
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||||
return parts.join(";");
|
||||
}
|
||||
function patchToSetup(str) {
|
||||
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
|
||||
for (let tok of String(str).split(";")) {
|
||||
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
||||
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } // lanes contain ":" → matched first
|
||||
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
||||
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
|
||||
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
|
||||
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
||||
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
||||
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- base64url(JSON) set-list codec ---
|
||||
function b64u(str) { return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
|
||||
function unb64u(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent(escape(atob(s))); }
|
||||
|
||||
|
||||
function codeToSetlist(code) {
|
||||
const o = JSON.parse(unb64u(code));
|
||||
return { title: o.t || "Shared set list", description: o.d || "", items: (o.i || []).map((x) => ({ name: x.n || "Item", ...patchToSetup(x.p) })) };
|
||||
}
|
||||
37
src/setlists.js
Normal file
37
src/setlists.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Demo set list (each item authored in the share language — also exercises the parser).
|
||||
const SEED_SETLISTS = [
|
||||
{ title: "🥁 Styles", description: "Grooves & feels — load one, press Space, and click pads to shape the accents.", items: [
|
||||
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
|
||||
["Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"],
|
||||
// Purdie half-time shuffle: triplet grid, backbeat on 3, snare ghosts (normal) around it
|
||||
["Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"],
|
||||
// Samba in 2/4 (16ths): surdo strong on beat 2, steady ganzá, tamborim teleco-teco
|
||||
["Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."],
|
||||
// Nañigo / 6/8 bembé bell over a 12/8 grid, low drum on the two main pulses
|
||||
["Nañigo (6/8 bembé)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"],
|
||||
["6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"],
|
||||
["7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"],
|
||||
["5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"],
|
||||
] },
|
||||
{ title: "🎯 Practice", description: "Polyrhythms, independence and tempo / gap tools.", items: [
|
||||
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||||
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
|
||||
["Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"],
|
||||
["Accents — cycle the pads", "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Tempo builder 80↑", "t80;woodblock:4;rmp80/4/4"],
|
||||
["Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"],
|
||||
] },
|
||||
// A continuous ~4:00 song: each item has a bar length (b<n>) so it auto-advances (with
|
||||
// Continue on) through tempo ramps and shifting styles. Durations ≈ bars × beats × 60/bpm.
|
||||
{ title: "🎵 Song — continuous (~4:00)", description: "A full song: turn on Continue, press ▶ on “Intro”, and it plays straight through (~4 min) — segments auto-advance on their bar counts, through tempo ramps and shifting styles.", items: [
|
||||
["Intro — hats & kick", "t88;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"],
|
||||
["Groove in — backbeat", "t88;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Half-time shuffle", "t92;b12;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"],
|
||||
["Build — ramp 92→120", "t92;b16;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"],
|
||||
["Four-on-the-floor (909)", "t124;b18;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"],
|
||||
["Samba break (2/4)", "t116;b24;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."],
|
||||
["Peak — 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"],
|
||||
["Outro — ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"],
|
||||
] },
|
||||
];
|
||||
Loading…
Reference in a new issue