From cb22239363b1406e6a98d8ccf0fc1ebfb3dbc7c6 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 2 Aug 2026 20:17:18 +1000 Subject: [PATCH] The soundtrack is built out of the signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last structural promise in the design doc. No level gets a chord any more. Every bed in the game is derived from a real number belonging to the era it plays under, and src/score.js says which one and why in a comment above each line. coldboot/stack 1000 Hz, the reference tone every piece of telecoms test gear on earth agrees on. The fortress is tuned to a calibration signal. timewait 50 Hz mains and its harmonics. The room's own hum was always the entire score; now the bed agrees with it. war the 1800 Hz V.32 carrier folded down, with dial-up hiss under it, because everybody on EFnet was on a modem. smash 0x90 is the NOP. Read as a number it is 144, so the sled has a pitch and the level is tuned to the byte you slide down. worm seven partials, and the seventh one is detuned. One in seven, as a chord. cuckoo 45.45 baud — the Model 33 rate, 22 ms a bit. Fifty of them in a basement is what he actually heard all night. board the 1200 Hz V.22bis carrier folded four times, which is the doc's exact phrase: a modem carrier detuned into a pad. shell the 750 Hz terminal bell over Helsinki's 50 Hz mains. handshake silence. The fight is the score; anything underneath is noise competing with the only thing in this game that has to be listened to. copper 20 Hz ringing current, 50 Hz mains, and Strowger stepping at ten pulses a second. Three real clocks, and the cathedral is the beat they make against each other. bluebox the six MF tones at once. That is literally what is inside a blue box. climb all of it, layered. root 2600 Hz folded six times until it is a low room tone. The number the whole game turns on, finally at rest. ESTABLISHED gets its own cue rather than a bed: 350 and 440, which is the dial tone the handshake arena opened on and called the sound a line makes when nothing is happening — with something finally on it. Fixed while building: the pulse gate was driven by a bare square oscillator swinging -1..+1, so its off half was not silence, it was the same tone phase-inverted. Tremolo where a clock was wanted. DC offset now. All 14 hops swept clean, and no voices leak between them. Co-Authored-By: Claude Opus 5 --- README.md | 26 +++++- STAGES.md | 2 +- src/levels/climb.js | 5 +- src/levels/coldboot.js | 4 - src/levels/handshake.js | 1 - src/levels/root.js | 10 +-- src/levels/smash.js | 3 - src/levels/stack.js | 4 +- src/levels/war.js | 3 - src/levels/worm.js | 4 - src/main.js | 3 + src/score.js | 181 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 215 insertions(+), 31 deletions(-) create mode 100644 src/score.js diff --git a/README.md b/README.md index deeeb65..f73cf56 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,31 @@ every frequency is the real one: 350+440 dial tone, the DTMF matrix, R1 MF pairs real 100 ms KP / 60 ms digit timing, 2600 Hz, ANSam at 2100 Hz reversing phase every 450 ms, Bell 103 FSK. About 300 lines and zero megabytes. +## The score + +[`src/score.js`](src/score.js) — no level gets a chord somebody picked because it +sounded nice. Every bed is derived from a real number belonging to its era: + +| hop | built from | +|---|---| +| coldboot / stack | **1000 Hz**, the reference tone every piece of telecoms test gear agrees on, folded down | +| timewait | **50 Hz** mains and its harmonics — the room's own hum is the whole score | +| war | the **1800 Hz** V.32 carrier, folded, with dial-up hiss under it | +| smash | **0x90** — the NOP byte read as 144 Hz, so the sled has a pitch | +| worm | seven partials, and the **seventh one is wrong** | +| cuckoo | **45.45 baud**, the Model 33 teletype rate, as a clock | +| board | the **1200 Hz** V.22bis carrier, folded four times — the doc's own instruction | +| shell | the **750 Hz** terminal bell over Helsinki's 50 Hz mains | +| handshake | *nothing.* The fight is the score and anything under it is noise | +| copper | **20 Hz** ringing current, **50 Hz** mains, **10 pps** Strowger stepping — three real clocks beating against each other | +| bluebox | the **six MF tones**. Literally what a blue box contains | +| climb | all of it at once | +| root | **2600 Hz**, folded six times until it is a low room tone, finally at rest | + +Some come out beautiful and some come out ugly. That's correct — a 1980 exchange +wasn't tuned to A=440, and pretending otherwise would be the same lie as setting the +ANSI art in a proportional font. + ## Layout ``` @@ -124,5 +149,4 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee - **Multiplayer (`CHECKSUM`)** — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to. - **Playing both fragments** — fragmenting costs TTL, it doesn't split you into two packets you alternate between. Add when a level has a corridor narrow enough to need it. - **The 200 philes** — 74 written in [`src/philes.js`](src/philes.js), all readable with `P`. Adding the next 126 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found. -- **Era-generated music** — the design doc says each level's pad is built from that era's protocol. Right now they're chords. - Levels run 3–8 minutes against a design doc that asks for 5–20. The genres, mechanics and set pieces are all in — the *duration* is still short. See [STAGES.md](STAGES.md) for a per-hop breakdown of what's in and what's next. diff --git a/STAGES.md b/STAGES.md index 8871947..70963e3 100644 --- a/STAGES.md +++ b/STAGES.md @@ -359,7 +359,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1. | | | |---|---| | **The philes** | 75 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. | -| **Audio** | Every tone is real but the *music* isn't built — the design says each level's pad is generated from that era's protocol and right now they're just chords. | +| ~~**Audio**~~ | **DONE** — `src/score.js`. Every bed derived from a real number: 1 kHz reference tone, 50 Hz mains, 0x90 as a pitch, 45.45 baud, the 1200 Hz carrier, 20 Hz ringing current, the six MF tones, 2600 folded six times. The handshake is scored silent on purpose. | | ~~**The retrain**~~ | **DONE** — `src/retrain.js`. Fires once per run after hop 51, drops you a notch, and the recovery is the 450 ms ANSam beat from hop 47. Terminal levels opt out. | | **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. | | **The 64 hops** | 13 levels covering 64 hop numbers. Either fill in more levels or make the existing ones properly own their ranges. | diff --git a/src/levels/climb.js b/src/levels/climb.js index de28bc4..2c378f5 100644 --- a/src/levels/climb.js +++ b/src/levels/climb.js @@ -146,8 +146,6 @@ export default { camera.position.set(0, 0, 0); camera.far = 1400; camera.updateProjectionMatrix(); - - A.pad([55, 82.41, 110, 164.81, 220], { gain: 0.05, type: 'sawtooth' }); S.hiss = A.carrier(0.05); ctx.hint('A Dsteer   SPACEseize   Euse'); @@ -275,7 +273,7 @@ export default { if (S.z > END_AT && !S.done) { S.done = true; (async () => { - S.hiss.stop(); A.stopPad(); + S.hiss.stop(); ctx.game.flags.climbClean = S.passed; ctx.game.flags.climbGates = S.gates.length; if (S.passed >= 6) ctx.phile('climb'); @@ -483,7 +481,6 @@ export default { ctx.link.unsplit(ctx.camera); S.hiss?.stop(); S.gates?.forEach(g => g.seizing?.stop(0.05)); - A.stopPad(); ctx.link.glitch = 0; document.body.classList.remove('ink-dark'); ctx.hint(''); diff --git a/src/levels/coldboot.js b/src/levels/coldboot.js index 37d1db1..aefeff8 100644 --- a/src/levels/coldboot.js +++ b/src/levels/coldboot.js @@ -103,8 +103,6 @@ export default { look = new Look(); look.yaw = Math.PI / 2; // stand in your slot and look along the curve camera.near = 0.05; camera.far = 1200; camera.updateProjectionMatrix(); - - A.pad([174.61, 261.63, 329.63], { gain: 0.03, type: 'sine' }); // sterile. major. nobody's home. ctx.hint('CLICKlook around'); S.line = 0; @@ -169,7 +167,6 @@ export default { S.phase = 'dma'; S.dma = 0; ctx.hint(''); ctx.hush?.(); - A.stopPad(); A.thud(48, 1.2, 0.4); streaks.visible = true; S.camY = ctx.camera.position.y; @@ -215,7 +212,6 @@ export default { async unmount(ctx) { if (S.sc) { S.sc.stop(); S.sc = null; } - A.stopPad(); ctx.link.glitch = 0; In.unlock(); }, diff --git a/src/levels/handshake.js b/src/levels/handshake.js index fe2bdf0..2b5af9e 100644 --- a/src/levels/handshake.js +++ b/src/levels/handshake.js @@ -526,7 +526,6 @@ export default { async unmount(ctx) { const cur = BOSS[ORDER[S.i]]; if (cur && cur.exit) cur.exit(); - A.stopPad(); ctx.link.glitch = 0; ctx.hint(''); }, diff --git a/src/levels/root.js b/src/levels/root.js index a5c7bd6..32a10c8 100644 --- a/src/levels/root.js +++ b/src/levels/root.js @@ -6,6 +6,7 @@ import * as THREE from '../../vendor/three.module.js'; import * as A from '../audio.js'; +import * as Score from '../score.js'; import * as In from '../input.js'; import { Body, Look, sprite, glow, damp, clamp } from '../kit.js'; import { TOTAL as PHILE_TOTAL } from '../philes.js'; @@ -62,8 +63,6 @@ export default { S.ackMesh.position.set(0, 1.2, -220); S.ackMesh.visible = false; scene.add(S.ackMesh); - - A.pad([220, 329.63, 440], { gain: 0.028 }); ctx.hint('WASDwalk
CLICKcapture mouse'); await ctx.card('ROOT', 'hop 64 · one TTL remaining', 3400); ctx.say('you are here.', 3600); @@ -103,7 +102,6 @@ export default { async theAck(ctx) { S.phase = 'ack'; ctx.hush(); - A.stopPad(); await ctx.sleep(3000); S.ackMesh.visible = true; @@ -120,8 +118,7 @@ export default { await ctx.sleep(4200); ctx.say('somewhere in all of that — somebody answered.', 4600); await ctx.sleep(4800); - - A.pad([261.63, 329.63, 392, 523.25], { gain: 0.05 }); + Score.established(); await ctx.flash('ESTABLISHED', 'est', 2600); ctx.say('', 0); ctx.hush(); @@ -134,7 +131,7 @@ export default { ctx.hush(); // and then, because that is what TCP is and always was - A.stopPad(); + Score.stop(2.2); for (const [f, label] of [[440, 'FIN'], [523.25, 'FIN-ACK'], [392, 'ACK'], [0, 'CLOSED']]) { if (f) A.burst(f, f * 1.5, 0.2, 0.12); ctx.say(label, 1400); @@ -240,7 +237,6 @@ export default { }, async unmount(ctx) { - A.stopPad(); In.unlock(); ctx.hint(''); }, diff --git a/src/levels/smash.js b/src/levels/smash.js index 19baa0e..6e60240 100644 --- a/src/levels/smash.js +++ b/src/levels/smash.js @@ -187,8 +187,6 @@ export default { camera.position.set(0, 4, 15); camera.far = 400; camera.updateProjectionMatrix(); - - A.pad([98, 146.83, 196], { gain: 0.03 }); ctx.hint('A Dmove
SPACEjump'); await ctx.card('SMASHING THE STACK', 'hop 17 · for fun and profit', 3000); ctx.say('you are the input. walk right.', 4000, 'ALEPH ONE'); @@ -399,7 +397,6 @@ export default { }, async unmount(ctx) { - A.stopPad(); ctx.link.glitch = 0; ctx.hint(''); }, diff --git a/src/levels/stack.js b/src/levels/stack.js index 5e4b24c..77bfb40 100644 --- a/src/levels/stack.js +++ b/src/levels/stack.js @@ -221,8 +221,6 @@ export default { S.look.yaw = 0.4; S.spawn = S.body.pos.clone(); camera.far = 900; camera.updateProjectionMatrix(); - - A.pad([110, 164.81, 220, 329.63], { gain: 0.032 }); ctx.hint('WASDmove
SPACEjump
CLICKcapture mouse'); await ctx.card('7 APPLICATION', 'hop 2 · the descent begins', 2400); ctx.say(LAYERS[0].note, 4200); @@ -478,5 +476,5 @@ export default { setTimeout(() => { S.missing = false; }, 400); }, - async unmount(ctx) { A.stopPad(); In.unlock(); ctx.hint(''); }, + async unmount(ctx) { In.unlock(); ctx.hint(''); }, }; diff --git a/src/levels/war.js b/src/levels/war.js index 740de64..f149fc5 100644 --- a/src/levels/war.js +++ b/src/levels/war.js @@ -120,8 +120,6 @@ export default { // ── the rival crew. they show up after the first rejoin and they want the // same empty room you do. S.rivals = []; - - A.pad([146.83, 220, 293.66], { gain: 0.03, type: 'sawtooth' }); ctx.hint('WASDmove
Etake ops / possess
1–4use it while it works'); await ctx.card('THE WAR', 'hop 10 · efnet, 1998', 2800); ctx.say('nobody owns this room. that has never once stopped anybody.', 4600); @@ -436,7 +434,6 @@ export default { }, async unmount(ctx) { - A.stopPad(); ctx.link.glitch = 0; ctx.hint(''); }, diff --git a/src/levels/worm.js b/src/levels/worm.js index 5312f13..d910889 100644 --- a/src/levels/worm.js +++ b/src/levels/worm.js @@ -100,8 +100,6 @@ export default { camera.position.set(0, 42, 30); camera.lookAt(0, 0, 0); camera.far = 300; camera.updateProjectionMatrix(); - - A.pad([65.41, 98, 130.81], { gain: 0.035, type: 'triangle' }); ctx.hint('1 2 3choose vector'); await ctx.card('2 NOVEMBER 1988', 'hop 21 · approximately 60,000 machines', 3400); ctx.say('you are the worm now. you get three ways in and one bug you did not write.', 5400); @@ -214,7 +212,6 @@ export default { S.done = true; ctx.link.glitch = 0; (async () => { - A.stopPad(); await ctx.sleep(1400); ctx.spendTTL(1); ctx.game.flags.wormSaved = S.cured; @@ -288,7 +285,6 @@ export default { }, async unmount(ctx) { - A.stopPad(); ctx.link.glitch = 0; ctx.hint(''); }, diff --git a/src/main.js b/src/main.js index e8a67e2..0268b9d 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,7 @@ import { Link } from './link.js'; import { World } from './kit.js'; import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js'; import * as Retrain from './retrain.js'; +import * as Score from './score.js'; import coldboot from './levels/coldboot.js'; import stack from './levels/stack.js'; @@ -156,6 +157,7 @@ export async function goto(id, { restart = false } = {}) { HUD.hush(); await HUD.veil(1, 420); A.stopPad(); + Score.stop(0.6); if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } } In.releaseText(); In.clearRemap(); In.unlock(); @@ -195,6 +197,7 @@ export async function goto(id, { restart = false } = {}) { const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })(); HUD.veil(0, 520); await mounting; + Score.play(id); Retrain.arm(ctx, nextLevel); refreshHud(); saveGame(); diff --git a/src/score.js b/src/score.js new file mode 100644 index 0000000..eb569cf --- /dev/null +++ b/src/score.js @@ -0,0 +1,181 @@ +// score.js — the soundtrack is built out of the signal. +// +// No level gets a chord that somebody picked because it sounded nice. Every bed in +// this game is derived from a real number belonging to the era it plays under: the +// mains frequency in the room, the ringing current on the pair, the carrier of the +// modem you are talking through, the byte value of a NOP, the baud rate of a +// teletype, the six frequencies of an MF keypad. +// +// Some of them come out beautiful and some of them come out ugly. That is the +// correct outcome. A 1980 telephone exchange was not tuned to A=440 and pretending +// otherwise would be the same lie as drawing the ANSI art in a proportional font. + +import * as A from './audio.js'; + +let VOICES = []; + +function reg(o) { VOICES.push(o); return o; } + +// ── builders ────────────────────────────────────────────────────────────── + +// A stack of detuned oscillators through a slowly-breathing lowpass. Give it real +// frequencies and it becomes a pad without ever choosing a note. +function drone(freqs, { gain = 0.04, type = 'sine', detune = 5, cut = 1800, breathe = 0.06 } = {}) { + const ac = A.AC(); if (!ac) return null; + const t = ac.currentTime; + const g = ac.createGain(); g.gain.value = 0; g.connect(A.music()); + g.gain.linearRampToValueAtTime(gain, t + 2.6); + const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cut; lp.connect(g); + const oscs = []; + freqs.forEach((f, i) => { + for (const d of [-detune, detune]) { + const o = ac.createOscillator(); o.type = type; o.frequency.value = f; o.detune.value = d; + const og = ac.createGain(); og.gain.value = 1 / (freqs.length * 2.2); + o.connect(og); og.connect(lp); o.start(t + i * 0.07); + oscs.push(o); + } + }); + const lfo = ac.createOscillator(); lfo.frequency.value = breathe; + const lg = ac.createGain(); lg.gain.value = cut * 0.35; + lfo.connect(lg); lg.connect(lp.frequency); lfo.start(t); + oscs.push(lfo); + return reg({ g, oscs }); +} + +// A tone gated at a protocol's own rate. The rhythm is not a rhythm, it is a clock +// that already existed. +function pulse(rateHz, freq, { gain = 0.03, duty = 0.35, type = 'square', cut = 900 } = {}) { + const ac = A.AC(); if (!ac) return null; + const t = ac.currentTime; + const g = ac.createGain(); g.gain.value = 0; g.connect(A.music()); + g.gain.linearRampToValueAtTime(gain, t + 1.6); + const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cut; lp.connect(g); + const o = ac.createOscillator(); o.type = type; o.frequency.value = freq; + // The gate is driven by a square oscillator, which swings -1..+1 — so it needs a + // DC offset equal to the swing, or the "off" half of the cycle is not silence, it + // is the same tone phase-inverted, and you get tremolo where you wanted a clock. + const gate = ac.createGain(); gate.gain.value = duty; + o.connect(gate); gate.connect(lp); o.start(t); + const clk = ac.createOscillator(); clk.type = 'square'; clk.frequency.value = rateHz; + const clkG = ac.createGain(); clkG.gain.value = duty; + clk.connect(clkG); clkG.connect(gate.gain); clk.start(t); + return reg({ g, oscs: [o, clk] }); +} + +// Filtered noise, for the levels where the era's signature sound is a surface. +function hiss(centre, { gain = 0.02, q = 0.9, wobble = 0 } = {}) { + const ac = A.AC(); if (!ac) return null; + const src = A.noise(3); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; + bp.frequency.value = centre; bp.Q.value = q; + const g = ac.createGain(); g.gain.value = 0; g.connect(A.music()); + src.connect(bp); bp.connect(g); src.start(); + g.gain.linearRampToValueAtTime(gain, ac.currentTime + 2.2); + let iv = null; + if (wobble) iv = setInterval(() => bp.frequency.setTargetAtTime( + centre * (1 + (Math.random() - 0.5) * wobble), ac.currentTime, 0.4), 900); + return reg({ g, src, iv }); +} + +// f, f/2, f/4 ... — a carrier folded down until it is music. The doc's exact phrase +// for the board is "a modem carrier detuned into a pad", and this is that. +const sub = (f, n = 4) => Array.from({ length: n }, (_, i) => f / Math.pow(2, i)); +// n partials of a fundamental +const parts = (f, n) => Array.from({ length: n }, (_, i) => f * (i + 1)); + +// ── the beds ────────────────────────────────────────────────────────────── + +const BEDS = { + + // 1000 Hz is the reference tone every piece of telecoms test gear in the world + // agrees on. The fortress at the top of the stack is tuned to a calibration tone. + coldboot: () => drone(sub(1000, 4), { gain: 0.030, cut: 2600, breathe: 0.04 }), + stack: () => drone(sub(1000, 5), { gain: 0.032, cut: 2200, breathe: 0.05 }), + + // 50 Hz mains and its harmonics. The room's own hum is the entire score, and + // striplight() in the level supplies the top of it. + timewait: () => drone([50, 100, 150], { gain: 0.026, cut: 400, breathe: 0.03 }), + + // V.32 ran a 1800 Hz carrier. 1998 is that, folded down, with the dial-up hiss + // still audible underneath because everybody on EFnet was on a modem. + war: () => { drone(sub(1800, 5), { gain: 0.028, type: 'sawtooth', cut: 1100, breathe: 0.09 }); + return hiss(1800, { gain: 0.012, q: 0.8, wobble: 0.3 }); }, + + // 0x90 is the NOP. It is also, read as a number, 144 — so the sled has a pitch, + // and the whole level is tuned to the byte you slide down. + smash: () => drone(parts(144, 5), { gain: 0.028, type: 'triangle', cut: 1500, breathe: 0.07 }), + + // one in seven. seven partials, and the seventh is the one that is wrong. + worm: () => { const f = parts(58, 7); f[6] *= 1.031; return drone(f, { gain: 0.030, cut: 700, breathe: 0.05 }); }, + + // 45.45 baud — the Baudot rate a Model 33 teletype ran at, which is 22 ms a bit. + // He had fifty of them in a basement and they are what he heard all night. + cuckoo: () => { pulse(45.45 / 8, 96, { gain: 0.022, duty: 0.3, cut: 500 }); + return drone([58, 87], { gain: 0.020, cut: 420, breathe: 0.04 }); }, + + // V.22bis put 2400 bits a second down a 1200 Hz carrier. The board's theme is + // that carrier, folded down four times, which is the doc's own instruction. + board: () => drone(sub(1200, 5), { gain: 0.030, cut: 1400, breathe: 0.05 }), + + // a Unix box has no signature frequency, so it gets the two it actually made: + // the terminal bell at 750 Hz, folded, over Helsinki's 50 Hz mains. + shell: () => drone([...sub(750, 4), 50], { gain: 0.026, cut: 1600, breathe: 0.045 }), + + // the handshake is scored by the fight itself. anything under it would be noise + // competing with the only thing in the game that has to be listened to. + handshake: () => null, + + // ringing current is 20 Hz at about ninety volts, and the exchange runs on 50 Hz + // mains, and a step-by-step switch pulses at ten a second. Three real clocks, and + // the cathedral is the beat they make against each other. + copper: () => { drone([20, 40, 50, 100], { gain: 0.030, cut: 320, breathe: 0.03 }); + return pulse(10 / 8, 62, { gain: 0.018, duty: 0.22, cut: 260 }); }, + + // the six MF tones, all at once. This is literally what a blue box contains. + bluebox: () => drone(A.MF_TONES, { gain: 0.020, cut: 1900, breathe: 0.05, detune: 3 }), + + // everything, layered: the reference tone, the carrier, the mains and 2600. + climb: () => { drone([...sub(1000, 3), ...sub(1200, 3), 50], { gain: 0.030, type: 'sawtooth', cut: 1300, breathe: 0.11 }); + return hiss(2600, { gain: 0.010, q: 2.0, wobble: 0.15 }); }, + + // 2600 Hz, folded all the way down until it is a low room tone. The number the + // whole game turns on, finally at rest. + root: () => drone(sub(2600, 6), { gain: 0.026, cut: 2000, breathe: 0.03 }), +}; + +// ── cues ────────────────────────────────────────────────────────────────── + +// ESTABLISHED. The handshake arena opened on 350 + 440 and called it "the sound a +// line makes when nothing is happening". This is the same two frequencies with +// something finally on them. +export function established() { + return drone([350, 440, 700, 880, 1320], { gain: 0.055, cut: 2400, breathe: 0.02, detune: 3 }); +} + +// ── control ─────────────────────────────────────────────────────────────── + +export function play(id) { + stop(); + const build = BEDS[id]; + if (!build) return; + try { build(); } catch (e) { console.error('score failed for', id, e); } +} + +export function stop(rel = 1.8) { + const ac = A.AC(); + const dying = VOICES; VOICES = []; + if (!ac) return; + const t = ac.currentTime; + dying.forEach(v => { + if (v.iv) clearInterval(v.iv); + try { + v.g.gain.cancelScheduledValues(t); + v.g.gain.setValueAtTime(v.g.gain.value, t); + v.g.gain.linearRampToValueAtTime(0, t + rel); + (v.oscs || []).forEach(o => o.stop(t + rel + 0.1)); + if (v.src) setTimeout(() => { try { v.src.stop(); } catch (e) {} }, (rel + 0.2) * 1000); + } catch (e) { /* already stopped */ } + }); +} + +export const voices = () => VOICES.length;