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:
parent
678482a0f2
commit
4babd1f4ef
9 changed files with 73 additions and 45 deletions
File diff suppressed because one or more lines are too long
14
build.sh
14
build.sh
|
|
@ -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)
|
||||||
|
|
|
||||||
10
editor.html
10
editor.html
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(){
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(){
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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(){
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue