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 #!/usr/bin/env bash
# Assemble the deployed single-file pages from source + shared partials + assets/. # 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 # Every page (the Concepts landing, the editor app, and the device/form-factor
# the info-*.html pages) is a source that shares code via markers: # pages) is a source that shares code via markers:
# /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS) # /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS, header/footer/chrome)
# @BUILD:favicon@ / @BUILD:logo-*@ / /*@BUILD:samples@*/{} inline base64 assets # @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 # 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 — # (zero deps, works fully offline). deploy.sh runs this first. dist/ is generated —
# don't edit or commit it. # don't edit or commit it.
@ -12,20 +12,18 @@ set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
mkdir -p dist mkdir -p dist
python3 - <<'PY' python3 - <<'PY'
import json, os, pathlib, re import os, pathlib, re
A = pathlib.Path("assets") A = pathlib.Path("assets")
SAMPLES = json.dumps(json.load(open(A / "samples.json")))
def build(name): def build(name):
src = pathlib.Path(name).read_text() src = pathlib.Path(name).read_text()
# 1) inline shared partials (function-replacement: no backslash/group interpretation) # 1) inline shared partials (function-replacement: no backslash/group interpretation)
src = re.sub(r"/\*@BUILD:include:([^@]+)@\*/", src = re.sub(r"/\*@BUILD:include:([^@]+)@\*/",
lambda m: pathlib.Path(m.group(1)).read_text().rstrip("\n"), src) 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: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-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: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}" assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
out = pathlib.Path("dist") / name out = pathlib.Path("dist") / name
out.write_text(src) out.write_text(src)

View file

@ -434,7 +434,7 @@ let meterSeq = 0; // id counter
/* ---- shared engine: audio voices, scheduler primitives, share-language codec. /* ---- shared engine: audio voices, scheduler primitives, share-language codec.
Inlined from src/engine.js by build.sh; identical in player.html. ---- */ 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@*/ /*@BUILD:include:src/engine.js@*/
/* ========================================================================= /* =========================================================================
@ -552,12 +552,12 @@ function setBpm(v) {
========================================================================= */ ========================================================================= */
function laneColor(id) { return `hsl(${(id * 67) % 360} 70% 62%)`; } 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 id = ++meterSeq;
const p = parseGroups(groupsStr); const p = parseGroups(groupsStr);
const m = { const m = {
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts, 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) 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, tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
el: null, stripEl: null, barEl: null, 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 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 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) { function applyLanes(lanes) {
while (meters.length) removeMeter(meters[0].id); while (meters.length) removeMeter(meters[0].id);
for (const c of lanes) { 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]; const m = meters[meters.length - 1];
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute" 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){ function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr); return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts, 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}; }); tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
} }
function startAudio(){ function startAudio(){

View file

@ -316,7 +316,7 @@ function buildMeters(lanes){
return (lanes||[]).map(c=>{ return (lanes||[]).map(c=>{
const p=parseGroups(c.groupsStr); const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts, 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}; tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
}); });
} }

View file

@ -133,7 +133,7 @@ function scheduler(){
function buildMeters(lanes){ function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr); return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts, 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}; }); tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
} }
function startAudio(){ function startAudio(){

View file

@ -1,15 +1,17 @@
/* ========================================================================= /* =========================================================================
SHARED ENGINE inlined into BOTH index.html and player.html by build.sh. SHARED ENGINE inlined into every page by build.sh.
Audio voices, the Web Audio look-ahead scheduler primitives (PORTS TO Audio voices (all SYNTHESIZED no samples), the Web Audio look-ahead
FIRMWARE), and the share-language codec. Each host supplies its own state scheduler primitives (PORTS TO FIRMWARE), and the share-language codec.
globals (state, meters, ramp, trainer, segBars, masterBeat), its own Each host supplies its own state globals (state, meters, ramp, trainer,
setBpm, advanceMaster and scheduler(), and its own SAMPLES object (the segBars, masterBeat), its own setBpm, advanceMaster and scheduler().
editor inlines CC0 one-shots; the player passes {} for pure synth). 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 LOOKAHEAD_MS = 25, SCHEDULE_AHEAD = 0.12;
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
let audioCtx = null, masterGain = null, noiseBuf = null, schedulerTimer = null; 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 --- // --- grouping: "2+2+3" → groups / beatsPerBar / group-start indices ---
@ -24,18 +26,13 @@ function parseGroups(str) {
return { groups, beatsPerBar, groupStarts }; return { groups, beatsPerBar, groupStarts };
} }
// --- audio context + embedded-sample decode --- // --- audio context ---
function ensureAudio() { function ensureAudio() {
if (audioCtx) return; if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)(); audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain(); masterGain = audioCtx.createGain();
masterGain.gain.value = state.volume; masterGain.gain.value = state.volume;
masterGain.connect(audioCtx.destination); 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 --- // --- 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"], ["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"], ["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 // Default kit points the friendly GM names at the punchier 808/909 renders
const SAMPLE_RATE = { tomMid: 1.19 }; // ~3 semitones // (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) { function playInstrument(type, time, level) {
const buf = sampleBuffers[type] || sampleBuffers[SAMPLE_ALIAS[type]]; (DRUMS[KIT_ALIAS[type] || type] || DRUMS[type] || DRUMS.beep)(time, level);
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) --- // --- scheduler primitives (PORTS TO FIRMWARE) ---
@ -140,7 +155,8 @@ function scheduleMeterTick(m, time) {
if (!m.enabled || isMutedAt(time)) return; if (!m.enabled || isMutedAt(time)) return;
const lvl = m.beatsOn[tickInBar] | 0; // dynamics: 0 mute · 1 normal · 2 accent · 3 ghost const lvl = m.beatsOn[tickInBar] | 0; // dynamics: 0 mute · 1 normal · 2 accent · 3 ghost
if (!lvl) return; 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); } 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; let s = c.sound + ":" + c.groupsStr;
const spb = c.stepsPerBeat || 1; const spb = c.stepsPerBeat || 1;
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths 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)); 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 (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.poly) s += "~";
if (c.enabled === false) s += "!"; // "!" = silenced / disabled if (c.enabled === false) s += "!"; // "!" = silenced / disabled
return s; return s;
} }
function laneStrToCfg(tok) { 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); } 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; const ci = tok.indexOf(":"); if (ci < 0) return null;
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = 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); } 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("/"); 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; } 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; let 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) let beatsOn;
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0) if (eucK != null) { // k hits spread evenly; first hit accented
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1)); 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"; 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 --- // --- share-language codec: patch ⇄ setup ---

View file

@ -173,7 +173,7 @@ function scheduler(){
function buildMeters(lanes){ function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr); return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts, 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}; }); tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
} }
function startAudio(){ function startAudio(){

View file

@ -369,7 +369,7 @@ function buildMeters(lanes){
return (lanes||[]).map(c=>{ return (lanes||[]).map(c=>{
const p=parseGroups(c.groupsStr); const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts, 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}; tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
}); });
} }