Phase A — engine v2: drop samples, add conventions + per-lane dB gain

- Remove the VCSL sample kit entirely (editor 351K → 141K). All voices are
  synthesized; the friendly GM names now alias to the punchier 808/909 renders
  (KIT_ALIAS). build.sh drops the @BUILD:samples inlining; assets/samples.json gone.
- Conventions (backward-compatible): GM note-number aliases (36=kick…), '-'/'_'
  rest aliases in step patterns, Euclidean (k,n[,rot]) shorthand.
- Per-lane gain in dB (@<db> in the grammar) applied as a velocity multiplier at
  schedule time — no stutter; threaded through every host's buildMeters + the
  editor's lanes (knob UI comes in Phase B).
- 15/15 engine round-trip tests pass; pages console-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 10:33:56 -05:00
parent 678482a0f2
commit 4babd1f4ef
9 changed files with 73 additions and 45 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Assemble the deployed single-file pages from source + shared partials + assets/.
#
# Every page (the landing index.html, the editor.html app, the device mockups and
# the info-*.html pages) is a source that shares 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
# Every page (the Concepts landing, the editor app, and the device/form-factor
# pages) is a source that shares code via markers:
# /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS, header/footer/chrome)
# @BUILD:favicon@ / @BUILD:logo-*@ inline base64 assets (voices are all synthesized — no samples)
# 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.
@ -12,20 +12,18 @@ set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
mkdir -p dist
python3 - <<'PY'
import json, os, pathlib, re
import os, pathlib, re
A = pathlib.Path("assets")
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
# 2) inline base64 assets (voices are all synthesized now — no samples)
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@*/{}", SAMPLES)
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
out = pathlib.Path("dist") / name
out.write_text(src)

View file

@ -434,7 +434,7 @@ let meterSeq = 0; // id counter
/* ---- 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
const SAMPLES = {}; // all voices are synthesized (samples removed; 808/909 renders are the kit)
/*@BUILD:include:src/engine.js@*/
/* =========================================================================
@ -552,12 +552,12 @@ function setBpm(v) {
========================================================================= */
function laneColor(id) { return `hsl(${(id * 67) % 360} 70% 62%)`; }
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false, swing = false) {
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false, swing = false, gainDb = 0) {
const id = ++meterSeq;
const p = parseGroups(groupsStr);
const m = {
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
stepsPerBeat, sound, enabled: true, poly: !!poly, swing: !!swing, color: laneColor(id),
stepsPerBeat, sound, enabled: true, poly: !!poly, swing: !!swing, gainDb: gainDb || 0, color: laneColor(id),
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP dynamics mask (one entry per pad: 0 mute / 1 normal / 2 accent)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
el: null, stripEl: null, barEl: null,
@ -709,11 +709,11 @@ const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs:
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, swing: !!m.swing, beatsOn: m.beatsOn.slice() })); }
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, swing: !!m.swing, gainDb: m.gainDb || 0, beatsOn: m.beatsOn.slice() })); }
function applyLanes(lanes) {
while (meters.length) removeMeter(meters[0].id);
for (const c of lanes) {
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly, c.swing);
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly, c.swing, c.gainDb);
const m = meters[meters.length - 1];
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
}

View file

@ -190,7 +190,7 @@ function scheduler(){
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,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){

View file

@ -316,7 +316,7 @@ 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,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
});
}

View file

@ -133,7 +133,7 @@ function scheduler(){
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,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){

View file

@ -1,15 +1,17 @@
/* =========================================================================
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).
SHARED ENGINE inlined into every page by build.sh.
Audio voices (all SYNTHESIZED no samples), 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().
Conventions (kept close to what people already know): GM drum names +
note-number aliases, drum-tab step patterns (X accent / x normal / g ghost /
. - _ rest), subdivisions /2 /3 /4, Euclidean (k,n) shorthand, per-lane gain
in dB (@<db>), and ~ = polymeter.
========================================================================= */
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 ---
@ -24,18 +26,13 @@ function parseGroups(str) {
return { groups, beatsPerBar, groupStarts };
}
// --- audio context + embedded-sample decode ---
// --- audio context ---
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 ---
@ -118,12 +115,30 @@ const VOICES = [
["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
// Default kit points the friendly GM names at the punchier 808/909 renders
// (samples removed — those synth voices sound better). Pick "kick808"/"kick909"
// etc. explicitly for a specific machine flavour.
const KIT_ALIAS = {
kick: "kick909", snare: "snare909", clap: "clap909",
hatClosed: "hat909", hatOpen: "openHat808", ride: "ride909", crash: "crash909",
cowbell: "cowbell808",
};
// General-MIDI percussion note numbers → our voice names (so MIDI/DAW users can type numbers)
const GM_NUM = {
35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare",
41: "tomLow", 42: "hatClosed", 43: "tomLow", 44: "hatClosed", 45: "tomMid",
46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash", 50: "tomHigh",
51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves",
76: "woodblock", 77: "woodblock",
};
// Euclidean / Bjorklund-class even distribution: k hits over n steps, rotated by rot
function euclid(k, n, rot) {
n = Math.max(1, n | 0); k = Math.max(0, Math.min(n, k | 0)); rot = (((rot | 0) % n) + n) % n;
const r = []; for (let i = 0; i < n; i++) { const j = (i + rot) % n; r.push(((j * k) % n) < k ? 1 : 0); }
return r;
}
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);
(DRUMS[KIT_ALIAS[type] || type] || DRUMS[type] || DRUMS.beep)(time, level);
}
// --- scheduler primitives (PORTS TO FIRMWARE) ---
@ -140,7 +155,8 @@ function scheduleMeterTick(m, time) {
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);
const lin = m.gainDb ? Math.pow(10, m.gainDb / 20) : 1; // per-lane dB gain → linear, applied at schedule time (no stutter)
playInstrument(m.sound, time, (lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6) * lin);
}
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
@ -161,27 +177,42 @@ 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 on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . 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.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
if (c.poly) s += "~";
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
return s;
}
function laneStrToCfg(tok) {
let poly = false, disabled = false;
let poly = false, disabled = false, gainDb = 0;
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
const at = tok.indexOf("@"); if (at >= 0) { gainDb = parseFloat(tok.slice(at + 1)) || 0; tok = tok.slice(0, at); } // @<db> gain
const ci = tok.indexOf(":"); if (ci < 0) return null;
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
if (/^\d+$/.test(sound) && GM_NUM[sound]) sound = GM_NUM[sound]; // GM note number → name
// Euclidean shorthand (k,n[,rot]) — replaces an explicit =pattern
let eucK = null, eucN = null, eucRot = 0;
const em = rest.match(/\((\d+)(?:,(\d+))?(?:,(\d+))?\)/);
if (em) { eucK = +em[1]; eucN = em[2] != null ? +em[2] : null; eucRot = em[3] != null ? +em[3] : 0; rest = rest.slice(0, em.index) + rest.slice(em.index + em[0].length); }
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)
let bpb = parseGroups(groupsStr).beatsPerBar;
let beatsOn;
if (eucK != null) { // k hits spread evenly; first hit accented
let n = eucN || (bpb * sub);
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
let first = true;
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
} else {
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0); no pattern → first of each beat accented
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 };
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled, gainDb };
}
// --- share-language codec: patch ⇄ setup ---

View file

@ -173,7 +173,7 @@ function scheduler(){
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,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){

View file

@ -369,7 +369,7 @@ 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,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
});
}