From 7eb49513ddf928b0e3e9283d13f3b09ce525fff1 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 13:52:53 -0600 Subject: [PATCH 01/14] Ranges: the debug switch read only where there is a process The value ranges' debug line read process.env, which a compile worker in the browser does not have: every script with a program failed to compile in the editor with "process is not defined". It is read only in Node now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/ranges.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compiler/ranges.ts b/compiler/ranges.ts index 28834a8..946e015 100644 --- a/compiler/ranges.ts +++ b/compiler/ranges.ts @@ -571,7 +571,8 @@ export function rangeNumbers(program: Program): void { if (done) break; } if (unknown) return; - if (process.env.RANGES_DEBUG) for (const [k, v] of vars) if (!isAny(v)) console.log(`ranges ${k} = ${v[0]} … ${v[1]}`); + // (Node only: a worker in the browser has no `process`, and reading it there threw — every program failed to compile.) + if (typeof process !== "undefined" && process.env?.RANGES_DEBUG) for (const [k, v] of vars) if (!isAny(v)) console.log(`ranges ${k} = ${v[0]} … ${v[1]}`); annotate = true; next = new Map(); nextCells = new Map(); From eca3d5caf4c82b9212a7061da4a3c6ec0accb3c1 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 13:53:59 -0600 Subject: [PATCH 02/14] Sounds, step 1: two channels, a limiter, sinc resampling, Ogg Vorbis for long clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clip has one channel or two: pan() places it (a balance, 0 as it was), mono() folds it, and a mix with a two-sided part is two-sided. limit() keeps a clip under a ceiling (−1.5 dB, room for Ogg's overshoot) with a 5 ms look-ahead and a 0.1 s release; normalize() brings its peak there. Resampling is a windowed sinc now, so a note pitched up no longer folds its top back down. A clip a program plays that lasts 2 seconds or more goes in the map as Ogg Vorbis (libvorbis in wasm, from wasm-media-encoders, copied to dist/vorbis.wasm by the build), encoded by the compile worker only for a build, never for a check as the user types. Shorter clips, untouched imports, wav() clips and any clip a trigger() plays stay WAVs. music.render() makes the notes again at 44 100 Hz and limits the mix; tracks take { pan, volume }. probes/korobeiniki-ab.ts plays the old format against the new one in the game. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compile.ts | 19 ++- compiler/compiler.ts | 11 +- compiler/declarations.ts | 28 ++- compiler/entry.ts | 2 + compiler/generated/stdlib.ts | 2 +- compiler/runtime.ts | 12 +- compiler/sound.ts | 318 +++++++++++++++++++++++++++-------- compiler/vorbis.ts | 125 ++++++++++++++ docs/v4-plan.md | 39 +++++ editor.ts | 7 +- package-lock.json | 33 +++- package.json | 5 +- probes/korobeiniki-ab.ts | 76 +++++++++ script.ts | 19 ++- scripts/build-fixture.mts | 7 +- scripts/copy-vorbis.mjs | 35 ++++ service.ts | 9 +- stdlib/music.ts | 101 +++++++++-- tests/music.test.ts | 23 +++ tests/sound.test.ts | 129 +++++++++++++- 20 files changed, 871 insertions(+), 129 deletions(-) create mode 100644 compiler/vorbis.ts create mode 100644 probes/korobeiniki-ab.ts create mode 100644 scripts/copy-vorbis.mjs diff --git a/compile.ts b/compile.ts index e9f650f..a9210d3 100644 --- a/compile.ts +++ b/compile.ts @@ -52,6 +52,10 @@ export const gametestUrl = (dist: string = DEFAULT_DIST) => export const allowlistUrl = (dist: string = DEFAULT_DIST) => dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/allowlist.json` : `${dist.replace(/\/+$/, "")}/allowlist.json`; +/** The Ogg Vorbis encoder (`compiler/vorbis.ts`, `dist/vorbis.wasm`), at this release's own tag: fetched only by a build that has a long clip to put in the map. */ +export const vorbisUrl = (dist: string = DEFAULT_DIST) => + dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/vorbis.wasm` : `${dist.replace(/\/+$/, "")}/vorbis.wasm`; + /** * What the worker is told to import. The editor's loader turns a fetched plugin into * `blob:` modules, and the compiler's own blob URL is the compiler the main thread would @@ -68,12 +72,15 @@ export interface CompileInput { names: ScriptNames; /** Run the script's tests after the compile: the world they start from, and which of them. */ tests?: TestRunOptions; + /** Encode the sounds the map is to get (a build): a check leaves an Ogg Vorbis one as its name (`compiler/vorbis.ts`). */ + sounds?: boolean; } interface CompileRequest extends CompileInput { id: number; moduleUrl: string; libUrl: string; + vorbisUrl?: string; } interface CompileResponse { @@ -89,7 +96,7 @@ importScripts(${JSON.stringify(TS_URL)}); let loading = null; let lib = null; self.onmessage = async (e) => { - const { id, moduleUrl, libUrl, files, names, tests } = e.data; + const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests } = e.data; try { if (!loading) loading = import(moduleUrl); let mod; @@ -101,7 +108,13 @@ self.onmessage = async (e) => { if (!r.ok) throw new Error("Could not load the standard library from " + libUrl + " (" + r.status + ")."); lib = await r.text(); } - postMessage({ id, result: mod.compileScript(self.ts, files, names, { lib, tests }) }); + const result = mod.compileScript(self.ts, files, names, { lib, tests }); + // A build's long clips encoded as Ogg Vorbis; a check's left as names, their samples not sent. + if (typeof mod.finishSounds === "function") { + const encoder = vorbisUrl && result.sounds.some((s) => s.vorbis) ? await mod.loadVorbis(vorbisUrl) : null; + mod.finishSounds(result.sounds, encoder); + } + postMessage({ id, result }); } catch (err) { postMessage({ id, error: String((err && err.message) || err) }); } @@ -259,7 +272,7 @@ export function compileInBackground(input: CompileInput, dist: string = DEFAULT_ } return new Promise((resolve, reject) => { pending.set(id, { resolve, reject, timer: setTimeout(() => timeOut(id), COMPILE_TIMEOUT_MS) }); - const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}) }; + const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}) }; w.postMessage(req); }).catch((err: Error) => { if (err.message === RETRY) return compileInBackground(input, dist); diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 8b15744..92b433a 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -37,6 +37,7 @@ import { allTables, type ScriptNames } from "./names"; import { Collector, createRuntime, type GameFunctionValue, type ProgramDescriptor, type ScriptString } from "./runtime"; import { newBody, Structured, type Body } from "./structured"; import { english, type Translate } from "../i18n"; +import type { ClipFile } from "./sound"; export type { ScriptString } from "./runtime"; export { DEATHS_TABLE_ADDRESS } from "./runtime"; @@ -145,8 +146,12 @@ export interface CompileResult { sources: (TriggerSource | null)[]; /** Local string table: a record's `text` / `wav` field `k > 0` means `strings[k - 1]`. */ strings: ScriptString[]; - /** The clips the script plays (`sound.ts`), as the WAVs the map's archive gets, by their names there: once each. */ - sounds: { path: string; bytes: Uint8Array }[]; + /** + * The clips the script plays (`sound.ts`), as the files the map's archive gets, by their names there: once each. An + * Ogg Vorbis one has its samples instead of bytes until `finishSounds` (`vorbis.ts`) encodes it, and neither after a + * check that did not ask for it: the map is to get it from a build. + */ + sounds: ClipFile[]; diagnostics: ScriptDiagnostic[]; /** The programs' variables, in declaration order; a function's copies and results are left out. */ variables: VariableInfo[]; @@ -540,7 +545,7 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa ? runTests(registry, { triggers, sources, ir, strings: collector.strings, locate: where }, options.tests) : { list: listTests(registry).list, results: [], only: [], ms: 0 }; } - return result({ ir, input, triggers, sources, strings: collector.strings, sounds: [...collector.sounds].map(([path, bytes]) => ({ path, bytes })), variables, programs, buildTime, hints, tests, ...(registry.empty ? {} : { modules: [...linked] }) }); + return result({ ir, input, triggers, sources, strings: collector.strings, sounds: [...collector.sounds.values()], variables, programs, buildTime, hints, tests, ...(registry.empty ? {} : { modules: [...linked] }) }); } /** diff --git a/compiler/declarations.ts b/compiler/declarations.ts index c658a8a..d56e3d9 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -489,14 +489,18 @@ ${kw}interface Image { * A program plays one with playWav(boom); the build puts each clip it plays in the map once. * In the game a sound starts within 21 ms of its frame; playing it again adds a second copy beside the * first, and nothing stops one, so a clip's length and loudness are what is heard. 32 play at once, and - * copies add with no limiter: keep peak under 1 where they overlap. A clip goes in the map as IMA ADPCM, - * a quarter of 16-bit, unless that would distort it (the jumps of a square or saw wave): then as 16-bit. + * copies add with no limiter: keep peak under 1 where they overlap. A clip of 2 seconds or more that a + * program plays goes in the map as Ogg Vorbis (Remastered plays it). A shorter one, or one a trigger() + * plays (which 1.16.1 plays too, and it plays no Ogg), is a WAV: IMA ADPCM, a quarter of 16-bit, unless + * that would distort it (the jumps of a square or saw wave) or it has two channels: then 16-bit. */ ${kw}interface Clip { /** How long it plays. */ readonly seconds: number; /** Samples a second. */ readonly rate: number; + /** 1, or 2 for a clip with a left and a right (pan(), or a mix with one). */ + readonly channels: 1 | 2; /** The loudest sample, 0 to 1; above 1 is cut off when the map's file is written. */ readonly peak: number; /** The part from \`from\` to \`to\` seconds (the end when left out). */ @@ -520,6 +524,23 @@ ${kw}interface Clip { * decay, sustain and release: \`tone("C4", 0.5).envelope([0, 0], [0.01, 1], [0.1, 0.6], [0.4, 0.6], [0.5, 0])\` */ envelope(...points: readonly (readonly [number, number])[]): Clip; + /** + * Where it is heard, −1 left to 1 right: a balance, the far side turned down, so 0 is the clip as it + * was. The clip then has two channels. + */ + pan(position: number): Clip; + /** Its two channels as one. */ + mono(): Clip; + /** + * Never louder than \`ceiling\` (0.84, −1.5 dB, when left out): turned down smoothly over the 5 ms before + * each peak that would go over, and back up over \`release\` seconds (0.1). What a mix goes through last, + * so that the parts can add up without cutting off: \`mixdown(parts).limit()\`. + */ + limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; + /** Louder or quieter, so its loudest sample is \`peak\` (0.84, −1.5 dB, when left out). */ + normalize(peak?: number): Clip; + /** In the map as a WAV however long it is, for StarCraft 1.16.1, which plays no Ogg. */ + wav(): Clip; } /** * When the script is built: a tone of \`pitch\` — hertz, or a note's name: "A4" (440), "C#5", "Eb3" — for @@ -534,8 +555,9 @@ ${kw}function silence(seconds: number, options?: { readonly rate?: number }): Cl /** * When the script is built: clips laid over one another, each from its own time in seconds, as one clip — * a song mixed before the game, every note exactly in time. \`mixdown([{ clip: kick, at: 0 }, { clip: hat, at: 0.25 }])\` + * At the first part's rate, or \`rate\`; two channels when a part has two. \`seconds\`: that long at least. */ -${kw}function mixdown(parts: readonly { readonly clip: Clip; readonly at: number }[], seconds?: number): Clip; +${kw}function mixdown(parts: readonly { readonly clip: Clip; readonly at: number }[], seconds?: number, rate?: number): Clip; /** A brush of cover(): which of the shapes (0 the first), centred on tile (x, y) of the picture. */ ${kw}interface Brush { readonly x: number; readonly y: number; readonly shape: number } /** diff --git a/compiler/entry.ts b/compiler/entry.ts index 4885a35..2a57307 100644 --- a/compiler/entry.ts +++ b/compiler/entry.ts @@ -9,6 +9,8 @@ * module bundled on its own as `dist/compiler.js`. */ export { compileScript } from "./compiler"; +/** The worker encodes a build's Ogg Vorbis sounds with these (`vorbis.ts`). */ +export { finishSounds, loadVorbis } from "./vorbis"; /** Where this module was loaded from — what the worker is told to import. */ export const ENTRY_URL: string = import.meta.url; diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index 8bdb88e..2e3d5fa 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye. A kit's step is its sounds' letters (\"kh\": kick and hat). Everything is worked out when the script is built: each\n * note an instrument plays becomes a clip once (`trigscript`'s Clip, put in the map once however often it plays), and\n * the song a table of which clip starts on which frame, which `play` reads while the game runs.\n *\n * What the game does with it (the sound probe, 2026-10-19): a step is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /** Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. */\n note(name: string, seconds: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number) => Clip): (name: string, seconds: number) => Clip {\n const made = new Map();\n return (name, seconds) => {\n const key = `${name} ${seconds.toFixed(4)}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length.\n */\nexport function synth(options: { readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\"; readonly volume?: number; readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number } = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const attack = options.attack ?? 0.005, decay = options.decay ?? 0.08, sustain = options.sustain ?? 0.7, release = options.release ?? 0.06;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const held = Math.max(attack + decay, seconds - release);\n return tone(name, held + release, { wave, volume }).envelope([0, 0], [attack, 1], [attack + decay, sustain], [held, sustain], [held + release, 0]);\n }),\n });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n}\n\n/** A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the eye. */\nexport function track(instrument: Instrument | Kit, pattern: string | readonly string[]): Track {\n const text = typeof pattern === \"string\" ? pattern : pattern.join(\" \");\n return Object.freeze({ instrument, steps: Object.freeze(text.split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\")) });\n}\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /** Frames a step lasts. */\n readonly stepFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose step is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once. */\n readonly voices: number;\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Refused\n * when more than 32 notes would sound at once.\n */\nexport function song(options: { readonly bpm: number; readonly steps?: number }, ...tracks: readonly Track[]): Song {\n const perBeat = options.steps ?? 4;\n if (!(options.bpm > 0) || !(perBeat > 0)) throw new Error(\"song() takes a tempo, { bpm: 120 }, and steps a beat (4 by default).\");\n const stepFrames = Math.max(1, Math.round(60 / options.bpm / perBeat / FRAME));\n const stepSeconds = stepFrames * FRAME;\n const notes: { at: number; clip: Clip }[] = [];\n let steps = 0;\n for (const t of tracks) {\n const ws = t.steps;\n steps = Math.max(steps, ws.length);\n ws.forEach((w, s) => {\n if (w === \"-\" || w === \".\") return;\n if (t.instrument.kind === \"kit\") {\n for (const letter of w) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n notes.push({ at: s * stepFrames, clip: hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n for (const name of w.split(\"+\")) {\n midi(name);\n notes.push({ at: s * stepFrames, clip: t.instrument.note(name, length * stepSeconds) });\n }\n });\n }\n notes.sort((a, b) => a.at - b.at);\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n voices = Math.max(voices, sounding);\n if (sounding > VOICES) throw new Error(`At step ${n.at / stepFrames + 1}, ${sounding} notes would sound at once; the game plays ${VOICES}. Shorten notes or thin the chords.`);\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const length = Math.max(steps * stepFrames, ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n return Object.freeze({\n stepFrames, bpm: 60 / (stepFrames * FRAME * perBeat), steps, frames: length, seconds: length * FRAME,\n clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices,\n });\n}\n\n/** A song mixed into one clip when the script is built: every note exactly in time, one sound playing. */\nexport function render(s: Song): Clip {\n return mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = { sampler, synth, kit, track, song, render, play, loop };\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye. A kit's step is its sounds' letters (\"kh\": kick and hat). Everything is worked out when the script is built: each\n * note an instrument plays becomes a clip once (`trigscript`'s Clip, put in the map once however often it plays), and\n * the song a table of which clip starts on which frame, which `play` reads while the game runs.\n *\n * What the game does with it (the sound probe, 2026-10-19): a step is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length.\n */\nexport function synth(options: { readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\"; readonly volume?: number; readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number } = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const attack = options.attack ?? 0.005, decay = options.decay ?? 0.08, sustain = options.sustain ?? 0.7, release = options.release ?? 0.06;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(attack + decay, seconds - release);\n return tone(name, held + release, { wave, volume, ...(rate === undefined ? {} : { rate }) }).envelope([0, 0], [attack, 1], [attack + decay, sustain], [held, sustain], [held + release, 0]);\n }),\n });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the\n * instrument plays.\n */\nexport function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number } = {}): Track {\n const text = typeof pattern === \"string\" ? pattern : pattern.join(\" \");\n const pan = options.pan ?? 0, volume = options.volume ?? 1;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`);\n return Object.freeze({ instrument, steps: Object.freeze(text.split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\")), pan, volume });\n}\n\n/** A clip as a track plays it: its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1) return c;\n let p = made.get(c);\n if (!p) {\n p = t.volume === 1 ? c : c.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /** Frames a step lasts. */\n readonly stepFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose step is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once. */\n readonly voices: number;\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Refused\n * when more than 32 notes would sound at once.\n */\nexport function song(options: { readonly bpm: number; readonly steps?: number }, ...tracks: readonly Track[]): Song {\n const perBeat = options.steps ?? 4;\n if (!(options.bpm > 0) || !(perBeat > 0)) throw new Error(\"song() takes a tempo, { bpm: 120 }, and steps a beat (4 by default).\");\n const stepFrames = Math.max(1, Math.round(60 / options.bpm / perBeat / FRAME));\n const stepSeconds = stepFrames * FRAME;\n const notes: { at: number; clip: Clip }[] = [];\n const score: Score[] = [];\n let steps = 0;\n for (const t of tracks) {\n const ws = t.steps, made = new Map();\n steps = Math.max(steps, ws.length);\n ws.forEach((w, s) => {\n if (w === \"-\" || w === \".\") return;\n if (t.instrument.kind === \"kit\") {\n for (const letter of w) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n notes.push({ at: s * stepFrames, clip: placed(t, made, hit) });\n score.push({ at: s * stepFrames, track: t, made, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n for (const name of w.split(\"+\")) {\n midi(name);\n notes.push({ at: s * stepFrames, clip: placed(t, made, t.instrument.note(name, length * stepSeconds)) });\n score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds });\n }\n });\n }\n notes.sort((a, b) => a.at - b.at);\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n voices = Math.max(voices, sounding);\n if (sounding > VOICES) throw new Error(`At step ${n.at / stepFrames + 1}, ${sounding} notes would sound at once; the game plays ${VOICES}. Shorten notes or thin the chords.`);\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const length = Math.max(steps * stepFrames, ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n const made: Song = Object.freeze({\n stepFrames, bpm: 60 / (stepFrames * FRAME * perBeat), steps, frames: length, seconds: length * FRAME,\n clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices,\n });\n scores.set(made, score);\n return made;\n}\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. The notes are\n * made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a note played in the game note by note\n * is made at 22 050, to keep the map small), and the mix goes through a limiter (`Clip.limit`) so that it never goes\n * over −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when a track is panned.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number } } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s);\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n const parts = score\n ? score.map((n) => ({ clip: n.hit ? placed(n.track, n.made, n.hit) : placedAt(n, rate), at: n.at * FRAME }))\n : s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME }));\n const mixed = mixdown(parts, s.seconds, rate);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/** A note of a score made at `rate`, as its track plays it: once a note, rate and track. */\nconst atRate = new WeakMap, Map>();\nfunction placedAt(n: Score, rate: number): Clip {\n let byNote = atRate.get(n.made);\n if (!byNote) { byNote = new Map(); atRate.set(n.made, byNote); }\n const key = `${n.name} ${n.seconds!.toFixed(4)} ${rate}`;\n let c = byNote.get(key);\n if (!c) {\n const note = (n.track.instrument as Instrument).note(n.name!, n.seconds!, rate);\n c = note;\n if (n.track.volume !== 1) c = c.gain(n.track.volume);\n if (n.track.pan !== 0) c = c.pan(n.track.pan);\n byNote.set(key, c);\n }\n return c;\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = { sampler, synth, kit, track, song, render, play, loop };\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/compiler/runtime.ts b/compiler/runtime.ts index ddca8c6..fd2d12f 100644 --- a/compiler/runtime.ts +++ b/compiler/runtime.ts @@ -29,7 +29,7 @@ import { PLAYER_COLORS, TABLE_FIELDS, TABLE_SIZE, tableOfBrand, type TableField, import { LOCAL_FIELDS, localName, MEMORY_FIELDS, MEMORY_GROUPS, memoryTableField } from "./memory"; import { coverValue } from "./cover"; import { isVideo } from "./media"; -import { clipFile, isClip, mixdown, noise, silence, tone } from "./sound"; +import { clipFile, isClip, mixdown, noise, silence, tone, type ClipFile } from "./sound"; /** `memory(address, …)` reads `deaths` at player `EPD(address)`, unit 0: the deaths table starts here in 1.16.1's memory. */ export const DEATHS_TABLE_ADDRESS = 0x58a364; @@ -217,8 +217,8 @@ export class Collector { /** The functions at the top of the script's files, by where they are: what their bodies compute when the script is built. */ readonly functions = new Map HoistedThunks }>(); readonly strings: ScriptString[] = []; - /** The clips the script plays, as the WAVs the map's archive gets, by their names there (`sound.ts`). */ - readonly sounds = new Map(); + /** The clips the script plays, as the files the map's archive gets, by their names there (`sound.ts`). */ + readonly sounds = new Map(); /** The script emitted hyper triggers: the trigger loop runs twelve times a second, not once in two. */ hyper = false; /** Locations a library took with reserveLocation(), by the reason it gave: the location's number. */ @@ -303,8 +303,10 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: switch (kind) { case "text": case "wav": if (builtText(v) !== undefined) v = builtText(v); - // A clip (`sound.ts`): its WAV goes in the map's archive, and the action plays it by the name it goes under. - if (kind === "wav" && isClip(v)) { const file = clipFile(v); collector.sounds.set(file.path, file.bytes); v = file.path; } + // A clip (`sound.ts`): its file goes in the map's archive, and the action plays it by the name it goes under. A + // trigger's is a WAV whatever its length — the triggers play in 1.16.1 too, which plays no Ogg; a program's + // (Remastered) is Ogg Vorbis when it is long. + if (kind === "wav" && isClip(v)) { const file = clipFile(collector.running ? v.wav() : v); collector.sounds.set(file.path, file); v = file.path; } if (typeof v === "string") return v === "" ? 0 : collector.localString({ text: v }); if (typeof v === "number") return v === 0 ? 0 : collector.localString({ index: integer(v, what) }); throw new ScriptError(`${what}: expected text, got ${describe(v)}.`); diff --git a/compiler/sound.ts b/compiler/sound.ts index 00c39c4..7027237 100644 --- a/compiler/sound.ts +++ b/compiler/sound.ts @@ -4,13 +4,14 @@ * the rate chosen, as IMA ADPCM — four bits a sample, the coding the game's own sounds use and the one the map gets. * The source file is not kept, as a video's and an image's are not: what the build reads is the same everywhere. * - * What a script gets is a `Clip` (below), worked on when the script is built — trimmed, made louder or quieter, - * faded, reversed, pitched, joined, mixed, repeated — or made from nothing by `tone`, `noise` and `silence`. A clip a - * program plays (`playWav(boom)`) is written into the map's archive when the script is built, as a WAV named by what - * it holds (`staredit\wav\trigscript-.wav`), once however often it is played; nothing else of it reaches the - * game. The WAV is IMA ADPCM, a quarter of 16-bit, but for a tone the coding would make harsh, which goes in as - * 16-bit PCM: ADPCM codes each sample as a step from the last, and the jumps of a square or a saw wave come back - * ringing — 8 dB, heard as a harsh end to every note of the first sound demo (2026-10-19). See `adpcmWillDo`. + * What a script gets is a `Clip` (below), one channel or two, worked on when the script is built — trimmed, made + * louder or quieter, faded, reversed, pitched, joined, mixed, repeated, panned, limited — or made from nothing by + * `tone`, `noise` and `silence`. A clip a program plays (`playWav(boom)`) is written into the map's archive when the + * script is built, named by what it holds (`staredit\wav\trigscript-.wav` or `.ogg`), once however often it is + * played; nothing else of it reaches the game. Two seconds or more is Ogg Vorbis (`clipFile`, `vorbis.ts`); shorter is + * a WAV of IMA ADPCM, a quarter of 16-bit, but for a tone the coding would make harsh, which goes in as 16-bit PCM: + * ADPCM codes each sample as a step from the last, and the jumps of a square or a saw wave come back ringing — 8 dB, + * heard as a harsh end to every note of the first sound demo (2026-10-19). See `adpcmWillDo`. * * What StarCraft: Remastered does with them (the sound probe, 2026-10-19): a sound starts on its mixer's 21.3 ms grid, * up to one buffer after its frame; playing one again starts a second copy beside the first, and nothing stops one, @@ -110,15 +111,16 @@ export function adpcmWav(data: Uint8Array, rate: number, frames: number): Uint8A return out; } -/** A WAV of 16-bit PCM: what the map gets for a clip ADPCM would distort. */ -export function pcmWav(samples: Float32Array, rate: number): Uint8Array { - const out = new Uint8Array(44 + samples.length * 2), v = new DataView(out.buffer); +/** A WAV of 16-bit PCM, one channel or two: what the map gets for a clip ADPCM would distort, or a short one with two sides. */ +export function pcmWav(samples: Float32Array | readonly Float32Array[], rate: number): Uint8Array { + const chans = samples instanceof Float32Array ? [samples] : samples, n = chans.length, frames = chans[0].length, size = frames * n * 2; + const out = new Uint8Array(44 + size), v = new DataView(out.buffer); const text = (at: number, s: string) => { for (let i = 0; i < s.length; i++) out[at + i] = s.charCodeAt(i); }; - text(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); text(8, "WAVE"); - text(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); - v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); - text(36, "data"); v.setUint32(40, samples.length * 2, true); - samples.forEach((x, i) => v.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, x)) * 32767), true)); + text(0, "RIFF"); v.setUint32(4, 36 + size, true); text(8, "WAVE"); + text(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, n, true); + v.setUint32(24, rate, true); v.setUint32(28, rate * 2 * n, true); v.setUint16(32, 2 * n, true); v.setUint16(34, 16, true); + text(36, "data"); v.setUint32(40, size, true); + for (let i = 0; i < frames; i++) for (let c = 0; c < n; c++) v.setInt16(44 + (i * n + c) * 2, Math.round(Math.max(-1, Math.min(1, chans[c][i])) * 32767), true); return out; } @@ -211,6 +213,8 @@ export interface Clip { readonly __trigscript: "clip"; readonly seconds: number; readonly rate: number; + /** 1, or 2 for a clip with a left and a right. */ + readonly channels: 1 | 2; /** The loudest sample, 0 … 1 (more than 1 clips when the map's file is written). */ readonly peak: number; trim(from: number, to?: number): Clip; @@ -222,9 +226,14 @@ export interface Clip { mix(other: Clip, at?: number): Clip; repeat(times: number): Clip; envelope(...points: readonly (readonly [number, number])[]): Clip; + pan(position: number): Clip; + mono(): Clip; + limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; + normalize(peak?: number): Clip; + wav(): Clip; } -interface ClipInside { samples: Float32Array; adpcm?: Uint8Array } +interface ClipInside { channels: readonly Float32Array[]; adpcm?: Uint8Array; wav?: boolean } const inside = new WeakMap(); export const isClip = (v: unknown): v is Clip => typeof v === "object" && v !== null && (v as Clip).__trigscript === "clip"; @@ -234,66 +243,108 @@ const num = (v: unknown, what: string): number => { return v; }; -/** `samples` at `rate`, as a rate of its own, by straight lines between samples. */ +/* + * Resampling by a windowed sinc: each new sample the old ones weighed by sin(πx)/(πx) under a Blackman window 16 samples + * each side, its cutoff lowered to the new rate's half when that is the lower, so a note pitched up does not fold its + * top back down as other pitches. (Straight lines between samples did both: dulled the top and folded it.) + */ +const HALF = 16, RES = 512; +const KERNEL = (() => { + const t = new Float32Array(HALF * RES + 2); + for (let i = 0; i < t.length; i++) { + const u = i / RES; + const sinc = u === 0 ? 1 : Math.sin(Math.PI * u) / (Math.PI * u); + t[i] = u >= HALF ? 0 : sinc * (0.42 + 0.5 * Math.cos((Math.PI * u) / HALF) + 0.08 * Math.cos((2 * Math.PI * u) / HALF)); + } + return t; +})(); + +/** `samples` at `rate`, as a rate of its own. */ function resample(samples: Float32Array, from: number, to: number): Float32Array { if (from === to) return samples; const n = Math.max(1, Math.round((samples.length * to) / from)), out = new Float32Array(n), r = from / to; + const c = Math.min(1, to / from), reach = HALF / c, last = samples.length - 1; for (let i = 0; i < n; i++) { - const p = i * r, a = Math.floor(p), f = p - a; - out[i] = (samples[a] ?? 0) * (1 - f) + (samples[a + 1] ?? samples[a] ?? 0) * f; + const p = i * r, a = Math.max(0, Math.floor(p - reach) + 1), b = Math.min(last, Math.floor(p + reach)); + let sum = 0; + for (let j = a; j <= b; j++) { + const u = Math.abs(j - p) * c * RES, k = u | 0; + sum += samples[j] * (KERNEL[k] + (KERNEL[k + 1] - KERNEL[k]) * (u - k)); + } + out[i] = sum * c; } return out; } -/** The clip these samples are. */ -export function makeClip(samples: Float32Array, rate: number, adpcm?: Uint8Array): Clip { - if (samples.length / rate > MAX_SECONDS) throw new Error(`A clip is at most ${MAX_SECONDS / 60} minutes long, not ${(samples.length / rate / 60).toFixed(1)}.`); +/** A clip's channels at `rate` and as many channels as `channels`: a mono one heard the same from both sides. */ +const channelsAt = (c: Clip, rate: number, channels: number): Float32Array[] => { + const own = inside.get(c)!.channels.map((s) => resample(s, c.rate, rate)); + return channels === 2 && own.length === 1 ? [own[0], own[0]] : own; +}; + +/** The clip these channels are (one, or a left and a right of the same length). */ +export function makeClip(samples: Float32Array | readonly Float32Array[], rate: number, adpcm?: Uint8Array, wav?: boolean): Clip { + const chans: readonly Float32Array[] = samples instanceof Float32Array ? [samples] : samples; + if (chans.length !== 1 && chans.length !== 2) throw new Error("A clip has one channel or two."); + const frames = chans[0].length; + if (chans.some((s) => s.length !== frames)) throw new Error("A clip's channels are the same length."); + if (frames / rate > MAX_SECONDS) throw new Error(`A clip is at most ${MAX_SECONDS / 60} minutes long, not ${(frames / rate / 60).toFixed(1)}.`); let peak = 0; - for (const s of samples) peak = Math.max(peak, Math.abs(s)); - const seconds = samples.length / rate; - const at = (t: number) => Math.max(0, Math.min(samples.length, Math.round(t * rate))); - const map = (f: (s: number, i: number) => number) => makeClip(samples.map(f), rate); + for (const s of chans) for (const x of s) peak = Math.max(peak, Math.abs(x)); + const seconds = frames / rate, channels = chans.length as 1 | 2; + const at = (t: number) => Math.max(0, Math.min(frames, Math.round(t * rate))); + // A new clip from each channel, keeping whether it was asked to stay a WAV. + const each = (f: (s: Float32Array, side: number) => Float32Array) => makeClip(chans.map(f), rate, undefined, wav); + const map = (f: (x: number, i: number) => number) => each((s) => s.map(f)); const clip: Clip = { __trigscript: "clip", seconds, rate, + channels, peak, trim(from, to = seconds) { num(from, "trim()'s start"); num(to, "trim()'s end"); if (from < 0 || to > seconds + 1e-9 || to <= from) throw new RangeError(`trim(${from}, ${to}) is not inside the clip, which is ${seconds.toFixed(3)} seconds.`); - return makeClip(samples.slice(at(from), at(to)), rate); + return each((s) => s.slice(at(from), at(to))); }, gain(factor) { if (num(factor, "gain()") < 0) throw new RangeError(`gain() is 0 or more (0.5 half as loud, 2 twice), not ${factor}.`); - return map((s) => s * factor); + return map((x) => x * factor); }, fade(fadeIn, fadeOut = 0) { num(fadeIn, "fade()'s in"); num(fadeOut, "fade()'s out"); if (fadeIn < 0 || fadeOut < 0) throw new RangeError("fade() takes seconds, 0 or more."); - const a = fadeIn * rate, b = fadeOut * rate, n = samples.length; - return map((s, i) => s * Math.min(1, a ? i / a : 1, b ? (n - 1 - i) / b : 1)); + const a = fadeIn * rate, b = fadeOut * rate; + return map((x, i) => x * Math.min(1, a ? i / a : 1, b ? (frames - 1 - i) / b : 1)); }, - reverse() { return makeClip(samples.slice().reverse(), rate); }, + reverse() { return each((s) => s.slice().reverse()); }, pitch(semitones) { // Faster and higher, or slower and lower, as a sampler plays a note: the clip read at another speed. const ratio = 2 ** (num(semitones, "pitch()") / 12); - return makeClip(resample(samples, rate * ratio, rate), rate); + return each((s) => resample(s, rate * ratio, rate)); }, then(...clips) { - const parts = [samples, ...clips.map((c) => { if (!isClip(c)) throw new Error("then() joins clips."); return resample(inside.get(c)!.samples, c.rate, rate); })]; - const out = new Float32Array(parts.reduce((s, p) => s + p.length, 0)); - let o = 0; - for (const p of parts) { out.set(p, o); o += p.length; } - return makeClip(out, rate); + for (const c of clips) if (!isClip(c)) throw new Error("then() joins clips."); + const n = Math.max(channels, ...clips.map((c) => c.channels)); + const parts = [clip, ...clips].map((c) => channelsAt(c, rate, n)); + const total = parts.reduce((s, p) => s + p[0].length, 0); + return makeClip(Array.from({ length: n }, (_, side) => { + const out = new Float32Array(total); + let o = 0; + for (const p of parts) { out.set(p[side], o); o += p[side].length; } + return out; + }), rate, undefined, wav); }, mix(other, start = 0) { if (!isClip(other)) throw new Error("mix() takes a clip."); if (num(start, "mix()'s start") < 0) throw new RangeError("mix()'s start is 0 seconds or more."); - const o = resample(inside.get(other)!.samples, other.rate, rate), from = Math.round(start * rate); - const out = new Float32Array(Math.max(samples.length, from + o.length)); - out.set(samples); - for (let i = 0; i < o.length; i++) out[from + i] += o[i]; - return makeClip(out, rate); + const n = Math.max(channels, other.channels), mine = channelsAt(clip, rate, n), theirs = channelsAt(other, rate, n), from = Math.round(start * rate); + return makeClip(mine.map((s, side) => { + const o = theirs[side], out = new Float32Array(Math.max(frames, from + o.length)); + out.set(s); + for (let i = 0; i < o.length; i++) out[from + i] += o[i]; + return out; + }), rate, undefined, wav); }, envelope(...points) { // Loudness through the clip, a straight line between [seconds, level] points: the first level before the first, @@ -304,29 +355,104 @@ export function makeClip(samples: Float32Array, rate: number, adpcm?: Uint8Array return [num(p[0], "a point's seconds") * rate, num(p[1], "a point's level")] as const; }); for (let k = 1; k < ps.length; k++) if (ps[k][0] < ps[k - 1][0]) throw new RangeError("envelope()'s points go forward in time."); - let k = 0; - return map((v, i) => { + const levels = new Float32Array(frames); + for (let i = 0, k = 0; i < frames; i++) { while (k < ps.length && ps[k][0] <= i) k++; - const level = k === 0 ? ps[0][1] : k === ps.length ? ps[k - 1][1] : ps[k - 1][1] + ((ps[k][1] - ps[k - 1][1]) * (i - ps[k - 1][0])) / (ps[k][0] - ps[k - 1][0] || 1); - return v * level; - }); + levels[i] = k === 0 ? ps[0][1] : k === ps.length ? ps[k - 1][1] : ps[k - 1][1] + ((ps[k][1] - ps[k - 1][1]) * (i - ps[k - 1][0])) / (ps[k][0] - ps[k - 1][0] || 1); + } + return map((x, i) => x * levels[i]); }, repeat(times) { if (!Number.isInteger(num(times, "repeat()")) || times < 1) throw new RangeError(`repeat() takes a whole number of times, 1 or more, not ${times}.`); - const out = new Float32Array(samples.length * times); - for (let k = 0; k < times; k++) out.set(samples, k * samples.length); - return makeClip(out, rate); + return each((s) => { + const out = new Float32Array(s.length * times); + for (let k = 0; k < times; k++) out.set(s, k * s.length); + return out; + }); + }, + pan(position) { + // A balance: the far side turned down, the near one kept, so the middle (0) is the clip as it was. + if (!(num(position, "pan()") >= -1 && position <= 1)) throw new RangeError(`pan() is −1 (left) to 1 (right), 0 the middle, not ${position}.`); + const [left, right] = channels === 2 ? chans : [chans[0], chans[0]]; + const l = Math.min(1, 1 - position), r = Math.min(1, 1 + position); + return makeClip([left.map((x) => x * l), right.map((x) => x * r)], rate, undefined, wav); + }, + mono() { + if (channels === 1) return clip; + return makeClip(chans[0].map((x, i) => (x + chans[1][i]) / 2), rate, undefined, wav); }, + limit(options = {}) { + const ceiling = options.ceiling ?? LIMIT_CEILING, release = options.release ?? 0.1; + if (!(num(ceiling, "limit()'s ceiling") > 0 && ceiling <= 1)) throw new RangeError(`limit()'s ceiling is more than 0 and at most 1, not ${ceiling}.`); + if (!(num(release, "limit()'s release") >= 0)) throw new RangeError("limit()'s release is seconds, 0 or more."); + const gain = limiterGain(chans, rate, ceiling, release); + return each((s) => s.map((x, i) => x * gain[i])); + }, + normalize(to = LIMIT_CEILING) { + if (!(num(to, "normalize()") > 0 && to <= 1)) throw new RangeError(`normalize() makes the loudest sample more than 0 and at most 1, not ${to}.`); + return peak === 0 ? clip : map((x) => (x * to) / peak); + }, + wav() { return wav ? clip : makeClip(chans, rate, adpcm, true); }, }; - inside.set(clip, { samples, ...(adpcm ? { adpcm } : {}) }); + inside.set(clip, { channels: chans, ...(adpcm ? { adpcm } : {}), ...(wav ? { wav } : {}) }); return Object.freeze(clip); } /** - * Many clips laid over one another, each from its own time (seconds), as one clip at the first's rate: what a song - * sounds like when it is mixed when the script is built. `seconds` makes it that long at least. + * The level `limit` keeps a clip under unless told otherwise, and `normalize` brings it to: −1.5 dB. Ogg Vorbis comes + * back up to a dB louder at its peaks than what went in (a song limited to −1 dB peaked at −0.16 dB, decoded), so the + * margin keeps a limited song from clipping in the game. + */ +export const LIMIT_CEILING = 0.84; +/** How far ahead the limiter looks, in seconds: it turns down this long before a peak, so the peak is never over. */ +const LOOKAHEAD = 0.005; + +/** + * The limiter's gain each sample, the same for both sides: under the ceiling everywhere. What each sample needs (the + * ceiling over its loudest side, 1 at most) is taken at its lowest over the next 5 ms, then averaged over 5 ms — each + * average covers the window of every sample before the peak, so it is no higher than the peak needs, and the gain + * goes down smoothly over the 5 ms before it — and it comes back up over `release` seconds. */ -export function mixdown(parts: unknown, seconds?: unknown): Clip { +function limiterGain(chans: readonly Float32Array[], rate: number, ceiling: number, release: number): Float32Array { + const n = chans[0].length, L = Math.max(1, Math.round(LOOKAHEAD * rate)); + const need = new Float32Array(n); + for (let i = 0; i < n; i++) { + let m = 0; + for (const s of chans) m = Math.max(m, Math.abs(s[i])); + need[i] = m > ceiling ? ceiling / m : 1; + } + // The lowest over [i, i + L): a queue of indices whose needs rise, the front the lowest. + const low = new Float32Array(n), queue = new Int32Array(n + L); + let head = 0, tail = 0; + for (let j = 0; j < n + L - 1; j++) { + if (j < n) { + while (tail > head && need[queue[tail - 1]] >= need[j]) tail--; + queue[tail++] = j; + } + const i = j - L + 1; + if (i < 0) continue; + while (queue[head] < i) head++; + low[i] = need[queue[head]]; + } + // Averaged over (i − L, i], then released. + const gain = new Float32Array(n), back = release > 0 ? 1 - Math.exp(-1 / (release * rate)) : 1; + // (Before the first sample the need is 1: the sum starts at L ones.) + let sum = L, g = 1; + for (let i = 0; i < n; i++) { + sum += low[i] - (i >= L ? low[i - L] : 1); + const target = Math.min(1, sum / L); + g = target < g ? target : g + (target - g) * back; + gain[i] = g; + } + return gain; +} + +/** + * Many clips laid over one another, each from its own time (seconds), as one clip at the first's rate (or `rate`): + * what a song sounds like when it is mixed when the script is built. Two channels when any part has two. `seconds` + * makes it that long at least. + */ +export function mixdown(parts: unknown, seconds?: unknown, rate?: number): Clip { if (!Array.isArray(parts) || parts.length === 0) throw new Error("mixdown() takes { clip, at } parts, one at least."); const list = parts.map((p, k) => { const c = (p as { clip?: unknown })?.clip, at = (p as { at?: unknown })?.at ?? 0; @@ -334,30 +460,76 @@ export function mixdown(parts: unknown, seconds?: unknown): Clip { if (num(at, "a part's time") < 0) throw new RangeError("A part's time is 0 seconds or more."); return { c, at: at as number }; }); - const rate = list[0].c.rate; - const length = Math.max(seconds === undefined ? 0 : Math.round(num(seconds, "mixdown()'s length") * rate), ...list.map((p) => Math.round(p.at * rate) + Math.round(p.c.seconds * rate))); - if (length / rate > MAX_SECONDS) throw new RangeError(`A clip is at most ${MAX_SECONDS / 60} minutes long.`); - const out = new Float32Array(length); + if (rate !== undefined) checkRate(rate); + const to = rate ?? list[0].c.rate; + const n = Math.max(...list.map((p) => p.c.channels)); + const length = Math.max(seconds === undefined ? 0 : Math.round(num(seconds, "mixdown()'s length") * to), ...list.map((p) => Math.round(p.at * to) + Math.round(p.c.seconds * to))); + if (length / to > MAX_SECONDS) throw new RangeError(`A clip is at most ${MAX_SECONDS / 60} minutes long.`); + const out = Array.from({ length: n }, () => new Float32Array(length)); + // A clip played many times is resampled once. + const made = new Map(); for (const p of list) { - const s = resample(inside.get(p.c)!.samples, p.c.rate, rate), from = Math.round(p.at * rate); - for (let i = 0; i < s.length && from + i < length; i++) out[from + i] += s[i]; + let s = made.get(p.c); + if (!s) { s = channelsAt(p.c, to, n); made.set(p.c, s); } + const from = Math.round(p.at * to); + for (let side = 0; side < n; side++) { + const src = s[side], dst = out[side]; + for (let i = 0; i < src.length && from + i < length; i++) dst[from + i] += src[i]; + } } - return makeClip(out, rate); + return makeClip(out, to); +} + +/** A clip's samples, mono (for the compiler's own work and the editor's player, not in the declarations). */ +export const clipSamples = (c: Clip): Float32Array => { + const ch = inside.get(c)!.channels; + return ch.length === 1 ? ch[0] : ch[0].map((x, i) => (x + ch[1][i]) / 2); +}; +/** A clip's channels as they are. */ +export const clipChannels = (c: Clip): readonly Float32Array[] => inside.get(c)!.channels; + +/** + * At this length or more a clip a program plays goes in the map as Ogg Vorbis (Remastered plays it; 1.16.1 plays only + * WAVs): a song, mixed, is a tenth the size and keeps its top and its two sides. Shorter ones stay WAVs, which start on + * their frame (the sound probe), as do an import as it was (its own ADPCM), a clip a trigger plays (`runtime.ts`), and + * one made `wav()`. + */ +export const VORBIS_SECONDS = 2; +/** libvorbis's quality, −1 … 10: 5 is about 160 kbit/s for two channels at 44.1 kHz. */ +export const VORBIS_QUALITY = 5; + +/** What the map's archive gets for a clip: a WAV's bytes now, or the samples of an Ogg Vorbis encoded when the map is built. */ +export interface ClipFile { + path: string; + bytes?: Uint8Array; + vorbis?: { rate: number; channels: readonly Float32Array[] }; } -/** A clip's samples (for the compiler's own work and the editor's player, not in the declarations). */ -export const clipSamples = (c: Clip): Float32Array => inside.get(c)!.samples; - -/** The WAV the map's archive gets for a clip, and the name it goes under: named by what it holds, so it goes in once. */ -export function clipFile(c: Clip): { path: string; bytes: Uint8Array } { - const { samples, adpcm } = inside.get(c)!; - // An import as it was keeps its own ADPCM; anything else is ADPCM where that is faithful, 16-bit where not. - const coded = adpcm ?? encodeAdpcm(samples, c.rate); - const bytes = adpcm || adpcmWillDo(samples, c.rate, coded) ? adpcmWav(coded, c.rate, samples.length) : pcmWav(samples, c.rate); - // FNV-1a over the file: two clips that are the same file share it. - let h = 0x811c9dc5; +/** FNV-1a over bytes, from where another call left off. */ +const fnv = (bytes: Uint8Array, h = 0x811c9dc5): number => { for (const b of bytes) h = Math.imul(h ^ b, 0x01000193) >>> 0; - return { path: `staredit\\wav\\trigscript-${h.toString(16).padStart(8, "0")}.wav`, bytes }; + return h; +}; + +/** The file the map's archive gets for a clip, and the name it goes under: named by what it holds, so it goes in once. */ +export function clipFile(c: Clip): ClipFile { + const { channels, adpcm, wav } = inside.get(c)!; + if (!wav && !adpcm && c.seconds >= VORBIS_SECONDS) { + // Named by the samples, the rate and the quality, so the name is known before it is encoded. + let h = fnv(new TextEncoder().encode(`vorbis ${VORBIS_QUALITY} ${c.rate} ${channels.length}`)); + for (const s of channels) h = fnv(new Uint8Array(s.buffer, s.byteOffset, s.byteLength), h); + return { path: `staredit\\wav\\trigscript-${h.toString(16).padStart(8, "0")}.ogg`, vorbis: { rate: c.rate, channels } }; + } + // An import as it was keeps its own ADPCM; anything else mono is ADPCM where that is faithful, 16-bit where not; two + // channels are 16-bit. + let bytes: Uint8Array; + if (channels.length === 2) bytes = pcmWav(channels, c.rate); + else { + const coded = adpcm ?? encodeAdpcm(channels[0], c.rate); + bytes = adpcm || adpcmWillDo(channels[0], c.rate, coded) ? adpcmWav(coded, c.rate, channels[0].length) : pcmWav(channels, c.rate); + } + // Two clips that are the same file share it. + return { path: `staredit\\wav\\trigscript-${fnv(bytes).toString(16).padStart(8, "0")}.wav`, bytes }; } /** The value an import of a `.sound` file gives, kept while the text is the same (a build reads the files each time). */ diff --git a/compiler/vorbis.ts b/compiler/vorbis.ts new file mode 100644 index 0000000..ccf704f --- /dev/null +++ b/compiler/vorbis.ts @@ -0,0 +1,125 @@ +/** + * Ogg Vorbis for the clips a build puts in the map at two seconds or more (`sound.ts`'s `clipFile`): libvorbis and + * libogg compiled to WebAssembly by wasm-media-encoders (MIT; libvorbis and libogg BSD), its `ogg.wasm` copied to + * `dist/vorbis.wasm` by the build. The glue is this file's own, a few calls into the module's exports: the package's + * JavaScript carries both its encoders inline, and instantiates them only asynchronously. + * + * A compile only names a Vorbis clip (by its samples); it is encoded when the map is to get it — a build, not a check + * as the user types — by `finishSounds`, which the compile worker calls when asked, and `scripts/build-fixture.mts` + * calls for a probe map. Encoding three minutes of two channels takes about a second and a half. + */ +import { VORBIS_QUALITY, type ClipFile } from "./sound"; + +/** What the module exports, as wasm-media-encoders 0.7.0 builds it. */ +interface Exports { + memory: WebAssembly.Memory; + _initialize(): void; + malloc(bytes: number): number; + free(at: number): void; + enc_init(params: number): number; + enc_get_pcm(ref: number, frames: number): number; + enc_encode(ref: number, frames: number): number; + enc_flush(ref: number): number; + enc_get_out_buf(ref: number): number; + enc_free(ref: number): void; +} + +/** Samples handed to the encoder at a time. */ +const CHUNK = 8192; + +export interface VorbisEncoder { + /** One or two channels of samples (−1 … 1) at `rate`, as an Ogg Vorbis file: the same bytes for the same samples. */ + encode(channels: readonly Float32Array[], rate: number, quality?: number): Uint8Array; +} + +/** An encoder from the module's bytes (or the module), made at once: a worker, or Node, may compile a module this size synchronously. */ +export function vorbisEncoder(wasm: BufferSource | WebAssembly.Module): VorbisEncoder { + const module = wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm); + const instance = new WebAssembly.Instance(module, { + wasi_snapshot_preview1: { proc_exit: (code: number) => { throw new Error(`The Vorbis encoder stopped (exit ${code}).`); } }, + env: { emscripten_notify_memory_growth: () => {} }, + }); + const x = instance.exports as unknown as Exports; + x._initialize(); + // The memory may grow inside any call: a view is made after the call it is for. + const bytesOut = (ref: number, n: number) => new Uint8Array(x.memory.buffer, x.enc_get_out_buf(ref), n).slice(); + return { + encode(channels, rate, quality = VORBIS_QUALITY) { + const n = channels.length; + if (n !== 1 && n !== 2) throw new Error("Vorbis here takes one channel or two."); + const params = x.malloc(16); + if (!params) throw new Error("The Vorbis encoder is out of memory."); + const view = new DataView(x.memory.buffer); + view.setUint32(params, n, true); + view.setUint32(params + 4, rate, true); + view.setFloat32(params + 8, quality, true); + // The stream's serial number: fixed, so a build writes the same file for the same samples. + view.setInt32(params + 12, 0x54726753, true); + const ref = x.enc_init(params); + x.free(params); + if (!ref) throw new Error("The Vorbis encoder would not start."); + try { + const parts: Uint8Array[] = []; + const frames = channels[0].length; + for (let i = 0; i < frames; i += CHUNK) { + const m = Math.min(CHUNK, frames - i); + const table = x.enc_get_pcm(ref, m); + if (!table) throw new Error("The Vorbis encoder is out of memory."); + const pointers = new Int32Array(x.memory.buffer, table, n); + for (let c = 0; c < n; c++) new Float32Array(x.memory.buffer, pointers[c], m).set(channels[c].subarray(i, i + m)); + const out = x.enc_encode(ref, m); + if (out < 0) throw new Error(`The Vorbis encoder failed (${out}).`); + if (out > 0) parts.push(bytesOut(ref, out)); + } + const last = x.enc_flush(ref); + if (last < 0) throw new Error(`The Vorbis encoder failed (${last}).`); + if (last > 0) parts.push(bytesOut(ref, last)); + const file = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let o = 0; + for (const p of parts) { file.set(p, o); o += p.length; } + return file; + } finally { + x.enc_free(ref); + } + }, + }; +} + +/** Files encoded already, by their names (which say what they hold): a build after a build of the same song is quick. */ +const encoded = new Map(); +const KEEP = 32; + +/** + * Give each sound of a compile its bytes: the Vorbis ones encoded, with `encoder` — or, with none, left as names only + * (a check, whose sounds the map does not get) and their samples dropped, so the result is small to send. + */ +export function finishSounds(sounds: ClipFile[], encoder: VorbisEncoder | null): void { + for (const s of sounds) { + if (!s.vorbis) continue; + if (encoder) { + let bytes = encoded.get(s.path); + if (!bytes) { + bytes = encoder.encode(s.vorbis.channels, s.vorbis.rate); + if (encoded.size >= KEEP) encoded.delete(encoded.keys().next().value!); + encoded.set(s.path, bytes); + } + s.bytes = bytes; + } + delete s.vorbis; + } +} + +/** The encoder, its module fetched from `url` once. */ +let loading: { url: string; encoder: Promise } | null = null; + +export function loadVorbis(url: string): Promise { + if (loading?.url !== url) { + const encoder = fetch(url).then(async (r) => { + if (!r.ok) throw new Error(`Could not load the Vorbis encoder from ${url} (${r.status}).`); + return vorbisEncoder(await r.arrayBuffer()); + }); + encoder.catch(() => { if (loading?.encoder === encoder) loading = null; }); + loading = { url, encoder }; + } + return loading.encoder; +} diff --git a/docs/v4-plan.md b/docs/v4-plan.md index b86375e..3ab09a0 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1641,6 +1641,45 @@ freely within 32 voices, each note's length and loudness baked into its file. note by note against the same song mixed at build, onset by onset (34 of them): 7.8 ms apart on average (sd), 16 at most, no drift over the song; equally loud second by second; a hit every 589.5 ms for the 588 written. +### Sounds: step 1 of the quality work, the format and the master (built 2026-10-19) + +The user's direction after the music demo: make the instruments and the sound good enough for a showpiece — an +arrangement of Korobeiniki (the folk song is in the public domain; the Tetris arrangements and look are not, so the +parts around the melody are our own). Step 1 is what every later step sounds through: the file the map gets, the rate +a mix is made at, two channels, and a limiter. Steps after it: the instruments themselves (filters, detune, FM, +reverb, delay, drive), song structure, music that follows the game. + +- **Ogg Vorbis** (`compiler/vorbis.ts`): libvorbis + libogg compiled to WebAssembly by wasm-media-encoders 0.7.0 (MIT; + BSD), its `ogg.wasm` copied to `dist/vorbis.wasm` by the build with a LEGAL file; our own glue, a few calls into its + exports, instantiated synchronously (the package's JavaScript carries both its encoders inline and only + instantiates asynchronously). A clip a program plays that lasts 2 seconds or more goes in as Ogg Vorbis at + quality 5; a shorter one stays a WAV (they start on their frame — Ogg's start is not measured yet), as does an + untouched import (its own ADPCM), one made `wav()`, and any clip a `trigger()` plays (1.16.1 plays those triggers and + no Ogg). The fixed stream serial makes the file the same for the same samples — in Node and in the browser alike. +- **Encoded only for a build.** A compile names an Ogg clip by a hash of its samples; the compile worker encodes it + when the request says `sounds` (Apply, `service.build`), fetching `dist/vorbis.wasm` once, and keeps 32 encoded files + by name. A check as the user types leaves the name alone and does not send the samples back. `withSounds` refuses a + sound with no bytes that the map does not already hold. Three minutes of two channels encode in about 1.5 s. +- **Two channels**: `Clip.channels`, `pan(position)` (a balance, so 0 is the clip as it was), `mono()`; a mix, a join + or a mixdown with a two-channel part is two channels. A short two-channel clip is 16-bit WAV. Tracks take + `{ pan, volume }`, each clip a track plays made once. +- **Resampling** by a windowed sinc (Blackman, 16 each side, cutoff at the lower rate's half) in place of straight + lines, which dulled the top and folded a pitched-up note's harmonics back down (a 3 kHz sine an octave up: images + under 0.2 % now). +- **The limiter** (`Clip.limit`, `normalize`): look-ahead 5 ms (the lowest need over the next 5 ms, averaged over 5 ms, + so the gain is down smoothly before the peak and never above what it needs), release 0.1 s, the two sides together. + Ceiling −1.5 dB: Ogg Vorbis comes back up to a dB louder at its peaks (a song limited to −1 dB decoded at −0.16 dB; + at −1.5 dB, −0.45). +- **`music.render(song, { rate, limit })`**: the notes made again at 44 100 Hz (a song's own clips, for play, stay at + 22 050), through the limiter unless `limit: false`. +- **Measured** on `probes/korobeiniki-ab.ts` (32 s, seven tracks; A as before: 22 050 Hz, one channel, ADPCM WAV, + turned down to fit; B: 44 100 Hz, panned, limited, Ogg): above 11 kHz A has nothing (−56 dB), B −36 dB; B's sides + (L − R) −27 dB; B 2.1 dB louder at the same peak; A 355 KB, B 559 KB. Compile with both renders 1.1 s, encode 0.3 s. + Lifted game clean (0 ending, A at frame 98, B at 930). Headless Chromium through the real compile worker: a check + returns the name only, a build the Ogg (558 554 bytes, as Node), a second build from the cache. +- **Found on the way:** `ranges.ts` read `process.env` in the browser's worker, where there is none — every program + failed to compile in the editor. Guarded (its own commit). + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/editor.ts b/editor.ts index 13f43b6..2cd1e22 100644 --- a/editor.ts +++ b/editor.ts @@ -1396,12 +1396,12 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa * A keystroke during the compile supersedes it with the newer text's check; the newer * text is what the user wants built, so it is compiled again — a few times at most. */ - const compileNow = async (asked: TestRunOptions = automaticTests()): Promise => { + const compileNow = async (asked: TestRunOptions = automaticTests(), sounds = false): Promise => { if (timer !== null) { clearTimeout(timer); timer = null; } for (let attempt = 0; attempt < 3; attempt++) { if (cancelled || !generated) return null; try { - const a = await svc.prepare(files, generated, asked); + const a = await svc.prepare(files, generated, asked, sounds); applyResult(a.compiled, asked); return a; } catch (err) { @@ -1433,7 +1433,8 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa setStatus("busy", t("Running the script…")); try { for (let attempt = 0; ; attempt++) { - const a = await compileNow(); + // (A build: the map gets its sounds, the long ones encoded now.) + const a = await compileNow(automaticTests(), true); if (!a || cancelled) return false; if (!a.compiled.ok || diagnostics.length) { const n = diagnostics.length || a.compiled.diagnostics.length; diff --git a/package-lock.json b/package-lock.json index 026d4b2..c2cb93b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "monaco-editor": "^0.56.0", "mopaq": "^1.3.0", "typescript": "~6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "wasm-media-encoders": "^0.7.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -754,6 +755,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1728,6 +1739,13 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1918,6 +1936,19 @@ } } }, + "node_modules/wasm-media-encoders": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/wasm-media-encoders/-/wasm-media-encoders-0.7.0.tgz", + "integrity": "sha512-Sp4wUasgxOK/IFfNhpon6LQQgYGwtpxyV4isjGIe1rvhnJL3w2KYr4f+CdqDNtGPDcgCDRY+uBanVfSx6Si0WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.10" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 64c66b1..9fae224 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\" && esbuild compiler/entry.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/compiler.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/entry.ts. The compile worker imports this when the plugin is compiled into the editor.\" && esbuild compiler/testingEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --external:node:* --outfile=dist/testing.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/testingEntry.ts. The plugin never loads this: it is what another repository's tests import to run triggers.\" && dts-bundle-generator --silent --no-banner --project tsconfig.json -o dist/testing.d.ts compiler/testingEntry.ts && esbuild compiler/gameTestEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --outfile=dist/gametest.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/gameTestEntry.ts. The plugin hands its text to a host of the lifted game, which runs a script's tests beside the game.\" && node -e \"const fs=require('fs');fs.writeFileSync('dist/allowlist.json',JSON.stringify(JSON.parse(fs.readFileSync('data/allowlist.json','utf8'))))\"", + "build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\" && esbuild compiler/entry.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/compiler.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/entry.ts. The compile worker imports this when the plugin is compiled into the editor.\" && esbuild compiler/testingEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --external:node:* --outfile=dist/testing.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/testingEntry.ts. The plugin never loads this: it is what another repository's tests import to run triggers.\" && dts-bundle-generator --silent --no-banner --project tsconfig.json -o dist/testing.d.ts compiler/testingEntry.ts && esbuild compiler/gameTestEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --outfile=dist/gametest.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/gameTestEntry.ts. The plugin hands its text to a host of the lifted game, which runs a script's tests beside the game.\" && node -e \"const fs=require('fs');fs.writeFileSync('dist/allowlist.json',JSON.stringify(JSON.parse(fs.readFileSync('data/allowlist.json','utf8'))))\" && node scripts/copy-vorbis.mjs", "dev": "npm run build -- --watch", "typecheck": "tsc -p tsconfig.json", "test": "vitest run", @@ -29,6 +29,7 @@ "monaco-editor": "^0.56.0", "mopaq": "^1.3.0", "typescript": "~6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "wasm-media-encoders": "^0.7.0" } } diff --git a/probes/korobeiniki-ab.ts b/probes/korobeiniki-ab.ts new file mode 100644 index 0000000..390315c --- /dev/null +++ b/probes/korobeiniki-ab.ts @@ -0,0 +1,76 @@ +// The music library's first quality step, heard in StarCraft: Remastered: Korobeiniki (a Russian folk song, in the +// public domain; the melody only — the bass, the chords, the arpeggio and the drums are this file's own) mixed when +// the script is built, twice. A is how a mix went in before: 22 050 Hz, one channel, as a WAV, turned down so it does +// not clip. B is the same notes made again at 44 100 Hz, the tracks spread left and right, through the limiter, as Ogg +// Vorbis. Host it as a multiplayer game, alone, with the game's music off. It repeats. +// +// npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx +// npx tsx scripts/build-fixture.mts probes/korobeiniki-ab.ts fixtures/eud/korobeiniki-ab.scx fixtures/eud/sound-base.scx +import { music } from "trigscript/music"; + +// Eighth notes: two steps a beat, eight a bar; five frames a step is 143 beats a minute. +const MELODY = [ + "E5 - B4 C5 D5 - C5 B4", "A4 - A4 C5 E5 - D5 C5", "B4 - - C5 D5 - E5 -", "C5 - A4 - A4 - . .", + "D5 - - F5 A5 - G5 F5", "E5 - - C5 E5 - D5 C5", "B4 - B4 C5 D5 - E5 -", "C5 - A4 - A4 - . .", +]; +/** A bar a chord: its root low, and its notes. */ +const CHORDS: readonly (readonly [string, string, string])[] = [ + ["A2", "E3", "A3+C4+E4"], ["A2", "E3", "A3+C4+E4"], ["E2", "B2", "G#3+B3+E4"], ["A2", "E3", "A3+C4+E4"], + ["D2", "A2", "A3+D4+F4"], ["C2", "G2", "G3+C4+E4"], ["E2", "B2", "G#3+B3+E4"], ["A2", "E3", "A3+C4+E4"], +]; +const bassBar = ([root, fifth]: readonly [string, string, string]) => `${root} - . ${root} ${fifth} . ${root} ${fifth}`; +const arpBar = ([, , notes]: readonly [string, string, string]) => { + const [a, b, c] = notes.split("+").map((n) => n.slice(0, -1) + (Number(n.slice(-1)) + 1)); + return `${a} ${b} ${c} ${b} ${a} ${b} ${c} ${b}`; +}; +const padBar = ([, , notes]: readonly [string, string, string]) => `${notes} - - - - - - -`; + +const lead = music.synth({ wave: "square", volume: 0.2, decay: 0.12, sustain: 0.55, release: 0.1 }); +const low = music.synth({ wave: "saw", volume: 0.09, decay: 0.1, sustain: 0.5, release: 0.08 }); +const bass = music.synth({ wave: "triangle", volume: 0.55, attack: 0.003, decay: 0.15, sustain: 0.6, release: 0.05 }); +const arp = music.synth({ wave: "triangle", volume: 0.16, decay: 0.12, sustain: 0.2, release: 0.1 }); +const pad = music.synth({ wave: "triangle", volume: 0.09, attack: 0.12, decay: 0.3, sustain: 0.8, release: 0.4 }); +const drums = music.kit({ + k: tone(140, 0.03, { volume: 0.8 }).then(tone(85, 0.05, { volume: 0.8 }), tone(55, 0.12, { volume: 0.7 })).fade(0, 0.14), + s: noise(0.16, { volume: 0.4 }).fade(0, 0.15).mix(tone(200, 0.08, { volume: 0.35 }).fade(0, 0.07)), + h: noise(0.04, { volume: 0.14 }).fade(0, 0.035), +}); + +const rest = (bars: number) => Array.from({ length: bars }, () => ". . . . . . . ."); +const twice = (xs: readonly T[]) => [...xs, ...xs]; +const BEAT = "k h h h s h kh h", FILL = "k h s h s s s s"; +const tracks = (wide: boolean) => [ + music.track(lead, [...rest(2), ...twice(MELODY), "A4 - - - . . . ."], { pan: wide ? -0.15 : 0 }), + music.track(low, [...rest(10), ...MELODY.map((bar) => bar.replace(/([A-G]#?)(\d)/g, (_, n: string, o: string) => n + (Number(o) - 1))), ". . . . . . . ."], { pan: wide ? 0.45 : 0 }), + music.track(bass, [...twice(CHORDS).slice(0, 2).map(bassBar), ...twice(CHORDS).map(bassBar), "A2 - - - . . . ."]), + music.track(arp, [...rest(2), ...CHORDS.map(() => ". . . . . . . ."), ...CHORDS.map(arpBar), ". . . . . . . ."], { pan: wide ? 0.6 : 0 }), + music.track(pad, [...rest(2), ...twice(CHORDS).map(padBar), "A3+C4+E4 - - - - - - -"], { pan: wide ? -0.5 : 0 }), + music.track(pad, [...rest(2), ...twice(CHORDS).map((c) => padBar(c).replace(/(\d)(?=[+ ])/g, (o) => String(Number(o) + 1))), ". . . . . . . ."], { pan: wide ? 0.5 : 0, volume: 0.6 }), + music.track(drums, [BEAT, FILL, ...Array.from({ length: 15 }, () => BEAT), FILL, "k . . . . . . ."]), +]; + +const before = music.song({ bpm: 144, steps: 2 }, ...tracks(false)); +const after = music.song({ bpm: 144, steps: 2 }, ...tracks(true)); +const mixedA = music.render(before, { rate: 22050, limit: false }); +// A: as a mix went in before — one channel at 22 050, as a WAV, and turned down until it no longer clips. +const A = (mixedA.peak > 1 ? mixedA.gain(0.98 / mixedA.peak) : mixedA).wav(); +const B = music.render(after); + +const PARK = reserveLocation("korobeiniki: the camera"); + +program(async () => { + removeUnit(P1, units.AnyUnit); + removeUnit(P2, units.AnyUnit); + centerLocation(PARK, 64 * 32, 112 * 32); + centerView(PARK); + displayText(`\x04Korobeiniki, A then B, ${Math.round(after.bpm)} beats a minute, ${Math.round(B.seconds)} seconds each. Turn the game's music off. It repeats.`); + await sleep(frames(96)); + while (true) { + displayText(`\x04A: as before. 22 kHz, one channel, a WAV, turned down to fit (peak ${mixedA.peak.toFixed(2)} before).`); + playWav(A, 0); + await sleep(frames(Math.ceil(A.seconds / 0.042) + 72)); + displayText("\x04B: 44 kHz, left and right, the limiter, Ogg Vorbis."); + playWav(B, 0); + await sleep(frames(Math.ceil(B.seconds / 0.042) + 72)); + } +}); diff --git a/script.ts b/script.ts index 9378bb3..7351d6a 100644 --- a/script.ts +++ b/script.ts @@ -108,15 +108,22 @@ export function isScriptMember(name: string): boolean { return memberKey(name).startsWith(memberKey(SCRIPT_FOLDER)); } -/** A WAV a build wrote for a clip the script plays (`compiler/sound.ts`): `staredit\wav\trigscript-.wav`. */ -export const isBuiltSound = (name: string): boolean => /^staredit\\wav\\trigscript-[0-9a-f]{8}\.wav$/.test(memberKey(name)); +/** A file a build wrote for a clip the script plays (`compiler/sound.ts`): `staredit\wav\trigscript-.wav`, or `.ogg`. */ +export const isBuiltSound = (name: string): boolean => /^staredit\\wav\\trigscript-[0-9a-f]{8}\.(wav|ogg)$/.test(memberKey(name)); -/** The members with the clips of this build in place of the last one's. */ -export function withSounds(extras: Extras, sounds: readonly { path: string; bytes: Uint8Array }[]): Map { +/** + * The members with the clips of this build in place of the last one's. A clip already in the map keeps its bytes as + * they are: the name says what it holds. One that is not, and has no bytes — an Ogg Vorbis clip from a check, which + * leaves it unencoded — is refused: the map would play nothing. + */ +export function withSounds(extras: Extras, sounds: readonly { path: string; bytes?: Uint8Array }[]): Map { const next = new Map(); for (const [name, bytes] of extras) if (!isBuiltSound(name)) next.set(name, bytes); - // A clip already in the map keeps its bytes as they are: the name says what it holds. - for (const s of sounds) next.set(s.path, [...extras].find(([name]) => memberKey(name) === memberKey(s.path))?.[1] ?? s.bytes); + for (const s of sounds) { + const bytes = [...extras].find(([name]) => memberKey(name) === memberKey(s.path))?.[1] ?? s.bytes; + if (!bytes) throw new Error(`The sound ${s.path} was not encoded: compile it as a build (with its sounds) before installing it.`); + next.set(s.path, bytes); + } return next; } diff --git a/scripts/build-fixture.mts b/scripts/build-fixture.mts index f3d40b3..26594d2 100644 --- a/scripts/build-fixture.mts +++ b/scripts/build-fixture.mts @@ -22,6 +22,7 @@ import { serializeIr } from "../compiler/eud"; import { buildPlugins } from "../compiler/input"; import { readTriggerMap, readVariableMap } from "../compiler/gameVariables"; import { resolveStrings } from "../script"; +import { finishSounds, vorbisEncoder } from "../compiler/vorbis"; import { defaultScriptNames } from "../compiler/names"; import { defaultLib } from "../bundle/lib.mjs"; import { scriptFiles } from "./script-files.mts"; @@ -97,8 +98,10 @@ if (home && scn.locations?.[0] && !scn.locations[0].nameIndex && scn.locations[0 markDirty(scn, "TRIG", "UNIT"); const ir = serializeIr(r.ir, r.strings, r.input); const extras = loaded.archive ? await readExtras(loaded.archive, loaded.files) : new Map(); -// The clips the script plays (compiler/sound.ts), as the editor's Apply writes them. -for (const s of r.sounds) extras.set(s.path, s.bytes); +// The clips the script plays (compiler/sound.ts), as the editor's Apply writes them: the long ones Ogg Vorbis, encoded +// here as a build in the editor encodes them (compiler/vorbis.ts). +if (r.sounds.some((s) => s.vorbis)) finishSounds(r.sounds, vorbisEncoder(readFileSync(resolve(import.meta.dirname, "..", "node_modules", "wasm-media-encoders", "wasm", "ogg.wasm")))); +for (const s of r.sounds) extras.set(s.path, s.bytes!); const withStrings = await saveMap(serializeScenario(scn), { extras, compress: "pkware", listfile: true }); // PREP_ONLY=1: the prepared map as it is, not built: what the editor opens in a run end to end, and builds itself. if (process.env.PREP_ONLY) { writeFileSync(resolve(outPath), withStrings); process.exit(0); } diff --git a/scripts/copy-vorbis.mjs b/scripts/copy-vorbis.mjs new file mode 100644 index 0000000..df3b5a8 --- /dev/null +++ b/scripts/copy-vorbis.mjs @@ -0,0 +1,35 @@ +// Part of `npm run build`: the Ogg Vorbis encoder (compiler/vorbis.ts) as dist/vorbis.wasm, beside the compiler that +// fetches it, with its licences — wasm-media-encoders' (MIT) and libvorbis's and libogg's (BSD), which it is built from. +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const pkg = resolve(import.meta.dirname, "..", "node_modules", "wasm-media-encoders"); +const { version } = JSON.parse(readFileSync(resolve(pkg, "package.json"), "utf8")); +copyFileSync(resolve(pkg, "wasm", "ogg.wasm"), resolve(import.meta.dirname, "..", "dist", "vorbis.wasm")); +writeFileSync(resolve(import.meta.dirname, "..", "dist", "vorbis.wasm.LEGAL.txt"), `dist/vorbis.wasm is wasm-media-encoders ${version}'s wasm/ogg.wasm: libvorbis and libogg compiled to WebAssembly. + +wasm-media-encoders — https://github.com/arseneyr/wasm-media-encoders +${readFileSync(resolve(pkg, "LICENSE"), "utf8").trim()} + +libvorbis and libogg — https://xiph.org/ +libvorbis: Copyright (c) 2002-2020 Xiph.org Foundation +libogg: Copyright (c) 2002, Xiph.org Foundation + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following + disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided with the distribution. +- Neither the name of the Xiph.org Foundation nor the names of its contributors may be used to endorse or promote + products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \`\`AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +`); diff --git a/service.ts b/service.ts index e3b2a24..f995a0b 100644 --- a/service.ts +++ b/service.ts @@ -381,14 +381,15 @@ export class ScriptService { /** * Compile into an artifact `install` can check: the files as they are now, against the * map in front and its names as they are now (`map`, when the caller already has them — - * the editor keeps a copy that follows the map's events). + * the editor keeps a copy that follows the map's events). With `sounds`, a build's: its + * long clips encoded as Ogg Vorbis for the map, which a check as the user types does not wait for. */ - async prepare(input: ScriptInput, map: MapNames | null = this.names(), tests?: TestRunOptions): Promise { + async prepare(input: ScriptInput, map: MapNames | null = this.names(), tests?: TestRunOptions, sounds = false): Promise { if (!map) throw new Error("No map is open."); const files = this.filesOf(input); const document = this.documentId(); const archived = hashFiles(this.state()?.files ?? {}); - const compiled = await this.compiler({ files, names: map.names, ...(tests ? { tests } : {}) }, this.dist()); + const compiled = await this.compiler({ files, names: map.names, ...(tests ? { tests } : {}), ...(sounds ? { sounds } : {}) }, this.dist()); const artifact = { files, compiled, document, context: map.context, archived }; this.lastArtifact = artifact; return artifact; @@ -439,7 +440,7 @@ export class ScriptService { */ async build(input: ScriptInput, options: BuildOptions = {}): Promise { for (let attempt = 0; ; attempt++) { - const out = this.install(await this.prepare(input), options); + const out = this.install(await this.prepare(input, undefined, undefined, true), options); if (out.refused !== "changed" || attempt >= 2) return out; } } diff --git a/stdlib/music.ts b/stdlib/music.ts index 146fa05..6f0e63c 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -41,8 +41,11 @@ const VOICES = 32; /** What plays a pattern's notes: each note a clip, made when the script is built. */ export interface Instrument { readonly kind: "notes"; - /** Note `name` ("C4", "F#5") lasting `seconds`, release and all: a clip to play on its own, too. */ - note(name: string, seconds: number): Clip; + /** + * Note `name` ("C4", "F#5") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a + * second, for an instrument that makes its notes (render asks for 44 100). + */ + note(name: string, seconds: number, rate?: number): Clip; } /** What plays a pattern's letters: a clip a letter. */ @@ -61,12 +64,12 @@ function midi(name: string): number { } /** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */ -function remembering(make: (name: string, seconds: number) => Clip): (name: string, seconds: number) => Clip { +function remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip { const made = new Map(); - return (name, seconds) => { - const key = `${name} ${seconds.toFixed(4)}`; + return (name, seconds, rate) => { + const key = `${name} ${seconds.toFixed(4)} ${rate ?? ""}`; let c = made.get(key); - if (!c) { c = make(name, seconds); made.set(key, c); } + if (!c) { c = make(name, seconds, rate); made.set(key, c); } return c; }; } @@ -96,9 +99,9 @@ export function synth(options: { readonly wave?: "sine" | "square" | "triangle" const attack = options.attack ?? 0.005, decay = options.decay ?? 0.08, sustain = options.sustain ?? 0.7, release = options.release ?? 0.06; return Object.freeze({ kind: "notes" as const, - note: remembering((name, seconds) => { + note: remembering((name, seconds, rate) => { const held = Math.max(attack + decay, seconds - release); - return tone(name, held + release, { wave, volume }).envelope([0, 0], [attack, 1], [attack + decay, sustain], [held, sustain], [held + release, 0]); + return tone(name, held + release, { wave, volume, ...(rate === undefined ? {} : { rate }) }).envelope([0, 0], [attack, 1], [attack + decay, sustain], [held, sustain], [held + release, 0]); }), }); } @@ -113,14 +116,41 @@ export function kit(hits: Readonly>): Kit { export interface Track { readonly instrument: Instrument | Kit; readonly steps: readonly string[]; + /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */ + readonly pan: number; + /** How loud, 1 as the instrument plays. */ + readonly volume: number; } -/** A pattern for an instrument: steps a word each — a note or notes joined by +, "-" to hold, "." to rest, "|" for the eye. */ -export function track(instrument: Instrument | Kit, pattern: string | readonly string[]): Track { +/** + * A pattern for an instrument: steps a word each — a note or notes joined by +, "-" to hold, "." to rest, "|" for the + * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the + * instrument plays. + */ +export function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number } = {}): Track { const text = typeof pattern === "string" ? pattern : pattern.join(" "); - return Object.freeze({ instrument, steps: Object.freeze(text.split(/\s+/).filter((w) => w !== "" && w !== "|")) }); + const pan = options.pan ?? 0, volume = options.volume ?? 1; + if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`); + if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`); + return Object.freeze({ instrument, steps: Object.freeze(text.split(/\s+/).filter((w) => w !== "" && w !== "|")), pan, volume }); } +/** A clip as a track plays it: its volume and its pan, made once a clip a track. */ +function placed(t: Track, made: Map, c: Clip): Clip { + if (t.pan === 0 && t.volume === 1) return c; + let p = made.get(c); + if (!p) { + p = t.volume === 1 ? c : c.gain(t.volume); + if (t.pan !== 0) p = p.pan(t.pan); + made.set(c, p); + } + return p; +} + +/** What a song's notes are, kept for render, which makes them again at its own rate. */ +interface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly name?: string; readonly seconds?: number; readonly hit?: Clip } +const scores = new WeakMap(); + /** A song, when the script is built: the tables `play` reads, and what it takes. */ export interface Song { /** Frames a step lasts. */ @@ -151,9 +181,10 @@ export function song(options: { readonly bpm: number; readonly steps?: number }, const stepFrames = Math.max(1, Math.round(60 / options.bpm / perBeat / FRAME)); const stepSeconds = stepFrames * FRAME; const notes: { at: number; clip: Clip }[] = []; + const score: Score[] = []; let steps = 0; for (const t of tracks) { - const ws = t.steps; + const ws = t.steps, made = new Map(); steps = Math.max(steps, ws.length); ws.forEach((w, s) => { if (w === "-" || w === ".") return; @@ -161,7 +192,8 @@ export function song(options: { readonly bpm: number; readonly steps?: number }, for (const letter of w) { const hit = t.instrument.hits[letter]; if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(", ")}).`); - notes.push({ at: s * stepFrames, clip: hit }); + notes.push({ at: s * stepFrames, clip: placed(t, made, hit) }); + score.push({ at: s * stepFrames, track: t, made, hit }); } return; } @@ -170,7 +202,8 @@ export function song(options: { readonly bpm: number; readonly steps?: number }, while (s + length < ws.length && ws[s + length] === "-") length++; for (const name of w.split("+")) { midi(name); - notes.push({ at: s * stepFrames, clip: t.instrument.note(name, length * stepSeconds) }); + notes.push({ at: s * stepFrames, clip: placed(t, made, t.instrument.note(name, length * stepSeconds)) }); + score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds }); } }); } @@ -186,15 +219,47 @@ export function song(options: { readonly bpm: number; readonly steps?: number }, const clips: Clip[] = []; const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; }); const length = Math.max(steps * stepFrames, ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME))); - return Object.freeze({ + const made: Song = Object.freeze({ stepFrames, bpm: 60 / (stepFrames * FRAME * perBeat), steps, frames: length, seconds: length * FRAME, clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, }); + scores.set(made, score); + return made; +} + +/** + * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. The notes are + * made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a note played in the game note by note + * is made at 22 050, to keep the map small), and the mix goes through a limiter (`Clip.limit`) so that it never goes + * over −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a + * program plays goes in the map as Ogg Vorbis, two channels when a track is panned. + */ +export function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number } } = {}): Clip { + const rate = options.rate ?? 44100, score = scores.get(s); + // (A song from elsewhere, without its score: its own clips, at their rate.) + const parts = score + ? score.map((n) => ({ clip: n.hit ? placed(n.track, n.made, n.hit) : placedAt(n, rate), at: n.at * FRAME })) + : s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })); + const mixed = mixdown(parts, s.seconds, rate); + const limit = options.limit ?? true; + return limit === false ? mixed : mixed.limit(limit === true ? {} : limit); } -/** A song mixed into one clip when the script is built: every note exactly in time, one sound playing. */ -export function render(s: Song): Clip { - return mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds); +/** A note of a score made at `rate`, as its track plays it: once a note, rate and track. */ +const atRate = new WeakMap, Map>(); +function placedAt(n: Score, rate: number): Clip { + let byNote = atRate.get(n.made); + if (!byNote) { byNote = new Map(); atRate.set(n.made, byNote); } + const key = `${n.name} ${n.seconds!.toFixed(4)} ${rate}`; + let c = byNote.get(key); + if (!c) { + const note = (n.track.instrument as Instrument).note(n.name!, n.seconds!, rate); + c = note; + if (n.track.volume !== 1) c = c.gain(n.track.volume); + if (n.track.pan !== 0) c = c.pan(n.track.pan); + byNote.set(key, c); + } + return c; } /** diff --git a/tests/music.test.ts b/tests/music.test.ts index 9687f21..e219934 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -95,6 +95,29 @@ program(async () => { await music.play(song); }); ); expect(errors(r)).toEqual([]); }); + + it("renders at 44.1 kHz, its notes made again at that rate, panned tracks on their sides, limited under −1.5 dB", () => { + const r = check( + `const lead = music.synth({ wave: "saw", volume: 0.5 }); +const pad = music.synth({ wave: "triangle", volume: 0.5 }); +const s = music.song({ bpm: 120 }, music.track(lead, "A4 - E5 - A5 - - -", { pan: -0.5 }), music.track(pad, "A3+C4+E4 - - - - - - -", { pan: 0.5, volume: 0.8 })); +const one = music.render(s), raw = music.render(s, { limit: false }), low = music.render(s, { rate: 22050 });`, + `if (one.rate !== 44100 || one.channels !== 2 || low.rate !== 22050) fail(\`\${one.rate} \${one.channels} \${low.rate}\`); +if (!(raw.peak > 1) || one.peak > 0.8401) fail(\`\${raw.peak} \${one.peak}\`); +if (s.clips.some((c) => c.channels !== 2 || c.rate !== 22050)) fail("the song's own clips: two sides, at 22 050");`, + ); + expect(errors(r)).toEqual([]); + }); + + it("plays a panned track's notes, each once, with two sides", () => { + const r = check( + `const lead = music.synth(); +const s = music.song({ bpm: 120 }, music.track(lead, "A4 A4 A4 B4", { pan: 1 }), music.track(lead, "A4 . . .", { volume: 0.5 }));`, + `if (s.clips.length !== 3 || s.clips.filter((c) => c.channels === 2).length !== 2) fail(\`\${s.clips.length} \${s.clips.map((c) => c.channels)}\`);`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check(`const lead = music.synth();`, `music.track(lead, "A4", { pan: 3 });`)).join("\n")).toMatch(/−1 \(left\) to 1/); + }); }); describe("what the library is made of", () => { diff --git a/tests/sound.test.ts b/tests/sound.test.ts index 5f590bc..da54c23 100644 --- a/tests/sound.test.ts +++ b/tests/sound.test.ts @@ -7,9 +7,12 @@ import { describe, expect, it } from "vitest"; import { compileScript } from "../compiler/compiler"; import { defaultScriptNames } from "../compiler/names"; import { defaultLib } from "../bundle/lib.mjs"; -import { adpcmFidelity, adpcmWav, clipFile, periodicity, clipSamples, decodeAdpcm, describeSound, encodeAdpcm, hertz, makeClip, noise, readSound, silence, tone, writeSound } from "../compiler/sound"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { adpcmFidelity, adpcmWav, clipChannels, clipFile, periodicity, clipSamples, decodeAdpcm, describeSound, encodeAdpcm, hertz, LIMIT_CEILING, makeClip, mixdown, noise, readSound, silence, tone, writeSound } from "../compiler/sound"; +import { finishSounds, vorbisEncoder } from "../compiler/vorbis"; import { describeMedia, isMediaFile } from "../compiler/media"; -import { buildScript, readFiles, withFiles } from "../script"; +import { buildScript, isBuiltSound, readFiles, withFiles, withSounds } from "../script"; const LIB = defaultLib(); const sine = (hz: number, seconds: number, rate = 22050, amplitude = 0.5) => Float32Array.from({ length: Math.round(seconds * rate) }, (_, i) => amplitude * Math.sin((2 * Math.PI * hz * i) / rate)); @@ -111,7 +114,7 @@ describe("a Clip when the script is built", () => { }); it("gives the map ADPCM but for a tone it would make harsh, which goes in as 16-bit", () => { - const format = (c: ReturnType) => new DataView(clipFile(c).bytes.buffer).getUint16(20, true); + const format = (c: ReturnType) => new DataView(clipFile(c).bytes!.buffer).getUint16(20, true); expect(format(tone("C5", 0.3))).toBe(0x11); expect(format(tone("C5", 0.3, { wave: "square", volume: 0.25 }).fade(0.005, 0.12))).toBe(1); expect(adpcmFidelity(clipSamples(tone("C5", 0.3)), 22050)).toBeGreaterThan(30); @@ -119,7 +122,7 @@ describe("a Clip when the script is built", () => { expect(format(noise(0.2).fade(0, 0.15))).toBe(0x11); expect(periodicity(clipSamples(noise(0.2)), 22050)).toBeLessThan(0.3); expect(periodicity(clipSamples(tone("C5", 0.2, { wave: "square" })), 22050)).toBeGreaterThan(0.9); - const pcm = clipFile(tone("C5", 0.3, { wave: "square" })).bytes; + const pcm = clipFile(tone("C5", 0.3, { wave: "square" })).bytes!; expect(pcm.length).toBe(44 + Math.round(0.3 * 22050) * 2); }); @@ -138,6 +141,122 @@ describe("a Clip when the script is built", () => { }); }); +describe("two channels, the limiter and resampling", () => { + /** A tone's strength at `hz` in samples at `rate`. */ + const amp = (s: Float32Array, hz: number, rate: number) => { let re = 0, im = 0; for (let i = 0; i < s.length; i++) { re += s[i] * Math.cos((2 * Math.PI * hz * i) / rate); im += s[i] * Math.sin((2 * Math.PI * hz * i) / rate); } return (2 * Math.hypot(re, im)) / s.length; }; + + it("pans by a balance: the far side down, the middle as it was", () => { + const a = tone("A4", 0.2, { volume: 0.5 }); + expect(a.channels).toBe(1); + const left = a.pan(-1), [l, r] = clipChannels(left); + expect([left.channels, Math.max(...l), Math.max(...r)]).toEqual([2, Math.max(...clipSamples(a)), 0]); + const [ml, mr] = clipChannels(a.pan(0)); + expect(Array.from(ml)).toEqual(Array.from(mr)); + const [hl, hr] = clipChannels(a.pan(0.5)); + expect(Math.max(...hl) / Math.max(...hr)).toBeCloseTo(0.5, 2); + expect(() => a.pan(2)).toThrow(/−1 \(left\) to 1/); + expect(left.mono().channels).toBe(1); + }); + + it("mixes and joins one channel with two as two, the one heard from both sides", () => { + const mono = tone("A4", 0.2), right = tone("E5", 0.2).pan(1); + for (const c of [mono.mix(right), mono.then(right), mixdown([{ clip: mono, at: 0 }, { clip: right, at: 0.1 }])]) expect(c.channels).toBe(2); + const [l] = clipChannels(mono.mix(right)); + expect(Array.from(l)).toEqual(Array.from(clipSamples(mono))); + // The methods keep both sides. + expect([right.gain(0.5), right.fade(0.01, 0.01), right.trim(0, 0.1), right.reverse(), right.pitch(3), right.repeat(2), right.envelope([0, 1], [0.2, 0])].map((c) => c.channels)).toEqual([2, 2, 2, 2, 2, 2, 2]); + }); + + it("limits a mix under its ceiling, smoothly, and leaves a quiet one alone", () => { + const loud = mixdown(Array.from({ length: 6 }, (_, k) => ({ clip: tone(220 * (k + 1), 0.5, { volume: 0.4 }), at: k * 0.02 }))); + expect(loud.peak).toBeGreaterThan(1); + const limited = loud.limit(); + expect(limited.peak).toBeLessThanOrEqual(LIMIT_CEILING + 1e-6); + expect(limited.seconds).toBe(loud.seconds); + // No step in the gain: from one sample to the next it changes by little. + const g = clipSamples(limited).map((x, i) => (Math.abs(clipSamples(loud)[i]) > 0.05 ? x / clipSamples(loud)[i] : NaN)).filter((x) => !Number.isNaN(x)); + let jump = 0; + for (let i = 1; i < g.length; i++) jump = Math.max(jump, Math.abs(g[i] - g[i - 1])); + expect(jump).toBeLessThan(0.05); + const quiet = tone("A4", 0.3, { volume: 0.3 }); + expect(Array.from(clipSamples(quiet.limit()))).toEqual(Array.from(clipSamples(quiet))); + expect(quiet.normalize().peak).toBeCloseTo(LIMIT_CEILING, 3); + // Both sides turned down together, so the balance stays. + const [l, r] = clipChannels(loud.pan(0.5).limit()); + expect(Math.max(...l) / Math.max(...r)).toBeCloseTo(0.5, 1); + }); + + it("pitches up without folding the top back down as other pitches", () => { + // A 3 kHz sine an octave up, at 22 050: 6 kHz, and the straight-line resampler's images near 16 kHz folded to 5 kHz. + const up = clipSamples(tone(3000, 0.5).pitch(12)), rate = 22050; + expect(amp(up, 6000, rate)).toBeGreaterThan(0.45); + for (const hz of [4000, 5000, 7000, 9000]) expect(amp(up, hz, rate), `${hz}`).toBeLessThan(0.002); + }); + + it("joins clips of another rate at full width: a 44.1 kHz mix keeps a 15 kHz tone", () => { + const hi = tone(15000, 0.3, { rate: 44100 }); + const mixed = mixdown([{ clip: hi, at: 0 }, { clip: tone("A4", 0.3), at: 0 }], undefined, 44100); + expect(mixed.rate).toBe(44100); + expect(amp(clipSamples(mixed), 15000, 44100)).toBeGreaterThan(0.45); + }); +}); + +describe("what the map gets for a clip", () => { + const wasm = readFileSync(resolve(import.meta.dirname, "..", "node_modules", "wasm-media-encoders", "wasm", "ogg.wasm")); + const encoder = vorbisEncoder(wasm); + + it("a long clip as Ogg Vorbis, named by its samples before it is encoded; a short one or wav() as a WAV", () => { + const long = tone("A4", 2.5), f = clipFile(long); + expect(f.path).toMatch(/^staredit\\wav\\trigscript-[0-9a-f]{8}\.ogg$/); + expect(f.bytes).toBeUndefined(); + expect(f.vorbis?.rate).toBe(22050); + expect(clipFile(tone("A4", 2.5)).path).toBe(f.path); + expect(clipFile(tone("A4", 2.5, { volume: 0.4 })).path).not.toBe(f.path); + expect(clipFile(long.wav()).path).toMatch(/\.wav$/); + expect(clipFile(long.wav().gain(0.5)).path).toMatch(/\.wav$/); + expect(isBuiltSound(f.path)).toBe(true); + }); + + it("a short clip with two sides as a 16-bit WAV of two channels", () => { + const f = clipFile(tone("A4", 0.2).pan(-0.5)), v = new DataView(f.bytes!.buffer); + expect([v.getUint16(20, true), v.getUint16(22, true), v.getUint16(32, true), v.getUint16(34, true)]).toEqual([1, 2, 4, 16]); + expect(f.bytes!.length).toBe(44 + Math.round(0.2 * 22050) * 4); + }); + + it("encodes Ogg Vorbis the same each time, of the clip's length, a tenth of 16-bit", () => { + const song = mixdown([{ clip: tone("A4", 3, { rate: 44100 }).pan(-0.3), at: 0 }, { clip: tone("E5", 3, { rate: 44100 }).pan(0.3), at: 0 }]); + const a = encoder.encode(clipChannels(song), song.rate), b = encoder.encode(clipChannels(song), song.rate); + expect(String.fromCharCode(...a.slice(0, 4))).toBe("OggS"); + expect(Array.from(a)).toEqual(Array.from(b)); + expect(a.length).toBeLessThan((song.seconds * song.rate * 4) / 10); + // The last page's granule position: the samples it holds. + let last = 0; + for (let i = a.length - 27; i >= 0; i--) if (a[i] === 0x4f && a[i + 1] === 0x67 && a[i + 2] === 0x67 && a[i + 3] === 0x53) { last = i; break; } + expect(Number(new DataView(a.buffer, a.byteOffset).getBigUint64(last + 6, true))).toBe(Math.round(song.seconds * song.rate)); + }); + + it("is encoded by a build, and only named by a check; a trigger's long clip is a WAV", () => { + const files = { "main.ts": `const long = tone("A4", 3).pan(0.4); +trigger(P1, [always()], [playWav(tone("C5", 3), 0)]); +program(async () => { playWav(long, 0); }); +` }; + const built = compileScript(ts, files, defaultScriptNames(), { lib: LIB }); + expect(built.diagnostics).toEqual([]); + const ogg = built.sounds.find((s) => s.path.endsWith(".ogg"))!, wav = built.sounds.find((s) => s.path.endsWith(".wav"))!; + // The trigger's: a WAV, ADPCM as a sine allows. + expect([ogg.vorbis?.channels.length, new DataView(wav.bytes!.buffer).getUint16(20, true)]).toEqual([2, 0x11]); + const check = compileScript(ts, files, defaultScriptNames(), { lib: LIB }); + finishSounds(check.sounds, null); + expect(check.sounds.find((s) => s.path === ogg.path)).toEqual({ path: ogg.path }); + // A check's unencoded clip is refused unless the map has it already. + expect(() => withSounds(new Map(), check.sounds)).toThrow(/was not encoded/); + expect(withSounds(new Map([[ogg.path, new Uint8Array([1])]]), check.sounds).get(ogg.path)).toEqual(new Uint8Array([1])); + finishSounds(built.sounds, encoder); + expect(built.sounds.every((s) => s.bytes && !s.vorbis)).toBe(true); + expect(String.fromCharCode(...ogg.bytes!.slice(0, 4))).toBe("OggS"); + }); +}); + describe("a script that plays clips", () => { const boom = writeSound({ rate: 22050, samples: sine(110, 0.5), source: "boom.wav" }); const compile = (main: string) => compileScript(ts, { "main.ts": main, "sfx/boom.sound": boom }, defaultScriptNames(), { lib: LIB }); @@ -170,7 +289,7 @@ program(async () => { playWav(boom, 0); await nextFrame(); playWav(both, 0); pla it("keeps an imported sound's own samples when nothing changed it", () => { const r = compile(`import boom from "./sfx/boom.sound";\nprogram(async () => { playWav(boom, 0); });\n`); - const d = readSound(boom), wav = r.sounds[0].bytes; + const d = readSound(boom), wav = r.sounds[0].bytes!; expect(Array.from(wav.slice(60))).toEqual(Array.from(d.adpcm)); }); From 993770e08547385404a794755dd5fd66ff7356c0 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 14:02:09 -0600 Subject: [PATCH 03/14] Plan: the Korobeiniki A/B as played: the Ogg on time, whole, both sides, unclipped Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- docs/v4-plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 3ab09a0..46829be 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1677,6 +1677,11 @@ reverb, delay, drive), song structure, music that follows the game. (L − R) −27 dB; B 2.1 dB louder at the same peak; A 355 KB, B 559 KB. Compile with both renders 1.1 s, encode 0.3 s. Lifted game clean (0 ending, A at frame 98, B at 930). Headless Chromium through the real compile worker: a check returns the name only, a build the Ogg (558 554 bytes, as Node), a second build from the cache. +- **Played 2026-10-19** (recording 13.59.06.34): A at 6.771 s, B at 41.715 s, A again at 76.659 s — 34.944 s apart + both times, the 832 frames written: the Ogg starts as the WAV does and costs the game no time. B as heard against + the file: 31.9 dB over all 32 s (the recording's AAC; the WAV A came back at 24.5), the worst 100 ms 19.5 dB — no + dropout; L − R follows the file's at 0.996; peaks back at 0.97 of full scale (the whole 1.08×, the peaks 1.00–1.05×: + something after the game rounds the top off gently) — no clipping. - **Found on the way:** `ranges.ts` read `process.env` in the browser's worker, where there is none — every program failed to compile in the editor. Guarded (its own commit). From 07168d6504cb64115bc392b1d592d8857ea5f203 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 14:39:50 -0600 Subject: [PATCH 04/14] Sounds, step 2: filters, drive, echo, plate reverb, chorus, compressor, FM, plucks, presets, drums Clips get filter() (a state-variable filter, swept by points, and shelf and peak biquads), drive(), delay(), reverb() (a Dattorro plate), chorus() and compress(). tone() glides and stacks detuned voices across both sides; fm() and pluck() are new sources. The music library's synth has a filter with its own envelope, unison, drive and glide; fmSynth and strings are new instruments; music.presets and music.drums() are ready-made, written on the public API. Tracks take fx and a reverb send, render a shared reverb and a master chain. A bar is a whole number of frames, a step need not be; play refuses more than 32 voices, render does not. Clips are lazy: their length and recipe are known at once, their samples only when a build writes the map or a script reads peak. A check names each file by its recipe and makes no sample, so a song's mix no longer reruns as the user types; builds get 60 seconds. probes/korobeiniki.ts arranges the folk song on all of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compile.ts | 12 +- compiler/compiler.ts | 10 +- compiler/declarations.ts | 53 +++- compiler/dsp.ts | 316 +++++++++++++++++++++++ compiler/generated/stdlib.ts | 2 +- compiler/runtime.ts | 10 +- compiler/sound.ts | 488 +++++++++++++++++++++++++++-------- compiler/structured.ts | 13 +- docs/v4-plan.md | 35 +++ probes/korobeiniki.ts | 139 ++++++++++ script.ts | 4 +- stdlib/music.ts | 296 ++++++++++++++++----- tests/music.test.ts | 54 +++- tests/sound.test.ts | 111 +++++++- 14 files changed, 1335 insertions(+), 208 deletions(-) create mode 100644 compiler/dsp.ts create mode 100644 probes/korobeiniki.ts diff --git a/compile.ts b/compile.ts index a9210d3..f1f46e0 100644 --- a/compile.ts +++ b/compile.ts @@ -66,6 +66,8 @@ export const vorbisUrl = (dist: string = DEFAULT_DIST) => export const workerModuleUrl = (dist: string = DEFAULT_DIST) => (ENTRY_URL.startsWith("blob:") && !blobRefused ? ENTRY_URL : compilerUrl(dist)); export const COMPILE_TIMEOUT_MS = 15_000; +/** A build's compile makes the script's sounds too — a long song mixed and encoded — and gets longer. */ +export const BUILD_TIMEOUT_MS = 60_000; export interface CompileInput { files: ScriptFiles; @@ -108,7 +110,8 @@ self.onmessage = async (e) => { if (!r.ok) throw new Error("Could not load the standard library from " + libUrl + " (" + r.status + ")."); lib = await r.text(); } - const result = mod.compileScript(self.ts, files, names, { lib, tests }); + // A check names the script's clips and makes none of them; a build makes them, the long ones encoded below. + const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl) }); // A build's long clips encoded as Ogg Vorbis; a check's left as names, their samples not sent. if (typeof mod.finishSounds === "function") { const encoder = vorbisUrl && result.sounds.some((s) => s.vorbis) ? await mod.loadVorbis(vorbisUrl) : null; @@ -212,14 +215,14 @@ function breakWorker(reason: string) { } /** The script ran too long: drop this worker (the next request starts another) and say so. */ -function timeOut(id: number) { +function timeOut(id: number, ms: number) { const p = pending.get(id); if (!p) return; pending.delete(id); worker?.terminate(); worker = null; rejectAll("The compile was stopped."); - p.reject(new Error(`The script did not finish in ${COMPILE_TIMEOUT_MS / 1000} seconds. Is there an endless loop outside program()?`)); + p.reject(new Error(`The script did not finish in ${ms / 1000} seconds. Is there an endless loop outside program()?`)); } /** The worker, started if there is none. Throws `CompilerUnavailable` when one cannot be made, or could not a moment ago. */ @@ -271,7 +274,8 @@ export function compileInBackground(input: CompileInput, dist: string = DEFAULT_ p.reject(new CompileSuperseded()); } return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject, timer: setTimeout(() => timeOut(id), COMPILE_TIMEOUT_MS) }); + const ms = input.sounds ? BUILD_TIMEOUT_MS : COMPILE_TIMEOUT_MS; + pending.set(id, { resolve, reject, timer: setTimeout(() => timeOut(id, ms), ms) }); const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}) }; w.postMessage(req); }).catch((err: Error) => { diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 92b433a..08a5cfd 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -148,8 +148,8 @@ export interface CompileResult { strings: ScriptString[]; /** * The clips the script plays (`sound.ts`), as the files the map's archive gets, by their names there: once each. An - * Ogg Vorbis one has its samples instead of bytes until `finishSounds` (`vorbis.ts`) encodes it, and neither after a - * check that did not ask for it: the map is to get it from a build. + * Ogg Vorbis one has its samples instead of bytes until `finishSounds` (`vorbis.ts`) encodes it; after a compile with + * `sounds: false` (a check) each is its name alone: the map is to get them from a build. */ sounds: ClipFile[]; diagnostics: ScriptDiagnostic[]; @@ -188,6 +188,11 @@ export interface CompileOptions { emitter?: boolean; /** Told which loops the emitter took and which it left, and why. */ emitterReport?: (line: string) => void; + /** + * false: the clips the script plays are named, not made — a check as the user types, whose sounds the map does not + * get (`sound.ts`'s clips work their samples out only when asked). A build's compile leaves it out. + */ + sounds?: boolean; } export { LowerError }; @@ -388,6 +393,7 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa /* ── Run ── */ const collector = new Collector(); + collector.soundContents = options.sounds !== false; const registry = new TestRegistry(); const where = (err: unknown) => { const at = locate(err, linked); return at.file ? { file: at.file, line: at.line ?? 1, ...(at.column ? { column: at.column } : {}) } : null; }; registry.where = () => where(new Error()) ?? { file: ENTRY_FILE, line: 1 }; diff --git a/compiler/declarations.ts b/compiler/declarations.ts index d56e3d9..5e41b12 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -541,13 +541,62 @@ ${kw}interface Clip { normalize(peak?: number): Clip; /** In the map as a WAV however long it is, for StarCraft 1.16.1, which plays no Ogg. */ wav(): Clip; + /** + * A filter. "lowpass" keeps what is below \`cutoff\` hertz, "highpass" what is above, "bandpass" what is + * near it, "notch" all but it — \`cutoff\` a number, or [seconds, hertz] points to sweep it (a synth's + * filter opening: \`saw.filter("lowpass", [[0, 300], [0.1, 4000], [0.6, 800]], { resonance: 0.6 })\`); + * \`resonance\` 0 to 1 rings at the cutoff. "lowshelf" and "highshelf" turn everything below or above + * \`cutoff\` up or down by \`gain\` dB, "peak" what is around it, \`q\` wide (0.707). + */ + filter(type: "lowpass" | "highpass" | "bandpass" | "notch" | "lowshelf" | "highshelf" | "peak", cutoff: number | readonly (readonly [number, number])[], options?: { readonly resonance?: number; readonly gain?: number; readonly q?: number }): Clip; + /** + * Saturation, \`dB\` of push (6 warm, 18 gritty, 30 fuzz): quiet parts as they were, loud ones rounded + * off toward a square — the harmonics a guitar amp or a tape adds. + */ + drive(dB: number): Clip; + /** + * An echo \`seconds\` later (a beat's worth: 60 / bpm), each repeat \`feedback\` as loud (0.35) and a little + * duller (\`tone\`, the hertz kept, 5 000), \`mix\` its level (0.35). \`pingPong\`: left, right, left (two + * channels). The clip gets longer by the repeats. + */ + delay(seconds: number, options?: { readonly feedback?: number; readonly mix?: number; readonly tone?: number; readonly pingPong?: boolean }): Clip; + /** + * A plate reverb: the room around the sound, different left and right (two channels), the clip longer + * by its tail. \`decay\` 0 to 1 (0.6; 0.3 a small room, 0.9 a hall), \`damping\` 0 to 1 (0.4: how fast the + * top dies), \`predelay\` seconds before it starts (0.015), \`mix\` the reverb's level (0.3), \`dry\` the + * clip's (1), \`width\` 0 to 1 (1). + */ + reverb(options?: { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly mix?: number; readonly dry?: number; readonly width?: number }): Clip; + /** A chorus: copies a few ms late and wobbling, left and right apart (two channels). \`speed\` Hz (0.8), \`depth\` seconds (0.003), \`mix\` (0.5). */ + chorus(options?: { readonly speed?: number; readonly depth?: number; readonly mix?: number }): Clip; + /** + * A compressor: above \`threshold\` dB (−18), every \`ratio\` dB louder (4) comes out one, turned down + * over \`attack\` seconds (0.005) and back over \`release\` (0.12), eased over a \`knee\` dB wide (6); + * \`makeup\` dB after (0). Evens a part out, or glues a mix. + */ + compress(options?: { readonly threshold?: number; readonly ratio?: number; readonly attack?: number; readonly release?: number; readonly knee?: number; readonly makeup?: number }): Clip; } /** * When the script is built: a tone of \`pitch\` — hertz, or a note's name: "A4" (440), "C#5", "Eb3" — for - * \`seconds\`. wave: "sine" (the default), "square", "triangle" or "saw"; volume 0 to 1 (0.5). + * \`seconds\`, or [seconds, pitch] points to glide (a kick's drop: [[0, 150], [0.08, 45]]). wave: "sine" + * (the default), "square", "triangle" or "saw"; volume 0 to 1 (0.5). \`voices\` copies (1 to 16) spread + * \`detune\` cents apart from lowest to highest (20), and \`width\` 0 to 1 across left and right: a + * supersaw is \`tone("A3", 1, { wave: "saw", voices: 7, detune: 30, width: 0.8 })\`. * const beep = tone("A5", 0.1, { wave: "square", volume: 0.3 }).fade(0, 0.05); */ -${kw}function tone(pitch: number | string, seconds: number, options?: { readonly wave?: "sine" | "square" | "triangle" | "saw"; readonly volume?: number; readonly rate?: number }): Clip; +${kw}function tone(pitch: number | string | readonly (readonly [number, number | string])[], seconds: number, options?: { readonly wave?: "sine" | "square" | "triangle" | "saw"; readonly volume?: number; readonly rate?: number; readonly voices?: number; readonly detune?: number; readonly width?: number }): Clip; +/** + * When the script is built: two-operator FM — a sine whose phase another, \`ratio\` times its pitch, + * pushes by \`index\` (2; or [seconds, index] points, bright at the start and mellow after). ratio 1 a + * reed or an electric piano, 3.5 or 7 a bell, 0.5 a bass; \`feedback\` 0 to 2 gives the push an edge. + * const bell = fm("E5", 2, { ratio: 3.5, index: [[0, 6], [2, 0.3]], volume: 0.3 }).fade(0, 1.5); + */ +${kw}function fm(pitch: number | string | readonly (readonly [number, number | string])[], seconds: number, options?: { readonly ratio?: number; readonly index?: number | readonly (readonly [number, number])[]; readonly feedback?: number; readonly volume?: number; readonly rate?: number }): Clip; +/** + * When the script is built: a plucked string (Karplus–Strong) — a guitar, a harp, a koto. \`brightness\` + * 0 to 1 (0.5), \`decay\` seconds to fade away (2). + */ +${kw}function pluck(pitch: number | string, seconds: number, options?: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly rate?: number }): Clip; /** When the script is built: noise for \`seconds\` (the same every build) — with fade(), a hit or a burst. */ ${kw}function noise(seconds: number, options?: { readonly volume?: number; readonly rate?: number }): Clip; /** When the script is built: nothing for \`seconds\`, to put a gap between clips: \`a.then(silence(0.2), b)\`. */ diff --git a/compiler/dsp.ts b/compiler/dsp.ts new file mode 100644 index 0000000..6a4907c --- /dev/null +++ b/compiler/dsp.ts @@ -0,0 +1,316 @@ +/** + * What a clip goes through when the script is built (`sound.ts`'s `Clip` methods, and the music library's tracks): a + * filter, saturation, an echo, a reverb, a chorus, a compressor — and the sources beside `tone`: a stack of detuned + * voices, two-operator FM, a plucked string. Each works on channels of samples (−1 … 1) and gives new ones; nothing + * here knows about clips. The game hears only what comes out: none of this costs it anything, only the build's time. + * + * Everything is the same on every build: noise and phases come from fixed seeds. + */ + +export type Channels = readonly Float32Array[]; + +/** A value through time: [seconds, value] points, a straight line between them; the first before, the last after. */ +export type Points = readonly (readonly [number, number])[]; + +/** Each sample's value from points (or one value throughout), in their own units or, with `log`, by ratios. */ +export function curve(v: number | Points, rate: number, n: number, log = false): Float32Array { + const out = new Float32Array(n); + if (typeof v === "number") return out.fill(v); + const ps = v.map(([t, x]) => [t * rate, log ? Math.log(x) : x] as const); + for (let i = 0, k = 0; i < n; i++) { + while (k < ps.length && ps[k][0] <= i) k++; + const x = k === 0 ? ps[0][1] : k === ps.length ? ps[k - 1][1] : ps[k - 1][1] + ((ps[k][1] - ps[k - 1][1]) * (i - ps[k - 1][0])) / (ps[k][0] - ps[k - 1][0] || 1); + out[i] = log ? Math.exp(x) : x; + } + return out; +} + +/* ── Filters ── */ + +export type FilterType = "lowpass" | "highpass" | "bandpass" | "notch" | "lowshelf" | "highshelf" | "peak"; + +/** + * A state-variable filter (the trapezoidal form: stable however fast its cutoff moves): lowpass, highpass, bandpass or + * notch, its cutoff a frequency or points through time, `resonance` 0 (none, Q 0.5) to 1 (ringing at the cutoff). + */ +export function svf(chans: Channels, rate: number, type: "lowpass" | "highpass" | "bandpass" | "notch", cutoff: number | Points, resonance: number): Float32Array[] { + const n = chans[0].length, hz = curve(cutoff, rate, n, true); + const k = 2 - 1.96 * Math.max(0, Math.min(1, resonance)); + return chans.map((x) => { + const out = new Float32Array(n); + let ic1 = 0, ic2 = 0, lastHz = -1, a1 = 0, a2 = 0, a3 = 0, g = 0; + for (let i = 0; i < n; i++) { + const f = Math.min(hz[i], rate * 0.49); + if (f !== lastHz) { + lastHz = f; + g = Math.tan((Math.PI * f) / rate); + a1 = 1 / (1 + g * (g + k)); a2 = g * a1; a3 = g * a2; + } + const v0 = x[i], v3 = v0 - ic2, v1 = a1 * ic1 + a2 * v3, v2 = ic2 + a2 * ic1 + a3 * v3; + ic1 = 2 * v1 - ic1; ic2 = 2 * v2 - ic2; + out[i] = type === "lowpass" ? v2 : type === "highpass" ? v0 - k * v1 - v2 : type === "bandpass" ? v1 : v0 - k * v1; + } + return out; + }); +} + +/** A shelf or a peak (the cookbook biquads): `gain` dB below or above `hz`, or around it, `q` wide (0.707). */ +export function eq(chans: Channels, rate: number, type: "lowshelf" | "highshelf" | "peak", hz: number, gain: number, q = Math.SQRT1_2): Float32Array[] { + const w = (2 * Math.PI * Math.min(hz, rate * 0.49)) / rate, cos = Math.cos(w), sin = Math.sin(w), A = 10 ** (gain / 40); + let b0: number, b1: number, b2: number, a0: number, a1: number, a2: number; + if (type === "peak") { + const alpha = sin / (2 * q); + [b0, b1, b2, a0, a1, a2] = [1 + alpha * A, -2 * cos, 1 - alpha * A, 1 + alpha / A, -2 * cos, 1 - alpha / A]; + } else { + const alpha = (sin / 2) * Math.sqrt(2), s = 2 * Math.sqrt(A) * alpha; + [b0, b1, b2, a0, a1, a2] = type === "lowshelf" + ? [A * (A + 1 - (A - 1) * cos + s), 2 * A * (A - 1 - (A + 1) * cos), A * (A + 1 - (A - 1) * cos - s), A + 1 + (A - 1) * cos + s, -2 * (A - 1 + (A + 1) * cos), A + 1 + (A - 1) * cos - s] + : [A * (A + 1 + (A - 1) * cos + s), -2 * A * (A - 1 + (A + 1) * cos), A * (A + 1 + (A - 1) * cos - s), A + 1 - (A - 1) * cos + s, 2 * (A - 1 - (A + 1) * cos), A + 1 - (A - 1) * cos - s]; + } + return chans.map((x) => { + const out = new Float32Array(x.length); + let x1 = 0, x2 = 0, y1 = 0, y2 = 0; + for (let i = 0; i < x.length; i++) { + const y = (b0 * x[i] + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; + x2 = x1; x1 = x[i]; y2 = y1; y1 = y; out[i] = y; + } + return out; + }); +} + +/* ── Saturation ── */ + +/** + * Saturation: each sample through tanh after `dB` more gain, and the gain taken off again — a quiet part as it was, a + * loud one rounded, more and more like a square. Worked at twice the rate (`up`, `down`: the clip's resampler), so the + * harmonics it makes above half the rate do not fold back down. + */ +export function saturate(chans: Channels, dB: number, up: (s: Float32Array) => Float32Array, down: (s: Float32Array) => Float32Array): Float32Array[] { + const g = 10 ** (dB / 20); + return chans.map((x) => { + const n = x.length, hi = up(x); + for (let i = 0; i < hi.length; i++) hi[i] = Math.tanh(g * hi[i]) / g; + const back = down(hi); + return back.length === n ? back : Float32Array.from({ length: n }, (_, i) => back[i] ?? 0); + }); +} + +/* ── Time ── */ + +/** A delay line read a whole or a fractional number of samples back; its length a power of two, so wrapping is a mask. */ +class Line { + private readonly buf: Float32Array; + private readonly mask: number; + private at = 0; + constructor(size: number) { + let n = 4; + while (n < size + 2) n <<= 1; + this.buf = new Float32Array(n); + this.mask = n - 1; + } + write(x: number): void { this.buf[this.at] = x; this.at = (this.at + 1) & this.mask; } + /** `d` whole samples back (1 is the last written). */ + tap(d: number): number { return this.buf[(this.at - d) & this.mask]; } + /** `d` samples back, between samples when `d` is not whole. */ + read(d: number): number { + const p = this.at - d, i = Math.floor(p), f = p - i; + const a = this.buf[i & this.mask]; + return a + (this.buf[(i + 1) & this.mask] - a) * f; + } +} + +/** Seconds until a repeat that falls by `feedback` each time is 60 dB down, at most `cap`. */ +const tailOf = (feedback: number, every: number, cap: number) => Math.min(cap, feedback <= 0 ? every : every * Math.ceil(Math.log(0.001) / Math.log(Math.min(0.999, feedback)))); +/** Samples an echo adds to a clip: its repeats until they are 60 dB down, 30 seconds at most. */ +export const echoTail = (rate: number, seconds: number, feedback: number) => Math.round(tailOf(feedback, seconds, 30) * rate); + +/** + * An echo `seconds` after, each repeat `feedback` as loud and a little duller (a lowpass at `tone` Hz in the loop), + * `mix` its level beside the clip. `pingPong`: the repeats go left, right, left — the clip then has two channels. + */ +export function echo(chans: Channels, rate: number, seconds: number, feedback: number, mix: number, tone: number, pingPong: boolean): Float32Array[] { + const d = seconds * rate, n = chans[0].length, total = n + echoTail(rate, seconds, feedback); + const ins = pingPong && chans.length === 1 ? [chans[0], chans[0]] : chans; + const lp = Math.exp((-2 * Math.PI * tone) / rate); + if (!pingPong) { + return ins.map((x) => { + const out = new Float32Array(total), line = new Line(d); + let z = 0; + for (let i = 0; i < total; i++) { + const dry = i < n ? x[i] : 0, back = line.read(d); + z = back + (z - back) * lp; + line.write(dry + z * feedback); + out[i] = dry + back * mix; + } + return out; + }); + } + // Ping-pong: what goes in is heard left first, and each line feeds the other. + const [l, r] = ins, L = new Float32Array(total), R = new Float32Array(total), a = new Line(d), b = new Line(d); + let za = 0, zb = 0; + for (let i = 0; i < total; i++) { + const dl = i < n ? l[i] : 0, dr = i < n ? r[i] : 0, ba = a.read(d), bb = b.read(d); + za = ba + (za - ba) * lp; zb = bb + (zb - bb) * lp; + a.write((dl + dr) / 2 + zb * feedback); + b.write(za * feedback); + L[i] = dl + ba * mix; R[i] = dr + bb * mix; + } + return [L, R]; +} + +/** + * A plate reverb (after Dattorro's, 1997): the clip, summed to one, through a pre-delay, a little top cut and four + * diffusers into a figure-eight tank of two halves, each a wobbling allpass, a delay, damping, the decay and another + * allpass and delay, feeding the other; left and right are read from seven taps across the tank each, so the two are + * different rooms' worth of the same sound. `decay` 0 … 1 (0.5 a room, 0.9 a hall), `damping` 0 … 1 (how fast the top + * dies), `predelay` seconds, `mix` the reverb's level, `dry` the clip's. Two channels, `tail` samples longer than the + * clip (`plateTail`), the last 50 ms of them faded out. + */ +export function plate(chans: Channels, rate: number, o: { decay: number; damping: number; predelay: number; mix: number; dry: number; width: number }, tail: number): Float32Array[] { + const s = rate / 29761, n = chans[0].length; + const len = (x: number) => Math.round(x * s); + const preTap = Math.max(1, Math.round(o.predelay * rate)), pre = new Line(preTap + 1); + const diffusers = [[142, 0.75], [107, 0.75], [379, 0.625], [277, 0.625]].map(([d, g]) => ({ line: new Line(len(d)), d: len(d), g })); + const half = (ap1: number, d1: number, ap2: number, d2: number) => ({ + ap1: new Line(len(ap1) + len(16) + 2), ap1d: len(ap1), d1: new Line(len(d1)), d1n: len(d1), + ap2: new Line(len(ap2)), ap2d: len(ap2), d2: new Line(len(d2)), d2n: len(d2), damp: 0, + }); + const A = half(672, 4453, 1800, 3720), B = half(908, 4217, 2656, 3163); + const decay = Math.max(0, Math.min(0.97, o.decay)), damping = Math.max(0, Math.min(0.99, o.damping)), bw = 0.9995; + const excursion = len(16), lfo = (2 * Math.PI * 1.0) / rate; + // The output taps (Dattorro's table), left then right. + const T = [266, 2974, 1913, 1996, 1990, 187, 1066, 353, 3627, 1228, 2673, 2111, 335, 121].map((d) => Math.max(1, len(d))); + const cap = n + tail, fadeFrom = cap - Math.min(tail, Math.round(0.05 * rate)); + const L = new Float32Array(cap), R = new Float32Array(cap); +// The two halves of the tank, each fed by the other's end. + const run = (h: typeof A, input: number, phase: number, at: number) => { + const md = h.ap1d + excursion * Math.sin(lfo * at + phase); + const back1 = h.ap1.read(md), w1 = input + 0.7 * back1; + h.ap1.write(w1); + const y1 = back1 - 0.7 * w1; + h.d1.write(y1); + const d1 = h.d1.tap(h.d1n); + h.damp = (1 - damping) * d1 + damping * h.damp; + const into = h.damp * decay; + const back2 = h.ap2.tap(h.ap2d), w2 = into - 0.5 * back2; + h.ap2.write(w2); + h.d2.write(back2 + 0.5 * w2); + return h.d2.tap(h.d2n) * decay; + }; + let band = 0, lastA = 0, lastB = 0; + for (let i = 0; i < cap; i++) { + let x = 0; + if (i < n) { for (const c of chans) x += c[i]; x /= chans.length; } + pre.write(x); + let v = pre.tap(preTap); + band = bw * v + (1 - bw) * band; + v = band; + for (const ap of diffusers) { + const back = ap.line.tap(ap.d), w = v - ap.g * back; + ap.line.write(w); + v = back + ap.g * w; + } + const outA = run(A, v + lastB, 0, i), outB = run(B, v + lastA, Math.PI / 2, i); + lastA = outA; lastB = outB; + const yl = B.d1.tap(T[0]) + B.d1.tap(T[1]) - B.ap2.tap(T[2]) + B.d2.tap(T[3]) - A.d1.tap(T[4]) - A.ap2.tap(T[5]) - A.d2.tap(T[6]); + const yr = A.d1.tap(T[7]) + A.d1.tap(T[8]) - A.ap2.tap(T[9]) + A.d2.tap(T[10]) - B.d1.tap(T[11]) - B.ap2.tap(T[12]) - B.d2.tap(T[13]); + const mid = (yl + yr) / 2, side = ((yl - yr) / 2) * o.width; + const wl = 0.6 * o.mix * (mid + side), wr = 0.6 * o.mix * (mid - side); + const dl = i < n ? chans[0][i] : 0, dr = i < n ? chans[chans.length - 1][i] : 0; + const out = i < fadeFrom ? 1 : (cap - i) / (cap - fadeFrom); + L[i] = (o.dry * dl + wl) * out; R[i] = (o.dry * dr + wr) * out; + } + return [L, R]; +} + +/** + * How long a plate's tail lasts, in seconds: until a sound has gone round the tank enough times to be 60 dB down. A + * half of the tank is 0.36 s (its delays at Dattorro's rate) and takes `decay` off twice: 1.24 / −ln(decay) seconds, + * 12 at most, after the pre-delay. + */ +export const plateTail = (decay: number, predelay: number) => predelay + Math.min(12, 1.24 / -Math.log(Math.max(0.01, Math.min(0.97, decay)))); + +/** A chorus: the clip beside copies of itself a few milliseconds late and wobbling, left and right out of step. Two channels. */ +export function chorus(chans: Channels, rate: number, speed: number, depth: number, mix: number): Float32Array[] { + const n = chans[0].length, base = 0.012 * rate, swing = depth * rate, w = (2 * Math.PI * speed) / rate; + const ins = chans.length === 1 ? [chans[0], chans[0]] : chans; + return ins.map((x, side) => { + const out = new Float32Array(n), line = new Line(base + swing + 2); + for (let i = 0; i < n; i++) { + line.write(x[i]); + const wet = line.read(base + swing * Math.sin(w * i + side * (Math.PI / 2))); + out[i] = (1 - mix) * x[i] + mix * wet; + } + return out; + }); +} + +/** + * A compressor: above `threshold` dB, every `ratio` dB in is one out, eased over a `knee` dB wide; turning down over + * `attack` seconds, back up over `release`; both sides by the louder; `makeup` dB after. + */ +export function compressor(chans: Channels, rate: number, o: { threshold: number; ratio: number; attack: number; release: number; knee: number; makeup: number }): Float32Array[] { + const n = chans[0].length, att = Math.exp(-1 / Math.max(1, o.attack * rate)), rel = Math.exp(-1 / Math.max(1, o.release * rate)); + const gain = new Float32Array(n), slope = 1 - 1 / o.ratio, k = Math.max(0, o.knee); + let env = 0; + for (let i = 0; i < n; i++) { + let m = 0; + for (const c of chans) m = Math.max(m, Math.abs(c[i])); + const level = 20 * Math.log10(Math.max(m, 1e-9)), over = level - o.threshold; + const reduce = k > 0 && Math.abs(over) <= k / 2 ? (slope * (over + k / 2) ** 2) / (2 * k) : over > 0 ? slope * over : 0; + env = reduce > env ? att * env + (1 - att) * reduce : rel * env + (1 - rel) * reduce; + gain[i] = 10 ** ((o.makeup - env) / 20); + } + return chans.map((c) => { const out = new Float32Array(c.length); for (let i = 0; i < c.length; i++) out[i] = c[i] * gain[i]; return out; }); +} + +/* ── Sources ── */ + +/** A fixed sequence of numbers 0 … 1 from a seed: the same on every build. */ +export function seeded(seed: number): () => number { + let s = seed >>> 0 || 0x9e3779b9; + return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 0x100000000; }; +} + +/** + * Two-operator FM: a sine whose phase is pushed by another sine `ratio` times its pitch, `index` how far (points + * through time, for a bright start that mellows), `feedback` the modulator fed its own output (a saw-like edge). + */ +export function fmVoice(hz: Float32Array, rate: number, ratio: number, index: Float32Array, feedback: number): Float32Array { + const n = hz.length, out = new Float32Array(n); + let pc = 0, pm = 0, last = 0; + for (let i = 0; i < n; i++) { + const m = Math.sin(2 * Math.PI * pm + feedback * last); + last = m; + out[i] = Math.sin(2 * Math.PI * pc + index[i] * m); + pc += hz[i] / rate; pm += (hz[i] * ratio) / rate; + pc -= Math.floor(pc); pm -= Math.floor(pm); + } + return out; +} + +/** + * A plucked string (Karplus–Strong): a burst of noise in a loop one period long, averaged as it goes round so the top + * dies first. `brightness` 0 … 1 how much of the top the burst has and the loop keeps; `decay` seconds to fade 60 dB. + */ +export function pluckVoice(hz: number, rate: number, n: number, brightness: number, decay: number): Float32Array { + const period = rate / hz, out = new Float32Array(n), random = seeded(Math.round(hz * 1000)); + // The averaging delays half a sample as much as it averages: the loop is that much shorter, the rest a fraction + // read between samples. + const keep = 0.5 + 0.5 * brightness; + const line = new Line(period + 2), p = Math.max(2, period - 0.5 * (1 - keep)); + let lp = 0; + const burstLp = 0.15 + 0.85 * brightness; + for (let i = 0; i < Math.ceil(period) + 2; i++) { lp += burstLp * (random() * 2 - 1 - lp); line.write(lp); } + // Loss a round so the note is 60 dB down after `decay` seconds. + const rounds = (decay * rate) / period, loss = Math.pow(0.001, 1 / Math.max(1, rounds)); + let prev = 0; + for (let i = 0; i < n; i++) { + const x = line.read(p); + const y = loss * (keep * x + (1 - keep) * ((x + prev) / 2)); + prev = x; + line.write(y); + out[i] = x; + } + return out; +} diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index 2e3d5fa..ec2e89d 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye. A kit's step is its sounds' letters (\"kh\": kick and hat). Everything is worked out when the script is built: each\n * note an instrument plays becomes a clip once (`trigscript`'s Clip, put in the map once however often it plays), and\n * the song a table of which clip starts on which frame, which `play` reads while the game runs.\n *\n * What the game does with it (the sound probe, 2026-10-19): a step is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length.\n */\nexport function synth(options: { readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\"; readonly volume?: number; readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number } = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const attack = options.attack ?? 0.005, decay = options.decay ?? 0.08, sustain = options.sustain ?? 0.7, release = options.release ?? 0.06;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(attack + decay, seconds - release);\n return tone(name, held + release, { wave, volume, ...(rate === undefined ? {} : { rate }) }).envelope([0, 0], [attack, 1], [attack + decay, sustain], [held, sustain], [held + release, 0]);\n }),\n });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the\n * instrument plays.\n */\nexport function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number } = {}): Track {\n const text = typeof pattern === \"string\" ? pattern : pattern.join(\" \");\n const pan = options.pan ?? 0, volume = options.volume ?? 1;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`);\n return Object.freeze({ instrument, steps: Object.freeze(text.split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\")), pan, volume });\n}\n\n/** A clip as a track plays it: its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1) return c;\n let p = made.get(c);\n if (!p) {\n p = t.volume === 1 ? c : c.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /** Frames a step lasts. */\n readonly stepFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose step is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once. */\n readonly voices: number;\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Refused\n * when more than 32 notes would sound at once.\n */\nexport function song(options: { readonly bpm: number; readonly steps?: number }, ...tracks: readonly Track[]): Song {\n const perBeat = options.steps ?? 4;\n if (!(options.bpm > 0) || !(perBeat > 0)) throw new Error(\"song() takes a tempo, { bpm: 120 }, and steps a beat (4 by default).\");\n const stepFrames = Math.max(1, Math.round(60 / options.bpm / perBeat / FRAME));\n const stepSeconds = stepFrames * FRAME;\n const notes: { at: number; clip: Clip }[] = [];\n const score: Score[] = [];\n let steps = 0;\n for (const t of tracks) {\n const ws = t.steps, made = new Map();\n steps = Math.max(steps, ws.length);\n ws.forEach((w, s) => {\n if (w === \"-\" || w === \".\") return;\n if (t.instrument.kind === \"kit\") {\n for (const letter of w) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n notes.push({ at: s * stepFrames, clip: placed(t, made, hit) });\n score.push({ at: s * stepFrames, track: t, made, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n for (const name of w.split(\"+\")) {\n midi(name);\n notes.push({ at: s * stepFrames, clip: placed(t, made, t.instrument.note(name, length * stepSeconds)) });\n score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds });\n }\n });\n }\n notes.sort((a, b) => a.at - b.at);\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n voices = Math.max(voices, sounding);\n if (sounding > VOICES) throw new Error(`At step ${n.at / stepFrames + 1}, ${sounding} notes would sound at once; the game plays ${VOICES}. Shorten notes or thin the chords.`);\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const length = Math.max(steps * stepFrames, ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n const made: Song = Object.freeze({\n stepFrames, bpm: 60 / (stepFrames * FRAME * perBeat), steps, frames: length, seconds: length * FRAME,\n clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices,\n });\n scores.set(made, score);\n return made;\n}\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. The notes are\n * made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a note played in the game note by note\n * is made at 22 050, to keep the map small), and the mix goes through a limiter (`Clip.limit`) so that it never goes\n * over −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when a track is panned.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number } } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s);\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n const parts = score\n ? score.map((n) => ({ clip: n.hit ? placed(n.track, n.made, n.hit) : placedAt(n, rate), at: n.at * FRAME }))\n : s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME }));\n const mixed = mixdown(parts, s.seconds, rate);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/** A note of a score made at `rate`, as its track plays it: once a note, rate and track. */\nconst atRate = new WeakMap, Map>();\nfunction placedAt(n: Score, rate: number): Clip {\n let byNote = atRate.get(n.made);\n if (!byNote) { byNote = new Map(); atRate.set(n.made, byNote); }\n const key = `${n.name} ${n.seconds!.toFixed(4)} ${rate}`;\n let c = byNote.get(key);\n if (!c) {\n const note = (n.track.instrument as Instrument).note(n.name!, n.seconds!, rate);\n c = note;\n if (n.track.volume !== 1) c = c.gain(n.track.volume);\n if (n.track.pan !== 0) c = c.pan(n.track.pan);\n byNote.set(key, c);\n }\n return c;\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = { sampler, synth, kit, track, song, render, play, loop };\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye. A kit's step is its sounds' letters (\"kh\": kick and hat). Everything is worked out when the script is built: each\n * note an instrument plays becomes a clip once (`trigscript`'s Clip, put in the map once however often it plays), and\n * the song a table of which clip starts on which frame, which `play` reads while the game runs.\n *\n * What the game does with it (the sound probe, 2026-10-19): a step is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((midi(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the\n * instrument plays; `fx`: what the track goes through — `(c) => c.delay(0.21).chorus()` — its whole part when the song\n * is rendered (as a mixer's insert), each note on its own when it is played note by note; `reverb`: how much goes to\n * the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number; readonly fx?: (clip: Clip) => Clip; readonly reverb?: number } = {}): Track {\n const text = typeof pattern === \"string\" ? pattern : pattern.join(\" \");\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A track's reverb is 0 to 1, not ${reverb}.`);\n return Object.freeze({ instrument, steps: Object.freeze(text.split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\")), pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) });\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Played\n * note by note (`play`), a song is refused when more than 32 notes would sound at once; rendered, it has no limit.\n */\nexport function song(options: { readonly bpm: number; readonly steps?: number; readonly beats?: number }, ...tracks: readonly Track[]): Song {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"song() takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n const stepFrames = barFrames / (beats * perBeat);\n const stepSeconds = stepFrames * FRAME;\n const score: Score[] = [];\n let steps = 0;\n for (const t of tracks) {\n const ws = t.steps, made = new Map();\n steps = Math.max(steps, ws.length);\n ws.forEach((w, s) => {\n if (w === \"-\" || w === \".\") return;\n if (t.instrument.kind === \"kit\") {\n for (const letter of w) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: s * stepFrames, track: t, made, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n for (const name of w.split(\"+\")) {\n midi(name);\n score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps,\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through its `fx`,\n * at its volume, to its side; what each track sends (its `reverb`) into one reverb shared by them all (`reverb`, its\n * room); the whole through `master` — `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter\n * (`Clip.limit`) so it never goes over −1.5 dB however the parts add: `limit: false` leaves it as mixed,\n * `{ ceiling, release }` sets it. A long clip a program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n let part = mixdown(notes.map((n) => ({ clip: n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate), at: n.at * FRAME })), least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = { sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop };\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/compiler/runtime.ts b/compiler/runtime.ts index fd2d12f..e05daa0 100644 --- a/compiler/runtime.ts +++ b/compiler/runtime.ts @@ -29,7 +29,7 @@ import { PLAYER_COLORS, TABLE_FIELDS, TABLE_SIZE, tableOfBrand, type TableField, import { LOCAL_FIELDS, localName, MEMORY_FIELDS, MEMORY_GROUPS, memoryTableField } from "./memory"; import { coverValue } from "./cover"; import { isVideo } from "./media"; -import { clipFile, isClip, mixdown, noise, silence, tone, type ClipFile } from "./sound"; +import { clipFile, fm, isClip, mixdown, noise, pluck, silence, tone, type ClipFile } from "./sound"; /** `memory(address, …)` reads `deaths` at player `EPD(address)`, unit 0: the deaths table starts here in 1.16.1's memory. */ export const DEATHS_TABLE_ADDRESS = 0x58a364; @@ -219,6 +219,8 @@ export class Collector { readonly strings: ScriptString[] = []; /** The clips the script plays, as the files the map's archive gets, by their names there (`sound.ts`). */ readonly sounds = new Map(); + /** Whether those files are made (a build), or only named (a check: `CompileOptions.sounds`). */ + soundContents = true; /** The script emitted hyper triggers: the trigger loop runs twelve times a second, not once in two. */ hyper = false; /** Locations a library took with reserveLocation(), by the reason it gave: the location's number. */ @@ -306,7 +308,7 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: // A clip (`sound.ts`): its file goes in the map's archive, and the action plays it by the name it goes under. A // trigger's is a WAV whatever its length — the triggers play in 1.16.1 too, which plays no Ogg; a program's // (Remastered) is Ogg Vorbis when it is long. - if (kind === "wav" && isClip(v)) { const file = clipFile(collector.running ? v.wav() : v); collector.sounds.set(file.path, file); v = file.path; } + if (kind === "wav" && isClip(v)) { const file = clipFile(collector.running ? v.wav() : v, collector.soundContents); collector.sounds.set(file.path, file); v = file.path; } if (typeof v === "string") return v === "" ? 0 : collector.localString({ text: v }); if (typeof v === "number") return v === 0 ? 0 : collector.localString({ index: integer(v, what) }); throw new ScriptError(`${what}: expected text, got ${describe(v)}.`); @@ -630,6 +632,8 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: rt.noise = built(noise); rt.silence = built(silence); rt.mixdown = built(mixdown); + rt.fm = built(fm); + rt.pluck = built(pluck); rt.centerLocation = () => { throw new ScriptError("centerLocation() moves a location while the game runs: use it inside program(), as a statement — centerLocation(locations.Cursor, x, y)."); }; /* ── The game's tables ── */ @@ -808,6 +812,6 @@ export function runtimeNames(names: ScriptNames): string[] { out.push("seconds", "minutes", "frames", "cycles", "sleep", "nextFrame", "until", "sync", "select", "local", "start", "rose", "once", "shared", "clamp", "u32", "i32", "fixed"); out.push(...READER_NAMES, "races", "slots", "name", "color", "print"); out.push(...UNIT_CALL_NAMES, "colors", ...INPUT_CALL_NAMES); - out.push("textColors", "ui", "on", "reserveLocation", "cover", "tone", "noise", "silence", "mixdown", "__jsx", "__fragment", "__class", "__fn", "__listen"); + out.push("textColors", "ui", "on", "reserveLocation", "cover", "tone", "noise", "silence", "mixdown", "fm", "pluck", "__jsx", "__fragment", "__class", "__fn", "__listen"); return out; } diff --git a/compiler/sound.ts b/compiler/sound.ts index 7027237..d1a9942 100644 --- a/compiler/sound.ts +++ b/compiler/sound.ts @@ -19,6 +19,7 @@ * written. IMA ADPCM, PCM and Ogg Vorbis play; Ogg Opus does not. */ import { fromBase64, toBase64 } from "./rangeCoder"; +import { chorus, compressor, curve, echo, echoTail, eq, fmVoice, plate, plateTail, pluckVoice, saturate, seeded, svf, type FilterType, type Points } from "./dsp"; /** The extension of a sound among a script's files. */ export const SOUND_EXTENSION = ".sound"; @@ -208,14 +209,18 @@ function checkRate(rate: number): void { /* ── What a script gets ── */ -/** A sound when the script is built. `adpcm` is kept while a clip is its file's as it was, so the map gets those bytes. */ +/** + * A sound when the script is built. Its length, rate and channels are known at once; its samples are worked out only + * when something needs them — the map's file, at a build, or `peak` — so a check as the user types, which needs neither, + * runs a song's whole mix without making a sample of it. + */ export interface Clip { readonly __trigscript: "clip"; readonly seconds: number; readonly rate: number; /** 1, or 2 for a clip with a left and a right. */ readonly channels: 1 | 2; - /** The loudest sample, 0 … 1 (more than 1 clips when the map's file is written). */ + /** The loudest sample, 0 … 1 (more than 1 clips when the map's file is written). Reading it works the samples out. */ readonly peak: number; trim(from: number, to?: number): Clip; gain(factor: number): Clip; @@ -231,9 +236,25 @@ export interface Clip { limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; normalize(peak?: number): Clip; wav(): Clip; + filter(type: FilterType, cutoff: number | Points, options?: { readonly resonance?: number; readonly gain?: number; readonly q?: number }): Clip; + drive(dB: number): Clip; + delay(seconds: number, options?: { readonly feedback?: number; readonly mix?: number; readonly tone?: number; readonly pingPong?: boolean }): Clip; + reverb(options?: { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly mix?: number; readonly dry?: number; readonly width?: number }): Clip; + chorus(options?: { readonly speed?: number; readonly depth?: number; readonly mix?: number }): Clip; + compress(options?: { readonly threshold?: number; readonly ratio?: number; readonly attack?: number; readonly release?: number; readonly knee?: number; readonly makeup?: number }): Clip; } -interface ClipInside { channels: readonly Float32Array[]; adpcm?: Uint8Array; wav?: boolean } +interface ClipInside { + readonly frames: number; + /** The samples, once worked out; until then, what works them out. */ + channels?: readonly Float32Array[]; + compute?: () => readonly Float32Array[]; + peak?: number; + adpcm?: Uint8Array; + wav?: boolean; + /** What the clip is (`keyOf`). */ + key?: string; +} const inside = new WeakMap(); export const isClip = (v: unknown): v is Clip => typeof v === "object" && v !== null && (v as Clip).__trigscript === "clip"; @@ -243,6 +264,66 @@ const num = (v: unknown, what: string): number => { return v; }; +/** A clip's samples, worked out the first time they are asked for. */ +function samplesOf(c: Clip): readonly Float32Array[] { + const own = inside.get(c)!; + if (!own.channels) { + const made = own.compute!(); + if (made.length !== c.channels || made.some((s) => s.length !== own.frames)) throw new Error(`Internal: a clip's samples came out ${made.map((s) => s.length).join("+")} long, not ${c.channels} × ${own.frames}.`); + own.channels = made; + // What worked them out is let go, and with it the clips they came from. + own.compute = undefined; + } + return own.channels; +} + +/* + * What a clip is, as a key: worked out from how it was made — the clip it came from, the method and its arguments — or, + * for samples from elsewhere, from the samples. Two clips made the same way have the same key: the map's file is named + * by it (so a check knows the name without the samples), and a clip made the same way twice in one build is the same + * clip, its samples worked out once (kept by a weak reference: a clip nothing holds any more is let go). + */ +const cache = new Map>(); + +/** Two 32-bit hashes of a text, as 16 hex digits. */ +function hashText(text: string): string { + let a = 0x811c9dc5, b = 0x9747b28c; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + a = Math.imul(a ^ c, 0x01000193) >>> 0; + b = Math.imul(b ^ c, 0x5bd1e995) >>> 0; b = (b ^ (b >>> 15)) >>> 0; + } + return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); +} + +/** A clip's key: how it was made, or its samples. */ +export function keyOf(c: Clip): string { + const own = inside.get(c)!; + if (own.key) return own.key; + let a = 0x811c9dc5, b = 0x9747b28c; + for (const s of samplesOf(c)) { + const words = new Uint32Array(s.buffer, s.byteOffset, s.length); + for (let i = 0; i < words.length; i++) { a = Math.imul(a ^ words[i], 0x01000193) >>> 0; b = Math.imul(b ^ words[i], 0x5bd1e995) >>> 0; b = (b ^ (b >>> 15)) >>> 0; } + } + own.key = `s${c.rate}:${c.channels}:${a.toString(16)}${b.toString(16)}`; + return own.key; +} + +/** The key of a clip made by `op` from these arguments (clips among them by their keys). */ +const recipe = (op: string, args: unknown): string => hashText(`${op}|${JSON.stringify(args, (_, v) => (isClip(v) ? `clip:${keyOf(v)}` : v))}`); + +/** The clip `key` names, if one is still held; else `make`'s, kept under it. */ +function made(key: string, make: () => Clip): Clip { + const hit = cache.get(key)?.deref(); + if (hit) return hit; + const c = make(), own = inside.get(c)!; + if (own.key) return c; + own.key = key; + cache.set(key, new WeakRef(c)); + if (cache.size > 8192) for (const [k, r] of cache) if (!r.deref()) cache.delete(k); + return c; +} + /* * Resampling by a windowed sinc: each new sample the old ones weighed by sin(πx)/(πx) under a Blackman window 16 samples * each side, its cutoff lowered to the new rate's half when that is the lower, so a note pitched up does not fold its @@ -259,10 +340,13 @@ const KERNEL = (() => { return t; })(); +/** The length `n` samples at `from` come to at `to`. */ +const resampled = (n: number, from: number, to: number) => (from === to ? n : Math.max(1, Math.round((n * to) / from))); + /** `samples` at `rate`, as a rate of its own. */ function resample(samples: Float32Array, from: number, to: number): Float32Array { if (from === to) return samples; - const n = Math.max(1, Math.round((samples.length * to) / from)), out = new Float32Array(n), r = from / to; + const n = resampled(samples.length, from, to), out = new Float32Array(n), r = from / to; const c = Math.min(1, to / from), reach = HALF / c, last = samples.length - 1; for (let i = 0; i < n; i++) { const p = i * r, a = Math.max(0, Math.floor(p - reach) + 1), b = Math.min(last, Math.floor(p + reach)); @@ -276,75 +360,108 @@ function resample(samples: Float32Array, from: number, to: number): Float32Array return out; } +/** A clip's frames at `rate`. */ +const framesAt = (c: Clip, rate: number) => resampled(inside.get(c)!.frames, c.rate, rate); + /** A clip's channels at `rate` and as many channels as `channels`: a mono one heard the same from both sides. */ const channelsAt = (c: Clip, rate: number, channels: number): Float32Array[] => { - const own = inside.get(c)!.channels.map((s) => resample(s, c.rate, rate)); + const own = samplesOf(c).map((s) => resample(s, c.rate, rate)); return channels === 2 && own.length === 1 ? [own[0], own[0]] : own; }; +/** A plain loop over a channel: a typed array's own map() calls back far more slowly, and a song's notes come by the hundred. */ +const each = (s: Float32Array, f: (x: number, i: number) => number): Float32Array => { + const out = new Float32Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = f(s[i], i); + return out; +}; +const scale = (s: Float32Array, g: Float32Array | number): Float32Array => { + const out = new Float32Array(s.length); + if (typeof g === "number") for (let i = 0; i < s.length; i++) out[i] = s[i] * g; + else for (let i = 0; i < s.length; i++) out[i] = s[i] * g[i]; + return out; +}; + /** The clip these channels are (one, or a left and a right of the same length). */ export function makeClip(samples: Float32Array | readonly Float32Array[], rate: number, adpcm?: Uint8Array, wav?: boolean): Clip { const chans: readonly Float32Array[] = samples instanceof Float32Array ? [samples] : samples; if (chans.length !== 1 && chans.length !== 2) throw new Error("A clip has one channel or two."); const frames = chans[0].length; if (chans.some((s) => s.length !== frames)) throw new Error("A clip's channels are the same length."); + return lazyClip(rate, frames, chans.length as 1 | 2, () => chans, { channels: chans, ...(adpcm ? { adpcm } : {}), ...(wav ? { wav } : {}) }); +} + +/** A clip `frames` long whose samples `compute` works out when they are asked for. */ +function lazyClip(rate: number, frames: number, channels: 1 | 2, compute: () => readonly Float32Array[], extra: Partial = {}): Clip { if (frames / rate > MAX_SECONDS) throw new Error(`A clip is at most ${MAX_SECONDS / 60} minutes long, not ${(frames / rate / 60).toFixed(1)}.`); - let peak = 0; - for (const s of chans) for (const x of s) peak = Math.max(peak, Math.abs(x)); - const seconds = frames / rate, channels = chans.length as 1 | 2; + const seconds = frames / rate, wav = extra.wav === true; const at = (t: number) => Math.max(0, Math.min(frames, Math.round(t * rate))); - // A new clip from each channel, keeping whether it was asked to stay a WAV. - const each = (f: (s: Float32Array, side: number) => Float32Array) => makeClip(chans.map(f), rate, undefined, wav); - const map = (f: (x: number, i: number) => number) => each((s) => s.map(f)); + /** A clip made from this one by `op`, `n` frames and `count` channels long, its samples from these. */ + const derive = (op: string, args: unknown, n: number, count: 1 | 2, from: (chans: readonly Float32Array[]) => readonly Float32Array[], also: Partial = {}) => + made(recipe(`${keyOf(clip)}|${wav ? 1 : 0}|${op}`, args), () => lazyClip(rate, n, count, () => from(samplesOf(clip)), { ...(wav ? { wav } : {}), ...also })); + const same = (op: string, args: unknown, from: (chans: readonly Float32Array[]) => readonly Float32Array[]) => derive(op, args, frames, channels, from); const clip: Clip = { __trigscript: "clip", seconds, rate, channels, - peak, + get peak() { + const own = inside.get(clip)!; + if (own.peak === undefined) { + let p = 0; + for (const s of samplesOf(clip)) for (let i = 0; i < s.length; i++) { const x = s[i] < 0 ? -s[i] : s[i]; if (x > p) p = x; } + own.peak = p; + } + return own.peak; + }, trim(from, to = seconds) { num(from, "trim()'s start"); num(to, "trim()'s end"); if (from < 0 || to > seconds + 1e-9 || to <= from) throw new RangeError(`trim(${from}, ${to}) is not inside the clip, which is ${seconds.toFixed(3)} seconds.`); - return each((s) => s.slice(at(from), at(to))); + return derive("trim", [from, to], at(to) - at(from), channels, (cs) => cs.map((s) => s.slice(at(from), at(to)))); }, gain(factor) { if (num(factor, "gain()") < 0) throw new RangeError(`gain() is 0 or more (0.5 half as loud, 2 twice), not ${factor}.`); - return map((x) => x * factor); + return same("gain", [factor], (cs) => cs.map((s) => scale(s, factor))); }, fade(fadeIn, fadeOut = 0) { num(fadeIn, "fade()'s in"); num(fadeOut, "fade()'s out"); if (fadeIn < 0 || fadeOut < 0) throw new RangeError("fade() takes seconds, 0 or more."); const a = fadeIn * rate, b = fadeOut * rate; - return map((x, i) => x * Math.min(1, a ? i / a : 1, b ? (frames - 1 - i) / b : 1)); + return same("fade", [fadeIn, fadeOut], (cs) => cs.map((s) => each(s, (x, i) => x * Math.min(1, a ? i / a : 1, b ? (frames - 1 - i) / b : 1)))); }, - reverse() { return each((s) => s.slice().reverse()); }, + reverse() { return same("reverse", [], (cs) => cs.map((s) => s.slice().reverse())); }, pitch(semitones) { // Faster and higher, or slower and lower, as a sampler plays a note: the clip read at another speed. const ratio = 2 ** (num(semitones, "pitch()") / 12); - return each((s) => resample(s, rate * ratio, rate)); + return derive("pitch", [semitones], resampled(frames, rate * ratio, rate), channels, (cs) => cs.map((s) => resample(s, rate * ratio, rate))); }, then(...clips) { for (const c of clips) if (!isClip(c)) throw new Error("then() joins clips."); - const n = Math.max(channels, ...clips.map((c) => c.channels)); - const parts = [clip, ...clips].map((c) => channelsAt(c, rate, n)); - const total = parts.reduce((s, p) => s + p[0].length, 0); - return makeClip(Array.from({ length: n }, (_, side) => { - const out = new Float32Array(total); - let o = 0; - for (const p of parts) { out.set(p[side], o); o += p[side].length; } - return out; - }), rate, undefined, wav); + const n = Math.max(channels, ...clips.map((c) => c.channels)) as 1 | 2; + const total = frames + clips.reduce((s, c) => s + framesAt(c, rate), 0); + return derive("then", [clips], total, n, () => { + const parts = [clip, ...clips].map((c) => channelsAt(c, rate, n)); + return Array.from({ length: n }, (_, side) => { + const out = new Float32Array(total); + let o = 0; + for (const p of parts) { out.set(p[side], o); o += p[side].length; } + return out; + }); + }); }, mix(other, start = 0) { if (!isClip(other)) throw new Error("mix() takes a clip."); if (num(start, "mix()'s start") < 0) throw new RangeError("mix()'s start is 0 seconds or more."); - const n = Math.max(channels, other.channels), mine = channelsAt(clip, rate, n), theirs = channelsAt(other, rate, n), from = Math.round(start * rate); - return makeClip(mine.map((s, side) => { - const o = theirs[side], out = new Float32Array(Math.max(frames, from + o.length)); - out.set(s); - for (let i = 0; i < o.length; i++) out[from + i] += o[i]; - return out; - }), rate, undefined, wav); + const n = Math.max(channels, other.channels) as 1 | 2, from = Math.round(start * rate), length = Math.max(frames, from + framesAt(other, rate)); + return derive("mix", [other, start], length, n, () => { + const mine = channelsAt(clip, rate, n), theirs = channelsAt(other, rate, n); + return mine.map((s, side) => { + const o = theirs[side], out = new Float32Array(length); + out.set(s); + for (let i = 0; i < o.length; i++) out[from + i] += o[i]; + return out; + }); + }); }, envelope(...points) { // Loudness through the clip, a straight line between [seconds, level] points: the first level before the first, @@ -352,57 +469,111 @@ export function makeClip(samples: Float32Array | readonly Float32Array[], rate: if (points.length === 0) throw new Error("envelope() takes [seconds, level] points: envelope([0, 0], [0.01, 1], [0.3, 0])."); const ps = points.map((p, k) => { if (!Array.isArray(p) || p.length !== 2) throw new Error(`envelope()'s point ${k + 1} is [seconds, level].`); - return [num(p[0], "a point's seconds") * rate, num(p[1], "a point's level")] as const; + return [num(p[0], "a point's seconds"), num(p[1], "a point's level")] as [number, number]; }); for (let k = 1; k < ps.length; k++) if (ps[k][0] < ps[k - 1][0]) throw new RangeError("envelope()'s points go forward in time."); - const levels = new Float32Array(frames); - for (let i = 0, k = 0; i < frames; i++) { - while (k < ps.length && ps[k][0] <= i) k++; - levels[i] = k === 0 ? ps[0][1] : k === ps.length ? ps[k - 1][1] : ps[k - 1][1] + ((ps[k][1] - ps[k - 1][1]) * (i - ps[k - 1][0])) / (ps[k][0] - ps[k - 1][0] || 1); - } - return map((x, i) => x * levels[i]); + return same("envelope", ps, (cs) => { const levels = curve(ps, rate, frames); return cs.map((s) => scale(s, levels)); }); }, repeat(times) { if (!Number.isInteger(num(times, "repeat()")) || times < 1) throw new RangeError(`repeat() takes a whole number of times, 1 or more, not ${times}.`); - return each((s) => { + return derive("repeat", [times], frames * times, channels, (cs) => cs.map((s) => { const out = new Float32Array(s.length * times); for (let k = 0; k < times; k++) out.set(s, k * s.length); return out; - }); + })); }, pan(position) { // A balance: the far side turned down, the near one kept, so the middle (0) is the clip as it was. if (!(num(position, "pan()") >= -1 && position <= 1)) throw new RangeError(`pan() is −1 (left) to 1 (right), 0 the middle, not ${position}.`); - const [left, right] = channels === 2 ? chans : [chans[0], chans[0]]; const l = Math.min(1, 1 - position), r = Math.min(1, 1 + position); - return makeClip([left.map((x) => x * l), right.map((x) => x * r)], rate, undefined, wav); + return derive("pan", [position], frames, 2, (cs) => [scale(cs[0], l), scale(cs[cs.length - 1], r)]); }, mono() { if (channels === 1) return clip; - return makeClip(chans[0].map((x, i) => (x + chans[1][i]) / 2), rate, undefined, wav); + return derive("mono", [], frames, 1, (cs) => [each(cs[0], (x, i) => (x + cs[1][i]) / 2)]); }, limit(options = {}) { const ceiling = options.ceiling ?? LIMIT_CEILING, release = options.release ?? 0.1; if (!(num(ceiling, "limit()'s ceiling") > 0 && ceiling <= 1)) throw new RangeError(`limit()'s ceiling is more than 0 and at most 1, not ${ceiling}.`); if (!(num(release, "limit()'s release") >= 0)) throw new RangeError("limit()'s release is seconds, 0 or more."); - const gain = limiterGain(chans, rate, ceiling, release); - return each((s) => s.map((x, i) => x * gain[i])); + return same("limit", [ceiling, release], (cs) => { const g = limiterGain(cs, rate, ceiling, release); return cs.map((s) => scale(s, g)); }); }, normalize(to = LIMIT_CEILING) { if (!(num(to, "normalize()") > 0 && to <= 1)) throw new RangeError(`normalize() makes the loudest sample more than 0 and at most 1, not ${to}.`); - return peak === 0 ? clip : map((x) => (x * to) / peak); + return same("normalize", [to], (cs) => { const p = clip.peak; return p === 0 ? cs : cs.map((s) => scale(s, to / p)); }); + }, + wav() { + if (wav) return clip; + const own = inside.get(clip)!; + return made(`${keyOf(clip)}|wav`, () => lazyClip(rate, frames, channels, () => samplesOf(clip), { wav: true, ...(own.adpcm ? { adpcm: own.adpcm } : {}) })); + }, + filter(type, cutoff, options = {}) { + const between = (v: number | undefined, d: number, lo: number, hi: number, what: string) => { + const x = v ?? d; + if (!(num(x, what) >= lo && x <= hi)) throw new RangeError(`${what} is ${lo} to ${hi}, not ${x}.`); + return x; + }; + const hzOk = (hz: unknown, what: string) => { if (!(num(hz, what) > 0)) throw new RangeError(`${what} is hertz, more than 0, not ${String(hz)}.`); }; + if (type === "lowshelf" || type === "highshelf" || type === "peak") { + if (typeof cutoff !== "number") throw new Error(`filter("${type}") takes one frequency, not points.`); + hzOk(cutoff, "filter()'s frequency"); + const gain = between(options.gain, 0, -48, 48, `filter("${type}")'s gain (dB)`), q = between(options.q, Math.SQRT1_2, 0.1, 30, "filter()'s q"); + return same("eq", [type, cutoff, gain, q], (cs) => eq(cs, rate, type, Math.min(cutoff, rate * 0.49), gain, q)); + } + if (!["lowpass", "highpass", "bandpass", "notch"].includes(type)) throw new Error(`filter()'s type is "lowpass", "highpass", "bandpass", "notch", "lowshelf", "highshelf" or "peak", not ${JSON.stringify(type)}.`); + if (typeof cutoff === "number") hzOk(cutoff, "filter()'s cutoff"); + else if (Array.isArray(cutoff) && cutoff.length > 0) cutoff.forEach((p, k) => { + if (!Array.isArray(p) || p.length !== 2) throw new Error(`filter()'s cutoff point ${k + 1} is [seconds, hertz].`); + num(p[0], "a cutoff point's seconds"); hzOk(p[1], "a cutoff point's hertz"); + }); + else throw new Error("filter()'s cutoff is hertz, or [seconds, hertz] points to sweep it."); + const resonance = between(options.resonance, 0, 0, 1, "filter()'s resonance"); + return same("filter", [type, cutoff, resonance], (cs) => svf(cs, rate, type as "lowpass", cutoff, resonance)); + }, + drive(dB) { + if (!(num(dB, "drive()") >= 0 && dB <= 48)) throw new RangeError(`drive() is 0 to 48 dB of push, not ${dB}.`); + if (dB === 0) return clip; + return same("drive", [dB], (cs) => saturate(cs, dB, (s) => resample(s, rate, rate * 2), (s) => resample(s, rate * 2, rate))); + }, + delay(seconds, options = {}) { + const feedback = options.feedback ?? 0.35, mix = options.mix ?? 0.35, toneHz = options.tone ?? 5000, pingPong = options.pingPong === true; + if (!(num(seconds, "delay()'s time") > 0 && seconds <= 5)) throw new RangeError(`delay()'s time is more than 0, at most 5 seconds, not ${seconds}.`); + if (!(num(feedback, "delay()'s feedback") >= 0 && feedback < 1)) throw new RangeError(`delay()'s feedback is 0 to less than 1, not ${feedback}.`); + if (!(num(mix, "delay()'s mix") >= 0 && mix <= 1)) throw new RangeError(`delay()'s mix is 0 to 1, not ${mix}.`); + if (!(num(toneHz, "delay()'s tone") > 0)) throw new RangeError(`delay()'s tone is hertz, more than 0, not ${toneHz}.`); + return derive("delay", [seconds, feedback, mix, toneHz, pingPong], frames + echoTail(rate, seconds, feedback), pingPong ? 2 : channels, (cs) => echo(cs, rate, seconds, feedback, mix, Math.min(toneHz, rate * 0.45), pingPong)); + }, + reverb(options = {}) { + const o = { decay: options.decay ?? 0.6, damping: options.damping ?? 0.4, predelay: options.predelay ?? 0.015, mix: options.mix ?? 0.3, dry: options.dry ?? 1, width: options.width ?? 1 }; + for (const [k, v, hi] of [["decay", o.decay, 1], ["damping", o.damping, 1], ["predelay", o.predelay, 0.5], ["mix", o.mix, 2], ["dry", o.dry, 2], ["width", o.width, 1]] as const) { + if (!(num(v, `reverb()'s ${k}`) >= 0 && v <= hi)) throw new RangeError(`reverb()'s ${k} is 0 to ${hi}, not ${v}.`); + } + const tail = Math.round(plateTail(o.decay, o.predelay) * rate); + return derive("reverb", o, frames + tail, 2, (cs) => plate(cs, rate, o, tail)); + }, + chorus(options = {}) { + const speed = options.speed ?? 0.8, depth = options.depth ?? 0.003, mix = options.mix ?? 0.5; + if (!(num(speed, "chorus()'s speed") > 0 && speed <= 20)) throw new RangeError(`chorus()'s speed is more than 0, at most 20 Hz, not ${speed}.`); + if (!(num(depth, "chorus()'s depth") >= 0 && depth <= 0.01)) throw new RangeError(`chorus()'s depth is 0 to 0.01 seconds, not ${depth}.`); + if (!(num(mix, "chorus()'s mix") >= 0 && mix <= 1)) throw new RangeError(`chorus()'s mix is 0 to 1, not ${mix}.`); + return derive("chorus", [speed, depth, mix], frames, 2, (cs) => chorus(cs, rate, speed, depth, mix)); + }, + compress(options = {}) { + const o = { threshold: options.threshold ?? -18, ratio: options.ratio ?? 4, attack: options.attack ?? 0.005, release: options.release ?? 0.12, knee: options.knee ?? 6, makeup: options.makeup ?? 0 }; + if (!(num(o.threshold, "compress()'s threshold") <= 0 && o.threshold >= -60)) throw new RangeError(`compress()'s threshold is −60 to 0 dB, not ${o.threshold}.`); + if (!(num(o.ratio, "compress()'s ratio") >= 1 && o.ratio <= 100)) throw new RangeError(`compress()'s ratio is 1 to 100, not ${o.ratio}.`); + for (const [k, v] of [["attack", o.attack], ["release", o.release], ["knee", o.knee]] as const) if (!(num(v, `compress()'s ${k}`) >= 0)) throw new RangeError(`compress()'s ${k} is 0 or more, not ${v}.`); + if (!(num(o.makeup, "compress()'s makeup") >= -24 && o.makeup <= 24)) throw new RangeError(`compress()'s makeup is −24 to 24 dB, not ${o.makeup}.`); + return same("compress", o, (cs) => compressor(cs, rate, o)); }, - wav() { return wav ? clip : makeClip(chans, rate, adpcm, true); }, }; - inside.set(clip, { channels: chans, ...(adpcm ? { adpcm } : {}), ...(wav ? { wav } : {}) }); + inside.set(clip, { frames, compute, ...extra }); return Object.freeze(clip); } -/** - * The level `limit` keeps a clip under unless told otherwise, and `normalize` brings it to: −1.5 dB. Ogg Vorbis comes +/** The level `limit` keeps a clip under unless told otherwise, and `normalize` brings it to: −1.5 dB. Ogg Vorbis comes * back up to a dB louder at its peaks than what went in (a song limited to −1 dB peaked at −0.16 dB, decoded), so the - * margin keeps a limited song from clipping in the game. - */ + * margin keeps a limited song from clipping in the game. */ export const LIMIT_CEILING = 0.84; /** How far ahead the limiter looks, in seconds: it turns down this long before a peak, so the peak is never over. */ const LOOKAHEAD = 0.005; @@ -434,9 +605,8 @@ function limiterGain(chans: readonly Float32Array[], rate: number, ceiling: numb while (queue[head] < i) head++; low[i] = need[queue[head]]; } - // Averaged over (i − L, i], then released. + // Averaged over (i − L, i], then released. (Before the first sample the need is 1: the sum starts at L ones.) const gain = new Float32Array(n), back = release > 0 ? 1 - Math.exp(-1 / (release * rate)) : 1; - // (Before the first sample the need is 1: the sum starts at L ones.) let sum = L, g = 1; for (let i = 0; i < n; i++) { sum += low[i] - (i >= L ? low[i - L] : 1); @@ -462,31 +632,33 @@ export function mixdown(parts: unknown, seconds?: unknown, rate?: number): Clip }); if (rate !== undefined) checkRate(rate); const to = rate ?? list[0].c.rate; - const n = Math.max(...list.map((p) => p.c.channels)); - const length = Math.max(seconds === undefined ? 0 : Math.round(num(seconds, "mixdown()'s length") * to), ...list.map((p) => Math.round(p.at * to) + Math.round(p.c.seconds * to))); + const n = Math.max(...list.map((p) => p.c.channels)) as 1 | 2; + const length = Math.max(seconds === undefined ? 0 : Math.round(num(seconds, "mixdown()'s length") * to), ...list.map((p) => Math.round(p.at * to) + framesAt(p.c, to))); if (length / to > MAX_SECONDS) throw new RangeError(`A clip is at most ${MAX_SECONDS / 60} minutes long.`); - const out = Array.from({ length: n }, () => new Float32Array(length)); - // A clip played many times is resampled once. - const made = new Map(); - for (const p of list) { - let s = made.get(p.c); - if (!s) { s = channelsAt(p.c, to, n); made.set(p.c, s); } - const from = Math.round(p.at * to); - for (let side = 0; side < n; side++) { - const src = s[side], dst = out[side]; - for (let i = 0; i < src.length && from + i < length; i++) dst[from + i] += src[i]; + return made(recipe("mixdown", [list.map((p) => [p.c, p.at]), length, to]), () => lazyClip(to, length, n, () => { + const out = Array.from({ length: n }, () => new Float32Array(length)); + // A clip played many times is resampled once. + const at = new Map(); + for (const p of list) { + let s = at.get(p.c); + if (!s) { s = channelsAt(p.c, to, n); at.set(p.c, s); } + const from = Math.round(p.at * to); + for (let side = 0; side < n; side++) { + const src = s[side], dst = out[side]; + for (let i = 0; i < src.length && from + i < length; i++) dst[from + i] += src[i]; + } } - } - return makeClip(out, to); + return out; + })); } /** A clip's samples, mono (for the compiler's own work and the editor's player, not in the declarations). */ export const clipSamples = (c: Clip): Float32Array => { - const ch = inside.get(c)!.channels; - return ch.length === 1 ? ch[0] : ch[0].map((x, i) => (x + ch[1][i]) / 2); + const ch = samplesOf(c); + return ch.length === 1 ? ch[0] : each(ch[0], (x, i) => (x + ch[1][i]) / 2); }; /** A clip's channels as they are. */ -export const clipChannels = (c: Clip): readonly Float32Array[] => inside.get(c)!.channels; +export const clipChannels = (c: Clip): readonly Float32Array[] => samplesOf(c); /** * At this length or more a clip a program plays goes in the map as Ogg Vorbis (Remastered plays it; 1.16.1 plays only @@ -497,6 +669,11 @@ export const clipChannels = (c: Clip): readonly Float32Array[] => inside.get(c)! export const VORBIS_SECONDS = 2; /** libvorbis's quality, −1 … 10: 5 is about 160 kbit/s for two channels at 44.1 kHz. */ export const VORBIS_QUALITY = 5; +/** + * Goes into every file's name with the clip's key: a clip made the same way is the same file, so a map keeps the bytes + * of one it already has (`withSounds`). When what a method does changes, this changes, and a map's files are made again. + */ +const ENGINE = "sound 2"; /** What the map's archive gets for a clip: a WAV's bytes now, or the samples of an Ogg Vorbis encoded when the map is built. */ export interface ClipFile { @@ -505,31 +682,29 @@ export interface ClipFile { vorbis?: { rate: number; channels: readonly Float32Array[] }; } -/** FNV-1a over bytes, from where another call left off. */ -const fnv = (bytes: Uint8Array, h = 0x811c9dc5): number => { - for (const b of bytes) h = Math.imul(h ^ b, 0x01000193) >>> 0; - return h; -}; - -/** The file the map's archive gets for a clip, and the name it goes under: named by what it holds, so it goes in once. */ -export function clipFile(c: Clip): ClipFile { - const { channels, adpcm, wav } = inside.get(c)!; - if (!wav && !adpcm && c.seconds >= VORBIS_SECONDS) { - // Named by the samples, the rate and the quality, so the name is known before it is encoded. - let h = fnv(new TextEncoder().encode(`vorbis ${VORBIS_QUALITY} ${c.rate} ${channels.length}`)); - for (const s of channels) h = fnv(new Uint8Array(s.buffer, s.byteOffset, s.byteLength), h); - return { path: `staredit\\wav\\trigscript-${h.toString(16).padStart(8, "0")}.ogg`, vorbis: { rate: c.rate, channels } }; +/** + * The file the map's archive gets for a clip, and the name it goes under: named by what the clip is (its key), so it + * goes in once however often it is played, and the name is known without the samples. `contents`: the bytes (or, for + * an Ogg, the samples to encode) too — a build's; a check, which the map does not get, leaves them out and makes none. + */ +export function clipFile(c: Clip, contents = true): ClipFile { + const own = inside.get(c)!; + if (!own.wav && !own.adpcm && c.seconds >= VORBIS_SECONDS) { + const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|vorbis ${VORBIS_QUALITY}|${keyOf(c)}`)}.ogg`; + return contents ? { path, vorbis: { rate: c.rate, channels: samplesOf(c) } } : { path }; } + const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|wav|${keyOf(c)}`)}.wav`; + if (!contents) return { path }; // An import as it was keeps its own ADPCM; anything else mono is ADPCM where that is faithful, 16-bit where not; two // channels are 16-bit. + const channels = samplesOf(c); let bytes: Uint8Array; if (channels.length === 2) bytes = pcmWav(channels, c.rate); else { - const coded = adpcm ?? encodeAdpcm(channels[0], c.rate); - bytes = adpcm || adpcmWillDo(channels[0], c.rate, coded) ? adpcmWav(coded, c.rate, channels[0].length) : pcmWav(channels, c.rate); + const coded = own.adpcm ?? encodeAdpcm(channels[0], c.rate); + bytes = own.adpcm || adpcmWillDo(channels[0], c.rate, coded) ? adpcmWav(coded, c.rate, channels[0].length) : pcmWav(channels, c.rate); } - // Two clips that are the same file share it. - return { path: `staredit\\wav\\trigscript-${fnv(bytes).toString(16).padStart(8, "0")}.wav`, bytes }; + return { path, bytes }; } /** The value an import of a `.sound` file gives, kept while the text is the same (a build reads the files each time). */ @@ -571,36 +746,123 @@ const length = (seconds: unknown, what: string, rate: number) => { return Math.max(1, Math.round((seconds as number) * rate)); }; -/** A tone: `pitch` (hertz or a note's name) for `seconds`, its wave's shape, 5 ms in and out so it does not click. */ -export function tone(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly wave?: "sine" | "square" | "triangle" | "saw" }): Clip { - const { volume, rate } = optionsOf(options, "tone()"), hz = hertz(pitch), n = length(seconds, "tone()", rate); +/** A pitch, or a glide, checked: hertz or a note's name, or [seconds, pitch] points (a kick's drop, a slide between notes). */ +const pitchOf = (pitch: unknown, what: string): number | [number, number][] => { + if (!Array.isArray(pitch)) return hertz(pitch); + if (pitch.length === 0) throw new Error(`${what}'s pitch points are [seconds, pitch] pairs, one at least.`); + return pitch.map((p, k) => { + if (!Array.isArray(p) || p.length !== 2) throw new Error(`${what}'s pitch point ${k + 1} is [seconds, pitch].`); + return [num(p[0], "a pitch point's seconds"), hertz(p[1])] as [number, number]; + }); +}; + +type Wave = "sine" | "square" | "triangle" | "saw"; +/** PolyBLEP: what rounds off a jump at phase 0 of a wave whose phase moves `dt` a sample. */ +function blep(t: number, dt: number): number { + if (t < dt) { t /= dt; return t + t - t * t - 1; } + if (t > 1 - dt) { t = (t - 1) / dt; return t * t + t + t + 1; } + return 0; +} + +/** One sample of a wave at phase `p` (0 … 1), `dt` the phase a sample moves: square and saw rounded off by PolyBLEP. */ +function waveAt(wave: Wave, p: number, dt: number): number { + switch (wave) { + case "sine": return Math.sin(2 * Math.PI * p); + case "square": { const q = p + 0.5; return (p < 0.5 ? 1 : -1) + blep(p, dt) - blep(q >= 1 ? q - 1 : q, dt); } + case "triangle": return 1 - 4 * Math.abs(p - 0.5); + case "saw": return 2 * p - 1 - blep(p, dt); + } +} + +/** A clip made from nothing by `op`: its arguments name it, its samples are worked out when they are asked for. */ +const source = (op: string, args: unknown, rate: number, n: number, channels: 1 | 2, compute: () => Float32Array[]) => made(recipe(op, args), () => lazyClip(rate, n, channels, compute)); + +/** + * A tone: `pitch` (hertz, a note's name, or [seconds, pitch] points to glide) for `seconds`, its wave's shape, 5 ms in + * and out so it does not click. `voices`: that many copies, spread `detune` cents from lowest to highest, each from + * its own phase — a supersaw, a wide pad — and `width` 0 … 1 spreads them left to right (two channels). + */ +export function tone(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly wave?: Wave; readonly voices?: number; readonly detune?: number; readonly width?: number }): Clip { + const { volume, rate } = optionsOf(options, "tone()"), n = length(seconds, "tone()", rate); const wave = options?.wave ?? "sine"; - // Square and saw with their jumps rounded off by PolyBLEP, so nothing above half the rate folds back as other pitches. - const dt = hz / rate; - const blep = (t: number) => (t < dt ? ((t /= dt), t + t - t * t - 1) : t > 1 - dt ? ((t = (t - 1) / dt), t * t + t + t + 1) : 0); - const shape = { - sine: (p: number) => Math.sin(2 * Math.PI * p), - square: (p: number) => (p < 0.5 ? 1 : -1) + blep(p) - blep((p + 0.5) % 1), - triangle: (p: number) => 1 - 4 * Math.abs(p - 0.5), - saw: (p: number) => 2 * p - 1 - blep(p), - }[wave]; - if (!shape) throw new Error(`tone()'s wave is "sine", "square", "triangle" or "saw", not ${JSON.stringify(wave)}.`); - const out = new Float32Array(n), ramp = 0.005 * rate; - for (let i = 0; i < n; i++) out[i] = volume * shape((i * hz / rate) % 1) * Math.min(1, i / ramp, (n - 1 - i) / ramp); - return makeClip(out, rate); + if (!["sine", "square", "triangle", "saw"].includes(wave)) throw new Error(`tone()'s wave is "sine", "square", "triangle" or "saw", not ${JSON.stringify(wave)}.`); + const voices = options?.voices ?? 1, detune = options?.detune ?? (voices > 1 ? 20 : 0), width = options?.width ?? 0; + if (!(Number.isInteger(voices) && voices >= 1 && voices <= 16)) throw new RangeError(`tone()'s voices is a whole number, 1 to 16, not ${voices}.`); + if (!(num(detune, "tone()'s detune") >= 0 && detune <= 1200)) throw new RangeError(`tone()'s detune is 0 to 1 200 cents, not ${detune}.`); + if (!(num(width, "tone()'s width") >= 0 && width <= 1)) throw new RangeError(`tone()'s width is 0 to 1, not ${width}.`); + const p = pitchOf(pitch, "tone()"), stereo = voices > 1 && width > 0; + return source("tone", [p, n, rate, wave, volume, voices, detune, width], rate, n, stereo ? 2 : 1, () => { + const hz = curve(p, rate, n, true), ramp = 0.005 * rate; + const L = new Float32Array(n), R = stereo ? new Float32Array(n) : L, random = seeded(0x51ed + voices); + const one = voices === 1 ? 1 : 1 / Math.sqrt(voices); + for (let v = 0; v < voices; v++) { + const at = voices === 1 ? 0 : v / (voices - 1) - 0.5, ratio = 2 ** ((detune * at) / 1200); + const pos = stereo ? width * 2 * at : 0, l = one * Math.min(1, 1 - pos), r = one * Math.min(1, 1 + pos); + let ph = voices === 1 ? 0 : random(); + for (let i = 0; i < n; i++) { + const dt = (hz[i] * ratio) / rate, x = waveAt(wave, ph, dt); + if (stereo) { L[i] += l * x; R[i] += r * x; } else L[i] += one * x; + ph += dt; ph -= Math.floor(ph); + } + } + for (let i = 0; i < n; i++) { + const g = volume * Math.min(1, i / ramp, (n - 1 - i) / ramp); + L[i] *= g; if (stereo) R[i] *= g; + } + return stereo ? [L, R] : [L]; + }); +} + +/** + * Two-operator FM — a sine whose phase another sine, `ratio` times its pitch, pushes by `index` (or [seconds, index] + * points: bright at the start, mellow after). 1 a warm reed, 3.5 or 7 a bell, 0.5 a bass. + */ +export function fm(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly ratio?: number; readonly index?: number | readonly (readonly [number, number])[]; readonly feedback?: number }): Clip { + const { volume, rate } = optionsOf(options, "fm()"), n = length(seconds, "fm()", rate); + const ratio = options?.ratio ?? 1, index = options?.index ?? 2, feedback = options?.feedback ?? 0; + if (!(num(ratio, "fm()'s ratio") > 0 && ratio <= 32)) throw new RangeError(`fm()'s ratio is more than 0, at most 32, not ${ratio}.`); + if (!(num(feedback, "fm()'s feedback") >= 0 && feedback <= 2)) throw new RangeError(`fm()'s feedback is 0 to 2, not ${feedback}.`); + if (typeof index !== "number" && !Array.isArray(index)) throw new Error("fm()'s index is a number or [seconds, index] points."); + const p = pitchOf(pitch, "fm()"); + return source("fm", [p, n, rate, ratio, index, feedback, volume], rate, n, 1, () => { + const out = fmVoice(curve(p, rate, n, true), rate, ratio, curve(index as number | Points, rate, n), feedback), ramp = 0.005 * rate; + for (let i = 0; i < n; i++) out[i] *= volume * Math.min(1, i / ramp, (n - 1 - i) / ramp); + return [out]; + }); +} + +/** + * A plucked string (Karplus–Strong) — a guitar, a harp, a koto. `brightness` 0 … 1 (0.5), `decay` seconds to fade away + * (2 when left out; the note ends at `seconds` regardless). + */ +export function pluck(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly brightness?: number; readonly decay?: number }): Clip { + const { volume, rate } = optionsOf(options, "pluck()"), n = length(seconds, "pluck()", rate), hz = hertz(pitch); + const brightness = options?.brightness ?? 0.5, decay = options?.decay ?? 2; + if (!(num(brightness, "pluck()'s brightness") >= 0 && brightness <= 1)) throw new RangeError(`pluck()'s brightness is 0 to 1, not ${brightness}.`); + if (!(num(decay, "pluck()'s decay") > 0)) throw new RangeError(`pluck()'s decay is seconds, more than 0, not ${decay}.`); + if (hz < 20 || hz > rate / 4) throw new RangeError(`pluck() plays 20 Hz to a quarter of its rate, not ${hz.toFixed(0)} Hz.`); + return source("pluck", [hz, n, rate, brightness, decay, volume], rate, n, 1, () => { + const out = pluckVoice(hz, rate, n, brightness, decay), ramp = 0.003 * rate; + let peak = 0; + for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(out[i])); + for (let i = 0; i < n; i++) out[i] *= (volume / (peak || 1)) * Math.min(1, (n - 1 - i) / ramp); + return [out]; + }); } /** Noise for `seconds`: every sample a random one, the same on every build. */ export function noise(seconds: unknown, options?: MakeOptions): Clip { const { volume, rate } = optionsOf(options, "noise()"), n = length(seconds, "noise()", rate); - let seed = 0x2545f491; - const out = new Float32Array(n); - for (let i = 0; i < n; i++) { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; out[i] = volume * ((seed >>> 0) / 0x80000000 - 1); } - return makeClip(out, rate); + return source("noise", [n, rate, volume], rate, n, 1, () => { + let seed = 0x2545f491; + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; out[i] = volume * ((seed >>> 0) / 0x80000000 - 1); } + return [out]; + }); } /** Nothing, for `seconds`: a gap to join between clips. */ export function silence(seconds: unknown, options?: { readonly rate?: number }): Clip { - const { rate } = optionsOf(options, "silence()"); - return makeClip(new Float32Array(length(seconds, "silence()", rate)), rate); + const { rate } = optionsOf(options, "silence()"), n = length(seconds, "silence()", rate); + return source("silence", [n, rate], rate, n, 1, () => [new Float32Array(n)]); } diff --git a/compiler/structured.ts b/compiler/structured.ts index 55067ea..1aeed4d 100644 --- a/compiler/structured.ts +++ b/compiler/structured.ts @@ -369,6 +369,15 @@ export interface Emitted { refs?: TS.ClassDeclaration[]; } +/** A field of a value of the build, read as the program is lowered: a getter that refuses says why, as a call would. */ +function readField(value: unknown, name: string): unknown { + try { + return (value as Record)[name]; + } catch (err) { + throw new LowerError(err instanceof Error ? err.message : String(err)); + } +} + export class Structured { private readonly c: StructuredContext; private readonly ts: typeof TS; @@ -799,13 +808,13 @@ export class Structured { if (!obj || obj.value === null || obj.value === undefined) return undefined; // A field of the unit selected (\`local.selected.hp\`) is read when the map is played, as any unit's is. if (isSelected(obj.value) || isSelectionCount(obj.value)) return undefined; - return { value: (obj.value as Record)[e.name.text] }; + return { value: readField(obj.value, e.name.text) }; } if (ts.isElementAccessExpression(e)) { const obj = sub(e.expression); const key = sub(e.argumentExpression); if (!obj || !key || obj.value === null || obj.value === undefined) return undefined; - return { value: (obj.value as Record)[String(key.value)] }; + return { value: readField(obj.value, String(key.value)) }; } if (ts.isCallExpression(e)) { const callee = sub(e.expression); diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 46829be..93f79d8 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1685,6 +1685,41 @@ reverb, delay, drive), song structure, music that follows the game. - **Found on the way:** `ranges.ts` read `process.env` in the browser's worker, where there is none — every program failed to compile in the editor. Guarded (its own commit). +### Sounds: step 2, the instruments and the effects (built 2026-10-19) + +User after step 1's A/B: "B sounds way better, go for step 2". + +- **Effects** (`compiler/dsp.ts`, as `Clip` methods): `filter` — a trapezoidal state-variable filter (lowpass, highpass, + bandpass, notch; cutoff a number or [seconds, hertz] points swept log-linearly; resonance 0 … 1) and cookbook + biquads (lowshelf, highshelf, peak); `drive(dB)` — tanh at twice the rate; `delay` — feedback with a lowpass in the + loop, ping-pong; `reverb` — a plate after Dattorro (four diffusers, a figure-eight tank with modulated allpasses, + seven taps a side: L/R correlation 0.00); `chorus`; `compress` (soft knee, linked). Measured: the 1 kHz lowpass + −3.1 dB at its cutoff, the +6 dB shelf +5.9, plucks within 0.1 cent. +- **Sources**: `tone` takes [seconds, pitch] points (glides) and `voices`/`detune`/`width` (supersaw, two channels); + `fm()` two operators, index points, feedback; `pluck()` Karplus–Strong, tuned by a fractional delay. +- **The library**: `music.synth` gets a filter with its own envelope (octaves above the cutoff), unison, drive, glide; + `music.fmSynth`, `music.strings`; `music.presets` (supersaw, pad, bass, acid, lead, chip, keys, bell, pluck) and + `music.drums()` (k s c h o t r, 44.1 kHz) written in TrigScript itself on the public API. Tracks take `fx` (on the + whole part in `render`, on each note in `play`) and `reverb` (a send); `render` takes `reverb` (the shared room) and + `master`. A song's bar is a whole number of frames, a step need not be (sixteenths at 143 bpm are 2.5): `play` puts + a note on its nearest frame (the mixer's own grid is 21 ms). The 32-voice limit moved from `song()` to `play` (the + note tables are made when first read; a getter that refuses is a build error now — `structured.ts`'s `readField`). +- **Lazy clips.** The first arrangement took 8 s to compile, every check as the user types: a check reruns the whole + script, renders and all. A cache by recipe did not help — the song's intermediate parts are ~125M samples against a + 32M budget, so LRU thrashed and the second compile missed everything. What fixed it: a clip knows its length, + channels and recipe key at once and works its samples out only when asked (`peak`, a build's file). A check + (`CompileOptions.sounds: false`, the worker's default without `sounds`) names each clip's file by its key — with an + `ENGINE` tag so changed DSP never keeps a map's old bytes — and makes no sample. Reverb and echo tails are formulas + (a plate's: 1.24 / −ln decay s) so lengths need no samples. Clips made the same way are one object (a weak-reference + map). Hot loops are plain loops (typed-array `map()` and a closure a sample were most of the 8 s). Builds get 60 s. +- **Measured** (`probes/korobeiniki.ts`: 34 bars, nine tracks, intro / A1 / A2 / break / A3 / outro, 63 s; A = step 1's + mix): check 0.6 s cold, 0.4 warm in Node; headless Chromium worker check 1.1 s, build 3.6 s, re-check after an edit + 0.26 s, second build 0.24 s, the Ogg bytes as Node's. Stems balanced by LUFS (supersaw +6 dB, bell −6, keys −3, …); + the mix −11.8 LUFS (A −10.9), sections −22 → −12.5 → −12 → −13.5 → −11.8, peak −0.27 dB decoded; 1.25 MB Ogg. Lifted + game clean. Map 1.8 MB. +- **Found on the way:** the key hash's second half could go negative (`b ^ (b >>> 15)` is signed), putting a minus + in a file's name that `isBuiltSound` would not know — fixed, and every name of 200 checked. + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/probes/korobeiniki.ts b/probes/korobeiniki.ts new file mode 100644 index 0000000..7872591 --- /dev/null +++ b/probes/korobeiniki.ts @@ -0,0 +1,139 @@ +// Step 2 of the music library's quality work, heard in StarCraft: Remastered: Korobeiniki (a Russian folk song, in +// the public domain; the melody only — the bass, the chords, the arpeggios, the counter-line and the drums are this +// file's own) arranged for the instruments and effects of "trigscript/music", mixed when the script is built. A is +// step 1's mix (plain waves, the same notes as before); B is the arrangement. Host it as a multiplayer game, alone, +// with the game's music off. It repeats. +// +// npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx +// npx tsx scripts/build-fixture.mts probes/korobeiniki.ts fixtures/eud/korobeiniki.scx fixtures/eud/sound-base.scx +import { music } from "trigscript/music"; + +/* ── The notes ── */ + +// The melody in eighths, a bar a string (the folk song's two phrases). +const MELODY = [ + "E5 - B4 C5 D5 - C5 B4", "A4 - A4 C5 E5 - D5 C5", "B4 - - C5 D5 - E5 -", "C5 - A4 - A4 - . .", + "D5 - - F5 A5 - G5 F5", "E5 - - C5 E5 - D5 C5", "B4 - B4 C5 D5 - E5 -", "C5 - A4 - A4 - . .", +]; +type Chord = readonly [root: string, fifth: string, notes: string]; +const CHORDS: readonly Chord[] = [ + ["A1", "E2", "A3+C4+E4"], ["A1", "E2", "A3+C4+E4"], ["E1", "B1", "G#3+B3+E4"], ["A1", "E2", "A3+C4+E4"], + ["D2", "A1", "A3+D4+F4"], ["C2", "G1", "G3+C4+E4"], ["E1", "B1", "G#3+B3+E4"], ["A1", "E2", "A3+C4+E4"], +]; +const AM: Chord = CHORDS[0]; + +/** `x`, `n` times over. */ +const times = (n: number, x: T): T[] => Array.from({ length: n }, () => x); +/** A note's name `by` octaves up or down. */ +const octave = (note: string, by: number) => note.replace(/(-?\d)$/, (o) => String(Number(o) + by)); +const chordUp = (notes: string, by: number) => notes.split("+").map((n) => octave(n, by)).join("+"); +/** An eighth-note bar as sixteenths: each step twice as long. */ +const sixteenths = (bar: string) => bar.split(" ").map((w) => (w === "." ? ". ." : `${w} -`)).join(" "); +const rest = (n = 1) => Array.from({ length: n }, () => times(16, ".").join(" ")); +/** A bar of one held chord. */ +const held = (notes: string) => [notes, ...times(15, "-")].join(" "); + +// Sixteen steps a bar from here on. +const bassBar = ([r, f]: Chord) => [r, "-", ".", r, ".", r, r, "-", f, "-", ".", f, ".", octave(r, 1), r, "-"].join(" "); +const acidBar = ([r, f]: Chord) => { + const R = octave(r, 1), F = octave(f, 1), O = octave(r, 2); + return [R, ".", O, ".", F, R, ".", O, R, ".", F, ".", O, ".", R, F].join(" "); +}; +const arpBar = ([, , notes]: Chord) => { + const [a, b, c] = notes.split("+").map((n) => octave(n, 1)); + return [a, b, c, octave(a, 1), c, b, a, b, c, octave(a, 1), octave(b, 1), octave(a, 1), c, b, c, b].join(" "); +}; + +/** A bar of drums: the steps each letter plays on. */ +const beat = (hits: Readonly>) => Array.from({ length: 16 }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join("") || ".").join(" "); +const EIGHTHS = [0, 2, 4, 6, 8, 10, 12, 14], ALL = Array.from({ length: 16 }, (_, i) => i); +const GROOVE = beat({ k: [0, 7, 8, 10], s: [4, 12], h: EIGHTHS }); +const DRIVE = beat({ k: [0, 7, 8, 10], s: [4, 12], c: [4, 12], h: ALL.filter((i) => i % 4 !== 2), o: [2, 6, 10, 14] }); +const FILL = beat({ k: [0, 8], s: [4, 10, 11, 12, 13, 14, 15], t: [8, 9], h: [0, 2, 6] }); +const BUILD = [beat({ h: EIGHTHS }), beat({ k: [0, 8], h: EIGHTHS, s: [12, 14, 15] })]; + +/* ── The instruments ── */ + +const lead = music.presets.lead({ volume: 0.15 }); +const saw = music.presets.supersaw({ volume: 0.24 }); +const bell = music.presets.bell({ volume: 0.035 }); +const pad = music.presets.pad({ volume: 0.07 }); +const bass = music.presets.bass({ volume: 0.38 }); +const acid = music.presets.acid({ volume: 0.14 }); +const keys = music.presets.keys({ volume: 0.11 }); +const strum = music.presets.pluck({ volume: 0.15, decay: 0.9 }); +const kit = music.drums(); + +const BEAT = 60 / 143; +const echo = (c: Clip) => c.delay(BEAT * 0.75, { feedback: 0.3, mix: 0.22, pingPong: true, tone: 4000 }); + +/* ── The arrangement: intro 4, A1 8, A2 8, break 4, A3 8, outro 2 ── */ + +const tune = MELODY.map(sixteenths); +const TRACKS = [ + // The melody: the lead in A1, the supersaw in A2 and A3; the bell an octave up in A2, A3 and the break. + music.track(lead, [...rest(4), ...tune, ...rest(22)], { pan: -0.1, fx: echo, reverb: 0.25 }), + music.track(saw, [...rest(12), ...tune, ...rest(4), ...tune, held("A4"), ...rest(1)], { fx: (c) => echo(c).chorus({ mix: 0.25 }), reverb: 0.3 }), + music.track(bell, [...rest(12), ...tune.map((b) => b.replace(/[A-G]#?\d/g, (n) => octave(n, 1))), ...tune.slice(0, 4).map((b) => b.replace(/[A-G]#?\d/g, (n) => octave(n, 1))), ...tune.map((b) => b.replace(/[A-G]#?\d/g, (n) => octave(n, 1))), held("A5"), ...rest(1)], { pan: 0.3, reverb: 0.55 }), + // Under it: the pad all through, the plucked arpeggio in the intro and A2, the keys in the break, the acid in A3. + music.track(pad, [...CHORDS.slice(0, 4).map(([, , n]) => held(n)), ...[...CHORDS, ...CHORDS].map(([, , n]) => held(n)), ...CHORDS.slice(0, 4).map(([, , n]) => held(chordUp(n, 1))), ...CHORDS.map(([, , n]) => held(n)), held("A3+C4+E4"), ...rest(1)], { fx: (c) => c.filter("highpass", 180).chorus({ mix: 0.35 }), reverb: 0.5 }), + music.track(strum, [...CHORDS.slice(0, 4).map(arpBar), ...rest(8), ...CHORDS.map(arpBar), ...rest(14)], { pan: 0.45, fx: (c) => c.delay(BEAT / 2, { feedback: 0.25, mix: 0.2, pingPong: true }), reverb: 0.35 }), + music.track(keys, [...rest(20), ...CHORDS.slice(0, 4).map(([, , n]) => [n, "-", "-", ".", ".", ".", n, "-", ".", ".", n, "-", "-", "-", ".", "."].join(" ")), ...rest(10)], { pan: -0.2, fx: (c) => c.chorus({ mix: 0.3 }), reverb: 0.4 }), + music.track(acid, [...rest(24), ...CHORDS.map(acidBar), ...rest(2)], { pan: -0.35, fx: echo, reverb: 0.15 }), + music.track(bass, [...rest(2), bassBar(AM), bassBar(AM), ...CHORDS.map(bassBar), ...CHORDS.map(bassBar), ...CHORDS.slice(0, 4).map(([r]) => held(octave(r, 1))), ...CHORDS.map(bassBar), held("A1"), ...rest(1)], { fx: (c) => c.compress({ threshold: -20, ratio: 3 }) }), + music.track(kit, [...rest(2), ...BUILD, ...times(7, GROOVE), FILL, ...times(7, DRIVE), FILL, ...rest(3), beat({ s: [8, 10, 12, 13, 14, 15], k: [0] }), ...times(7, DRIVE), FILL, beat({ k: [0], c: [0] }), ...rest(1)], { fx: (c) => c.compress({ threshold: -16, ratio: 3, attack: 0.003 }), reverb: 0.1 }), +]; +const song = music.song({ bpm: 143, steps: 4 }, ...TRACKS); +const B = music.render(song, { + reverb: { decay: 0.72, damping: 0.45, predelay: 0.02 }, + master: (mix) => mix.compress({ threshold: -14, ratio: 2, attack: 0.01, release: 0.15, makeup: 3 }).filter("highshelf", 9000, { gain: 1.5 }), +}); + +/* ── A: step 1's mix, as it was ── */ + +const STEP1 = MELODY; +const s1Bass = ([root, fifth]: readonly [string, string, string]) => `${root} - . ${root} ${fifth} . ${root} ${fifth}`; +const S1_CHORDS: readonly (readonly [string, string, string])[] = CHORDS.map(([r, f, n]) => [octave(r, 1), octave(f, 1), n]); +const s1Arp = ([, , notes]: readonly [string, string, string]) => { const [a, b, c] = notes.split("+").map((n) => octave(n, 1)); return `${a} ${b} ${c} ${b} ${a} ${b} ${c} ${b}`; }; +const s1Rest = (bars: number) => Array.from({ length: bars }, () => ". . . . . . . ."); +const s1Twice = (xs: readonly T[]) => [...xs, ...xs]; +const s1Lead = music.synth({ wave: "square", volume: 0.2, decay: 0.12, sustain: 0.55, release: 0.1 }); +const s1Low = music.synth({ wave: "saw", volume: 0.09, decay: 0.1, sustain: 0.5, release: 0.08 }); +const s1BassI = music.synth({ wave: "triangle", volume: 0.55, attack: 0.003, decay: 0.15, sustain: 0.6, release: 0.05 }); +const s1ArpI = music.synth({ wave: "triangle", volume: 0.16, decay: 0.12, sustain: 0.2, release: 0.1 }); +const s1Pad = music.synth({ wave: "triangle", volume: 0.09, attack: 0.12, decay: 0.3, sustain: 0.8, release: 0.4 }); +const s1Drums = music.kit({ + k: tone(140, 0.03, { volume: 0.8 }).then(tone(85, 0.05, { volume: 0.8 }), tone(55, 0.12, { volume: 0.7 })).fade(0, 0.14), + s: noise(0.16, { volume: 0.4 }).fade(0, 0.15).mix(tone(200, 0.08, { volume: 0.35 }).fade(0, 0.07)), + h: noise(0.04, { volume: 0.14 }).fade(0, 0.035), +}); +const s1Pads = (bar: readonly [string, string, string]) => `${bar[2]} - - - - - - -`; +const before = music.song({ bpm: 143, steps: 2 }, + music.track(s1Lead, [...s1Rest(2), ...s1Twice(STEP1), "A4 - - - . . . ."], { pan: -0.15 }), + music.track(s1Low, [...s1Rest(10), ...STEP1.map((bar) => bar.replace(/[A-G]#?\d/g, (n) => octave(n, -1))), ". . . . . . . ."], { pan: 0.45 }), + music.track(s1BassI, [...s1Twice(S1_CHORDS).slice(0, 2).map(s1Bass), ...s1Twice(S1_CHORDS).map(s1Bass), "A2 - - - . . . ."]), + music.track(s1ArpI, [...s1Rest(2), ...S1_CHORDS.map(() => ". . . . . . . ."), ...S1_CHORDS.map(s1Arp), ". . . . . . . ."], { pan: 0.6 }), + music.track(s1Pad, [...s1Rest(2), ...s1Twice(S1_CHORDS).map(s1Pads), "A3+C4+E4 - - - - - - -"], { pan: -0.5 }), + music.track(s1Pad, [...s1Rest(2), ...s1Twice(S1_CHORDS).map((c) => s1Pads(c).replace(/(\d)(?=[+ ])/g, (o) => String(Number(o) + 1))), ". . . . . . . ."], { pan: 0.5, volume: 0.6 }), + music.track(s1Drums, ["k h h h s h kh h", "k h s h s s s s", ...Array.from({ length: 15 }, () => "k h h h s h kh h"), "k h s h s s s s", "k . . . . . . ."]), +); +const A = music.render(before); + +const PARK = reserveLocation("korobeiniki: the camera"); + +program(async () => { + removeUnit(P1, units.AnyUnit); + removeUnit(P2, units.AnyUnit); + centerLocation(PARK, 64 * 32, 112 * 32); + centerView(PARK); + displayText(`\x04Korobeiniki: A (${Math.round(A.seconds)} s) then B (${Math.round(B.seconds)} s), ${Math.round(song.bpm)} beats a minute. Turn the game's music off. It repeats.`); + await sleep(frames(96)); + while (true) { + displayText("\x04A: step 1. Plain waves, the same notes as last time."); + playWav(A, 0); + await sleep(frames(Math.ceil(A.seconds / 0.042) + 72)); + displayText("\x04B: step 2. The arrangement: filters, supersaws, FM, plucks, the drum kit, echoes, chorus, reverb, the master."); + playWav(B, 0); + await sleep(frames(Math.ceil(B.seconds / 0.042) + 96)); + } +}); diff --git a/script.ts b/script.ts index 7351d6a..3e6338c 100644 --- a/script.ts +++ b/script.ts @@ -108,8 +108,8 @@ export function isScriptMember(name: string): boolean { return memberKey(name).startsWith(memberKey(SCRIPT_FOLDER)); } -/** A file a build wrote for a clip the script plays (`compiler/sound.ts`): `staredit\wav\trigscript-.wav`, or `.ogg`. */ -export const isBuiltSound = (name: string): boolean => /^staredit\\wav\\trigscript-[0-9a-f]{8}\.(wav|ogg)$/.test(memberKey(name)); +/** A file a build wrote for a clip the script plays (`compiler/sound.ts`): `staredit\wav\trigscript-.wav`, or `.ogg` (8 hex digits before 3.13, 16 since). */ +export const isBuiltSound = (name: string): boolean => /^staredit\\wav\\trigscript-(?:[0-9a-f]{8}|[0-9a-f]{16})\.(wav|ogg)$/.test(memberKey(name)); /** * The members with the clips of this build in place of the last one's. A clip already in the map keeps its bytes as diff --git a/stdlib/music.ts b/stdlib/music.ts index 6f0e63c..eba306e 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -31,7 +31,7 @@ * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing, * nothing to change while it plays. A program plays to its own player; a program for each player plays to each. */ -import { game, sleep, frames, playWav, tone, mixdown, type Clip } from "trigscript"; +import { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip } from "trigscript"; /** Seconds a frame lasts on Fastest, the speed a map is played at. */ const FRAME = 0.042; @@ -90,22 +90,144 @@ export function sampler(clip: Clip, root: string = "C4", options: { readonly rel }); } +/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */ +export interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number } + +/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */ +function shapePoints(shape: Required, held: number): [number, number][] { + const { attack, decay, sustain, release } = shape; + const h = Math.max(attack + decay, held); + return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]]; +} +const full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release }); + +/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */ +export interface SynthFilter { + readonly type?: "lowpass" | "highpass" | "bandpass"; + readonly cutoff: number; + readonly resonance?: number; + /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */ + readonly amount?: number; + /** The filter's own attack, decay, sustain and release, as a note's loudness has. */ + readonly envelope?: Shape; +} + +export interface SynthOptions extends Shape { + readonly wave?: "sine" | "square" | "triangle" | "saw"; + readonly volume?: number; + /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */ + readonly voices?: number; + readonly detune?: number; + readonly width?: number; + readonly filter?: SynthFilter; + /** Saturation after the filter, dB of push (`Clip.drive`). */ + readonly drive?: number; + /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */ + readonly glide?: number; + readonly glideTime?: number; +} + /** * An instrument made of tones: `wave` ("sine", "square", "triangle", "saw") shaped by an attack, a decay to the - * sustain level, and a release after the note's length. + * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter + * that opens and closes with each note with `filter`, saturated with `drive`. */ -export function synth(options: { readonly wave?: "sine" | "square" | "triangle" | "saw"; readonly volume?: number; readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number } = {}): Instrument { +export function synth(options: SynthOptions = {}): Instrument { const wave = options.wave ?? "square", volume = options.volume ?? 0.2; - const attack = options.attack ?? 0.005, decay = options.decay ?? 0.08, sustain = options.sustain ?? 0.7, release = options.release ?? 0.06; + const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 }); + const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 }); + const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0; + return Object.freeze({ + kind: "notes" as const, + note: remembering((name, seconds, rate) => { + const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release; + const r = rate === undefined ? {} : { rate }; + const pitch = options.glide ? [[0, 440 * 2 ** ((midi(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name; + let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r }); + if (f) { + const amount = f.amount ?? 0, base = f.cutoff; + const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]); + c = c.filter(f.type ?? "lowpass", cutoff, { resonance: f.resonance ?? 0.1 }); + } + if (options.drive) c = c.drive(options.drive); + return c.envelope(...shapePoints(amp, held)); + }), + }); +} + +/** + * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to + * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5). + */ +export function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument { + const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 }); + const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25; + return Object.freeze({ + kind: "notes" as const, + note: remembering((name, seconds, rate) => { + const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release; + return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held)); + }), + }); +} + +/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */ +export function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument { + const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3; return Object.freeze({ kind: "notes" as const, note: remembering((name, seconds, rate) => { - const held = Math.max(attack + decay, seconds - release); - return tone(name, held + release, { wave, volume, ...(rate === undefined ? {} : { rate }) }).envelope([0, 0], [attack, 1], [attack + decay, sustain], [held, sustain], [held + release, 0]); + const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4)); + return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05); }), }); } +/** + * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its + * own: `music.presets.supersaw({ volume: 0.1 })`. + */ +export const presets = { + /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */ + supersaw: (o: SynthOptions = {}) => synth({ wave: "saw", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }), + /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */ + pad: (o: SynthOptions = {}) => synth({ wave: "saw", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }), + /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */ + bass: (o: SynthOptions = {}) => synth({ wave: "saw", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }), + /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */ + acid: (o: SynthOptions = {}) => synth({ wave: "saw", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }), + /** A warm lead: two square waves a little apart, softened by a filter. */ + lead: (o: SynthOptions = {}) => synth({ wave: "square", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }), + /** The old consoles' lead: a bare square wave. */ + chip: (o: SynthOptions = {}) => synth({ wave: "square", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }), + /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */ + keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }), + /** A bell: FM at ratio 3.5, long and shimmering. */ + bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }), + /** A plucked string, a little bright. */ + pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }), +}; + +/** + * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim. At + * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1). + */ +export function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit { + const rate = options.rate ?? 44100, v = options.volume ?? 1; + const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: "square", volume: 0.16, rate })).reduce((a, b) => a.mix(b)); + const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0]) + .mix(noise(0.006, { volume: 0.35, rate }).filter("highpass", 3000)).drive(5); + const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: "triangle", volume: 0.6, rate }).envelope([0, 1], [0.15, 0]) + .mix(noise(0.28, { volume: 0.6, rate }).filter("highpass", 1400).filter("peak", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4); + const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at }); + const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate) + .filter("bandpass", 1300, { resonance: 0.35 }).gain(1.6); + const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter("highpass", 7200).filter("highshelf", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]); + const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter("bandpass", 2500)); + const rim = tone(1700, 0.025, { wave: "triangle", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter("highpass", 4000)).envelope([0, 1], [0.025, 0]); + return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v) }); +} + /** A kit: a clip a letter, played by a pattern's letters ("kh" plays k and h together). */ export function kit(hits: Readonly>): Kit { for (const k of Object.keys(hits)) if (k.length !== 1 || k === "." || k === "-" || k === "|") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`); @@ -120,27 +242,35 @@ export interface Track { readonly pan: number; /** How loud, 1 as the instrument plays. */ readonly volume: number; + /** What the track goes through: its whole part in `render`, each note on its own in `play`. */ + readonly fx?: (clip: Clip) => Clip; + /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */ + readonly reverb: number; } /** * A pattern for an instrument: steps a word each — a note or notes joined by +, "-" to hold, "." to rest, "|" for the * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the - * instrument plays. + * instrument plays; `fx`: what the track goes through — `(c) => c.delay(0.21).chorus()` — its whole part when the song + * is rendered (as a mixer's insert), each note on its own when it is played note by note; `reverb`: how much goes to + * the song's reverb when it is rendered, 0 … 1. */ -export function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number } = {}): Track { +export function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number; readonly fx?: (clip: Clip) => Clip; readonly reverb?: number } = {}): Track { const text = typeof pattern === "string" ? pattern : pattern.join(" "); - const pan = options.pan ?? 0, volume = options.volume ?? 1; + const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0; if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`); if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`); - return Object.freeze({ instrument, steps: Object.freeze(text.split(/\s+/).filter((w) => w !== "" && w !== "|")), pan, volume }); + if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A track's reverb is 0 to 1, not ${reverb}.`); + return Object.freeze({ instrument, steps: Object.freeze(text.split(/\s+/).filter((w) => w !== "" && w !== "|")), pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) }); } -/** A clip as a track plays it: its volume and its pan, made once a clip a track. */ +/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */ function placed(t: Track, made: Map, c: Clip): Clip { - if (t.pan === 0 && t.volume === 1) return c; + if (t.pan === 0 && t.volume === 1 && !t.fx) return c; let p = made.get(c); if (!p) { - p = t.volume === 1 ? c : c.gain(t.volume); + p = t.fx ? t.fx(c) : c; + if (t.volume !== 1) p = p.gain(t.volume); if (t.pan !== 0) p = p.pan(t.pan); made.set(c, p); } @@ -153,9 +283,15 @@ const scores = new WeakMap(); /** A song, when the script is built: the tables `play` reads, and what it takes. */ export interface Song { - /** Frames a step lasts. */ + /** + * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a + * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own + * mixer starts a sound in anyway; rendered, exactly on it. + */ readonly stepFrames: number; - /** The tempo it plays at: the nearest to the one asked for whose step is a whole number of frames. */ + /** Frames a bar lasts, a whole number. */ + readonly barFrames: number; + /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */ readonly bpm: number; /** Steps, the longest track's. */ readonly steps: number; @@ -167,20 +303,20 @@ export interface Song { /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */ readonly at: readonly number[]; readonly which: readonly number[]; - /** The most notes sounding at once. */ + /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */ readonly voices: number; } /** - * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Refused - * when more than 32 notes would sound at once. + * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Played + * note by note (`play`), a song is refused when more than 32 notes would sound at once; rendered, it has no limit. */ -export function song(options: { readonly bpm: number; readonly steps?: number }, ...tracks: readonly Track[]): Song { - const perBeat = options.steps ?? 4; - if (!(options.bpm > 0) || !(perBeat > 0)) throw new Error("song() takes a tempo, { bpm: 120 }, and steps a beat (4 by default)."); - const stepFrames = Math.max(1, Math.round(60 / options.bpm / perBeat / FRAME)); +export function song(options: { readonly bpm: number; readonly steps?: number; readonly beats?: number }, ...tracks: readonly Track[]): Song { + const perBeat = options.steps ?? 4, beats = options.beats ?? 4; + if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error("song() takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4)."); + const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME)); + const stepFrames = barFrames / (beats * perBeat); const stepSeconds = stepFrames * FRAME; - const notes: { at: number; clip: Clip }[] = []; const score: Score[] = []; let steps = 0; for (const t of tracks) { @@ -192,7 +328,6 @@ export function song(options: { readonly bpm: number; readonly steps?: number }, for (const letter of w) { const hit = t.instrument.hits[letter]; if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(", ")}).`); - notes.push({ at: s * stepFrames, clip: placed(t, made, hit) }); score.push({ at: s * stepFrames, track: t, made, hit }); } return; @@ -202,66 +337,89 @@ export function song(options: { readonly bpm: number; readonly steps?: number }, while (s + length < ws.length && ws[s + length] === "-") length++; for (const name of w.split("+")) { midi(name); - notes.push({ at: s * stepFrames, clip: placed(t, made, t.instrument.note(name, length * stepSeconds)) }); score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds }); } }); } - notes.sort((a, b) => a.at - b.at); - // How many sound at once: at each note's start, those started before it and not yet over. - let voices = 0; - notes.forEach((n, k) => { - let sounding = 0; - for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++; - voices = Math.max(voices, sounding); - if (sounding > VOICES) throw new Error(`At step ${n.at / stepFrames + 1}, ${sounding} notes would sound at once; the game plays ${VOICES}. Shorten notes or thin the chords.`); - }); - const clips: Clip[] = []; - const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; }); - const length = Math.max(steps * stepFrames, ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME))); + score.sort((a, b) => a.at - b.at); + // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game. + let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null; + const tables = () => { + if (table) return table; + const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!)) })); + // How many sound at once: at each note's start, those started before it and not yet over. + let voices = 0, peakStep = 0; + notes.forEach((n, k) => { + let sounding = 0; + for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++; + if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; } + }); + const clips: Clip[] = []; + const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; }); + const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME))); + table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames }; + return table; + }; + // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit). + const playable = () => { + const t = tables(); + if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`); + return t; + }; const made: Song = Object.freeze({ - stepFrames, bpm: 60 / (stepFrames * FRAME * perBeat), steps, frames: length, seconds: length * FRAME, - clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, + stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, + get frames() { return tables().frames; }, + get seconds() { return tables().frames * FRAME; }, + get clips() { return playable().clips; }, + get at() { return playable().at; }, + get which() { return playable().which; }, + get voices() { return tables().voices; }, }); scores.set(made, score); return made; } +/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */ +export interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number } + /** - * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. The notes are - * made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a note played in the game note by note - * is made at 22 050, to keep the map small), and the mix goes through a limiter (`Clip.limit`) so that it never goes - * over −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a - * program plays goes in the map as Ogg Vorbis, two channels when a track is panned. + * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a + * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a + * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through its `fx`, + * at its volume, to its side; what each track sends (its `reverb`) into one reverb shared by them all (`reverb`, its + * room); the whole through `master` — `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter + * (`Clip.limit`) so it never goes over −1.5 dB however the parts add: `limit: false` leaves it as mixed, + * `{ ceiling, release }` sets it. A long clip a program plays goes in the map as Ogg Vorbis, two channels when it has two. */ -export function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number } } = {}): Clip { - const rate = options.rate ?? 44100, score = scores.get(s); - // (A song from elsewhere, without its score: its own clips, at their rate.) - const parts = score - ? score.map((n) => ({ clip: n.hit ? placed(n.track, n.made, n.hit) : placedAt(n, rate), at: n.at * FRAME })) - : s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })); - const mixed = mixdown(parts, s.seconds, rate); +export function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip { + const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME; + let mixed: Clip; + if (!score) { + // (A song from elsewhere, without its score: its own clips, at their rate.) + mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate); + } else { + const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = []; + const byTrack = new Map(); + for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); } + for (const [t, notes] of byTrack) { + let part = mixdown(notes.map((n) => ({ clip: n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate), at: n.at * FRAME })), least, rate); + if (t.fx) part = t.fx(part); + if (t.volume !== 1) part = part.gain(t.volume); + if (t.pan !== 0) part = part.pan(t.pan); + parts.push({ clip: part, at: 0 }); + if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 }); + } + if (sends.length) { + const r = options.reverb ?? {}; + parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 }); + } + mixed = mixdown(parts, least, rate); + } + if (options.master) mixed = options.master(mixed); const limit = options.limit ?? true; return limit === false ? mixed : mixed.limit(limit === true ? {} : limit); } -/** A note of a score made at `rate`, as its track plays it: once a note, rate and track. */ -const atRate = new WeakMap, Map>(); -function placedAt(n: Score, rate: number): Clip { - let byNote = atRate.get(n.made); - if (!byNote) { byNote = new Map(); atRate.set(n.made, byNote); } - const key = `${n.name} ${n.seconds!.toFixed(4)} ${rate}`; - let c = byNote.get(key); - if (!c) { - const note = (n.track.instrument as Instrument).note(n.name!, n.seconds!, rate); - c = note; - if (n.track.volume !== 1) c = c.gain(n.track.volume); - if (n.track.pan !== 0) c = c.pan(n.track.pan); - byNote.set(key, c); - } - return c; -} - /** * Play a song, note by note, inside an async program: \`await music.play(song)\` resolves at its end, or * \`start(music.play(song))\` plays it beside the program. @@ -291,4 +449,4 @@ export const loop = game(async (s: Song) => { }); /** Everything above as one name: import { music } from "trigscript/music"; music.play(song). */ -export const music = { sampler, synth, kit, track, song, render, play, loop }; +export const music = { sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop }; diff --git a/tests/music.test.ts b/tests/music.test.ts index e219934..6a96782 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -55,9 +55,11 @@ describe("a song, when the script is built", () => { expect(errors(bad).join("\n")).toMatch(/Step 2: the kit has no "s"/); }); - it("refuses a song that would sound more than the game's 32 at once", () => { + it("refuses to play note by note a song that would sound more than the game's 32 at once, and renders it", () => { const chord = Array.from({ length: 33 }, (_, k) => ["C", "D", "E", "F", "G", "A", "B"][k % 7] + (2 + Math.floor(k / 7))).join("+"); - const r = check(`const lead = music.synth();\nconst s = music.song({ bpm: 120 }, music.track(lead, "${chord} -"));`, ""); + const setup = `const lead = music.synth();\nconst s = music.song({ bpm: 120 }, music.track(lead, "${chord} -"));`; + expect(errors(check(setup, `if (s.voices !== 33) fail(String(s.voices));\nconst one = music.render(s);`))).toEqual([]); + const r = check(setup, `program(async () => { await music.play(s); });`); expect(errors(r).join("\n")).toMatch(/33 notes would sound at once; the game plays 32/); }); @@ -120,6 +122,54 @@ const s = music.song({ bpm: 120 }, music.track(lead, "A4 A4 A4 B4", { pan: 1 }), }); }); +describe("instruments, effects and the mix", () => { + it("makes every preset's notes, and the drum kit's seven drums at 44.1 kHz", () => { + const r = check( + `const ps = [music.presets.supersaw(), music.presets.pad(), music.presets.bass(), music.presets.acid(), music.presets.lead(), music.presets.chip(), music.presets.keys(), music.presets.bell(), music.presets.pluck()]; +const kit = music.drums();`, + `for (const p of ps) { const n = p.note("A3", 0.3); if (!(n.peak > 0.01 && n.peak < 1)) fail(\`peak \${n.peak}\`); } +for (const k of ["k", "s", "c", "h", "o", "t", "r"]) { const d = kit.hits[k]; if (!d || d.rate !== 44100 || !(d.peak > 0.05)) fail(k); } +if (music.presets.supersaw().note("A3", 0.3).channels !== 2) fail("the supersaw is wide"); +if (music.presets.supersaw({ width: 0 }).note("A3", 0.3).channels !== 1) fail("its options over the preset's");`, + ); + expect(errors(r)).toEqual([]); + }); + + it("opens a synth's filter with each note: the top of a note brighter at its start than its end", () => { + const r = check( + `const s = music.synth({ wave: "saw", volume: 0.5, filter: { cutoff: 300, amount: 4, envelope: { decay: 0.2, sustain: 0 } } }); +const n = s.note("A2", 1, 44100);`, + `const early = n.trim(0.02, 0.07).fade(0.01, 0.01).filter("highpass", 2000).peak, late = n.trim(0.8, 0.85).fade(0.01, 0.01).filter("highpass", 2000).peak; +if (!(early > 4 * late)) fail(\`\${early} \${late}\`);`, + ); + expect(errors(r)).toEqual([]); + }); + + it("puts a track through its fx and the reverb its send asks for, and the whole through the master", () => { + const r = check( + `const lead = music.synth({ volume: 0.3 }); +const dry = music.song({ bpm: 120 }, music.track(lead, "A4 . . . . . . .")); +const wet = music.song({ bpm: 120 }, music.track(lead, "A4 . . . . . . .", { reverb: 0.8 })); +const echoed = music.song({ bpm: 120 }, music.track(lead, "A4 . . . . . . .", { fx: (c) => c.delay(0.25, { mix: 1 }) }));`, + `const a = music.render(dry), b = music.render(wet), c = music.render(echoed), quiet = music.render(dry, { master: (m) => m.gain(0.5) }); +if (!(b.seconds > a.seconds + 0.5)) fail(\`reverb tail \${a.seconds} \${b.seconds}\`); +if (!(c.seconds > a.seconds)) fail("echo"); +if (Math.abs(quiet.peak - a.peak / 2) > 0.01) fail(\`master \${quiet.peak} \${a.peak}\`);`, + ); + expect(errors(r)).toEqual([]); + }); + + it("keeps a bar a whole number of frames, a step need not be: sixteenths at 143 beats a minute", () => { + const r = check( + `const hat = music.kit({ h: noise(0.02) }); +const s = music.song({ bpm: 143, steps: 4 }, music.track(hat, "h h h h h h h h h h h h h h h h"));`, + `if (s.barFrames !== 40 || s.stepFrames !== 2.5 || Math.abs(s.bpm - 142.86) > 0.01) fail(\`\${s.barFrames} \${s.stepFrames} \${s.bpm}\`); +if (s.at.join(" ") !== "0 3 5 8 10 13 15 18 20 23 25 28 30 33 35 38") fail(s.at.join(" "));`, + ); + expect(errors(r)).toEqual([]); + }); +}); + describe("what the library is made of", () => { it("decides if (clip) when the script is built: a rest (null) plays nothing, a clip plays", () => { const r = compile(`const NOTES = [tone("A4", 0.1), null, tone("B4", 0.1)];\nprogram(async () => { for (const n of NOTES) { if (n) playWav(n, 0); await nextFrame(); } });\n`); diff --git a/tests/sound.test.ts b/tests/sound.test.ts index da54c23..ce48a54 100644 --- a/tests/sound.test.ts +++ b/tests/sound.test.ts @@ -9,7 +9,7 @@ import { defaultScriptNames } from "../compiler/names"; import { defaultLib } from "../bundle/lib.mjs"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { adpcmFidelity, adpcmWav, clipChannels, clipFile, periodicity, clipSamples, decodeAdpcm, describeSound, encodeAdpcm, hertz, LIMIT_CEILING, makeClip, mixdown, noise, readSound, silence, tone, writeSound } from "../compiler/sound"; +import { adpcmFidelity, adpcmWav, clipChannels, clipFile, fm, periodicity, pluck, clipSamples, decodeAdpcm, describeSound, encodeAdpcm, hertz, LIMIT_CEILING, makeClip, mixdown, noise, readSound, silence, tone, writeSound } from "../compiler/sound"; import { finishSounds, vorbisEncoder } from "../compiler/vorbis"; import { describeMedia, isMediaFile } from "../compiler/media"; import { buildScript, isBuiltSound, readFiles, withFiles, withSounds } from "../script"; @@ -135,7 +135,7 @@ describe("a Clip when the script is built", () => { it("gives the map one WAV a clip, named by what it holds", () => { const a = clipFile(tone("A4", 0.2)), b = clipFile(tone("A4", 0.2)), c = clipFile(tone("B4", 0.2)); - expect(a.path).toMatch(/^staredit\\wav\\trigscript-[0-9a-f]{8}\.wav$/); + expect(a.path).toMatch(/^staredit\\wav\\trigscript-[0-9a-f]{16}\.wav$/); expect(a.path).toBe(b.path); expect(c.path).not.toBe(a.path); }); @@ -205,9 +205,9 @@ describe("what the map gets for a clip", () => { const wasm = readFileSync(resolve(import.meta.dirname, "..", "node_modules", "wasm-media-encoders", "wasm", "ogg.wasm")); const encoder = vorbisEncoder(wasm); - it("a long clip as Ogg Vorbis, named by its samples before it is encoded; a short one or wav() as a WAV", () => { + it("a long clip as Ogg Vorbis, named by how it was made before it is encoded; a short one or wav() as a WAV", () => { const long = tone("A4", 2.5), f = clipFile(long); - expect(f.path).toMatch(/^staredit\\wav\\trigscript-[0-9a-f]{8}\.ogg$/); + expect(f.path).toMatch(/^staredit\\wav\\trigscript-[0-9a-f]{16}\.ogg$/); expect(f.bytes).toBeUndefined(); expect(f.vorbis?.rate).toBe(22050); expect(clipFile(tone("A4", 2.5)).path).toBe(f.path); @@ -235,6 +235,14 @@ describe("what the map gets for a clip", () => { expect(Number(new DataView(a.buffer, a.byteOffset).getBigUint64(last + 6, true))).toBe(Math.round(song.seconds * song.rate)); }); + it("makes no sample in a check: five minutes through a reverb is named at once, and its length known", () => { + const files = { "main.ts": `const long = noise(300, { rate: 44100 }).filter("lowpass", 2000).reverb({ decay: 0.9 }).limit();\nprogram(async () => { playWav(long, 0); await sleep(frames(Math.ceil(long.seconds / 0.042))); });\n` }; + const t = performance.now(), r = compileScript(ts, files, defaultScriptNames(), { lib: LIB, sounds: false }); + expect(r.diagnostics).toEqual([]); + expect(r.sounds).toEqual([{ path: expect.stringMatching(/\.ogg$/) }]); + expect(performance.now() - t).toBeLessThan(3000); + }); + it("is encoded by a build, and only named by a check; a trigger's long clip is a WAV", () => { const files = { "main.ts": `const long = tone("A4", 3).pan(0.4); trigger(P1, [always()], [playWav(tone("C5", 3), 0)]); @@ -245,12 +253,11 @@ program(async () => { playWav(long, 0); }); const ogg = built.sounds.find((s) => s.path.endsWith(".ogg"))!, wav = built.sounds.find((s) => s.path.endsWith(".wav"))!; // The trigger's: a WAV, ADPCM as a sine allows. expect([ogg.vorbis?.channels.length, new DataView(wav.bytes!.buffer).getUint16(20, true)]).toEqual([2, 0x11]); - const check = compileScript(ts, files, defaultScriptNames(), { lib: LIB }); - finishSounds(check.sounds, null); - expect(check.sounds.find((s) => s.path === ogg.path)).toEqual({ path: ogg.path }); + const check = compileScript(ts, files, defaultScriptNames(), { lib: LIB, sounds: false }); + expect(check.sounds).toEqual([{ path: ogg.path }, { path: wav.path }].sort((a, b) => (a.path === built.sounds[0].path ? -1 : b.path === built.sounds[0].path ? 1 : 0))); // A check's unencoded clip is refused unless the map has it already. expect(() => withSounds(new Map(), check.sounds)).toThrow(/was not encoded/); - expect(withSounds(new Map([[ogg.path, new Uint8Array([1])]]), check.sounds).get(ogg.path)).toEqual(new Uint8Array([1])); + expect(withSounds(new Map([[ogg.path, new Uint8Array([1])], [wav.path, new Uint8Array([2])]]), check.sounds).get(ogg.path)).toEqual(new Uint8Array([1])); finishSounds(built.sounds, encoder); expect(built.sounds.every((s) => s.bytes && !s.vorbis)).toBe(true); expect(String.fromCharCode(...ogg.bytes!.slice(0, 4))).toBe("OggS"); @@ -302,3 +309,91 @@ program(async () => { playWav(boom, 0); await nextFrame(); playWav(both, 0); pla expect(errors(compile(`import boom from "./sfx/boom.sound";\nboom.pitch("up");\n`)).join("\n")).toMatch(/main\.ts:2: .*string/); }); }); + +describe("what a clip goes through, and the sources beside tone", () => { + const R = 44100; + const amp = (s: Float32Array, hz: number, rate = R) => { let re = 0, im = 0; for (let i = 0; i < s.length; i++) { re += s[i] * Math.cos((2 * Math.PI * hz * i) / rate); im += s[i] * Math.sin((2 * Math.PI * hz * i) / rate); } return (2 * Math.hypot(re, im)) / s.length; }; + const db = (x: number) => 20 * Math.log10(x); + const sine = (hz: number) => tone(hz, 0.5, { rate: R, volume: 1 }); + + it("filters: a lowpass 3 dB down at its cutoff, resonance ringing there, a shelf by its gain", () => { + expect(db(amp(clipSamples(sine(250).filter("lowpass", 1000)), 250))).toBeGreaterThan(-1); + expect(db(amp(clipSamples(sine(1000).filter("lowpass", 1000, { resonance: 0.3 })), 1000))).toBeCloseTo(-3, 0); + expect(db(amp(clipSamples(sine(4000).filter("lowpass", 1000)), 4000))).toBeLessThan(-20); + expect(db(amp(clipSamples(sine(250).filter("highpass", 1000)), 250))).toBeLessThan(-20); + expect(db(amp(clipSamples(sine(1000).filter("lowpass", 1000, { resonance: 0.9 })), 1000))).toBeGreaterThan(10); + expect(db(amp(clipSamples(sine(10000).filter("highshelf", 3000, { gain: 6 })), 10000))).toBeCloseTo(6, 0); + // A sweep: closed, open, closed — the top of a saw louder in the middle than at the ends. + const swept = clipSamples(tone(110, 1, { wave: "saw", rate: R }).filter("lowpass", [[0, 200], [0.5, 8000], [1, 200]])); + const top = (from: number) => { let e = 0; for (let i = from; i < from + 2205; i++) e += (swept[i] - swept[i - 1]) ** 2; return e; }; + expect(top(R / 2 - 1100)).toBeGreaterThan(20 * top(2205)); + expect(() => sine(440).filter("lowpass", -5)).toThrow(/more than 0/); + expect(() => sine(440).filter("wobble" as never, 100)).toThrow(/"lowpass"/); + }); + + it("drives: quiet as it was, loud rounded off, at two channels' worth of harmonics", () => { + const quiet = tone(220, 0.3, { rate: R, volume: 0.01 }), loud = tone(220, 0.3, { rate: R, volume: 1 }); + expect(quiet.drive(18).peak).toBeCloseTo(quiet.peak, 3); + const d = clipSamples(loud.drive(18)); + expect(amp(d, 660) / amp(d, 220)).toBeGreaterThan(0.1); + expect(loud.drive(0)).toBe(loud); + }); + + it("echoes: each repeat later and quieter, the clip longer by them; ping-pong left then right", () => { + const click = makeClip(Float32Array.from({ length: 4410 }, (_, i) => (i < 10 ? 1 : 0)), R); + const e = click.delay(0.25, { feedback: 0.5, mix: 1, tone: 20000 }), s = clipSamples(e); + expect(e.seconds).toBeGreaterThan(2); + const at = (t: number) => Math.max(...s.slice(Math.round(t * R) - 50, Math.round(t * R) + 60).map(Math.abs)); + expect(at(0.25)).toBeGreaterThan(0.5); + expect(at(0.5) / at(0.25)).toBeCloseTo(0.5, 1); + const [l, r] = clipChannels(click.delay(0.25, { pingPong: true, mix: 1, feedback: 0.5 })); + const near = (c: Float32Array, t: number) => Math.max(...c.slice(Math.round(t * R) - 50, Math.round(t * R) + 60).map(Math.abs)); + expect(near(l, 0.25)).toBeGreaterThan(5 * near(r, 0.25)); + expect(near(r, 0.5)).toBeGreaterThan(5 * near(l, 0.5)); + }); + + it("reverbs: two different sides, a tail as long as its decay, quiet at the end, the same every build", () => { + const click = makeClip(Float32Array.from({ length: 4410 }, (_, i) => (i === 0 ? 1 : 0)), R); + const short = click.reverb({ decay: 0.3, mix: 1, dry: 0 }), long = click.reverb({ decay: 0.9, mix: 1, dry: 0 }); + expect(long.seconds).toBeGreaterThan(3 * short.seconds); + const [l, r] = clipChannels(long); + let lr = 0, ll = 0, rr = 0; + for (let i = 0; i < l.length; i++) { lr += l[i] * r[i]; ll += l[i] ** 2; rr += r[i] ** 2; } + expect(Math.abs(lr / Math.sqrt(ll * rr))).toBeLessThan(0.2); + expect(Math.max(...l.slice(-2205).map(Math.abs))).toBeLessThan(1e-3); + expect(Array.from(clipSamples(click.reverb()))).toEqual(Array.from(clipSamples(makeClip(clipSamples(click).slice(), R).reverb()))); + }); + + it("chorus and compress: a chorus two-sided; a compressor turning loud parts down by its ratio", () => { + expect(tone(220, 0.3, { rate: R }).chorus().channels).toBe(2); + // A sine at 0 dB through −20 dB, 4:1 settles 15 dB down. + const c = clipSamples(tone(220, 1, { rate: R, volume: 1 }).compress({ threshold: -20, ratio: 4, knee: 0, attack: 0.001, release: 0.05 })); + expect(db(Math.max(...c.slice(-4410)))).toBeCloseTo(-15, 0); + }); + + it("glides, stacks detuned voices across both sides, FM and plucked strings in tune", () => { + const drop = clipSamples(tone([[0, 200], [0.5, 50]], 0.5, { rate: R })); + const crossings = (s: Float32Array) => { let n = 0; for (let i = 1; i < s.length; i++) if (s[i - 1] < 0 !== s[i] < 0) n++; return n; }; + expect(crossings(drop.slice(0, 2205))).toBeGreaterThan(2 * crossings(drop.slice(-2205))); + const wide = tone("A3", 0.5, { wave: "saw", voices: 7, detune: 30, width: 0.8, rate: R }); + expect(wide.channels).toBe(2); + expect(tone("A3", 0.5, { wave: "saw", voices: 7 }).channels).toBe(1); + expect(() => tone("A3", 0.5, { voices: 40 })).toThrow(/1 to 16/); + const bell = clipSamples(fm(440, 0.5, { ratio: 3.5, index: 4, rate: R, volume: 1 })); + expect(amp(bell, 440 + 3.5 * 440)).toBeGreaterThan(0.05); + for (const hz of [110, 440]) { + const s = clipSamples(pluck(hz, 1, { rate: R })), from = 4410, n = 8192, cc = (L: number) => { let c = 0; for (let i = from; i < from + n; i++) c += s[i] * s[i + L]; return c; }; + let lag = 0, best = -Infinity; + for (let L = Math.floor((R / hz) * 0.9); L < (R / hz) * 1.1; L++) { const c = cc(L); if (c > best) { best = c; lag = L; } } + const a = cc(lag - 1), b = cc(lag), c2 = cc(lag + 1), period = lag + (0.5 * (a - c2)) / (a - 2 * b + c2); + expect(Math.abs(1200 * Math.log2(R / period / hz)), `${hz}`).toBeLessThan(2); + } + }); + + it("keeps the costly work by how a clip was made: the same reverb twice is one clip", () => { + const a = tone("A4", 0.5).gain(0.5).reverb(), b = tone("A4", 0.5).gain(0.5).reverb(); + expect(a).toBe(b); + expect(tone("A4", 0.5).gain(0.6).reverb()).not.toBe(a); + expect(tone("A4", 0.5).wav().reverb()).not.toBe(a); + }); +}); From 5c83ab165ebbff93ef8ec3b7dd52798566ab46f4 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 16:12:35 -0600 Subject: [PATCH 05/14] Plan: step 2 as played Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- docs/v4-plan.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 93f79d8..bbbfc0c 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1719,6 +1719,12 @@ User after step 1's A/B: "B sounds way better, go for step 2". game clean. Map 1.8 MB. - **Found on the way:** the key hash's second half could go negative (`b ^ (b >>> 15)` is signed), putting a minus in a file's name that `isBuiltSound` would not know — fixed, and every name of 200 checked. +- **Played 2026-10-19** (recording 16.08.43.37): A at 4.894 s, B at 39.859 s — 34.965 s apart. B as heard against the + file: the same level within 0.2 dB, second by second; no drift over 63 s (the best lag 0 samples in every 10 s); + 27 dB below 6 kHz and 8.5 dB above it (hats and air, 14% of the energy: the recording's AAC, A's sparser top came back + at 31.9 dB as before), the worst second 14 dB — no dropout; L − R follows the file's at 0.990; peaks 0.94 — no + clipping; the tail down to the recording's floor (−95 dB). User: "b sounds pretty sick yeah, maybe too strong/loud on + the beat/drums but thats stylistic complaints only" — the kit goes down in the showpiece's mix (step 5), not here. ### Closures and the missing methods (built 2026-10-17) From c9d7e5bb8bad7e21e9a68d5f90d68b1c227153d1 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 16:32:20 -0600 Subject: [PATCH 06/14] Music: songs of sections, chords by name, accents, swing, risers A song is written as sections that fill named parts (a pattern again until the section is full), with section effects and volumes; chords by name with templates that draw their notes by number, voice leading; transpose, stretch, repeat, beat; @ for how loud a step is; swing; riser, impact, the kit's crash and pump. Step 2's arrangement written again as sections gives the same file to the byte. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/generated/stdlib.ts | 2 +- docs/v4-plan.md | 31 +++ probes/korobeiniki-form.ts | 186 +++++++++++++++ stdlib/music.ts | 436 +++++++++++++++++++++++++++++++---- tests/music.test.ts | 118 +++++++++- 5 files changed, 728 insertions(+), 45 deletions(-) create mode 100644 probes/korobeiniki-form.ts diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index ec2e89d..04fc9bc 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye. A kit's step is its sounds' letters (\"kh\": kick and hat). Everything is worked out when the script is built: each\n * note an instrument plays becomes a clip once (`trigscript`'s Clip, put in the map once however often it plays), and\n * the song a table of which clip starts on which frame, which `play` reads while the game runs.\n *\n * What the game does with it (the sound probe, 2026-10-19): a step is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((midi(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the\n * instrument plays; `fx`: what the track goes through — `(c) => c.delay(0.21).chorus()` — its whole part when the song\n * is rendered (as a mixer's insert), each note on its own when it is played note by note; `reverb`: how much goes to\n * the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number; readonly fx?: (clip: Clip) => Clip; readonly reverb?: number } = {}): Track {\n const text = typeof pattern === \"string\" ? pattern : pattern.join(\" \");\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A track's reverb is 0 to 1, not ${reverb}.`);\n return Object.freeze({ instrument, steps: Object.freeze(text.split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\")), pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) });\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Played\n * note by note (`play`), a song is refused when more than 32 notes would sound at once; rendered, it has no limit.\n */\nexport function song(options: { readonly bpm: number; readonly steps?: number; readonly beats?: number }, ...tracks: readonly Track[]): Song {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"song() takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n const stepFrames = barFrames / (beats * perBeat);\n const stepSeconds = stepFrames * FRAME;\n const score: Score[] = [];\n let steps = 0;\n for (const t of tracks) {\n const ws = t.steps, made = new Map();\n steps = Math.max(steps, ws.length);\n ws.forEach((w, s) => {\n if (w === \"-\" || w === \".\") return;\n if (t.instrument.kind === \"kit\") {\n for (const letter of w) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: s * stepFrames, track: t, made, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n for (const name of w.split(\"+\")) {\n midi(name);\n score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps,\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through its `fx`,\n * at its volume, to its side; what each track sends (its `reverb`) into one reverb shared by them all (`reverb`, its\n * room); the whole through `master` — `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter\n * (`Clip.limit`) so it never goes over −1.5 dB however the parts add: `limit: false` leaves it as mixed,\n * `{ ceiling, release }` sets it. A long clip a program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n let part = mixdown(notes.map((n) => ({ clip: n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate), at: n.at * FRAME })), least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = { sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop };\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((midi(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(midi(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = midi(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = midi(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(midi).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n return compose(options, tracks, placedSections);\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n midi(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/docs/v4-plan.md b/docs/v4-plan.md index bbbfc0c..3ab4c55 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1726,6 +1726,37 @@ User after step 1's A/B: "B sounds way better, go for step 2". clipping; the tail down to the recording's floor (−95 dB). User: "b sounds pretty sick yeah, maybe too strong/loud on the beat/drums but thats stylistic complaints only" — the kit goes down in the showpiece's mix (step 5), not here. +### Sounds: step 3, writing a song (built 2026-10-19) + +User after step 2: "go for step 3". Nothing here makes a new sound beyond three clips; it is all for writing songs. + +- **Patterns.** "@" after a step's notes or letters says how loud ("C4+E4@0.6", "kh@1.3"; 0 to 4). `transpose`, + `stretch` (eighths written as sixteenths), `repeat`, `beat` (a kit's bar from the steps each letter plays on). +- **Chords.** `chord("E", 3, 1)` → "G#3+B3+E4": 23 kinds ("m", "7", "maj7", "m7b5", "sus4", "add9", …), inversions, + "C/E" over another bass. `over(chord, template)` draws a chord's notes by number (1 the lowest, past the last round + again an octave up, ^ and _ an octave either way): an arpeggio, a bass line, a comp. `chords(progression, template, + { octave, lead })` a template a chord; `lead` turns each chord to the inversion whose notes move least from the one + before — "Am E Dm C" comes out as step 2's hand-written voicings. +- **Sections.** `part(instrument, how heard)`, `section(bars, { part: pattern }, { name, fx, volume })`, + `arrange(options, parts, ...sections)`. A pattern shorter than its section plays again until it is full (it must + divide it); a part a section does not name rests; `section.with({ lead: null, saw: tune })` is the section changed. + `song.sections` says where each is (bar, frame) — for step 4's stems and the visualizer. A section's `fx` take its + length (`{ seconds, bars, bar }`) for their points; its notes are mixed apart from the section's start, tails and + all, and through them, before the part's own fx — so `render` only: `play` refuses a song with them, in words. A + section's `volume` is a velocity, so it plays either way. +- **Swing**: every second step `swing` of a step late; a note runs to its swung end step. +- **Between sections**: `riser(seconds)` (noise through a resonant band opening, a detuned saw rising), `impact()`, + the kit's crash (`x`), `pump(beat)` (a part ducking on each beat, as under a kick), `bars(bpm, n)` — seconds of n + bars as a song plays them, for a riser's length. +- **Proof.** Step 2's arrangement written again with `arrange` (`probes/korobeiniki-form.ts`, A) gives the same Ogg to + the byte (`trigscript-120f8d218410588b.ogg`): a song of sections is a song of tracks, the same recipes. +- **B** (73 s, 40 bars): intro (filters opening) / verse / build (riser, snare roll in crescendo, half a bar of + silence) / drop (four on the floor, impact, pumping) / break / the last verse a tone up / outro (the pad closing). + The kit at 0.6 for the user's "maybe too strong/loud on the beat/drums": before the master the drums are 2 LU under + everything else together (in A they matched it). Sections before the master −21 / −13.7 / −13.3 / −12.5 / −15.3 / + −12.4 / −15.8 LUFS; through a gentler master (threshold −12) −18.5 / −12.9 / −11.4 / −11.5 / −13.3 / −11.4 / −15.7, + −12.1 in all, peak −0.57 dB. The check 0.8 s. Lifted game clean. Map 2.7 MB. + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/probes/korobeiniki-form.ts b/probes/korobeiniki-form.ts new file mode 100644 index 0000000..3ae243c --- /dev/null +++ b/probes/korobeiniki-form.ts @@ -0,0 +1,186 @@ +// Step 3 of the music library's quality work, heard in StarCraft: Remastered: Korobeiniki (a Russian folk song, in +// the public domain; the melody only — the bass, the chords, the arpeggios, the counter-line, the drums and the form +// are this file's own) written as a song of sections. A is step 2's arrangement written again with `music.arrange`: +// the same notes, so the same file as last time to the byte. B is a longer arrangement that uses what step 3 adds — +// chord names and templates, voice leading, a build with a riser and a snare roll, a drop, impacts and crashes, the +// pad and the bass pumping with the beat, filters opening over a section, hats with accents, a little swing, the last +// verse a tone higher — and the drums quieter than step 2's. Host it as a multiplayer game, alone, with the game's +// music off. It repeats. +// +// npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx +// npx tsx scripts/build-fixture.mts probes/korobeiniki-form.ts fixtures/eud/korobeiniki-form.scx fixtures/eud/sound-base.scx +import { music } from "trigscript/music"; + +/* ── The notes ── */ + +// The melody in eighths, a bar a string (the folk song's two phrases). +const MELODY = [ + "E5 - B4 C5 D5 - C5 B4", "A4 - A4 C5 E5 - D5 C5", "B4 - - C5 D5 - E5 -", "C5 - A4 - A4 - . .", + "D5 - - F5 A5 - G5 F5", "E5 - - C5 E5 - D5 C5", "B4 - B4 C5 D5 - E5 -", "C5 - A4 - A4 - . .", +]; +const tune = music.stretch(MELODY, 2) as readonly string[]; +const BEAT = 60 / 143; +const echo = (c: Clip) => c.delay(BEAT * 0.75, { feedback: 0.3, mix: 0.22, pingPong: true, tone: 4000 }); +/** `x`, `n` times over. */ +const times = (n: number, x: T): T[] => Array.from({ length: n }, () => x); +const REST = times(16, ".").join(" "); +const rest = (n = 1) => times(n, REST); +/** A bar of one held chord. */ +const held = (notes: string) => [notes, ...times(15, "-")].join(" "); + +/* ── A: step 2's arrangement, as sections ── */ + +type Chord = readonly [root: string, fifth: string, notes: string]; +const CHORDS: readonly Chord[] = [ + ["A1", "E2", "A3+C4+E4"], ["A1", "E2", "A3+C4+E4"], ["E1", "B1", "G#3+B3+E4"], ["A1", "E2", "A3+C4+E4"], + ["D2", "A1", "A3+D4+F4"], ["C2", "G1", "G3+C4+E4"], ["E1", "B1", "G#3+B3+E4"], ["A1", "E2", "A3+C4+E4"], +]; +const AM: Chord = CHORDS[0]; +const up = (note: string, by: number) => note.replace(/(-?\d)$/, (o) => String(Number(o) + by)); +const chordUp = (notes: string, by: number) => notes.split("+").map((n) => up(n, by)).join("+"); +const bassBar = ([r, f]: Chord) => [r, "-", ".", r, ".", r, r, "-", f, "-", ".", f, ".", up(r, 1), r, "-"].join(" "); +const acidBar = ([r, f]: Chord) => { + const R = up(r, 1), F = up(f, 1), O = up(r, 2); + return [R, ".", O, ".", F, R, ".", O, R, ".", F, ".", O, ".", R, F].join(" "); +}; +const arpBar = ([, , notes]: Chord) => { + const [a, b, c] = notes.split("+").map((n) => up(n, 1)); + return [a, b, c, up(a, 1), c, b, a, b, c, up(a, 1), up(b, 1), up(a, 1), c, b, c, b].join(" "); +}; +const EIGHTHS = [0, 2, 4, 6, 8, 10, 12, 14], ALL = Array.from({ length: 16 }, (_, i) => i); +const GROOVE = music.beat({ k: [0, 7, 8, 10], s: [4, 12], h: EIGHTHS }); +const DRIVE = music.beat({ k: [0, 7, 8, 10], s: [4, 12], c: [4, 12], h: ALL.filter((i) => i % 4 !== 2), o: [2, 6, 10, 14] }); +const FILL = music.beat({ k: [0, 8], s: [4, 10, 11, 12, 13, 14, 15], t: [8, 9], h: [0, 2, 6] }); +const BUILD = [music.beat({ h: EIGHTHS }), music.beat({ k: [0, 8], h: EIGHTHS, s: [12, 14, 15] })]; +const pads = (list: readonly Chord[]) => list.map(([, , n]) => held(n)); +const octaveUp = (bars: readonly string[]) => music.transpose(bars, 12) as readonly string[]; + +const step2 = music.arrange({ bpm: 143, steps: 4 }, { + lead: music.part(music.presets.lead({ volume: 0.15 }), { pan: -0.1, fx: echo, reverb: 0.25 }), + saw: music.part(music.presets.supersaw({ volume: 0.24 }), { fx: (c) => echo(c).chorus({ mix: 0.25 }), reverb: 0.3 }), + bell: music.part(music.presets.bell({ volume: 0.035 }), { pan: 0.3, reverb: 0.55 }), + pad: music.part(music.presets.pad({ volume: 0.07 }), { fx: (c) => c.filter("highpass", 180).chorus({ mix: 0.35 }), reverb: 0.5 }), + strum: music.part(music.presets.pluck({ volume: 0.15, decay: 0.9 }), { pan: 0.45, fx: (c) => c.delay(BEAT / 2, { feedback: 0.25, mix: 0.2, pingPong: true }), reverb: 0.35 }), + keys: music.part(music.presets.keys({ volume: 0.11 }), { pan: -0.2, fx: (c) => c.chorus({ mix: 0.3 }), reverb: 0.4 }), + acid: music.part(music.presets.acid({ volume: 0.14 }), { pan: -0.35, fx: echo, reverb: 0.15 }), + bass: music.part(music.presets.bass({ volume: 0.38 }), { fx: (c) => c.compress({ threshold: -20, ratio: 3 }) }), + kit: music.part(music.drums(), { fx: (c) => c.compress({ threshold: -16, ratio: 3, attack: 0.003 }), reverb: 0.1 }), +}, + music.section(4, { pad: pads(CHORDS.slice(0, 4)), strum: CHORDS.slice(0, 4).map(arpBar), bass: [...rest(2), bassBar(AM), bassBar(AM)], kit: [...rest(2), ...BUILD] }, { name: "intro" }), + music.section(8, { lead: tune, pad: pads(CHORDS), bass: CHORDS.map(bassBar), kit: [...times(7, GROOVE), FILL] }, { name: "A1" }), + music.section(8, { saw: tune, bell: octaveUp(tune), pad: pads(CHORDS), strum: CHORDS.map(arpBar), bass: CHORDS.map(bassBar), kit: [...times(7, DRIVE), FILL] }, { name: "A2" }), + music.section(4, { + bell: octaveUp(tune.slice(0, 4)), pad: CHORDS.slice(0, 4).map(([, , n]) => held(chordUp(n, 1))), + keys: CHORDS.slice(0, 4).map(([, , n]) => [n, "-", "-", ".", ".", ".", n, "-", ".", ".", n, "-", "-", "-", ".", "."].join(" ")), + bass: CHORDS.slice(0, 4).map(([r]) => held(up(r, 1))), kit: [...rest(3), music.beat({ s: [8, 10, 12, 13, 14, 15], k: [0] })], + }, { name: "break" }), + music.section(8, { saw: tune, bell: octaveUp(tune), pad: pads(CHORDS), acid: CHORDS.map(acidBar), bass: CHORDS.map(bassBar), kit: [...times(7, DRIVE), FILL] }, { name: "A3" }), + music.section(2, { saw: [held("A4"), REST], bell: [held("A5"), REST], pad: [held("A3+C4+E4"), REST], bass: [held("A1"), REST], kit: [music.beat({ k: [0], c: [0] }), REST] }, { name: "outro" }), +); +const MASTER = (mix: Clip) => mix.compress({ threshold: -14, ratio: 2, attack: 0.01, release: 0.15, makeup: 3 }).filter("highshelf", 9000, { gain: 1.5 }); +const ROOM = { decay: 0.72, damping: 0.45, predelay: 0.02 }; +const A = music.render(step2, { reverb: ROOM, master: MASTER }); + +/* ── B: step 3's arrangement ── */ + +const PROG = "Am Am E Am Dm C E Am"; +const HALF = "Am Am E Am"; +const BAR = music.bars(143), SPB = BAR / 4; +// A chord a bar, voiced near the one before; the arpeggio, the bass and the acid line drawn over each chord by number. +const padBars = (p: string, octave = 3) => music.chords(p, held("1+2+3"), { octave, lead: true }); +const arps = (p: string) => music.chords(p, "1 2 3 4 3 2 1 2 3 4 5 4 3 2 3 2", { octave: 4, lead: true }); +const BASS = "1 - . 1 . 1 1 - 3 - . 3 . 1^ 1 -"; +const bassOf = (p: string) => music.chords(p, BASS, { octave: 1 }).map((bar, k) => ("CD".includes(music.chords(p, "1")[k][0]) ? music.transpose(bar, 12) as string : bar)); +const acidOf = (p: string) => music.chords(p, "1^ . 1^^ . 3^ 1^ . 1^^ 1^ . 3^ . 1^^ . 1^ 3^", { octave: 1 }).map((bar) => bar); +const hatsBar = (open: boolean) => times(4, `h@0.45 h@0.22 ${open ? "o@0.55" : "h@0.8"} h@0.25`).join(" "); +// The snare roll of the build: eighths, then sixteenths, louder bar by bar. +const roll = [ + music.beat({ k: [0, 4, 8, 12], s: [4, 12] }), + music.beat({ k: [0, 4, 8, 12], s: [0, 2, 4, 6, 8, 10, 12, 14] }), + ALL.map((i) => (i % 4 === 0 ? "ks" : "s") + `@${(0.45 + i * 0.02).toFixed(2)}`).join(" "), + ALL.map((i) => (i < 12 ? "s" : ".") + (i < 12 ? `@${(0.75 + i * 0.02).toFixed(2)}` : "")).join(" "), +]; +const FLOOR = music.beat({ k: [0, 4, 8, 12], s: [4, 12], c: [4, 12] }); +const FLOOR_FILL = music.beat({ k: [0, 4, 8, 10], s: [4, 12, 13, 14, 15], c: [4], t: [10, 11] }); +const sweep = (from: number, to: number) => (c: Clip, t: { seconds: number }) => c.filter("lowpass", [[0, from], [t.seconds, to]], { resonance: 0.2 }); +const kit = music.drums({ volume: 0.6 }); +const crash = kit.hits.x; +const fxKit = music.kit({ + r: music.riser(music.bars(143, 4), { volume: 0.32 }), + q: music.riser(music.bars(143, 2), { volume: 0.3 }), + i: music.impact({ volume: 0.55 }), + // A crash backwards: swelling into the bar after. + z: crash.trim(0, BAR).reverse().fade(0.4, 0.005), +}); +const pumping = music.pump(SPB, 0.5); +const up2 = (p: readonly string[]) => music.transpose(p, 2) as readonly string[]; + +const parts = { + lead: music.part(music.presets.lead({ volume: 0.15 }), { pan: -0.1, fx: echo, reverb: 0.25 }), + saw: music.part(music.presets.supersaw({ volume: 0.24 }), { fx: (c) => echo(c).chorus({ mix: 0.25 }), reverb: 0.3 }), + bell: music.part(music.presets.bell({ volume: 0.035 }), { pan: 0.3, reverb: 0.55 }), + pad: music.part(music.presets.pad({ volume: 0.075 }), { fx: (c) => c.filter("highpass", 180).chorus({ mix: 0.35 }), reverb: 0.5 }), + strum: music.part(music.presets.pluck({ volume: 0.15, decay: 0.9 }), { pan: 0.45, fx: (c) => c.delay(BEAT / 2, { feedback: 0.25, mix: 0.2, pingPong: true }), reverb: 0.35 }), + keys: music.part(music.presets.keys({ volume: 0.11 }), { pan: -0.2, fx: (c) => c.chorus({ mix: 0.3 }), reverb: 0.4 }), + acid: music.part(music.presets.acid({ volume: 0.13 }), { pan: -0.35, fx: echo, reverb: 0.15 }), + bass: music.part(music.presets.bass({ volume: 0.38 }), { fx: (c) => c.compress({ threshold: -20, ratio: 3 }) }), + kit: music.part(kit, { fx: (c) => c.compress({ threshold: -16, ratio: 3, attack: 0.003 }), reverb: 0.1 }), + hats: music.part(kit, { pan: 0.15, reverb: 0.08 }), + fx: music.part(fxKit, { reverb: 0.35 }), +}; + +const intro = music.section(4, { + pad: padBars(HALF), strum: arps(HALF), bass: [...rest(2), ...bassOf("Am Am")], + hats: [REST, REST, times(8, "h@0.3 .").join(" "), hatsBar(false)], fx: [REST, REST, REST, "z" + " .".repeat(15)], +}, { name: "intro", fx: { pad: sweep(300, 9000), strum: sweep(500, 12000) } }); +const verse = music.section(8, { + lead: tune, pad: padBars(PROG), bass: bassOf(PROG), + kit: [GROOVE.replace(/^k/, "kx"), ...times(6, GROOVE), FILL], hats: hatsBar(false), +}, { name: "verse", volume: { lead: 0.8, pad: 0.55, bass: 0.75, kit: 0.7, hats: 0.7 } }); +const build = music.section(4, { + bell: octaveUp(tune.slice(0, 4)), pad: padBars(HALF), strum: arps(HALF), + bass: music.chords(HALF, "1 . 1 . 1 . 1 . 1 . 1 . 1 . 1 .", { octave: 1 }), + kit: roll, hats: [hatsBar(false), hatsBar(false), times(16, "h@0.5").join(" "), REST], fx: "r" + " .".repeat(63), +}, { name: "build", fx: { pad: sweep(400, 12000), strum: sweep(800, 14000) }, volume: { bass: 0.8 } }); +const drop = music.section(8, { + saw: tune, bell: octaveUp(tune), pad: padBars(PROG), strum: arps(PROG), bass: bassOf(PROG), + kit: [FLOOR.replace(/^k/, "kx"), ...times(6, FLOOR), FLOOR_FILL], hats: hatsBar(true), fx: ["i" + " .".repeat(15), ...rest(7)], +}, { name: "drop", fx: { pad: pumping, bass: pumping, strum: pumping } }); +const breakdown = music.section(4, { + bell: octaveUp(tune.slice(4, 8)), pad: padBars("Dm C E Am", 4), + keys: music.chords("Dm C E Am", "1+2+3 - - . . . 1+2+3 - . . 1+2+3 - - - . .", { octave: 3, lead: true }), + bass: music.chords("Dm C E Am", held("1"), { octave: 2 }), + kit: [REST, REST, music.beat({ k: [0, 8] }), music.beat({ k: [0], s: [8, 10, 12, 13, 14, 15] })], + hats: [REST, REST, times(8, "h@0.35 .").join(" "), hatsBar(false)], fx: [REST, REST, "q" + " .".repeat(15), REST], +}, { name: "break", volume: { pad: 0.45, keys: 0.7, bass: 0.6, bell: 0.8 } }); +const last = music.section(8, { + saw: up2(tune), bell: up2(octaveUp(tune)), pad: up2(padBars(PROG)), acid: up2(acidOf(PROG)), bass: up2(bassOf(PROG)), + kit: [FLOOR.replace(/^k/, "kx"), ...times(6, FLOOR), FLOOR_FILL], hats: hatsBar(true), fx: ["i" + " .".repeat(15), ...rest(7)], +}, { name: "last verse, a tone up", fx: { pad: pumping, bass: pumping } }); +const outro = music.section(4, { + saw: [held("B4"), ...rest(3)], bell: [held("B5"), ...rest(3)], pad: up2([held("A3+C4+E4"), held("A3+C4+E4"), ...rest(2)]), + bass: [held("B1"), ...rest(3)], kit: [music.beat({ k: [0], x: [0] }), ...rest(3)], fx: ["i" + " .".repeat(15), ...rest(3)], +}, { name: "outro", fx: { pad: (c, t) => c.filter("lowpass", [[0, 6000], [t.seconds, 250]]) } }); + +const step3 = music.arrange({ bpm: 143, steps: 4, swing: 0.08 }, parts, intro, verse, build, drop, breakdown, last, outro); +// A gentler master than A's, so the drop stays bigger than the verse. +const B = music.render(step3, { reverb: ROOM, master: (mix) => mix.compress({ threshold: -12, ratio: 2, attack: 0.01, release: 0.15, makeup: 2.5 }).filter("highshelf", 9000, { gain: 1.5 }) }); + +const PARK = reserveLocation("korobeiniki: the camera"); + +program(async () => { + removeUnit(P1, units.AnyUnit); + removeUnit(P2, units.AnyUnit); + centerLocation(PARK, 64 * 32, 112 * 32); + centerView(PARK); + displayText(`\x04Korobeiniki: A (${Math.round(A.seconds)} s) then B (${Math.round(B.seconds)} s), ${Math.round(step3.bpm)} beats a minute. Turn the game's music off. It repeats.`); + await sleep(frames(96)); + while (true) { + displayText("\x04A: step 2, as you heard it last time."); + playWav(A, 0); + await sleep(frames(Math.ceil(A.seconds / 0.042) + 72)); + displayText("\x04B: step 3. Sections: intro, verse, build, drop, break, the last verse a tone up, outro."); + playWav(B, 0); + await sleep(frames(Math.ceil(B.seconds / 0.042) + 96)); + } +}); diff --git a/stdlib/music.ts b/stdlib/music.ts index eba306e..0df731a 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -21,11 +21,23 @@ * * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it ("C4", "F#5", "Bb2"), * several with + sound together ("C4+E4+G4"), "-" holds what sounds a step longer, "." is a rest, "|" is only for the - * eye. A kit's step is its sounds' letters ("kh": kick and hat). Everything is worked out when the script is built: each - * note an instrument plays becomes a clip once (`trigscript`'s Clip, put in the map once however often it plays), and - * the song a table of which clip starts on which frame, which `play` reads while the game runs. + * eye; "@" after a step's notes says how loud ("C4@0.5"). A kit's step is its sounds' letters ("kh": kick and hat). + * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s + * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which + * `play` reads while the game runs. * - * What the game does with it (the sound probe, 2026-10-19): a step is a whole number of frames (42 ms on Fastest), so + * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played + * again until the section is full — and chords by name, their notes drawn by a template: + * + * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) }; + * const verse = music.section(4, { + * keys: music.chords("Am F C G", "1+2+3 - . 1+2+3 . . 1+2+3 -", { octave: 4, lead: true }), + * bass: music.chords("Am F C G", "1 - . 1 . 1 3 -", { octave: 2 }), + * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8), + * }); + * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro); + * + * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down. * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing, @@ -209,7 +221,7 @@ export const presets = { }; /** - * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim. At + * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1). */ export function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit { @@ -225,7 +237,8 @@ export function drums(options: { readonly rate?: number; readonly volume?: numbe const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter("highpass", 7200).filter("highshelf", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]); const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter("bandpass", 2500)); const rim = tone(1700, 0.025, { wave: "triangle", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter("highpass", 4000)).envelope([0, 1], [0.025, 0]); - return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v) }); + const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter("highpass", 4500).filter("peak", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]); + return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) }); } /** A kit: a clip a letter, played by a pattern's letters ("kh" plays k and h together). */ @@ -234,6 +247,192 @@ export function kit(hits: Readonly>): Kit { return Object.freeze({ kind: "kit" as const, hits: Object.freeze({ ...hits }) }); } +/* ── Patterns: what is written, and what writes it ── */ + +/** A pattern: steps a word each, as one text or a text a bar. */ +export type Pattern = string | readonly string[]; + +/** A pattern's steps, the "|" left out. */ +const wordsOf = (p: Pattern): string[] => (typeof p === "string" ? p : p.join(" ")).split(/\s+/).filter((w) => w !== "" && w !== "|"); + +/** A step's sounds and how loud: "C4+E4@0.6" is C4 and E4 at 0.6; "kh" k and h at 1. */ +function velocityOf(word: string, step: number): { body: string; velocity: number } { + const at = word.indexOf("@"); + if (at < 0) return { body: word, velocity: 1 }; + const body = word.slice(0, at), v = Number(word.slice(at + 1)); + if (body === "" || body === "-" || body === "." || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: "@" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): "C4@0.6", "kh@1.3" — not ${JSON.stringify(word)}.`); + return { body, velocity: v }; +} + +const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +/** A note's name from its number: 60 is "C4". Sharps, not flats. */ +function nameOf(n: number): string { + const octave = Math.floor(n / 12) - 1; + if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`); + return NAMES[((n % 12) + 12) % 12] + octave; +} + +/** Each note of a step moved by a function of its number; holds, rests, kit letters and "@" kept. */ +const eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\d/g, (name) => nameOf(move(midi(name))))); + +/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */ +export function transpose(p: Pattern, semitones: number): Pattern { + if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`); + if (semitones === 0) return p; + return typeof p === "string" ? eachNote(p, (n) => n + semitones).join(" ") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(" ")); +} + +/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */ +export function stretch(p: Pattern, by: number): Pattern { + if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`); + const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(".") ? "." : "-"))].join(" ")).join(" "); + return typeof p === "string" ? one(p) : p.map(one); +} + +/** A pattern `n` times over, as one text. */ +export function repeat(p: Pattern, n: number): string { + if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`); + return Array.from({ length: n }, () => wordsOf(p).join(" ")).join(" "); +} + +/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */ +export function beat(hits: Readonly>, steps: number = 16): string { + for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`); + return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join("") || ".").join(" "); +} + +/** A chord's notes above its root, by what follows the root's name. */ +const QUALITIES: Readonly> = { + "": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], "+": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], "5": [0, 7], + "6": [0, 4, 7, 9], m6: [0, 3, 7, 9], "7": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9], + m7b5: [0, 3, 6, 10], "7sus4": [0, 5, 7, 10], "9": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14], +}; + +/** A chord's notes as numbers, lowest first. */ +function chordNumbers(symbol: string, octave: number, inversion: number): number[] { + const m = /^([A-G][#b]?)([^/]*)(?:\/([A-G][#b]?))?$/.exec(symbol); + const steps = m ? QUALITIES[m[2]] : undefined; + if (!m || !steps) throw new Error(`A chord is a root and what kind — "Am", "C", "G7", "Fmaj7", "Dm7", "Esus4", "Bdim", "C/E" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `"${q}"`).join(", ")}.`); + if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`); + const root = midi(m[1] + octave); + const notes = steps.map((s) => root + s); + for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12); + if (m[3]) { + // Over another bass: that note, below the rest. + let bass = midi(m[3] + octave); + while (bass >= notes[0]) bass -= 12; + notes.unshift(bass); + } + return notes; +} + +/** + * A chord's notes, as a step plays them: `chord("Am")` is "A4+C5+E5", its root in octave 4 (`octave`); `inversion` + * moves its lowest notes up an octave, one each — `chord("E", 3, 1)` is "G#3+B3+E4". "C/E" is C over an E below it. + */ +export function chord(symbol: string, octave: number = 4, inversion: number = 0): string { + return chordNumbers(symbol, octave, inversion).map(nameOf).join("+"); +} + +/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */ +const notesOf = (c: string, octave: number): number[] => (/\d/.test(c) ? c.split("+").map(midi).sort((a, b) => a - b) : chordNumbers(c, octave, 0)); + +/** + * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — "1 3 2 3", "1+2+3 - - -" + * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it + * an octave higher, each _ an octave lower ("1__" the root two octaves down, for a bass). "-", "." and "@" as in any + * pattern. `notes` is a chord's notes ("A3+C4+E4") or its name ("Am", with its root in octave `octave`). + */ +export function over(notes: string, template: Pattern, octave: number = 4): string { + const n = notesOf(notes, octave); + return wordsOf(template).map((w, s) => { + const { body, velocity } = velocityOf(w, s); + if (body === "-" || body === ".") return w; + const named = body.split("+").map((d) => { + const m = /^(\d+)([\^_]*)$/.exec(d); + if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — "1", "3^", "1__" — not ${JSON.stringify(d)}.`); + const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0); + return nameOf(n[k % n.length] + 12 * up); + }); + return named.join("+") + (velocity === 1 ? "" : `@${velocity}`); + }).join(" "); +} + +/** + * A progression played as `template` says, a template a chord: `chords("Am Am E Am Dm C E Am", "1_ 1 2 3")` — as + * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to + * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord. + */ +export function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] { + const octave = options.octave ?? 4, list = wordsOf(progression); + let before: number[] | null = null; + return list.map((c) => { + let n = notesOf(c, octave); + if (options.lead && before && !/\d/.test(c)) { + // Each inversion, an octave either side: the one whose notes are nearest those before. + const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length; + let best = n, cost = Infinity; + for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) { + const cand = chordNumbers(c, octave, inv).map((x) => x + shift); + const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length; + if (d < cost) { cost = d; best = cand; } + } + n = best; + } + before = n; + return over(n.map(nameOf).join("+"), template); + }); +} + +/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */ +export function bars(bpm: number, n: number = 1, beats: number = 4): number { + return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME; +} + +/** + * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat` + * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's: + * `{ fx: music.pump(music.bars(143) / 4) }`. + */ +export function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip { + if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error("pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1)."); + return (c) => { + const points: [number, number][] = []; + for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]); + points.push([c.seconds, 1]); + return c.envelope(...points); + }; +} + +/* ── Sounds between sections ── */ + +/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */ +export function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip { + const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds; + const swept = noise(s, { volume: v, rate }).filter("bandpass", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 }); + const lift = tone([[0, 220], [s, 880]], s, { wave: "saw", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter("lowpass", [[0, 500], [s, 6000]]); + return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01); +} + +/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */ +export function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip { + const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5; + return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0]) + .mix(noise(s, { volume: v * 0.5, rate }).filter("lowpass", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3); +} + +/** How a track or a part is heard. */ +export interface PartOptions { + /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */ + readonly pan?: number; + /** How loud, 1 as the instrument plays. */ + readonly volume?: number; + /** What it goes through: its whole part in `render`, each note on its own in `play`. */ + readonly fx?: (clip: Clip) => Clip; + /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */ + readonly reverb?: number; +} + /** What an instrument plays, step by step. */ export interface Track { readonly instrument: Instrument | Kit; @@ -248,20 +447,133 @@ export interface Track { readonly reverb: number; } +/** A part's options checked, and its defaults filled in. */ +function heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } { + const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0; + if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`); + if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`); + if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`); + return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) }; +} + /** * A pattern for an instrument: steps a word each — a note or notes joined by +, "-" to hold, "." to rest, "|" for the - * eye. `pan`: where it is heard, −1 left to 1 right (a panned track's notes have two channels); `volume`: 1 as the - * instrument plays; `fx`: what the track goes through — `(c) => c.delay(0.21).chorus()` — its whole part when the song - * is rendered (as a mixer's insert), each note on its own when it is played note by note; `reverb`: how much goes to - * the song's reverb when it is rendered, 0 … 1. + * eye; "@" after a step's notes says how loud, "C4@0.6". `pan`: where it is heard, −1 left to 1 right (a panned track's + * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through — + * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own + * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1. */ -export function track(instrument: Instrument | Kit, pattern: string | readonly string[], options: { readonly pan?: number; readonly volume?: number; readonly fx?: (clip: Clip) => Clip; readonly reverb?: number } = {}): Track { - const text = typeof pattern === "string" ? pattern : pattern.join(" "); - const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0; - if (!(pan >= -1 && pan <= 1)) throw new Error(`A track's pan is −1 (left) to 1 (right), not ${pan}.`); - if (!(volume >= 0)) throw new Error(`A track's volume is 0 or more, not ${volume}.`); - if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A track's reverb is 0 to 1, not ${reverb}.`); - return Object.freeze({ instrument, steps: Object.freeze(text.split(/\s+/).filter((w) => w !== "" && w !== "|")), pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) }); +export function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track { + return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, "track") }); +} + +/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */ +export interface Part extends Omit { readonly kind: "part" } + +/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */ +export function part(instrument: Instrument | Kit, options: PartOptions = {}): Part { + return Object.freeze({ kind: "part" as const, instrument, ...heard(options, "part") }); +} + +/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */ +export interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number } + +/** What a section is and does, besides its notes. */ +export interface SectionOptions { + /** What `song.sections` calls it. */ + readonly name?: string; + /** + * What a part goes through in this section only, given how long the section is — a filter opening over a build: + * `{ pad: (c, t) => c.filter("lowpass", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and + * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note. + */ + readonly fx?: Readonly Clip>>; + /** How loud a part is in this section only, 1 as it is. */ + readonly volume?: Readonly>; +} + +/** Bars of a song: what each part plays in them. */ +export interface Section { + readonly kind: "section"; + readonly name: string; + readonly bars: number; + readonly parts: Readonly>; + readonly fx: Readonly Clip>>; + readonly volume: Readonly>; + /** This section with some parts written again — null: that part rests — and these options over its own. */ + with(parts: Readonly>, options?: SectionOptions): Section; +} + +/** + * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`. + * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named + * rests. `options`: its name, a part's effects or volume for this section only. + */ +export function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section { + if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`); + for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`); + const made: Section = Object.freeze({ + kind: "section" as const, name: options.name ?? "", bars, + parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }), + with(changes: Readonly>, more: SectionOptions = {}): Section { + const next: Record = { ...parts }; + for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; } + return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } }); + }, + }); + return made; +} + +/** A song's tempo and grid. */ +export interface SongOptions { + readonly bpm: number; + /** Steps a beat (4: sixteenths). */ + readonly steps?: number; + /** Beats a bar (4). */ + readonly beats?: number; + /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */ + readonly swing?: number; +} + +/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */ +export interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number } + +/** Steps of a track that belong to one section, and what that section does to them. */ +interface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime } +const piecesOf = new WeakMap(); + +/** + * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says + * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse, + * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`. + */ +export function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song { + const { perBar, barFrames } = grid(options); + if (sections.length === 0) throw new Error("arrange() takes the song's sections, one at least."); + const names = Object.keys(parts); + for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) { + if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : ""} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(", ")}.`); + } + const tracks = names.map((k) => { + const steps: string[] = [], pieces: Piece[] = []; + for (const s of sections) { + const length = s.bars * perBar, from = steps.length, p = s.parts[k]; + if (p === undefined) for (let i = 0; i < length; i++) steps.push("."); + else { + const words = wordsOf(p); + if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : ""}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? "" : "s"} of ${perBar} steps evenly.`); + for (let i = 0; i < length; i++) steps.push(words[i % words.length]); + } + const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1; + if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } }); + } + const t = track(parts[k].instrument, steps, parts[k]); + if (pieces.length) piecesOf.set(t, pieces); + return t; + }); + let bar = 0; + const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); }); + return compose(options, tracks, placedSections); } /** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */ @@ -277,8 +589,8 @@ function placed(t: Track, made: Map, c: Clip): Clip { return p; } -/** What a song's notes are, kept for render, which makes them again at its own rate. */ -interface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly name?: string; readonly seconds?: number; readonly hit?: Clip } +/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */ +interface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip } const scores = new WeakMap(); /** A song, when the script is built: the tables `play` reads, and what it takes. */ @@ -295,6 +607,8 @@ export interface Song { readonly bpm: number; /** Steps, the longest track's. */ readonly steps: number; + /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */ + readonly sections: readonly SongSection[]; /** Frames it lasts, its last note's release included. */ readonly frames: number; readonly seconds: number; @@ -307,46 +621,67 @@ export interface Song { readonly voices: number; } +/** A song's grid: steps a bar, and frames a bar and a step. */ +function grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } { + const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0; + if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error("A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4)."); + if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`); + const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME)); + return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) }; +} + /** - * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default). Played - * note by note (`play`), a song is refused when more than 32 notes would sound at once; rendered, it has no limit. + * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing` + * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once; + * rendered, it has no limit. A song of sections: `arrange`. */ -export function song(options: { readonly bpm: number; readonly steps?: number; readonly beats?: number }, ...tracks: readonly Track[]): Song { - const perBeat = options.steps ?? 4, beats = options.beats ?? 4; - if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error("song() takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4)."); - const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME)); - const stepFrames = barFrames / (beats * perBeat); +export function song(options: SongOptions, ...tracks: readonly Track[]): Song { + return compose(options, tracks, []); +} + +function compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song { + const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0; const stepSeconds = stepFrames * FRAME; + // Where step s starts, in frames: every second step `swing` of a step late. + const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames; const score: Score[] = []; - let steps = 0; + let steps = 0, effects = false; for (const t of tracks) { - const ws = t.steps, made = new Map(); + const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? []; + if (pieces.some((p) => p.fx)) effects = true; steps = Math.max(steps, ws.length); + let k = 0; ws.forEach((w, s) => { - if (w === "-" || w === ".") return; + while (k < pieces.length && pieces[k].to <= s) k++; + const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined; + const { body, velocity: v } = velocityOf(w, s); + if (body === "-" || body === ".") return; + const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {}; if (t.instrument.kind === "kit") { - for (const letter of w) { + for (const letter of body) { const hit = t.instrument.hits[letter]; if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(", ")}).`); - score.push({ at: s * stepFrames, track: t, made, hit }); + score.push({ at: time(s), track: t, made, velocity, ...into, hit }); } return; } // A note lasts its step and every "-" after it. let length = 1; while (s + length < ws.length && ws[s + length] === "-") length++; - for (const name of w.split("+")) { + const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME; + for (const name of body.split("+")) { midi(name); - score.push({ at: s * stepFrames, track: t, made, name, seconds: length * stepSeconds }); + score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds }); } }); } score.sort((a, b) => a.at - b.at); + const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!); // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game. let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null; const tables = () => { if (table) return table; - const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!)) })); + const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) })); // How many sound at once: at each note's start, those started before it and not yet over. let voices = 0, peakStep = 0; notes.forEach((n, k) => { @@ -362,12 +697,13 @@ export function song(options: { readonly bpm: number; readonly steps?: number; r }; // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit). const playable = () => { + if (effects) throw new Error("A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song."); const t = tables(); if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`); return t; }; const made: Song = Object.freeze({ - stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, + stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]), get frames() { return tables().frames; }, get seconds() { return tables().frames * FRAME; }, get clips() { return playable().clips; }, @@ -385,11 +721,12 @@ export interface ReverbOptions { readonly decay?: number; readonly damping?: num /** * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a - * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through its `fx`, - * at its volume, to its side; what each track sends (its `reverb`) into one reverb shared by them all (`reverb`, its - * room); the whole through `master` — `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter - * (`Clip.limit`) so it never goes over −1.5 dB however the parts add: `limit: false` leaves it as mixed, - * `{ ceiling, release }` sets it. A long clip a program plays goes in the map as Ogg Vorbis, two channels when it has two. + * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a + * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`) + * into one reverb shared by them all (`reverb`, its room); the whole through `master` — + * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over + * −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a + * program plays goes in the map as Ogg Vorbis, two channels when it has two. */ export function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip { const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME; @@ -402,7 +739,19 @@ export function render(s: Song, options: { readonly rate?: number; readonly limi const byTrack = new Map(); for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); } for (const [t, notes] of byTrack) { - let part = mixdown(notes.map((n) => ({ clip: n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate), at: n.at * FRAME })), least, rate); + const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); }; + // The notes as they are, and each section's that has effects of its own mixed apart and through them. + const entries: { clip: Clip; at: number }[] = [], apart = new Map(); + for (const n of notes) { + if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); } + else entries.push({ clip: clipOf(n), at: n.at * FRAME }); + } + for (const [p, list] of apart) { + const from = p.from * s.stepFrames; + const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate); + entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME }); + } + let part = mixdown(entries, least, rate); if (t.fx) part = t.fx(part); if (t.volume !== 1) part = part.gain(t.volume); if (t.pan !== 0) part = part.pan(t.pan); @@ -449,4 +798,7 @@ export const loop = game(async (s: Song) => { }); /** Everything above as one name: import { music } from "trigscript/music"; music.play(song). */ -export const music = { sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop }; +export const music = { + sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop, + part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, +}; diff --git a/tests/music.test.ts b/tests/music.test.ts index 6a96782..0a04490 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -123,12 +123,12 @@ const s = music.song({ bpm: 120 }, music.track(lead, "A4 A4 A4 B4", { pan: 1 }), }); describe("instruments, effects and the mix", () => { - it("makes every preset's notes, and the drum kit's seven drums at 44.1 kHz", () => { + it("makes every preset's notes, and the drum kit's eight drums at 44.1 kHz", () => { const r = check( `const ps = [music.presets.supersaw(), music.presets.pad(), music.presets.bass(), music.presets.acid(), music.presets.lead(), music.presets.chip(), music.presets.keys(), music.presets.bell(), music.presets.pluck()]; const kit = music.drums();`, `for (const p of ps) { const n = p.note("A3", 0.3); if (!(n.peak > 0.01 && n.peak < 1)) fail(\`peak \${n.peak}\`); } -for (const k of ["k", "s", "c", "h", "o", "t", "r"]) { const d = kit.hits[k]; if (!d || d.rate !== 44100 || !(d.peak > 0.05)) fail(k); } +for (const k of ["k", "s", "c", "h", "o", "t", "r", "x"]) { const d = kit.hits[k]; if (!d || d.rate !== 44100 || !(d.peak > 0.05)) fail(k); } if (music.presets.supersaw().note("A3", 0.3).channels !== 2) fail("the supersaw is wide"); if (music.presets.supersaw({ width: 0 }).note("A3", 0.3).channels !== 1) fail("its options over the preset's");`, ); @@ -170,6 +170,120 @@ if (s.at.join(" ") !== "0 3 5 8 10 13 15 18 20 23 25 28 30 33 35 38") fail(s.at. }); }); +describe("writing a song", () => { + it("moves, stretches and repeats a pattern, and writes a kit's bar from the steps each letter plays on", () => { + const r = check( + `const t = music.transpose("A4 - C5 . E5@0.5 | kh", 3), bars = music.transpose(["B4 -", "Bb2 ."], -12) as readonly string[];`, + `if (t !== "C5 - D#5 . G5@0.5 kh") fail(t as string); +if (bars.join(" / ") !== "B3 - / A#1 .") fail(bars.join(" / ")); +const s = music.stretch("C4 - . D4", 2); +if (s !== "C4 - - - . . D4 -") fail(s as string); +if (music.repeat("k h", 3) !== "k h k h k h") fail("repeat"); +if (music.beat({ k: [0, 2], s: [2] }, 4) !== "k . ks .") fail(music.beat({ k: [0, 2], s: [2] }, 4));`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check("", `music.transpose("B9", 1);`)).join("\n")).toMatch(/C-1 to B9/); + }); + + it("names chords, turns them over, and puts one over another bass", () => { + const r = check("", + `const got = [music.chord("Am"), music.chord("E", 3, 1), music.chord("Dm7", 3), music.chord("C/E", 4), music.chord("Bbmaj7", 3), music.chord("F#dim", 4, 2)]; +const want = ["A4+C5+E5", "G#3+B3+E4", "D3+F3+A3+C4", "E3+C4+E4+G4", "A#3+D4+F4+A4", "C5+F#5+A5"]; +if (got.join() !== want.join()) fail(got.join(" "));`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check("", `music.chord("Hm");`)).join("\n")).toMatch(/A chord is a root and what kind/); + expect(errors(check("", `music.chord("Am", 4, 3);`)).join("\n")).toMatch(/Am has 3 notes: its inversion is 0 to 2/); + }); + + it("plays a chord's notes by number, round again an octave up, ^ and _ an octave either way", () => { + const r = check("", + `const a = music.over("Am", "1 2 3 4 5 . 1_ 3^^ 1+3@0.5 -", 3); +if (a !== "A3 C4 E4 A4 C5 . A2 E6 A3+E4@0.5 -") fail(a); +const p = music.chords("Am E Dm C", "1+2+3", { octave: 3, lead: true }); +if (p.join(" / ") !== "A3+C4+E4 / G#3+B3+E4 / A3+D4+F4 / G3+C4+E4") fail(p.join(" / ")); +const plain = music.chords("Am E", "1", { octave: 3 }); +if (plain.join() !== "A3,E3") fail(plain.join());`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check("", `music.over("Am", "0 1");`)).join("\n")).toMatch(/numbers from 1, the lowest/); + }); + + it("plays a step as loud as its @ says, a kit's letters and a chord's notes alike", () => { + const r = check( + `const lead = music.synth({ volume: 0.4 }); +const drums = music.kit({ k: tone(60, 0.1) }); +const s = music.song({ bpm: 120 }, music.track(lead, "A4 A4@0.5 A4@2"), music.track(drums, "k@0.25 . ."));`, + `const one = lead.note("A4", 0.6).peak, hit = tone(60, 0.1).peak; +const p = s.clips.map((c) => c.peak).sort((a, b) => a - b), want = [0.25 * hit, 0.5 * one, one, 2 * one].sort((a, b) => a - b); +if (s.clips.length !== 4 || p.some((x, k) => Math.abs(x - want[k]) > 0.002)) fail(\`\${p.join(" ")} / \${want.join(" ")}\`);`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check(`const lead = music.synth();`, `music.song({ bpm: 120 }, music.track(lead, "A4 -@0.5")).at;`)).join("\n")).toMatch(/Step 2: "@" follows a note/); + }); + + it("swings every second step late, and lengthens and shortens the notes to their swung steps", () => { + const r = check( + `const hat = music.kit({ h: noise(0.02) }), lead = music.synth({ release: 0.01 }); +const s = music.song({ bpm: 120, steps: 2, swing: 1 / 3 }, music.track(hat, "h h h h"), music.track(lead, "A4 B4 . ."));`, + `if (s.stepFrames !== 6 || s.at.join(" ") !== "0 0 8 8 12 20") fail(\`\${s.stepFrames} \${s.at.join(" ")}\`); +const lengths = s.clips.filter((c) => c.seconds > 0.1).map((c) => Math.round(c.seconds / 0.042)); +if (lengths.join() !== "8,4") fail(lengths.join());`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check(`const lead = music.synth();`, `music.song({ bpm: 120, swing: 1 }, music.track(lead, "A4"));`)).join("\n")).toMatch(/swing is 0/); + }); + + it("arranges sections: each fills the parts it names, a pattern again until it is full, the others resting", () => { + const r = check( + `const lead = music.synth(), drums = music.kit({ k: tone(60, 0.1), h: noise(0.02) }); +const parts = { lead: music.part(lead, { pan: 0.5 }), drums: music.part(drums) }; +const a = music.section(1, { lead: "C4 - E4 G4", drums: "k h" }, { name: "a" }); +const b = a.with({ lead: null }, { name: "b" }); +const s = music.arrange({ bpm: 120, steps: 1 }, parts, a, b, music.section(2, { lead: "A4 . . . . . . ." })); +const same = music.song({ bpm: 120, steps: 1 }, music.track(lead, "C4 - E4 G4 . . . . A4 . . . . . . .", { pan: 0.5 }), music.track(drums, "k h k h k h k h . . . . . . . ."));`, + `if (s.at.join() !== same.at.join() || s.which.join() !== same.which.join() || s.clips.length !== same.clips.length) fail(\`\${s.at.join()} / \${same.at.join()}\`); +if (s.steps !== 16) fail(String(s.steps)); +if (JSON.stringify(s.sections) !== JSON.stringify([{ name: "a", bar: 0, bars: 1, frame: 0 }, { name: "b", bar: 1, bars: 1, frame: 48 }, { name: "", bar: 2, bars: 2, frame: 96 }])) fail(JSON.stringify(s.sections)); +if (music.render(s).peak !== music.render(same).peak) fail("render");`, + ); + expect(errors(r)).toEqual([]); + const parts = `const parts = { lead: music.part(music.synth()) };`; + expect(errors(check(parts, `music.arrange({ bpm: 120 }, parts, music.section(1, { leed: "C4" }));`)).join("\n")).toMatch(/names "leed", which is no part of the song: its parts are lead/); + expect(errors(check(parts, `music.arrange({ bpm: 120 }, parts, music.section(2, { lead: "C4 . ." }, { name: "verse" }));`)).join("\n")).toMatch(/lead in verse: a pattern of 3 steps does not fill 2 bars of 16 steps evenly/); + }); + + it("puts a part through a section's own effects in render only, and plays a section's volume either way", () => { + const setup = `const lead = music.synth({ volume: 0.4, release: 0.01 }); +const parts = { lead: music.part(lead) }; +const loud = music.section(1, { lead: "A4 . . ." }), soft = loud.with({}, { volume: { lead: 0.5 } }); +const muted = loud.with({}, { fx: { lead: (c, t) => (t.bars === 1 && Math.abs(t.seconds - t.bar) < 1e-9 ? c.gain(0) : c) } }); +const v = music.arrange({ bpm: 120, steps: 1 }, parts, loud, soft); +const f = music.arrange({ bpm: 120, steps: 1 }, parts, loud, muted);`; + const r = check(setup, + `const p = v.which.map((k) => v.clips[k].peak); +if (Math.abs(p[1] / p[0] - 0.5) > 0.01) fail(p.join(" ")); +const one = music.render(f, { limit: false }); +if (!(one.trim(0, 0.5).peak > 0.2) || one.trim(2, 2.5).peak !== 0) fail(\`\${one.trim(0, 0.5).peak} \${one.trim(2, 2.5).peak}\`);`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check(setup, `program(async () => { await music.play(f); });`)).join("\n")).toMatch(/effects of its own, which only music.render mixes/); + }); + + it("makes a part pump with the beat, a riser as long as asked, an impact, and a section's length in seconds", () => { + const r = check( + `const pad = tone("A3", 2, { volume: 0.5 }).pan(0), pumped = music.pump(0.5, 0.5)(pad);`, + `const dip = pumped.trim(0.501, 0.52).peak, back = pumped.trim(0.95, 0.99).peak; +if (!(dip < 0.3 && back > 0.45)) fail(\`\${dip} \${back}\`); +if (Math.abs(music.bars(143, 4) - 4 * 40 * 0.042) > 1e-9) fail(String(music.bars(143, 4))); +const r = music.riser(music.bars(143, 2)); +if (Math.abs(r.seconds - 3.36) > 0.001 || !(r.trim(3.2, 3.3).peak > 4 * r.trim(0.2, 0.3).peak)) fail(\`\${r.seconds}\`); +if (!(music.impact().peak > 0.3)) fail("impact");`, + ); + expect(errors(r)).toEqual([]); + }); +}); + describe("what the library is made of", () => { it("decides if (clip) when the script is built: a rest (null) plays nothing, a clip plays", () => { const r = compile(`const NOTES = [tone("A4", 0.1), null, tone("B4", 0.1)];\nprogram(async () => { for (const n of NOTES) { if (n) playWav(n, 0); await nextFrame(); } });\n`); From 9d847e4050f29d8e58748d47b4b4093cf0edc076 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 16:43:49 -0600 Subject: [PATCH 07/14] Music: import a MIDI file as notes, and music.midi makes a song of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Video… takes a .mid: its tracks are read (formats 0 and 1, the first tempo, the time signature, names, programs, the drums on channel 10) and kept as a .notes file of the script. Clicking one shows its notes on a roll and plays them roughly. music.midi(notes, { Piano: keys }) plays each track named with an instrument, a kit (General MIDI drum numbers) or a part, quantized to the song's steps, velocities as accents, overlapping notes split into voices. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/declarations.ts | 36 +++++++ compiler/generated/stdlib.ts | 2 +- compiler/media.ts | 12 ++- compiler/notes.ts | 193 +++++++++++++++++++++++++++++++++++ editor.ts | 21 ++-- ko.ts | 10 +- notesImport.ts | 178 ++++++++++++++++++++++++++++++++ script.ts | 6 +- scripts/script-files.mts | 8 +- shell.ts | 1 + stdlib/music.ts | 81 +++++++++++++-- tests/notes.test.ts | 108 ++++++++++++++++++++ videoImport.ts | 11 +- 13 files changed, 630 insertions(+), 37 deletions(-) create mode 100644 compiler/notes.ts create mode 100644 notesImport.ts create mode 100644 tests/notes.test.ts diff --git a/compiler/declarations.ts b/compiler/declarations.ts index 5e41b12..ac7627c 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -483,6 +483,37 @@ ${kw}interface Image { */ halftone(cellWidth: number, cellHeight?: number, options?: { readonly diffuse?: boolean }): readonly string[]; } +/** + * Notes when the script is built, imported from a MIDI file (\`import tune from "./tune.notes"\`, made by + * Import Video… from a .mid): each track's notes, in beats from the start. \`music.midi(tune, { Piano: keys })\` + * makes a song of the tracks it names, each played by an instrument. + */ +${kw}interface Notes { + /** The tempo the file starts at, beats a minute. */ + readonly bpm: number; + /** Quarter notes a bar, by the file's time signature (4 for 4/4, 3 for 6/8). */ + readonly beats: number; + /** How often the tempo changes later in the file: a song plays at one tempo, the first. */ + readonly tempoChanges: number; + readonly tracks: readonly NotesTrack[]; + /** The MIDI file it came from. */ + readonly source?: string; +} + +/** A track of imported notes: one channel of one track of the MIDI file. */ +${kw}interface NotesTrack { + /** Its name in the file (unique among the tracks), or "Track 2", "Track 1 channel 3". */ + readonly name: string; + /** 1 … 16. */ + readonly channel: number; + /** The General MIDI instrument the file asks for, 1 (piano) … 128. */ + readonly program: number; + /** Whether it is on channel 10, the drums. */ + readonly drums: boolean; + /** Its notes in the order they start: \`at\` and \`beats\` in beats (quarter notes), \`pitch\` the MIDI number (60 is C4), \`note\` its name, \`velocity\` 1 … 127. */ + readonly notes: readonly { readonly at: number; readonly beats: number; readonly pitch: number; readonly note: string; readonly velocity: number }[]; +} + /** * A sound when the script is built: imported (\`import boom from "./boom.sound"\`, made by Import Video… in * the Explorer from any sound file) or made by tone(), noise() and silence(). Every method makes a new clip. @@ -1168,6 +1199,11 @@ declare module "*.sound" { const sound: Clip; export default sound; } +// ── Notes the script imports, from a MIDI file: import tune from "./tune.notes"; ── +declare module "*.notes" { + const notes: Notes; + export default notes; +} `; export function generateDeclarations(names: ScriptNames = defaultScriptNames(), options: DeclarationOptions = {}): string { diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index 04fc9bc..4b3302f 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction midi(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(midi(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((midi(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(midi(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = midi(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = midi(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(midi).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n return compose(options, tracks, placedSections);\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n midi(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact,\n};\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n return compose(options, tracks, placedSections);\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/compiler/media.ts b/compiler/media.ts index 088db10..4977ada 100644 --- a/compiler/media.ts +++ b/compiler/media.ts @@ -19,14 +19,16 @@ import { fromBase64, PROB_HALF, RangeDecoder, RangeEncoder, toBase64 } from "./rangeCoder"; import { describeImage, IMAGE_EXTENSION, imageValue } from "./image"; import { clipValue, describeSound, SOUND_EXTENSION } from "./sound"; +import { describeNotes, NOTES_EXTENSION, notesValue } from "./notes"; /** The extension of a video among a script's files. */ export const VIDEO_EXTENSION = ".video"; -/** A video, an image (`image.ts`) or a sound (`sound.ts`): a file of the script that is no TypeScript, whose import gives a value. */ -export const isMediaFile = (path: string): boolean => /\.(?:video|image|sound)$/i.test(path); +/** A video, an image (`image.ts`), a sound (`sound.ts`) or notes (`notes.ts`): a file of the script that is no TypeScript, whose import gives a value. */ +export const isMediaFile = (path: string): boolean => /\.(?:video|image|sound|notes)$/i.test(path); export const isVideoFile = (path: string): boolean => path.toLowerCase().endsWith(VIDEO_EXTENSION); export const isImageFile = (path: string): boolean => path.toLowerCase().endsWith(IMAGE_EXTENSION); export const isSoundFile = (path: string): boolean => path.toLowerCase().endsWith(SOUND_EXTENSION); +export const isNotesFile = (path: string): boolean => path.toLowerCase().endsWith(NOTES_EXTENSION); /** A game frame on Fastest, in milliseconds: what a video imported "at the game's speed" has a frame for. */ export const FASTEST_FRAME_MS = 42; @@ -175,11 +177,11 @@ export function describeVideo(text: string): string { } } -/** The value an import of a media file gives: an `Image` for an `.image`, a `Clip` for a `.sound`, a `Video` for a `.video`. */ -export const mediaValue = (path: string, text: string): unknown => (isImageFile(path) ? imageValue(text) : isSoundFile(path) ? clipValue(text) : videoValue(text)); +/** The value an import of a media file gives: an `Image` for an `.image`, a `Clip` for a `.sound`, `Notes` for a `.notes`, a `Video` for a `.video`. */ +export const mediaValue = (path: string, text: string): unknown => (isImageFile(path) ? imageValue(text) : isSoundFile(path) ? clipValue(text) : isNotesFile(path) ? notesValue(text) : videoValue(text)); /** What the Explorer's hover says about a media file. */ -export const describeMedia = (path: string, text: string): string => (isImageFile(path) ? describeImage(text) : isSoundFile(path) ? describeSound(text) : describeVideo(text)); +export const describeMedia = (path: string, text: string): string => (isImageFile(path) ? describeImage(text) : isSoundFile(path) ? describeSound(text) : isNotesFile(path) ? describeNotes(text) : describeVideo(text)); /* ── The coding: a tile's odds by the tiles around it, for LZMA's binary range coder (`rangeCoder.ts`) ── */ diff --git a/compiler/notes.ts b/compiler/notes.ts new file mode 100644 index 0000000..5b117a5 --- /dev/null +++ b/compiler/notes.ts @@ -0,0 +1,193 @@ +/** + * Notes a script imports (TrigScript 4, music step 3): `import tune from "./tune.notes"`. A `.notes` file is one of the + * script's files, kept in the map with the others: the notes of a MIDI file as the editor read it when it was + * imported — each track's name, channel and instrument, and each note's start, length, pitch and velocity, in beats. + * The MIDI file is not kept; what is kept is what the build reads. + * + * The file is text, one line of JSON. What a script gets is `Notes` (below), which `music.midi` turns into a song with + * an instrument for each track it names. + */ + +/** The extension of a script's notes. */ +export const NOTES_EXTENSION = ".notes"; +/** The format's version, in the file. */ +const VERSION = 1; + +/** A note: its start and length in beats (quarter notes) from the start, its MIDI number (60 is C4), how hard (1 … 127). */ +export interface NoteEvent { readonly at: number; readonly beats: number; readonly pitch: number; readonly velocity: number } + +/** A track of a MIDI file as imported: notes of one channel of one track of the file. */ +export interface NotesTrackData { + readonly name: string; + /** 1 … 16; 10 is the drums. */ + readonly channel: number; + /** The General MIDI instrument the file asks for, 1 … 128 (1 when it asks for none). */ + readonly program: number; + readonly notes: readonly NoteEvent[]; +} + +/** What a `.notes` file holds. */ +export interface NotesData { + /** The tempo the file starts at. */ + readonly bpm: number; + /** Quarter notes a bar, from its time signature (4 for 4/4, 3 for 6/8). */ + readonly beats: number; + /** Whether the tempo changes later (a song plays at one). */ + readonly tempoChanges: number; + readonly tracks: readonly NotesTrackData[]; + readonly source?: string; +} + +/* ── Reading a MIDI file ── */ + +/** The notes of a Standard MIDI File (format 0 or 1). Throws, in words for the person, when it is not one. */ +export function readMidi(bytes: Uint8Array, source?: string): NotesData { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const tag = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4)); + if (bytes.length < 14 || tag(0) !== "MThd") throw new Error("This is not a MIDI file: a MIDI file starts with MThd."); + const headerLength = view.getUint32(4), format = view.getUint16(8), count = view.getUint16(10), division = view.getUint16(12); + if (format > 1) throw new Error("This MIDI file holds separate songs (format 2): only formats 0 and 1 are read."); + if (division & 0x8000) throw new Error("This MIDI file counts time in SMPTE frames: only files that count in beats are read."); + const ppq = division; + let at = 8 + headerLength; + const tempos: [number, number][] = []; + let meter: [number, number] | null = null; + // Notes by file track and channel, in the order they turn up. + const groups = new Map(); + for (let t = 0; t < count && at + 8 <= bytes.length; t++) { + if (tag(at) !== "MTrk") { at += 8 + view.getUint32(at + 4); t--; continue; } + const end = Math.min(bytes.length, at + 8 + view.getUint32(at + 4)); + let p = at + 8, tick = 0, status = 0, trackName = ""; + const programs = new Map(); + const open = new Map(); + const varint = () => { let v = 0; for (let k = 0; k < 4; k++) { const b = bytes[p++]; v = (v << 7) | (b & 0x7f); if (!(b & 0x80)) break; } return v; }; + const group = (channel: number) => { + const key = `${t}:${channel}`; + let g = groups.get(key); + if (!g) { g = { track: t, channel, name: "", program: programs.get(channel) ?? 0, notes: [] }; groups.set(key, g); } + return g; + }; + const close = (channel: number, pitch: number, now: number) => { + const list = open.get(channel * 128 + pitch); + const on = list?.shift(); + if (on && now > on.tick) group(channel).notes.push({ at: on.tick / ppq, beats: (now - on.tick) / ppq, pitch, velocity: on.velocity }); + }; + while (p < end) { + tick += varint(); + let b = bytes[p]; + if (b & 0x80) { status = b; p++; } else if (!status) throw new Error("This MIDI file is damaged: a track starts with data and no event."); + b = status; + if (b === 0xff) { + const type = bytes[p++], length = varint(), data = bytes.subarray(p, p + length); + p += length; + if (type === 0x51 && length === 3) tempos.push([tick, (data[0] << 16) | (data[1] << 8) | data[2]]); + else if (type === 0x58 && length >= 2 && !meter) meter = [data[0], 2 ** data[1]]; + else if (type === 0x03 && !trackName) trackName = new TextDecoder("latin1").decode(data).trim(); + else if (type === 0x2f) break; + status = 0; + continue; + } + if (b === 0xf0 || b === 0xf7) { p += varint(); status = 0; continue; } + const kind = b & 0xf0, channel = b & 0x0f; + const one = bytes[p++], two = kind === 0xc0 || kind === 0xd0 ? 0 : bytes[p++]; + if (kind === 0x90 && two > 0) { + const key = channel * 128 + one; + const list = open.get(key); + // A note struck again while it sounds ends the first. + if (list?.length) close(channel, one, tick); + (open.get(key) ?? open.set(key, []).get(key)!).push({ tick, velocity: two }); + group(channel); + } else if (kind === 0x80 || kind === 0x90) close(channel, one, tick); + else if (kind === 0xc0) { programs.set(channel, one); const g = groups.get(`${t}:${channel}`); if (g && g.notes.length === 0) g.program = one; } + } + // A note never let go ends with its track. + for (const [key, list] of open) for (const on of list) if (tick > on.tick) group(key >> 7).notes.push({ at: on.tick / ppq, beats: (tick - on.tick) / ppq, pitch: key & 127, velocity: on.velocity }); + for (const g of groups.values()) if (g.track === t) g.name = trackName; + at = end; + } + tempos.sort((a, b) => a[0] - b[0]); + const first = tempos.find((x) => x[0] === 0) ?? tempos[0]; + const bpm = first ? 60_000_000 / first[1] : 120; + const kept = [...groups.values()].filter((g) => g.notes.length > 0); + const several = new Map(); + for (const g of kept) several.set(g.track, (several.get(g.track) ?? 0) + 1); + const tracks = kept.map((g) => ({ + name: g.name ? (several.get(g.track)! > 1 ? `${g.name} (channel ${g.channel + 1})` : g.name) : `Track ${g.track + 1}${several.get(g.track)! > 1 || count === 1 ? ` channel ${g.channel + 1}` : ""}`, + channel: g.channel + 1, program: g.program + 1, + notes: g.notes.sort((a, b) => a.at - b.at || a.pitch - b.pitch), + })); + if (tracks.length === 0) throw new Error("This MIDI file has no notes."); + // Names made unique, so a script can name each track. + const seen = new Map(); + for (const tr of tracks) { const n = (seen.get(tr.name) ?? 0) + 1; seen.set(tr.name, n); if (n > 1) tr.name = `${tr.name} ${n}`; } + let changes = 0; + for (let k = 1; k < tempos.length; k++) if (tempos[k][1] !== tempos[k - 1][1]) changes++; + return { bpm: Math.round(bpm * 1000) / 1000, beats: meter ? (meter[0] * 4) / meter[1] : 4, tempoChanges: changes, tracks, ...(source ? { source } : {}) }; +} + +/* ── The .notes file ── */ + +/** The `.notes` file's text: a note four numbers, in 960ths of a beat. */ +export function writeNotes(data: NotesData): string { + const q = (x: number) => Math.round(x * 960); + return JSON.stringify({ + trigscript: "notes", version: VERSION, bpm: data.bpm, beats: data.beats, tempoChanges: data.tempoChanges, ...(data.source ? { source: data.source } : {}), + tracks: data.tracks.map((t) => ({ name: t.name, channel: t.channel, program: t.program, notes: t.notes.flatMap((n) => [q(n.at), q(n.beats), n.pitch, n.velocity]) })), + }); +} + +/** A `.notes` file as the build reads it. Throws, in words for the person, when it is not one. */ +export function readNotes(text: string): NotesData { + let h: { trigscript?: unknown; version?: unknown; bpm?: unknown; beats?: unknown; tempoChanges?: unknown; source?: unknown; tracks?: unknown }; + try { h = JSON.parse(text); } catch { throw new Error("These are not notes TrigScript imported: a .notes file is one line of JSON."); } + if (h?.trigscript !== "notes") throw new Error("These are not notes TrigScript imported."); + if (h.version !== VERSION) throw new Error(`These notes are of version ${String(h.version)}; this TrigScript reads version ${VERSION}.`); + const bpm = Number(h.bpm), beats = Number(h.beats); + if (!(bpm > 0) || !(beats > 0) || !Array.isArray(h.tracks)) throw new Error("These notes are damaged: no tempo, no bar or no tracks."); + const tracks = h.tracks.map((t: { name?: unknown; channel?: unknown; program?: unknown; notes?: unknown }, k: number) => { + const ns = t?.notes; + if (!Array.isArray(ns) || ns.length % 4 !== 0 || ns.some((x) => typeof x !== "number" || !Number.isFinite(x))) throw new Error(`These notes are damaged: track ${k + 1}'s notes are not four numbers each.`); + const notes: NoteEvent[] = []; + for (let i = 0; i < ns.length; i += 4) notes.push({ at: ns[i] / 960, beats: ns[i + 1] / 960, pitch: ns[i + 2], velocity: ns[i + 3] }); + return { name: String(t.name ?? `Track ${k + 1}`), channel: Number(t.channel ?? 1), program: Number(t.program ?? 1), notes }; + }); + return { bpm, beats, tempoChanges: Number(h.tempoChanges ?? 0), tracks, ...(typeof h.source === "string" ? { source: h.source } : {}) }; +} + +/* ── What a script gets ── */ + +const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +/** A MIDI number's note name: 60 is "C4". */ +export const noteName = (n: number): string => NAMES[n % 12] + (Math.floor(n / 12) - 1); + +/** The value an import of a `.notes` file gives, kept while the text is the same (a build reads the files each time). */ +const values = new Map(); + +export function notesValue(text: string): unknown { + const kept = values.get(text); + if (kept) return kept; + const data = readNotes(text); + const value = Object.freeze({ + bpm: data.bpm, beats: data.beats, tempoChanges: data.tempoChanges, ...(data.source ? { source: data.source } : {}), + tracks: Object.freeze(data.tracks.map((t) => Object.freeze({ + name: t.name, channel: t.channel, program: t.program, drums: t.channel === 10, + notes: Object.freeze(t.notes.map((n) => Object.freeze({ at: n.at, beats: n.beats, pitch: n.pitch, note: noteName(n.pitch), velocity: n.velocity }))), + }))), + }); + if (values.size >= 8) values.delete(values.keys().next().value!); + values.set(text, value); + return value; +} + +/** What the hover on an import says: "notes, 4 tracks, 1,204 notes, 143 beats a minute, from tune.mid". */ +export function describeNotes(text: string): string { + try { + const d = readNotes(text); + const n = d.tracks.reduce((s, t) => s + t.notes.length, 0); + const end = Math.max(0, ...d.tracks.flatMap((t) => t.notes.map((x) => x.at + x.beats))); + const seconds = Math.round((end * 60) / d.bpm); + return `Notes: ${d.tracks.length} track${d.tracks.length === 1 ? "" : "s"} (${d.tracks.map((t) => t.name).join(", ")}), ${n.toLocaleString("en-US")} notes, ${Math.round(d.bpm * 100) / 100} beats a minute (${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")})${d.tempoChanges ? `, the tempo changing ${d.tempoChanges} time${d.tempoChanges === 1 ? "" : "s"} (a song plays at the first)` : ""}${d.source ? `, from ${d.source}` : ""}.`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} diff --git a/editor.ts b/editor.ts index 2cd1e22..26dcd70 100644 --- a/editor.ts +++ b/editor.ts @@ -61,9 +61,10 @@ import type { DebugRecording } from "./compiler/gameDebug"; import type { ScmSimDebugView } from "./vendor/scmSim"; import type { EudplibBuildEvent, EudplibService } from "./vendor/eudplib"; import type { Translate } from "./i18n"; -import { describeMedia, isImageFile, isMediaFile, isSoundFile } from "./compiler/media"; +import { describeMedia, isImageFile, isMediaFile, isNotesFile, isSoundFile } from "./compiler/media"; import { importMedia, showImage, showVideo } from "./videoImport"; import { isSoundSource, showSound } from "./soundImport"; +import { isMidiSource, showNotes } from "./notesImport"; export const TEMPLATE = `// TrigScript: ordinary TypeScript that runs when you build. Every trigger() call becomes // one trigger of the map, in order; code inside program(() => { … }) runs in the game. @@ -376,7 +377,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa shell.section({ title: t("Script"), actions: [ { icon: "new-file", title: t("New file…: main.ts imports it with import { … } from \"./name\""), run: () => { void newFile(); } }, { icon: "new-folder", title: t("New folder…: a folder is there while a file is in it, so its first file is asked for next"), run: () => { void newFolder(); } }, - { icon: "device-camera-video", title: t("Import Video…: a video, a picture or a sound as a file of the script"), run: () => { void importVideo(); } }, + { icon: "device-camera-video", title: t("Import Video…: a video, a picture, a sound or a MIDI file as a file of the script"), run: () => { void importVideo(); } }, ] }).body.append(fileList); const programList = el("ul", { className: "tsd-rows" }); const programsSection = shell.section({ title: t("Programs") }); @@ -559,7 +560,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (!editor) return; const p = normalizePath(path); if (files[p] === undefined) return; - if (isMediaFile(p)) { (isImageFile(p) ? showImage : isSoundFile(p) ? showSound : showVideo)(api, t, p, files[p]); return; } + if (isMediaFile(p)) { (isImageFile(p) ? showImage : isSoundFile(p) ? showSound : isNotesFile(p) ? showNotes : showVideo)(api, t, p, files[p]); return; } if (!openTabs.includes(p)) openTabs.push(p); editor.show(p); renderFiles(); @@ -613,7 +614,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa fileList.classList.remove("tsd-drop"); if (!fromOutside(e)) return; e.preventDefault(); - const file = [...(e.dataTransfer?.files ?? [])].find((f) => /^(video|image)\//.test(f.type) || /\.(mkv|webm|mp4|mov|gif)$/i.test(f.name) || isSoundSource(f)); + const file = [...(e.dataTransfer?.files ?? [])].find((f) => /^(video|image)\//.test(f.type) || /\.(mkv|webm|mp4|mov|gif)$/i.test(f.name) || isSoundSource(f) || isMidiSource(f)); if (file) void importVideo(file); }); @@ -666,7 +667,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const path = node.path; const fixed = path === ENTRY_FILE; const row = el("li", { className: path === active ? "tsd-row tsd-active" : "tsd-row", title: isMediaFile(path) ? `${path}\n${describeMedia(path, files[path])}` : path, onClick: () => openFile(path) }, - isMediaFile(path) ? shell.icon(isImageFile(path) ? "file-media" : isSoundFile(path) ? "unmute" : "device-camera-video") : el("span", { className: "tsd-ts" }, "TS"), + isMediaFile(path) ? shell.icon(isImageFile(path) ? "file-media" : isSoundFile(path) ? "unmute" : isNotesFile(path) ? "music" : "device-camera-video") : el("span", { className: "tsd-ts" }, "TS"), el("span", { className: broken.has(path) ? "tsd-name tsd-problem" : "tsd-name" }, node.name), el("span", { className: "tsd-row-actions" }, !fixed ? shell.iconButton({ icon: "edit", title: t("Rename or move…"), run: () => { void renameFile(path); } }).element : undefined, @@ -1712,7 +1713,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (answer === null) return null; let name = normalizePath(answer.trim()); if (name && folder) name = `${folder}/${name}`; - if (name && !/\.(?:tsx?|video|image|sound)$/i.test(name)) name += ext; + if (name && !/\.(?:tsx?|video|image|sound|notes)$/i.test(name)) name += ext; if (!FILE_NAME.test(name) || name.split("/").some((p) => p === "." || p === "..")) { value = answer; message = t("A file name is letters, digits, _ - and ., folders with /, ending in .ts."); continue; } if (files[name] !== undefined) { value = answer; message = t("There is already a {name}.", { name }); continue; } return name; @@ -1743,9 +1744,11 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa editor.add(got.path, got.text); if (got.kind === "rows") { openFile(got.path); return; } renderFiles(); - const stem = (got.path.split("/").pop() ?? got.path).replace(/\.(?:video|image|sound)$/i, ""); + const stem = (got.path.split("/").pop() ?? got.path).replace(/\.(?:video|image|sound|notes)$/i, ""); const name = stem.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string | undefined) => (c ? c.toUpperCase() : "")).replace(/^[^A-Za-z_]+/, "") || got.kind; - setStatus("ok", got.kind === "sound" + setStatus("ok", got.kind === "notes" + ? t("{path} is in the script: import {name} from \"./{path}\"; then music.midi({name}, { … }) makes a song of its tracks.", { path: got.path, name }) + : got.kind === "sound" ? t("{path} is in the script: import {name} from \"./{path}\"; then playWav({name}) plays it inside a program.", { path: got.path, name }) : got.kind === "image" ? t("{path} is in the script: import {name} from \"./{path}\"; then fog.picture({name}.threshold(128)) makes a picture of it.", { path: got.path, name }) @@ -1802,7 +1805,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const renameFile = async (path: string) => { if (!editor || path === ENTRY_FILE) return; - const name = await askName(t("Rename {path} to (a folder before the name moves it):", { path }), path, "", isMediaFile(path) ? (isImageFile(path) ? ".image" : isSoundFile(path) ? ".sound" : ".video") : ".ts"); + const name = await askName(t("Rename {path} to (a folder before the name moves it):", { path }), path, "", isMediaFile(path) ? (isImageFile(path) ? ".image" : isSoundFile(path) ? ".sound" : isNotesFile(path) ? ".notes" : ".video") : ".ts"); if (!name) return; moveTo(path, name); }; diff --git a/ko.ts b/ko.ts index e44f05c..80bda52 100644 --- a/ko.ts +++ b/ko.ts @@ -398,7 +398,7 @@ export const KO: Record = { "Nothing touched it in {frames} frames of the map played with nobody at it, nor in {tests, plural, one {# test} other {# tests}}: the line that did is one this run did not reach. A test that gets there will find it.": "아무도 없이 맵을 {frames} 프레임 플레이하고 테스트 {tests}개를 돌리는 동안 아무것도 닿지 않았습니다. 닿은 줄은 이번 실행이 이르지 못한 줄입니다. 거기까지 가는 테스트가 있으면 찾을 수 있습니다.", "Nothing touched it in {frames} frames of the map played with nobody at it: the line that did is one this run did not reach. A test that gets there will find it.": "아무도 없이 맵을 {frames} 프레임 플레이하는 동안 아무것도 닿지 않았습니다. 닿은 줄은 이번 실행이 이르지 못한 줄입니다. 거기까지 가는 테스트가 있으면 찾을 수 있습니다.", "Find an EUD Error…": "EUD 오류 찾기…", - "Import Video…: a video, a picture or a sound as a file of the script": "동영상 가져오기…: 동영상, 그림 또는 소리를 스크립트 파일로 만듭니다", + "Import Video…: a video, a picture, a sound or a MIDI file as a file of the script": "동영상 가져오기…: 동영상, 그림, 소리 또는 MIDI 파일을 스크립트 파일로 만듭니다", "{path} is in the script: import {name} from \"./{path}\"; then await fog.play(P1, {name}) plays it.": "{path}이(가) 스크립트에 들어갔습니다: import {name} from \"./{path}\"; 다음 await fog.play(P1, {name})로 재생합니다.", "Import Video…": "동영상 가져오기…", "{name} could not be opened: {message}": "{name}을(를) 열 수 없습니다: {message}", @@ -453,4 +453,12 @@ export const KO: Record = { "Rate": "샘플링 속도", "Kept in mono, as the game's own sounds are. A program plays it with playWav(); clips made from it — trim, gain, pitch — are made when the script is built.": "게임의 소리처럼 모노로 저장합니다. program에서 playWav()로 재생하고, 이것으로 만든 클립(trim, gain, pitch)은 스크립트를 빌드할 때 만들어집니다.", "{seconds} seconds at {rate} Hz{from}. Import it with import {name} from \"./{file}\", play it with playWav({name}).": "{seconds}초, {rate} Hz{from}. import {name} from \"./{file}\"로 가져오고 playWav({name})로 재생합니다.", + "{path} is in the script: import {name} from \"./{path}\"; then music.midi({name}, { … }) makes a song of its tracks.": "{path}이(가) 스크립트에 들어갔습니다. import {name} from \"./{path}\"로 가져오면 music.midi({name}, { … })가 그 트랙들로 곡을 만듭니다.", + "{tracks} tracks, {notes} notes, {bpm} beats a minute ({time}).": "트랙 {tracks}개, 음표 {notes}개, 분당 {bpm}박 ({time}).", + "The tempo changes later in the file; a song plays at the first.": "파일 뒤쪽에서 템포가 바뀝니다. 곡은 처음 템포로 재생됩니다.", + "{name} — the drums, {n} notes": "{name} — 드럼, 음표 {n}개", + "{name} — channel {channel}, instrument {program}, {n} notes": "{name} — 채널 {channel}, 악기 {program}, 음표 {n}개", + "Import MIDI": "MIDI 가져오기", + "The notes are kept, not the sounds: music.midi(notes, { Piano: keys }) plays each track you name with an instrument of the music library.": "소리가 아니라 음표를 저장합니다. music.midi(notes, { Piano: keys })는 이름을 준 트랙마다 음악 라이브러리의 악기로 연주합니다.", + "Import it with import {name} from \"./{file}\", make a song of it with music.midi({name}, { \"{track}\": music.presets.keys() }).": "import {name} from \"./{file}\"로 가져오고 music.midi({name}, { \"{track}\": music.presets.keys() })로 곡을 만듭니다.", }; diff --git a/notesImport.ts b/notesImport.ts new file mode 100644 index 0000000..7afab9f --- /dev/null +++ b/notesImport.ts @@ -0,0 +1,178 @@ +/** + * Import Video… for a MIDI file, and the notes viewer. A `.mid` the person picks is read here once and written as a + * `.notes` file of the script (`compiler/notes.ts`), which the build reads the same on every computer; `music.midi` + * makes a song of it. Clicking a `.notes` shows its notes on a roll, a colour a track, and plays them roughly — a + * sine a note — to hear that they are the right ones. + */ +import type { PluginApi } from "@scm-js/plugin-api"; +import type { Translate } from "./i18n"; +import { readMidi, readNotes, writeNotes, type NotesData } from "./compiler/notes"; + +export const MIDI_FILES = ".mid,.midi,audio/midi,audio/x-midi"; +export const isMidiSource = (f: File): boolean => /\.midi?$/i.test(f.name) || f.type === "audio/midi" || f.type === "audio/x-midi"; + +const COLORS = ["#9cc8ff", "#ffd75e", "#8ee08e", "#ff9c9c", "#d4a8ff", "#7fe0d8", "#ffb86b", "#c8c8c8"]; +const ROLL_STYLE = "width: 100%; max-width: 640px; height: 160px; display: block; margin: 0 auto 8px; background: #222;"; + +/** The notes on a roll: time across, pitch up, a colour a track (those `shown` says), a mark at `mark` beats. */ +function drawRoll(canvas: HTMLCanvasElement, data: NotesData, shown: (k: number) => boolean, mark = -1): void { + const w = (canvas.width = 640), h = (canvas.height = 160), ctx = canvas.getContext("2d")!; + ctx.fillStyle = "#222"; + ctx.fillRect(0, 0, w, h); + const all = data.tracks.flatMap((t, k) => (shown(k) ? t.notes : [])); + if (all.length === 0) return; + const end = Math.max(...all.map((n) => n.at + n.beats)), lo = Math.min(...all.map((n) => n.pitch)), hi = Math.max(...all.map((n) => n.pitch)); + const x = (beat: number) => (beat / end) * w, row = h / (hi - lo + 1); + data.tracks.forEach((t, k) => { + if (!shown(k)) return; + ctx.fillStyle = COLORS[k % COLORS.length]; + for (const n of t.notes) ctx.fillRect(x(n.at), h - (n.pitch - lo + 1) * row, Math.max(1, x(n.beats) - 0.5), Math.max(1, row - 0.5)); + }); + if (mark >= 0) { ctx.fillStyle = "#fff"; ctx.fillRect(Math.round(x(mark)), 0, 1, h); } +} + +/** A rough player: each note a sine with a short fade, the drums a click; the roll's mark follows. */ +function player(data: NotesData, shown: (k: number) => boolean, canvas: HTMLCanvasElement) { + let ctx: AudioContext | null = null, raf = 0, started = 0, playing = false; + const spb = 60 / data.bpm; + const stop = () => { playing = false; cancelAnimationFrame(raf); void ctx?.close(); ctx = null; drawRoll(canvas, data, shown); }; + const play = () => { + stop(); + ctx = new AudioContext(); + const out = ctx.createGain(); + out.gain.value = 0.15; + out.connect(ctx.destination); + started = ctx.currentTime + 0.1; + let last = 0; + data.tracks.forEach((t, k) => { + if (!shown(k)) return; + for (const n of t.notes) { + const at = started + n.at * spb, len = Math.max(0.03, Math.min(n.beats * spb, 4)), osc = ctx!.createOscillator(), g = ctx!.createGain(); + osc.type = t.channel === 10 ? "square" : "sine"; + osc.frequency.value = t.channel === 10 ? 80 + (n.pitch % 12) * 40 : 440 * 2 ** ((n.pitch - 69) / 12); + const peak = (n.velocity / 127) * (t.channel === 10 ? 0.4 : 0.5), tail = t.channel === 10 ? 0.04 : len; + g.gain.setValueAtTime(0, at); + g.gain.linearRampToValueAtTime(peak, at + 0.005); + g.gain.setValueAtTime(peak, at + Math.max(0.006, tail - 0.03)); + g.gain.linearRampToValueAtTime(0, at + tail); + osc.connect(g).connect(out); + osc.start(at); + osc.stop(at + tail + 0.01); + last = Math.max(last, n.at + n.beats); + } + }); + playing = true; + const mine = ctx; + const tick = () => { + if (!playing || ctx !== mine) return; + const beat = (mine.currentTime - started) / spb; + if (beat > last) { stop(); return; } + drawRoll(canvas, data, shown, Math.max(0, beat)); + raf = requestAnimationFrame(tick); + }; + tick(); + }; + return { play, stop }; +} + +/** How the notes are described: tracks, notes, tempo, length. */ +function about(t: Translate, data: NotesData): string { + const n = data.tracks.reduce((s, tr) => s + tr.notes.length, 0); + const end = Math.max(0, ...data.tracks.flatMap((tr) => tr.notes.map((x) => x.at + x.beats))); + const seconds = Math.round((end * 60) / data.bpm); + return t("{tracks} tracks, {notes} notes, {bpm} beats a minute ({time}).", { tracks: data.tracks.length, notes: n.toLocaleString(), bpm: Math.round(data.bpm * 100) / 100, time: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}` }) + + (data.tempoChanges ? " " + t("The tempo changes later in the file; a song plays at the first.") : ""); +} + +/** The tracks listed: a colour, the name, the channel, how many notes. */ +function trackList(api: PluginApi, t: Translate, data: NotesData): HTMLElement { + const el = api.ui.el; + const styled = (e: E, style: string) => { e.setAttribute("style", style); return e; }; + return styled(el("ul", {}, + ...data.tracks.map((tr, k) => el("li", {}, + styled(el("span", {}), `display: inline-block; width: 10px; height: 10px; margin-right: 6px; background: ${COLORS[k % COLORS.length]};`), + tr.channel === 10 + ? t("{name} — the drums, {n} notes", { name: tr.name, n: tr.notes.length }) + : t("{name} — channel {channel}, instrument {program}, {n} notes", { name: tr.name, channel: tr.channel, program: tr.program, n: tr.notes.length })))), + "list-style: none; padding: 0; margin: 0 0 8px; font-size: 12px; max-height: 140px; overflow: auto;"); +} + +/** + * Import a MIDI file: its tracks, a listen, the name; the `.notes` file it becomes, or null when the person cancelled + * or it could not be read. `taken` says whether a path is in use. + */ +export async function importNotes(api: PluginApi, t: Translate, file: File, taken: (path: string) => boolean, stem: string): Promise<{ path: string; text: string } | null> { + const w = api.ui.widgets, el = api.ui.el; + let data: NotesData; + try { data = readMidi(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { + await api.ui.alert(t("{name} could not be opened: {message}", { name: file.name, message: (err as Error).message })); + return null; + } + const canvas = el("canvas", {}) as HTMLCanvasElement; + canvas.setAttribute("style", ROLL_STYLE); + const status = w.hint(""); + const name = w.text({ value: `${stem}.notes` }); + const play = player(data, () => true, canvas); + let result: { path: string; text: string } | null = null; + await new Promise((closed) => { + api.ui.dialog({ + title: t("Import MIDI"), + size: "md", + mount(body) { + body.append( + canvas, + w.row(w.button(t("Play"), { onClick: () => play.play() }), w.button(t("Stop"), { onClick: () => play.stop() })), + trackList(api, t, data), + w.form([{ label: t("File"), field: name }]), + w.hint(about(t, data)), + w.hint(t("The notes are kept, not the sounds: music.midi(notes, { Piano: keys }) plays each track you name with an instrument of the music library.")), + status, + ); + drawRoll(canvas, data, () => true); + return () => { play.stop(); closed(); }; + }, + buttons: [ + { label: t("Cancel") }, + { + label: t("Import"), primary: true, + run: () => { + let path = name.value.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + if (!path.toLowerCase().endsWith(".notes")) path += ".notes"; + if (!/^[A-Za-z0-9_\-./]+$/.test(path) || path.split("/").some((p) => p === "." || p === ".." || p === "")) { status.textContent = t("A file name is letters, digits, _ - and ., folders with /."); return false; } + if (taken(path)) { status.textContent = t("There is already a {name}.", { name: path }); return false; } + result = { path, text: writeNotes(data) }; + return true; + }, + }, + ], + }); + }); + return result; +} + +/** A `.notes` file of the script, shown: its roll, its tracks, played roughly, and how a script uses it. */ +export function showNotes(api: PluginApi, t: Translate, path: string, text: string): void { + let data: NotesData; + try { data = readNotes(text); } catch (err) { void api.ui.alert(`${path}: ${(err as Error).message}`); return; } + const w = api.ui.widgets, el = api.ui.el; + const canvas = el("canvas", {}) as HTMLCanvasElement; + canvas.setAttribute("style", ROLL_STYLE); + const play = player(data, () => true, canvas); + const stem = (path.split("/").pop() ?? path).replace(/\.notes$/i, ""); + const name = stem.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string | undefined) => (c ? c.toUpperCase() : "")).replace(/^[^A-Za-z_]+/, "") || "notes"; + api.ui.dialog({ + title: path, + size: "md", + mount(body) { + body.append( + canvas, + w.row(w.button(t("Play"), { onClick: () => play.play() }), w.button(t("Stop"), { onClick: () => play.stop() })), + trackList(api, t, data), + w.hint(about(t, data) + (data.source ? ` · ${data.source}` : "")), + w.hint(t("Import it with import {name} from \"./{file}\", make a song of it with music.midi({name}, { \"{track}\": music.presets.keys() }).", { name, file: path.split("/").pop() ?? path, track: data.tracks[0]?.name ?? "Track 1" })), + ); + drawRoll(canvas, data, () => true); + return () => play.stop(); + }, + }); +} diff --git a/script.ts b/script.ts index 3e6338c..af84c2a 100644 --- a/script.ts +++ b/script.ts @@ -140,10 +140,10 @@ export function pathOf(member: string): string | null { } /** What a file of the script ends in: TypeScript, TypeScript with JSX, or a video, an image or a sound it imports (`compiler/media.ts`). */ -const SCRIPT_FILE = /\.(?:tsx?|video|image|sound)$/i; +const SCRIPT_FILE = /\.(?:tsx?|video|image|sound|notes)$/i; -/** A file name a script may use: letters, digits, `_`, `-`, `.`, folders with `/`, ending in `.ts` (or `.tsx`, `.video`, `.image`, `.sound`). */ -export const FILE_NAME = /^(?:[A-Za-z0-9_\-.]+\/)*[A-Za-z0-9_\-.]+\.(?:tsx?|video|image|sound)$/; +/** A file name a script may use: letters, digits, `_`, `-`, `.`, folders with `/`, ending in `.ts` (or `.tsx`, `.video`, `.image`, `.sound`, `.notes`). */ +export const FILE_NAME = /^(?:[A-Za-z0-9_\-.]+\/)*[A-Za-z0-9_\-.]+\.(?:tsx?|video|image|sound|notes)$/; function member(extras: Extras, name: string): Uint8Array | undefined { const key = memberKey(name); diff --git a/scripts/script-files.mts b/scripts/script-files.mts index be40ef6..8fa91fe 100644 --- a/scripts/script-files.mts +++ b/scripts/script-files.mts @@ -1,6 +1,6 @@ /** * A script on disk as the compiler takes it: the file itself as main.ts (or main.tsx), and every file it imports by a - * relative path, followed from file to file, under the path it has from the script's folder — videos, images and sounds too. For the + * relative path, followed from file to file, under the path it has from the script's folder — videos, images, sounds and notes too. For the * probes, whose libraries sit beside them (probes/lib/…). */ import { existsSync, readFileSync } from "node:fs"; @@ -15,10 +15,10 @@ export function scriptFiles(path: string): Record { files[as] = text; for (const m of text.matchAll(/\bfrom\s+["'](\.{1,2}\/[^"']+)["']/g)) { const base = resolve(dirname(file), m[1]); - const found = [base, `${base}.ts`, `${base}.tsx`].find((f) => existsSync(f) && /\.(tsx?|video|image|sound)$/.test(f)); + const found = [base, `${base}.ts`, `${base}.tsx`].find((f) => existsSync(f) && /\.(tsx?|video|image|sound|notes)$/.test(f)); if (!found) continue; - // A video, an image or a sound (compiler/media.ts) is taken as it is; nothing in it imports anything. - if (/\.(?:video|image|sound)$/.test(found)) files[relative(root, found).split("\\").join("/")] = readFileSync(found, "utf8"); + // A video, an image, a sound or notes (compiler/media.ts) is taken as it is; nothing in it imports anything. + if (/\.(?:video|image|sound|notes)$/.test(found)) files[relative(root, found).split("\\").join("/")] = readFileSync(found, "utf8"); else visit(found, relative(root, found).split("\\").join("/")); } }; diff --git a/shell.ts b/shell.ts index 67a537a..cb60406 100644 --- a/shell.ts +++ b/shell.ts @@ -30,6 +30,7 @@ const ICONS = { "device-camera-video": "ead9", "file-media": "eaea", unmute: "eb75", + music: "ec1b", "debug-pause": "ead1", folder: "ea83", "folder-opened": "eaf7", diff --git a/stdlib/music.ts b/stdlib/music.ts index 0df731a..2431499 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -43,7 +43,7 @@ * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing, * nothing to change while it plays. A program plays to its own player; a program for each player plays to each. */ -import { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip } from "trigscript"; +import { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip, type Notes } from "trigscript"; /** Seconds a frame lasts on Fastest, the speed a map is played at. */ const FRAME = 0.042; @@ -69,7 +69,7 @@ export interface Kit { const SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; /** A note's number, 69 for A4: "C4" is 60, "C#4" 61, "Db4" 61. */ -function midi(name: string): number { +function noteNumber(name: string): number { const m = /^([A-Ga-g])([#b]?)(-?\d)$/.exec(name); if (!m) throw new Error(`A note is a name with its octave — "C4", "F#5", "Bb2" — not ${JSON.stringify(name)}.`); return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0); @@ -92,11 +92,11 @@ function remembering(make: (name: string, seconds: number, rate?: number) => Cli * is as long as the recording. */ export function sampler(clip: Clip, root: string = "C4", options: { readonly release?: number; readonly volume?: number } = {}): Instrument { - const base = midi(root), release = options.release ?? 0.05, volume = options.volume ?? 1; + const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1; return Object.freeze({ kind: "notes" as const, note: remembering((name, seconds) => { - const pitched = clip.pitch(midi(name) - base).gain(volume); + const pitched = clip.pitch(noteNumber(name) - base).gain(volume); return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2)); }), }); @@ -154,7 +154,7 @@ export function synth(options: SynthOptions = {}): Instrument { note: remembering((name, seconds, rate) => { const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release; const r = rate === undefined ? {} : { rate }; - const pitch = options.glide ? [[0, 440 * 2 ** ((midi(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name; + const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name; let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r }); if (f) { const amount = f.amount ?? 0, base = f.cutoff; @@ -273,7 +273,7 @@ function nameOf(n: number): string { } /** Each note of a step moved by a function of its number; holds, rests, kit letters and "@" kept. */ -const eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\d/g, (name) => nameOf(move(midi(name))))); +const eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\d/g, (name) => nameOf(move(noteNumber(name))))); /** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */ export function transpose(p: Pattern, semitones: number): Pattern { @@ -314,12 +314,12 @@ function chordNumbers(symbol: string, octave: number, inversion: number): number const steps = m ? QUALITIES[m[2]] : undefined; if (!m || !steps) throw new Error(`A chord is a root and what kind — "Am", "C", "G7", "Fmaj7", "Dm7", "Esus4", "Bdim", "C/E" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `"${q}"`).join(", ")}.`); if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`); - const root = midi(m[1] + octave); + const root = noteNumber(m[1] + octave); const notes = steps.map((s) => root + s); for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12); if (m[3]) { // Over another bass: that note, below the rest. - let bass = midi(m[3] + octave); + let bass = noteNumber(m[3] + octave); while (bass >= notes[0]) bass -= 12; notes.unshift(bass); } @@ -335,7 +335,7 @@ export function chord(symbol: string, octave: number = 4, inversion: number = 0) } /** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */ -const notesOf = (c: string, octave: number): number[] => (/\d/.test(c) ? c.split("+").map(midi).sort((a, b) => a - b) : chordNumbers(c, octave, 0)); +const notesOf = (c: string, octave: number): number[] => (/\d/.test(c) ? c.split("+").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0)); /** * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — "1 3 2 3", "1+2+3 - - -" @@ -670,7 +670,7 @@ function compose(options: SongOptions, tracks: readonly Track[], sections: reado while (s + length < ws.length && ws[s + length] === "-") length++; const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME; for (const name of body.split("+")) { - midi(name); + noteNumber(name); score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds }); } }); @@ -715,6 +715,65 @@ function compose(options: SongOptions, tracks: readonly Track[], sections: reado return made; } +/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */ +const GM_DRUMS: Readonly> = { + 35: "k", 36: "k", 37: "r", 38: "s", 40: "s", 39: "c", 42: "h", 44: "h", 51: "h", 53: "h", 59: "h", 46: "o", + 41: "t", 43: "t", 45: "t", 47: "t", 48: "t", 50: "t", 49: "x", 52: "x", 55: "x", 57: "x", +}; + +/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */ +export interface MidiOptions extends Omit { + /** Beats a minute: the file's own when left out. */ + readonly bpm?: number; + /** A drum's MIDI number to a kit's letter, over General MIDI's (36 "k", 38 "s", 42 "h", 46 "o", 49 "x", …). */ + readonly drums?: Readonly>; +} + +/** + * A song of imported notes (`import tune from "./tune.notes"`): each track `parts` names — by its name, or its place + * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`; + * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its + * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose + * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says. + */ +export function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song { + const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) }; + const names = notes.tracks.map((t) => t.name); + const tracks: Track[] = []; + for (const [key, given] of Object.entries(parts)) { + const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\d+$/.test(key) ? Number(key) : -1; + const source = notes.tracks[index]; + if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(", ")} (or 0 to ${names.length - 1}).`); + const instrument = given.kind === "part" ? given.instrument : given, how = given.kind === "part" ? given : {}; + const kit = instrument.kind === "kit" ? instrument : null; + // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free. + type Word = { sounds: string[]; velocity: number; length: number }; + const voices: { words: Map; end: number; last: number }[] = []; + const placed = source.notes.map((n) => { + const from = Math.round(n.at * perBeat); + return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) }; + }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length); + for (const n of placed) { + let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; }); + if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; } + voice = voices.find((v) => v.end <= n.from); + if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); } + voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length }); + voice.end = n.from + n.length; + voice.last = n.from; + } + for (const v of voices) { + const steps: string[] = Array.from({ length: v.end }, () => "."); + for (const [at, w] of v.words) { + steps[at] = (kit ? w.sounds.join("") : w.sounds.join("+")) + (w.velocity === 1 ? "" : `@${w.velocity}`); + if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = "-"; + } + tracks.push(track(instrument, steps, how)); + } + } + return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []); +} + /** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */ export interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number } @@ -800,5 +859,5 @@ export const loop = game(async (s: Song) => { /** Everything above as one name: import { music } from "trigscript/music"; music.play(song). */ export const music = { sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop, - part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, + part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, }; diff --git a/tests/notes.test.ts b/tests/notes.test.ts new file mode 100644 index 0000000..c5e07ad --- /dev/null +++ b/tests/notes.test.ts @@ -0,0 +1,108 @@ +/** + * Notes imported from a MIDI file: the file read (tempo, time signature, track names, running status, a note struck + * again, the drums on channel 10), the `.notes` file written and read back, and a script making a song of them with + * `music.midi` — notes on their steps, held, struck together, overlapping notes in voices of their own, the drums by + * their General MIDI numbers. + */ +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { compileScript } from "../compiler/compiler"; +import { defaultScriptNames } from "../compiler/names"; +import { defaultLib } from "../bundle/lib.mjs"; +import { describeMedia, isMediaFile } from "../compiler/media"; +import { readMidi, readNotes, writeNotes } from "../compiler/notes"; + +/** A MIDI file's bytes: format 1, 480 a beat, its tracks' events given as [delta ticks, ...bytes]. */ +function smf(tracks: (readonly number[])[][]): Uint8Array { + const varint = (v: number) => { const out = [v & 0x7f]; while ((v >>= 7)) out.unshift((v & 0x7f) | 0x80); return out; }; + const chunks = tracks.map((events) => { + const body = events.flatMap(([delta, ...bytes]) => [...varint(delta), ...bytes]).concat([0, 0xff, 0x2f, 0]); + return [0x4d, 0x54, 0x72, 0x6b, (body.length >>> 24) & 255, (body.length >>> 16) & 255, (body.length >>> 8) & 255, body.length & 255, ...body]; + }); + return new Uint8Array([0x4d, 0x54, 0x68, 0x64, 0, 0, 0, 6, 0, 1, 0, tracks.length, 0x01, 0xe0, ...chunks.flat()]); +} +const name = (text: string) => [0, 0xff, 0x03, text.length, ...[...text].map((c) => c.charCodeAt(0))]; + +const FILE = smf([ + // The conductor: 140 beats a minute (428 571 µs a beat), 3/4, and later 120. + [[0, 0xff, 0x51, 3, 0x06, 0x8a, 0x1b], [0, 0xff, 0x58, 4, 3, 2, 24, 8], [1920, 0xff, 0x51, 3, 0x07, 0xa1, 0x20]], + // A piano: C4 + E4 a beat (running status for the second), G4 half a beat, then C5 struck again before it ends. + [name("Piano"), [0, 0xc0, 0], [0, 0x90, 60, 100], [0, 64, 100], [480, 0x80, 60, 0], [0, 64, 0], [0, 0x90, 67, 50], [240, 0x80, 67, 0], + [0, 0x90, 72, 100], [120, 0x90, 72, 100], [120, 0x80, 72, 0], [0, 0x80, 72, 0], [0, 0x90, 48, 127], [0, 0x90, 55, 100], [960, 0x80, 48, 0], [0, 0x80, 55, 0]], + // Drums on channel 10: kick and closed hat, hat, snare, a cowbell (56), which a kit has no letter for. + [name("Beat"), [0, 0x99, 36, 110], [0, 42, 80], [60, 36, 0], [0, 42, 0], [180, 42, 80], [60, 42, 0], [180, 38, 100], [0, 56, 100], [60, 38, 0], [0, 56, 0]], +]); + +const LIB = defaultLib(); +const compile = (main: string, notes: string) => compileScript(ts, { "main.ts": main, "tune.notes": notes }, defaultScriptNames(), { lib: LIB }); +const errors = (r: ReturnType) => r.diagnostics.map((d) => `${d.file}:${d.line}: ${d.message}`); + +describe("a MIDI file, read", () => { + it("keeps the first tempo, the bar from the time signature, the tracks by name and their notes in beats", () => { + const d = readMidi(FILE, "tune.mid"); + expect(d.bpm).toBeCloseTo(140, 3); + expect(d.beats).toBe(3); + expect(d.tempoChanges).toBe(1); + expect(d.tracks.map((t) => [t.name, t.channel, t.program])).toEqual([["Piano", 1, 1], ["Beat", 10, 1]]); + const piano = d.tracks[0].notes.map((n) => [n.at, n.beats, n.pitch, n.velocity]); + expect(piano).toEqual([[0, 1, 60, 100], [0, 1, 64, 100], [1, 0.5, 67, 50], [1.5, 0.25, 72, 100], [1.75, 0.25, 72, 100], [2, 2, 48, 127], [2, 2, 55, 100]]); + expect(d.tracks[1].notes.map((n) => n.pitch)).toEqual([36, 42, 42, 38, 56]); + }); + + it("says what is wrong with a file that is not one it reads", () => { + expect(() => readMidi(new Uint8Array([1, 2, 3]))).toThrow(/not a MIDI file/); + const two = FILE.slice(); two[9] = 2; + expect(() => readMidi(two)).toThrow(/format 2/); + expect(() => readMidi(smf([[[0, 0xff, 0x51, 3, 7, 0xa1, 0x20]]]))).toThrow(/no notes/); + }); + + it("writes a .notes file that reads back the same, and says what it holds", () => { + const d = readMidi(FILE, "tune.mid"); + const text = writeNotes(d); + expect(readNotes(text)).toEqual(d); + expect(isMediaFile("music/tune.notes")).toBe(true); + expect(describeMedia("tune.notes", text)).toBe("Notes: 2 tracks (Piano, Beat), 12 notes, 140 beats a minute (0:02), the tempo changing 1 time (a song plays at the first), from tune.mid."); + expect(() => readNotes("{}")).toThrow(/not notes TrigScript imported/); + }); +}); + +describe("a song of imported notes", () => { + const text = writeNotes(readMidi(FILE, "tune.mid")); + + it("gives a script the tracks and their notes", () => { + const r = compile(`import tune from "./tune.notes"; +const fail = (what: string) => { throw new Error(what); }; +if (tune.tracks.length !== 2 || tune.tracks[1].drums !== true || tune.tracks[0].notes[2].note !== "G4" || tune.beats !== 3) fail(JSON.stringify(tune.tracks[0].notes[2]));`, text); + expect(errors(r)).toEqual([]); + }); + + it("plays each track it names with an instrument: notes on their steps, held, struck together, overlaps in voices of their own", () => { + const r = compile(`import { music } from "trigscript/music"; +import tune from "./tune.notes"; +const fail = (what: string) => { throw new Error(what); }; +const keys = music.synth({ release: 0.01 }); +const s = music.midi(tune, { Piano: keys }); +if (s.barFrames !== 31 || Math.abs(s.bpm - 3 * 60 / (31 * 0.042)) > 1e-9) fail(\`\${s.barFrames} \${s.bpm}\`); +// Steps of 31/12 frames: C4+E4 on 0 for four steps, G4 at 4 for two, C5 at 6 and 7, C3+G3 at 8 for eight. +const at = s.at.map((f) => Math.round(f / s.stepFrames)); +if (at.join() !== "0,0,4,6,7,8,8") fail(at.join()); +const steps = s.which.map((k) => Math.round(s.clips[k].seconds / 0.042 / s.stepFrames)); +if (steps.join() !== "4,4,2,1,1,8,8") fail(steps.join()); +const loud = s.which.map((k) => s.clips[k].peak); +if (!(Math.abs(loud[2] / loud[0] - 0.5) < 0.02)) fail(loud.join());`, text); + expect(errors(r)).toEqual([]); + }); + + it("plays the drums by their General MIDI numbers, each as hard as it was struck, leaving out what the kit has no letter for", () => { + const r = compile(`import { music } from "trigscript/music"; +import tune from "./tune.notes"; +const fail = (what: string) => { throw new Error(what); }; +const kit = music.kit({ k: tone(60, 0.1), h: noise(0.02), s: noise(0.1) }); +const s = music.midi(tune, { 1: music.part(kit, { pan: 0.3 }) }, { steps: 2 }); +const at = s.at.map((f) => Math.round(f / s.stepFrames)); +if (at.join() !== "0,0,1,2" || s.clips.length !== 3) fail(\`\${at.join()} \${s.clips.length}\`);`, text); + expect(errors(r)).toEqual([]); + const bad = compile(`import { music } from "trigscript/music";\nimport tune from "./tune.notes";\nmusic.midi(tune, { Drums: music.drums() });`, text); + expect(errors(bad).join("\n")).toMatch(/no track "Drums": their tracks are "Piano", "Beat" \(or 0 to 1\)/); + }); +}); diff --git a/videoImport.ts b/videoImport.ts index c79bc29..1fbe9dd 100644 --- a/videoImport.ts +++ b/videoImport.ts @@ -19,6 +19,7 @@ import { cover, covers, prepareShapes } from "./compiler/cover"; import { FOG_SHAPES } from "./compiler/fogShapes"; import { decodeTrack, demux } from "./videoDecode"; import { importSound, isSoundSource, SOUND_FILES } from "./soundImport"; +import { importNotes, isMidiSource, MIDI_FILES } from "./notesImport"; /** A picture or a video the person picked, ready to be drawn at a size. */ interface Source { @@ -262,16 +263,20 @@ function imageModule(rows: string[], from: string): string { } /** What an import made: a `.video`, an `.image`, or a `.ts` of a picture's rows. */ -export interface ImportedFile { path: string; text: string; kind: "video" | "image" | "rows" | "sound" } +export interface ImportedFile { path: string; text: string; kind: "video" | "image" | "rows" | "sound" | "notes" } /** * Import Video…: pick a video or an image, choose the size (and, for ink, the threshold) over a preview, and get the * script file it becomes — or null when the person cancelled. `taken` says whether a path is in use. */ export async function importMedia(api: PluginApi, t: Translate, options: { mapWidth: number; mapHeight: number; taken: (path: string) => boolean; file?: File }): Promise { - const file = options.file ?? (await api.ui.pickFiles({ accept: `video/*,image/*,.mkv,.webm,.mp4,.mov,.gif,${SOUND_FILES}` }))[0]; + const file = options.file ?? (await api.ui.pickFiles({ accept: `video/*,image/*,.mkv,.webm,.mp4,.mov,.gif,${SOUND_FILES},${MIDI_FILES}` }))[0]; if (!file) return null; - // A sound has an import of its own (`soundImport.ts`). + // A MIDI file and a sound have imports of their own (`notesImport.ts`, `soundImport.ts`). + if (isMidiSource(file)) { + const got = await importNotes(api, t, file, options.taken, fileStem(file.name)); + return got ? { ...got, kind: "notes" } : null; + } if (isSoundSource(file)) { const got = await importSound(api, t, file, options.taken, fileStem(file.name)); return got ? { ...got, kind: "sound" } : null; From d7d3eaa609e2597e9d6ec1fc9c4cdf3df1eea7c1 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 16:51:37 -0600 Subject: [PATCH 08/14] Sound: the limiter keeps the wave between samples under its ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Judged by its samples, a limited mix peaked 1.1 to 1.6 dB higher between them, and a dense one decoded at 0.99. Each sample's need now comes from its true peak (windowed sinc, 16 a side, each eighth of a sample); the ceiling stays −1.5 dB; decoded peaks −0.7 dB at the most. ENGINE is sound 3, so maps make their files again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/declarations.ts | 2 +- compiler/generated/stdlib.ts | 2 +- compiler/sound.ts | 55 ++++++++++++++++++++++++++++++------ docs/v4-plan.md | 17 +++++++++++ stdlib/music.ts | 2 +- tests/sound.test.ts | 10 +++++++ 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/compiler/declarations.ts b/compiler/declarations.ts index ac7627c..368e89d 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -563,7 +563,7 @@ ${kw}interface Clip { /** Its two channels as one. */ mono(): Clip; /** - * Never louder than \`ceiling\` (0.84, −1.5 dB, when left out): turned down smoothly over the 5 ms before + * Never louder than \`ceiling\` (0.84, −1.5 dB, when left out), between the samples too: turned down smoothly over the 5 ms before * each peak that would go over, and back up over \`release\` seconds (0.1). What a mix goes through last, * so that the parts can add up without cutting off: \`mixdown(parts).limit()\`. */ diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index 4b3302f..d0173c7 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n return compose(options, tracks, placedSections);\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi,\n};\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n return compose(options, tracks, placedSections);\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/compiler/sound.ts b/compiler/sound.ts index d1a9942..a8ea410 100644 --- a/compiler/sound.ts +++ b/compiler/sound.ts @@ -571,25 +571,64 @@ function lazyClip(rate: number, frames: number, channels: 1 | 2, compute: () => return Object.freeze(clip); } -/** The level `limit` keeps a clip under unless told otherwise, and `normalize` brings it to: −1.5 dB. Ogg Vorbis comes - * back up to a dB louder at its peaks than what went in (a song limited to −1 dB peaked at −0.16 dB, decoded), so the - * margin keeps a limited song from clipping in the game. */ +/** The level `limit` keeps a clip under unless told otherwise, and `normalize` brings it to: −1.5 dB. `limit` keeps + * the wave between the samples under it too (its true peak), and Ogg Vorbis comes back a little louder at its peaks + * than what went in, so the margin keeps a limited song from clipping in the game. (Judged by the samples alone, a + * song limited to 0.84 peaked at 0.95 between them, and a dense one decoded at 0.99.) */ export const LIMIT_CEILING = 0.84; /** How far ahead the limiter looks, in seconds: it turns down this long before a peak, so the peak is never over. */ const LOOKAHEAD = 0.005; +/** Samples a side the wave between two samples is worked out from. */ +const SIDE = 16; +/** How loud the wave is each eighth of the way from a sample to the next: windowed sinc, 16 a side. (A quarter's steps + * miss the top of a wave near 20 kHz by half a dB; 8 a side miss a wave near 22 kHz by as much.) */ +const BETWEEN = [1, 2, 3, 4, 5, 6, 7].map((k) => k / 8).map((f) => { + const taps = new Float32Array(2 * SIDE); + let sum = 0; + for (let k = 0; k < 2 * SIDE; k++) { + const x = k - (SIDE - 1) - f, w = 0.42 + 0.5 * Math.cos((Math.PI * x) / (SIDE + 0.5)) + 0.08 * Math.cos((2 * Math.PI * x) / (SIDE + 0.5)); + taps[k] = (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x)) * w; + sum += taps[k]; + } + return taps.map((t) => t / sum); +}); + +/** + * How loud each sample is with the wave around it: itself, or higher where the wave rises between it and a sample + * beside it (its true peak, as a converter plays it back). Worked out only near the loud ones: the wave goes no higher + * than twice its samples around it, so a sample and its neighbours under half `over` cannot reach it. + */ +function truePeaks(s: Float32Array, over: number): Float32Array { + const n = s.length, out = new Float32Array(n), half = over / 2; + for (let i = 0; i < n; i++) out[i] = s[i] < 0 ? -s[i] : s[i]; + for (let i = 0; i + 1 < n; i++) { + if (out[i] < half && out[i + 1] < half) continue; + for (const taps of BETWEEN) { + let v = 0; + const from = i - (SIDE - 1); + if (from >= 0 && from + 2 * SIDE <= n) for (let k = 0; k < 2 * SIDE; k++) v += taps[k] * s[from + k]; + else for (let k = 0; k < 2 * SIDE; k++) { const j = from + k; if (j >= 0 && j < n) v += taps[k] * s[j]; } + if (v < 0) v = -v; + if (v > out[i]) out[i] = v; + if (v > out[i + 1]) out[i + 1] = v; + } + } + return out; +} + /** - * The limiter's gain each sample, the same for both sides: under the ceiling everywhere. What each sample needs (the - * ceiling over its loudest side, 1 at most) is taken at its lowest over the next 5 ms, then averaged over 5 ms — each + * The limiter's gain each sample, the same for both sides: under the ceiling everywhere, between the samples too. What + * each sample needs (the ceiling over its loudest side's true peak, 1 at most) is taken at its lowest over the next 5 ms, then averaged over 5 ms — each * average covers the window of every sample before the peak, so it is no higher than the peak needs, and the gain * goes down smoothly over the 5 ms before it — and it comes back up over `release` seconds. */ function limiterGain(chans: readonly Float32Array[], rate: number, ceiling: number, release: number): Float32Array { const n = chans[0].length, L = Math.max(1, Math.round(LOOKAHEAD * rate)); - const need = new Float32Array(n); + const need = new Float32Array(n), peaks = chans.map((s) => truePeaks(s, ceiling)); for (let i = 0; i < n; i++) { let m = 0; - for (const s of chans) m = Math.max(m, Math.abs(s[i])); + for (const p of peaks) if (p[i] > m) m = p[i]; need[i] = m > ceiling ? ceiling / m : 1; } // The lowest over [i, i + L): a queue of indices whose needs rise, the front the lowest. @@ -673,7 +712,7 @@ export const VORBIS_QUALITY = 5; * Goes into every file's name with the clip's key: a clip made the same way is the same file, so a map keeps the bytes * of one it already has (`withSounds`). When what a method does changes, this changes, and a map's files are made again. */ -const ENGINE = "sound 2"; +const ENGINE = "sound 3"; /** What the map's archive gets for a clip: a WAV's bytes now, or the samples of an Ogg Vorbis encoded when the map is built. */ export interface ClipFile { diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 3ab4c55..412f357 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1757,6 +1757,23 @@ User after step 2: "go for step 3". Nothing here makes a new sound beyond three −12.4 / −15.8 LUFS; through a gentler master (threshold −12) −18.5 / −12.9 / −11.4 / −11.5 / −13.3 / −11.4 / −15.7, −12.1 in all, peak −0.57 dB. The check 0.8 s. Lifted game clean. Map 2.7 MB. +- **MIDI** (3e669dc). Import Video… takes a `.mid`: `compiler/notes.ts` reads formats 0 and 1 (running status, a + note struck again ending the first, the first tempo, the time signature as quarter notes a bar, names, programs, + channel 10 the drums) into a `.notes` file of the script — a note four numbers in 960ths of a beat, the MIDI file not + kept. Clicking one shows a roll, a colour a track, and plays it roughly (a sine a note). `music.midi(notes, parts, + { steps, bpm, swing, drums })` makes a song of the tracks `parts` names (by name, or place from 0): notes to the + nearest step, velocity / 100 as "@" in twentieths, overlapping notes split into voices (a note joins a chord struck + with it, of its length and velocity, else the first voice free), a kit's letters by General MIDI number. Windows' + own town.mid (11 tracks, 2,121 notes, never committed): check 0.7 s, build 3.2 s. The dialog and the viewer are + type-checked, not yet run in a browser. +- **The limiter judged by its samples let the wave between them over** — found with town.mid, decoded at 0.99. The + limited PCM's samples peaked at −2.0 dB and its true peak at −0.4: the overshoot was ours, not Vorbis's. `limit` now + takes each sample's need from its true peak (windowed sinc, 16 a side, each eighth of a sample; computed only near + samples over half the ceiling), the ceiling back at −1.5 dB: true peaks −1.5, decoded sample peaks −0.7 (town), + −0.9 (step 2's mix), −1.1 (step 3's B). 8 a side under-read a wave near 22 kHz by 0.4 dB. `ENGINE` is "sound 3": a + file's name says how it was made, and a map keeps a file whose name it has, so a change in what a method does must + change it. The map played for step 3 was built before this (edb73ee). + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/stdlib/music.ts b/stdlib/music.ts index 2431499..9144364 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -784,7 +784,7 @@ export interface ReverbOptions { readonly decay?: number; readonly damping?: num * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`) * into one reverb shared by them all (`reverb`, its room); the whole through `master` — * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over - * −1.5 dB however the parts add: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a + * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a * program plays goes in the map as Ogg Vorbis, two channels when it has two. */ export function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip { diff --git a/tests/sound.test.ts b/tests/sound.test.ts index ce48a54..d1bb1b5 100644 --- a/tests/sound.test.ts +++ b/tests/sound.test.ts @@ -181,6 +181,16 @@ describe("two channels, the limiter and resampling", () => { const quiet = tone("A4", 0.3, { volume: 0.3 }); expect(Array.from(clipSamples(quiet.limit()))).toEqual(Array.from(clipSamples(quiet))); expect(quiet.normalize().peak).toBeCloseTo(LIMIT_CEILING, 3); + // Between the samples too: a tone near the top of hearing whose samples fall either side of its peaks. + const high = tone(19_000, 0.2, { volume: 1, rate: 44100 }).limit(), hs = clipSamples(high); + let between = 0; + for (let i = 64; i < hs.length - 64; i++) for (let p = 1; p < 16; p++) { + let v = 0; + for (let k = -48; k < 48; k++) { const t = k - p / 16, w = 0.42 + 0.5 * Math.cos((Math.PI * t) / 49) + 0.08 * Math.cos((2 * Math.PI * t) / 49); v += hs[i + k] * (Math.sin(Math.PI * t) / (Math.PI * t)) * w; } + between = Math.max(between, Math.abs(v)); + } + expect(between).toBeGreaterThan(0.8); + expect(between).toBeLessThanOrEqual(LIMIT_CEILING * 1.01); // Both sides turned down together, so the balance stays. const [l, r] = clipChannels(loud.pan(0.5).limit()); expect(Math.max(...l) / Math.max(...r)).toBeCloseTo(0.5, 1); From 33d67c0a58fb52e63c0076b3733badd303f62621 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 17:00:22 -0600 Subject: [PATCH 09/14] Plan: step 3 as played Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- docs/v4-plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 412f357..840e8ea 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1757,6 +1757,11 @@ User after step 2: "go for step 3". Nothing here makes a new sound beyond three −12.4 / −15.8 LUFS; through a gentler master (threshold −12) −18.5 / −12.9 / −11.4 / −11.5 / −13.3 / −11.4 / −15.7, −12.1 in all, peak −0.57 dB. The check 0.8 s. Lifted game clean. Map 2.7 MB. +- **Played 2026-10-19** (recording 16.56.34.39): A at 7.631 s, B at 73.488 s — 65.857 s apart, the 1,568 frames + the program waits (A's 1,496 and 72). B against the file: the same level within 0.3 dB second by second; the lag + −34 samples at every 10 s, so no drift over 71 s; 27 dB below 6 kHz, 9.5 above (the recording's AAC, as before); the + worst second 12.9 dB — no dropout; L − R follows the file's at 0.990; peaks 0.94 — no clipping; the outro's tail + followed down to −79 dB, under the recording's floor after. User: "B sounds amazing". - **MIDI** (3e669dc). Import Video… takes a `.mid`: `compiler/notes.ts` reads formats 0 and 1 (running status, a note struck again ending the first, the first tempo, the time signature as quarter notes a bar, names, programs, channel 10 the drums) into a `.notes` file of the script — a note four numbers in 960ths of a beat, the MIDI file not From 01f0547c36f24154f1ff615033dbd76b9f4925ba Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 17:23:06 -0600 Subject: [PATCH 10/14] Music: songs cut into bars and layers that the game steers bar by bar music.stems cuts a song of sections into a clip a bar a layer, each with its notes' tails, echoes and reverb; a Band made in the program says which section and layers play, and conduct plays them at each bar line, with stingers. together() turns clips that play together down by one gain; Clip.ogg(quality) puts a clip in as Ogg Vorbis at its own quality, however short. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/declarations.ts | 13 ++ compiler/generated/stdlib.ts | 2 +- compiler/runtime.ts | 5 +- compiler/sound.ts | 61 +++++++- compiler/vorbis.ts | 2 +- docs/v4-plan.md | 33 +++++ probes/korobeiniki-band.ts | 187 +++++++++++++++++++++++++ stdlib/music.ts | 263 ++++++++++++++++++++++++++++++++++- tests/music.test.ts | 67 +++++++++ tests/sound.test.ts | 26 +++- 10 files changed, 646 insertions(+), 13 deletions(-) create mode 100644 probes/korobeiniki-band.ts diff --git a/compiler/declarations.ts b/compiler/declarations.ts index 368e89d..eb108b1 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -567,6 +567,12 @@ ${kw}interface Clip { * each peak that would go over, and back up over \`release\` seconds (0.1). What a mix goes through last, * so that the parts can add up without cutting off: \`mixdown(parts).limit()\`. */ + /** + * The same clip, put in the map as Ogg Vorbis at \`quality\`, −1 … 10 (5, about 160 kbit/s for two channels, when + * left out): 4 is about 130, 3 about 110 — smaller files, for clips many of which play at once. However short it is + * (a clip shorter than two seconds is otherwise a WAV). + */ + ogg(quality?: number): Clip; limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; /** Louder or quieter, so its loudest sample is \`peak\` (0.84, −1.5 dB, when left out). */ normalize(peak?: number): Clip; @@ -638,6 +644,13 @@ ${kw}function silence(seconds: number, options?: { readonly rate?: number }): Cl * At the first part's rate, or \`rate\`; two channels when a part has two. \`seconds\`: that long at least. */ ${kw}function mixdown(parts: readonly { readonly clip: Clip; readonly at: number }[], seconds?: number, rate?: number): Clip; +/** + * Clips the game will play together, each from its time — the bars of a song played one after another and the layers + * over them — each turned down by one gain, so what they add up to never goes over \`peak\` (0.84, −1.5 dB), between + * the samples too: what a limiter does for a mix, for sounds the game adds up itself. Never turned up. The same clip + * given twice comes back as one. When the script is built: \`const [a, b] = together([{ clip: x, at: 0 }, { clip: y, at: 1 }])\` + */ +${kw}function together(parts: readonly { readonly clip: Clip; readonly at: number }[], options?: { readonly peak?: number }): Clip[]; /** A brush of cover(): which of the shapes (0 the first), centred on tile (x, y) of the picture. */ ${kw}interface Brush { readonly x: number; readonly y: number; readonly shape: number } /** diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index d0173c7..b680948 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n return compose(options, tracks, placedSections);\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi,\n};\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next bar line:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into bars, played bar by bar ── */\n\n/** How a song of sections is cut for the game to play bar by bar (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off at a bar line. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each bar's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the bars may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a bar's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the bars\n * after it cover the rest, and a shorter clip is a smaller map. A layer's last bar keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into bars for the game to play bar by bar, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /** Section `s`'s bar `b` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays none. */\n readonly bars: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n}\n\n/**\n * A song of sections (`arrange`) cut into bars, a clip each bar of each layer, for music that follows the game: at\n * each bar line the program says which section plays and which layers (`Band`, `conduct`). A bar's clip holds the\n * notes that start in it, with their tails, echoes and reverb, so a bar overlaps the next and nothing is cut off,\n * whichever comes after it; a bar the same as another is the same clip, one file in the map. Each layer is mixed as\n * `render` mixes the song, but with no master and no limiter between the parts — the game adds the clips up — so\n * all of them are turned down together until the song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a bar, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const bar = s.barFrames, barSeconds = bar * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0);\n // The notes of each bar of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / bar));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last bar with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A bar of a layer, mixed: each part's notes from the bar line, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the bar's last note ends, its release and all: a riser four bars long is kept whole.\n let end = barSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * bar) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * bar) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this bar's notes where they fall in it, then the bar cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * bar - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the bar's notes, kept for `tail` seconds and faded over the last of them — the bars after\n // cover the rest — but all of it after the layer's last bar.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through; the stingers on their own.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n const fitted = together(placed, { peak: options.peak ?? 0.84 });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingerNames = Object.keys(options.stingers ?? {});\n const stingers = stingerNames.map((k) => options.stingers![k]);\n let start = 0;\n const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: bar, bpm: s.bpm, beats: Math.round(((s.bpm * bar * FRAME) / 60) * 1000) / 1000,\n layers: Object.freeze(layers), sections: s.sections, bars: Object.freeze(bars), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which bar of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a bar at each bar line as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // at the next bar line\n * band.mute(stems.layer(\"drums\"));\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`, and the bar of it, from 0. */\n section = 0;\n bar = 0;\n /** Bars played since it started. */\n count = 0;\n /** The game's frame the bar playing began on: how far into the bar it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether at the next bar line (`go`) or when this one ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next bar line, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it at the next bar line (what sounds rings out). */\n playing = true;\n\n /** Go to a section at the next bar line, from its first bar. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from the next bar line. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next bar line. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop at the next bar line. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, bar by bar, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n while (band.playing) {\n // On the bar line: where to be.\n if (band.next >= 0 && (band.now || band.bar === 0)) {\n band.section = band.next;\n band.bar = 0;\n band.next = -1;\n }\n band.at = memory.game.frame;\n // The bar's clip of each layer that is on. (Which clip, by test: a clip is no value a program holds.)\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let b = 0; b < s.sections[k].bars; b++) if (band.bar === b) {\n for (let l = 0; l < s.layers.length; l++) if (s.bars[k][b][l]) if (band.on[l]) playWav(s.bars[k][b][l]!, 0);\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n await sleep(frames(s.barFrames));\n band.count++;\n band.bar++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.bar >= s.sections[k].bars) {\n band.bar = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/compiler/runtime.ts b/compiler/runtime.ts index e05daa0..7da889f 100644 --- a/compiler/runtime.ts +++ b/compiler/runtime.ts @@ -29,7 +29,7 @@ import { PLAYER_COLORS, TABLE_FIELDS, TABLE_SIZE, tableOfBrand, type TableField, import { LOCAL_FIELDS, localName, MEMORY_FIELDS, MEMORY_GROUPS, memoryTableField } from "./memory"; import { coverValue } from "./cover"; import { isVideo } from "./media"; -import { clipFile, fm, isClip, mixdown, noise, pluck, silence, tone, type ClipFile } from "./sound"; +import { clipFile, fm, isClip, mixdown, noise, pluck, silence, together, tone, type ClipFile } from "./sound"; /** `memory(address, …)` reads `deaths` at player `EPD(address)`, unit 0: the deaths table starts here in 1.16.1's memory. */ export const DEATHS_TABLE_ADDRESS = 0x58a364; @@ -632,6 +632,7 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: rt.noise = built(noise); rt.silence = built(silence); rt.mixdown = built(mixdown); + rt.together = built(together); rt.fm = built(fm); rt.pluck = built(pluck); rt.centerLocation = () => { throw new ScriptError("centerLocation() moves a location while the game runs: use it inside program(), as a statement — centerLocation(locations.Cursor, x, y)."); }; @@ -812,6 +813,6 @@ export function runtimeNames(names: ScriptNames): string[] { out.push("seconds", "minutes", "frames", "cycles", "sleep", "nextFrame", "until", "sync", "select", "local", "start", "rose", "once", "shared", "clamp", "u32", "i32", "fixed"); out.push(...READER_NAMES, "races", "slots", "name", "color", "print"); out.push(...UNIT_CALL_NAMES, "colors", ...INPUT_CALL_NAMES); - out.push("textColors", "ui", "on", "reserveLocation", "cover", "tone", "noise", "silence", "mixdown", "fm", "pluck", "__jsx", "__fragment", "__class", "__fn", "__listen"); + out.push("textColors", "ui", "on", "reserveLocation", "cover", "tone", "noise", "silence", "mixdown", "together", "fm", "pluck", "__jsx", "__fragment", "__class", "__fn", "__listen"); return out; } diff --git a/compiler/sound.ts b/compiler/sound.ts index a8ea410..ad1def7 100644 --- a/compiler/sound.ts +++ b/compiler/sound.ts @@ -236,6 +236,7 @@ export interface Clip { limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; normalize(peak?: number): Clip; wav(): Clip; + ogg(quality?: number): Clip; filter(type: FilterType, cutoff: number | Points, options?: { readonly resonance?: number; readonly gain?: number; readonly q?: number }): Clip; drive(dB: number): Clip; delay(seconds: number, options?: { readonly feedback?: number; readonly mix?: number; readonly tone?: number; readonly pingPong?: boolean }): Clip; @@ -252,6 +253,8 @@ interface ClipInside { peak?: number; adpcm?: Uint8Array; wav?: boolean; + /** The Ogg Vorbis quality it goes in the map at, when it is one (`VORBIS_QUALITY` when not said). */ + quality?: number; /** What the clip is (`keyOf`). */ key?: string; } @@ -502,6 +505,11 @@ function lazyClip(rate: number, frames: number, channels: 1 | 2, compute: () => if (!(num(to, "normalize()") > 0 && to <= 1)) throw new RangeError(`normalize() makes the loudest sample more than 0 and at most 1, not ${to}.`); return same("normalize", [to], (cs) => { const p = clip.peak; return p === 0 ? cs : cs.map((s) => scale(s, to / p)); }); }, + ogg(quality = VORBIS_QUALITY) { + if (!(num(quality, "ogg()'s quality") >= -1 && quality <= 10)) throw new RangeError(`ogg()'s quality is −1 to 10, not ${quality}.`); + if (wav) throw new Error("This clip was made a WAV (wav()): it cannot be an Ogg as well."); + return made(`${keyOf(clip)}|ogg ${quality}`, () => lazyClip(rate, frames, channels, () => samplesOf(clip), { quality })); + }, wav() { if (wav) return clip; const own = inside.get(clip)!; @@ -691,6 +699,48 @@ export function mixdown(parts: unknown, seconds?: unknown, rate?: number): Clip })); } +/** + * Clips that play together, each from its time — the bars of a song the game plays one after another and the layers + * over them — each turned down by one gain, so what they add up to never goes over `peak` (0.84, −1.5 dB), between the + * samples too, however loud each is: what a limiter does for a mix, for sounds the game adds up itself. Never turned + * up. A clip given several times is one clip back, and one file in the map. The gain is worked out when a build needs + * the samples, from the whole mix. + */ +export function together(parts: unknown, options?: { readonly peak?: number }): Clip[] { + if (!Array.isArray(parts) || parts.length === 0) throw new Error("together() takes { clip, at } parts, one at least."); + const list = parts.map((p, k) => { + const c = (p as { clip?: unknown })?.clip, at = (p as { at?: unknown })?.at ?? 0; + if (!isClip(c)) throw new Error(`together()'s part ${k + 1} has no clip.`); + if (num(at, "a part's time") < 0) throw new RangeError("A part's time is 0 seconds or more."); + return { clip: c, at: at as number }; + }); + const peak = options?.peak ?? LIMIT_CEILING; + if (!(num(peak, "together()'s peak") > 0 && peak <= 1)) throw new RangeError(`together()'s peak is above 0 and 1 at most, not ${peak}.`); + const whole = recipe("together", [list.map((p) => [p.clip, p.at]), peak]); + let gain: number | undefined; + const turn = () => { + if (gain === undefined) { + const sum = samplesOf(mixdown(list)); + let top = 0; + for (const side of sum) for (let i = 0; i < side.length; i++) { const x = side[i] < 0 ? -side[i] : side[i]; if (x > top) top = x; } + // The wave between the samples, near the loudest ones. + for (const side of sum) { const tp = truePeaks(side, top); for (let i = 0; i < tp.length; i++) if (tp[i] > top) top = tp[i]; } + gain = top > peak ? peak / top : 1; + } + return gain; + }; + const back = new Map(); + return list.map(({ clip }) => { + let c = back.get(clip); + if (!c) { + const own = inside.get(clip)!; + c = made(recipe("together part", [whole, clip]), () => lazyClip(clip.rate, own.frames, clip.channels, () => { const g = turn(); return samplesOf(clip).map((side) => { const out = new Float32Array(side.length); for (let i = 0; i < side.length; i++) out[i] = side[i] * g; return out; }); })); + back.set(clip, c); + } + return c; + }); +} + /** A clip's samples, mono (for the compiler's own work and the editor's player, not in the declarations). */ export const clipSamples = (c: Clip): Float32Array => { const ch = samplesOf(c); @@ -703,7 +753,7 @@ export const clipChannels = (c: Clip): readonly Float32Array[] => samplesOf(c); * At this length or more a clip a program plays goes in the map as Ogg Vorbis (Remastered plays it; 1.16.1 plays only * WAVs): a song, mixed, is a tenth the size and keeps its top and its two sides. Shorter ones stay WAVs, which start on * their frame (the sound probe), as do an import as it was (its own ADPCM), a clip a trigger plays (`runtime.ts`), and - * one made `wav()`. + * one made `wav()`. One made `ogg()` is an Ogg however short. */ export const VORBIS_SECONDS = 2; /** libvorbis's quality, −1 … 10: 5 is about 160 kbit/s for two channels at 44.1 kHz. */ @@ -718,7 +768,7 @@ const ENGINE = "sound 3"; export interface ClipFile { path: string; bytes?: Uint8Array; - vorbis?: { rate: number; channels: readonly Float32Array[] }; + vorbis?: { rate: number; channels: readonly Float32Array[]; quality: number }; } /** @@ -728,9 +778,10 @@ export interface ClipFile { */ export function clipFile(c: Clip, contents = true): ClipFile { const own = inside.get(c)!; - if (!own.wav && !own.adpcm && c.seconds >= VORBIS_SECONDS) { - const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|vorbis ${VORBIS_QUALITY}|${keyOf(c)}`)}.ogg`; - return contents ? { path, vorbis: { rate: c.rate, channels: samplesOf(c) } } : { path }; + if (!own.wav && !own.adpcm && (c.seconds >= VORBIS_SECONDS || own.quality !== undefined)) { + const quality = own.quality ?? VORBIS_QUALITY; + const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|vorbis ${quality}|${keyOf(c)}`)}.ogg`; + return contents ? { path, vorbis: { rate: c.rate, channels: samplesOf(c), quality } } : { path }; } const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|wav|${keyOf(c)}`)}.wav`; if (!contents) return { path }; diff --git a/compiler/vorbis.ts b/compiler/vorbis.ts index ccf704f..d2a5983 100644 --- a/compiler/vorbis.ts +++ b/compiler/vorbis.ts @@ -99,7 +99,7 @@ export function finishSounds(sounds: ClipFile[], encoder: VorbisEncoder | null): if (encoder) { let bytes = encoded.get(s.path); if (!bytes) { - bytes = encoder.encode(s.vorbis.channels, s.vorbis.rate); + bytes = encoder.encode(s.vorbis.channels, s.vorbis.rate, s.vorbis.quality); if (encoded.size >= KEEP) encoded.delete(encoded.keys().next().value!); encoded.set(s.path, bytes); } diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 840e8ea..2ce12d8 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1779,6 +1779,39 @@ User after step 2: "go for step 3". Nothing here makes a new sound beyond three file's name says how it was made, and a map keeps a file whose name it has, so a change in what a method does must change it. The map played for step 3 was built before this (edb73ee). +### Sounds: step 4, music that follows the game (built 2026-10-19) + +User after step 3 ("B sounds amazing"): "go for step 4". + +- **What the game allows.** No sound stops; a sound starts on the mixer's 21.3 ms grid after its frame; 32 at once, + added with no limiter. So the unit is a bar: `music.stems(song, { layers, tail, quality, peak, stingers })` cuts a + song of sections into a clip a bar a layer — the notes that start in the bar with their releases, echoes and reverb + (each bar its own reverb from its own sends), so a bar overlaps what follows whatever follows it. A bar the same as + another is the same clip. A bar under a section's effects is mixed where it falls in the whole section, then cut out, + so a sweep or a pump is where it was. +- **Tails.** A bar's clip keeps `tail` (2 s) after its last note ends, faded over the last second — the bars after + cover the rest; a layer's last bar keeps all of it (the outro rings out). From the bar line instead of the last note + cut the build's four-bar riser before its top (−3.7 dB of highs): measured, fixed. 3 s → 2 s: 7.2 → 6.5 MB. +- **Level.** The game adds the clips up, so there is no master compressor or limiter over them: `together(parts)` + (sound.ts, new) turns every bar down by one gain until the song played through stays under 0.84 between samples, + worked out when a build needs it. Played through, every layer on: −16.3 LUFS, sample peak −1.9 dB, true −1.2 (B was + −12.1 through its master). Baking the master's gain curve into each bar would make it as loud, but a bar played twice + would then be two files, and a check must name files without samples — not done. +- **Size.** `Clip.ogg(quality)` (new): Ogg Vorbis at its own quality, however short. Bars at 4 (70% of 5's bytes, by + ffmpeg's encoder on 20 of them). Korobeiniki, 40 bars × 4 layers: 114 distinct clips, all Ogg, 5.4 MB (8.0 MB before + the section-effect bars stopped running to their section's end and the tails were cut). The bass bars, 1.7 s, are + the first short Oggs a map plays: the recording says whether they start on time. +- **Faithful.** Every bar summed at its time against the continuous render (no master) scaled by one gain: the + samples differ (10 dB: chorus and the plate's modulation start again each bar) but lows, mids and highs match second + by second within 0–0.5 / −0.1–1.0 / 0–1.5 dB (the highs the bars' master shelf, +1.5 dB at 9 kHz). +- **The game side.** `class Band` (made inside the program; fields the game holds: section, bar, count, at, next/now, + loop, on[8], stung, playing; `go`, `queue`, `mute`, `unmute`, `sting`, `stop`) and `conduct(stems, band)`, a task + sleeping a bar at a time: on the bar line a pending section, then each layer's clip by unrolled test (a clip is no + value a program holds — `const c = s.bars[k][b][l]` in game code is refused; written inline it folds), the stinger, + then on, wrapping a section or the song. Simulated in the tests: jump, mute, stinger, loop at the bar lines. +- **Probe** `probes/korobeiniki-band.ts` (step 3's B as stems, 4 layers; keys 1–7 sections, D B H M layers, L loop, + Space crash): build 18 s, map 5.6 MB, check 1.1 s, lifted game clean. + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/probes/korobeiniki-band.ts b/probes/korobeiniki-band.ts new file mode 100644 index 0000000..6306788 --- /dev/null +++ b/probes/korobeiniki-band.ts @@ -0,0 +1,187 @@ +// Step 4 of the music library's quality work, heard in StarCraft: Remastered: Korobeiniki (a Russian folk song, in +// the public domain; the melody only — the rest is this file's own), step 3's arrangement cut into bars and layers +// (`music.stems`) and played bar by bar as the program says (`Band`, `music.conduct`): music that follows the game. +// Left alone it plays the song through, bar by bar, and starts again; the keys steer it. Host it as a multiplayer +// game, alone, with the game's music off. +// +// 1 … 7 go to a section at the next bar line: intro, verse, build, drop, break, last verse, outro +// D B H M the drums, the bass, the harmony (pad, arpeggio, piano), the melody: off or on from the next bar line +// L a section plays again when it ends (or goes on to the next) +// Space a crash on the next bar line +// +// npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx +// npx tsx scripts/build-fixture.mts probes/korobeiniki-band.ts fixtures/eud/korobeiniki-band.scx fixtures/eud/sound-base.scx +import { music, Band } from "trigscript/music"; +/* ── The notes ── */ + +// The melody in eighths, a bar a string (the folk song's two phrases). +const MELODY = [ + "E5 - B4 C5 D5 - C5 B4", "A4 - A4 C5 E5 - D5 C5", "B4 - - C5 D5 - E5 -", "C5 - A4 - A4 - . .", + "D5 - - F5 A5 - G5 F5", "E5 - - C5 E5 - D5 C5", "B4 - B4 C5 D5 - E5 -", "C5 - A4 - A4 - . .", +]; +const tune = music.stretch(MELODY, 2) as readonly string[]; +const BEAT = 60 / 143; +const echo = (c: Clip) => c.delay(BEAT * 0.75, { feedback: 0.3, mix: 0.22, pingPong: true, tone: 4000 }); +/** `x`, `n` times over. */ +const times = (n: number, x: T): T[] => Array.from({ length: n }, () => x); +const REST = times(16, ".").join(" "); +const rest = (n = 1) => times(n, REST); +/** A bar of one held chord. */ +const held = (notes: string) => [notes, ...times(15, "-")].join(" "); + +/* ── Step 2's helpers ── */ + +type Chord = readonly [root: string, fifth: string, notes: string]; +const CHORDS: readonly Chord[] = [ + ["A1", "E2", "A3+C4+E4"], ["A1", "E2", "A3+C4+E4"], ["E1", "B1", "G#3+B3+E4"], ["A1", "E2", "A3+C4+E4"], + ["D2", "A1", "A3+D4+F4"], ["C2", "G1", "G3+C4+E4"], ["E1", "B1", "G#3+B3+E4"], ["A1", "E2", "A3+C4+E4"], +]; +const AM: Chord = CHORDS[0]; +const up = (note: string, by: number) => note.replace(/(-?\d)$/, (o) => String(Number(o) + by)); +const chordUp = (notes: string, by: number) => notes.split("+").map((n) => up(n, by)).join("+"); +const bassBar = ([r, f]: Chord) => [r, "-", ".", r, ".", r, r, "-", f, "-", ".", f, ".", up(r, 1), r, "-"].join(" "); +const acidBar = ([r, f]: Chord) => { + const R = up(r, 1), F = up(f, 1), O = up(r, 2); + return [R, ".", O, ".", F, R, ".", O, R, ".", F, ".", O, ".", R, F].join(" "); +}; +const arpBar = ([, , notes]: Chord) => { + const [a, b, c] = notes.split("+").map((n) => up(n, 1)); + return [a, b, c, up(a, 1), c, b, a, b, c, up(a, 1), up(b, 1), up(a, 1), c, b, c, b].join(" "); +}; +const EIGHTHS = [0, 2, 4, 6, 8, 10, 12, 14], ALL = Array.from({ length: 16 }, (_, i) => i); +const GROOVE = music.beat({ k: [0, 7, 8, 10], s: [4, 12], h: EIGHTHS }); +const DRIVE = music.beat({ k: [0, 7, 8, 10], s: [4, 12], c: [4, 12], h: ALL.filter((i) => i % 4 !== 2), o: [2, 6, 10, 14] }); +const FILL = music.beat({ k: [0, 8], s: [4, 10, 11, 12, 13, 14, 15], t: [8, 9], h: [0, 2, 6] }); +const BUILD = [music.beat({ h: EIGHTHS }), music.beat({ k: [0, 8], h: EIGHTHS, s: [12, 14, 15] })]; +const pads = (list: readonly Chord[]) => list.map(([, , n]) => held(n)); +const octaveUp = (bars: readonly string[]) => music.transpose(bars, 12) as readonly string[]; + +const ROOM = { decay: 0.72, damping: 0.45, predelay: 0.02 }; + +/* ── Step 3's arrangement ── */ + +const PROG = "Am Am E Am Dm C E Am"; +const HALF = "Am Am E Am"; +const BAR = music.bars(143), SPB = BAR / 4; +// A chord a bar, voiced near the one before; the arpeggio, the bass and the acid line drawn over each chord by number. +const padBars = (p: string, octave = 3) => music.chords(p, held("1+2+3"), { octave, lead: true }); +const arps = (p: string) => music.chords(p, "1 2 3 4 3 2 1 2 3 4 5 4 3 2 3 2", { octave: 4, lead: true }); +const BASS = "1 - . 1 . 1 1 - 3 - . 3 . 1^ 1 -"; +const bassOf = (p: string) => music.chords(p, BASS, { octave: 1 }).map((bar, k) => ("CD".includes(music.chords(p, "1")[k][0]) ? music.transpose(bar, 12) as string : bar)); +const acidOf = (p: string) => music.chords(p, "1^ . 1^^ . 3^ 1^ . 1^^ 1^ . 3^ . 1^^ . 1^ 3^", { octave: 1 }).map((bar) => bar); +const hatsBar = (open: boolean) => times(4, `h@0.45 h@0.22 ${open ? "o@0.55" : "h@0.8"} h@0.25`).join(" "); +// The snare roll of the build: eighths, then sixteenths, louder bar by bar. +const roll = [ + music.beat({ k: [0, 4, 8, 12], s: [4, 12] }), + music.beat({ k: [0, 4, 8, 12], s: [0, 2, 4, 6, 8, 10, 12, 14] }), + ALL.map((i) => (i % 4 === 0 ? "ks" : "s") + `@${(0.45 + i * 0.02).toFixed(2)}`).join(" "), + ALL.map((i) => (i < 12 ? "s" : ".") + (i < 12 ? `@${(0.75 + i * 0.02).toFixed(2)}` : "")).join(" "), +]; +const FLOOR = music.beat({ k: [0, 4, 8, 12], s: [4, 12], c: [4, 12] }); +const FLOOR_FILL = music.beat({ k: [0, 4, 8, 10], s: [4, 12, 13, 14, 15], c: [4], t: [10, 11] }); +const sweep = (from: number, to: number) => (c: Clip, t: { seconds: number }) => c.filter("lowpass", [[0, from], [t.seconds, to]], { resonance: 0.2 }); +const kit = music.drums({ volume: 0.6 }); +const crash = kit.hits.x; +const fxKit = music.kit({ + r: music.riser(music.bars(143, 4), { volume: 0.32 }), + q: music.riser(music.bars(143, 2), { volume: 0.3 }), + i: music.impact({ volume: 0.55 }), + // A crash backwards: swelling into the bar after. + z: crash.trim(0, BAR).reverse().fade(0.4, 0.005), +}); +const pumping = music.pump(SPB, 0.5); +const up2 = (p: readonly string[]) => music.transpose(p, 2) as readonly string[]; + +const parts = { + lead: music.part(music.presets.lead({ volume: 0.15 }), { pan: -0.1, fx: echo, reverb: 0.25 }), + saw: music.part(music.presets.supersaw({ volume: 0.24 }), { fx: (c) => echo(c).chorus({ mix: 0.25 }), reverb: 0.3 }), + bell: music.part(music.presets.bell({ volume: 0.035 }), { pan: 0.3, reverb: 0.55 }), + pad: music.part(music.presets.pad({ volume: 0.075 }), { fx: (c) => c.filter("highpass", 180).chorus({ mix: 0.35 }), reverb: 0.5 }), + strum: music.part(music.presets.pluck({ volume: 0.15, decay: 0.9 }), { pan: 0.45, fx: (c) => c.delay(BEAT / 2, { feedback: 0.25, mix: 0.2, pingPong: true }), reverb: 0.35 }), + keys: music.part(music.presets.keys({ volume: 0.11 }), { pan: -0.2, fx: (c) => c.chorus({ mix: 0.3 }), reverb: 0.4 }), + acid: music.part(music.presets.acid({ volume: 0.13 }), { pan: -0.35, fx: echo, reverb: 0.15 }), + bass: music.part(music.presets.bass({ volume: 0.38 }), { fx: (c) => c.compress({ threshold: -20, ratio: 3 }) }), + kit: music.part(kit, { fx: (c) => c.compress({ threshold: -16, ratio: 3, attack: 0.003 }), reverb: 0.1 }), + hats: music.part(kit, { pan: 0.15, reverb: 0.08 }), + fx: music.part(fxKit, { reverb: 0.35 }), +}; + +const intro = music.section(4, { + pad: padBars(HALF), strum: arps(HALF), bass: [...rest(2), ...bassOf("Am Am")], + hats: [REST, REST, times(8, "h@0.3 .").join(" "), hatsBar(false)], fx: [REST, REST, REST, "z" + " .".repeat(15)], +}, { name: "intro", fx: { pad: sweep(300, 9000), strum: sweep(500, 12000) } }); +const verse = music.section(8, { + lead: tune, pad: padBars(PROG), bass: bassOf(PROG), + kit: [GROOVE.replace(/^k/, "kx"), ...times(6, GROOVE), FILL], hats: hatsBar(false), +}, { name: "verse", volume: { lead: 0.8, pad: 0.55, bass: 0.75, kit: 0.7, hats: 0.7 } }); +const build = music.section(4, { + bell: octaveUp(tune.slice(0, 4)), pad: padBars(HALF), strum: arps(HALF), + bass: music.chords(HALF, "1 . 1 . 1 . 1 . 1 . 1 . 1 . 1 .", { octave: 1 }), + kit: roll, hats: [hatsBar(false), hatsBar(false), times(16, "h@0.5").join(" "), REST], fx: "r" + " .".repeat(63), +}, { name: "build", fx: { pad: sweep(400, 12000), strum: sweep(800, 14000) }, volume: { bass: 0.8 } }); +const drop = music.section(8, { + saw: tune, bell: octaveUp(tune), pad: padBars(PROG), strum: arps(PROG), bass: bassOf(PROG), + kit: [FLOOR.replace(/^k/, "kx"), ...times(6, FLOOR), FLOOR_FILL], hats: hatsBar(true), fx: ["i" + " .".repeat(15), ...rest(7)], +}, { name: "drop", fx: { pad: pumping, bass: pumping, strum: pumping } }); +const breakdown = music.section(4, { + bell: octaveUp(tune.slice(4, 8)), pad: padBars("Dm C E Am", 4), + keys: music.chords("Dm C E Am", "1+2+3 - - . . . 1+2+3 - . . 1+2+3 - - - . .", { octave: 3, lead: true }), + bass: music.chords("Dm C E Am", held("1"), { octave: 2 }), + kit: [REST, REST, music.beat({ k: [0, 8] }), music.beat({ k: [0], s: [8, 10, 12, 13, 14, 15] })], + hats: [REST, REST, times(8, "h@0.35 .").join(" "), hatsBar(false)], fx: [REST, REST, "q" + " .".repeat(15), REST], +}, { name: "break", volume: { pad: 0.45, keys: 0.7, bass: 0.6, bell: 0.8 } }); +const last = music.section(8, { + saw: up2(tune), bell: up2(octaveUp(tune)), pad: up2(padBars(PROG)), acid: up2(acidOf(PROG)), bass: up2(bassOf(PROG)), + kit: [FLOOR.replace(/^k/, "kx"), ...times(6, FLOOR), FLOOR_FILL], hats: hatsBar(true), fx: ["i" + " .".repeat(15), ...rest(7)], +}, { name: "last verse, a tone up", fx: { pad: pumping, bass: pumping } }); +const outro = music.section(4, { + saw: [held("B4"), ...rest(3)], bell: [held("B5"), ...rest(3)], pad: up2([held("A3+C4+E4"), held("A3+C4+E4"), ...rest(2)]), + bass: [held("B1"), ...rest(3)], kit: [music.beat({ k: [0], x: [0] }), ...rest(3)], fx: ["i" + " .".repeat(15), ...rest(3)], +}, { name: "outro", fx: { pad: (c, t) => c.filter("lowpass", [[0, 6000], [t.seconds, 250]]) } }); + +const step3 = music.arrange({ bpm: 143, steps: 4, swing: 0.08 }, parts, intro, verse, build, drop, breakdown, last, outro); + +/* ── Cut for the game ── */ + +const LAYERS = ["drums", "bass", "harmony", "melody"]; +const st = music.stems(step3, { + layers: { drums: ["kit", "hats", "fx"], bass: ["bass"], harmony: ["pad", "strum", "keys"], melody: ["lead", "saw", "bell", "acid"] }, + reverb: ROOM, + master: (c) => c.filter("highshelf", 9000, { gain: 1.5 }), + stingers: { crash: crash.mix(music.impact({ volume: 0.45 })) }, +}); +const NAMES = st.sections.map((x) => x.name); +const KEYS = ["1", "2", "3", "4", "5", "6", "7"] as const; +const LAYER_KEYS = ["D", "B", "H", "M"] as const; + +const PARK = reserveLocation("korobeiniki: the camera"); + +program(async () => { + removeUnit(P1, units.AnyUnit); + removeUnit(P2, units.AnyUnit); + centerLocation(PARK, 64 * 32, 112 * 32); + centerView(PARK); + displayText(`\x04Korobeiniki, bar by bar: ${st.sections.length} sections, ${st.layers.length} layers, ${st.voices} sounds at once at most. Game music off.`); + displayText("\x04Let it play through once untouched, then: 1-7 a section, D B H M a layer, L loop, Space a crash."); + await sleep(frames(72)); + const band = new Band(); + start(music.conduct(st, band)); + let shown = -1; + while (true) { + for (let k = 0; k < KEYS.length; k++) if (keyPressed(P1, KEYS[k])) { band.go(k); displayText(`\x04→ ${NAMES[k]} at the next bar`); } + for (let l = 0; l < LAYER_KEYS.length; l++) if (keyPressed(P1, LAYER_KEYS[l])) { + band.on[l] = !band.on[l]; + if (band.on[l]) displayText(`\x04${LAYERS[l]} on`); else displayText(`\x04${LAYERS[l]} off`); + } + if (keyPressed(P1, "L")) { + band.loop = !band.loop; + if (band.loop) displayText("\x04each section plays again"); else displayText("\x04on through the song"); + } + if (keyPressed(P1, "Space")) band.sting(0); + if (band.section !== shown) { + shown = band.section; + for (let k = 0; k < NAMES.length; k++) if (band.section === k) displayText(`\x03${NAMES[k]}`); + } + await nextFrame(); + } +}); diff --git a/stdlib/music.ts b/stdlib/music.ts index 9144364..5ee4abe 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -37,13 +37,23 @@ * }); * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro); * + * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers + * bar by bar — which section, which layers, a stinger on the next bar line: + * + * const cut = music.stems(song, { layers: { drums: ["drums"], rest: ["keys", "bass"] } }); + * program(async () => { + * const band = new Band(); // import { music, Band } from "trigscript/music" + * start(music.conduct(cut, band)); + * if (danger) band.go(cut.section("chorus")); // at the next bar line + * }); + * * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down. * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing, * nothing to change while it plays. A program plays to its own player; a program for each player plays to each. */ -import { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, type Clip, type Notes } from "trigscript"; +import { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from "trigscript"; /** Seconds a frame lasts on Fastest, the speed a map is played at. */ const FRAME = 0.042; @@ -541,6 +551,8 @@ export interface SongSection { readonly name: string; readonly bar: number; read /** Steps of a track that belong to one section, and what that section does to them. */ interface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime } const piecesOf = new WeakMap(); +/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */ +const partsOf = new WeakMap(); /** * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says @@ -573,7 +585,9 @@ export function arrange(options: SongOptions, parts: Readonly { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); }); - return compose(options, tracks, placedSections); + const made = compose(options, tracks, placedSections); + partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] }))); + return made; } /** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */ @@ -828,6 +842,249 @@ export function render(s: Song, options: { readonly rate?: number; readonly limi return limit === false ? mixed : mixed.limit(limit === true ? {} : limit); } +/* ── Music that follows the game: a song cut into bars, played bar by bar ── */ + +/** How a song of sections is cut for the game to play bar by bar (`stems`). */ +export interface StemsOptions { + /** + * Which parts each layer holds, by name — `{ drums: ["kit", "hats"], bass: ["bass"], melody: ["lead", "saw"] }` — a + * layer the game can turn on and off at a bar line. A part in no layer is left out. + */ + readonly layers: Readonly>; + /** Samples a second (44 100). */ + readonly rate?: number; + /** The reverb's room, as `render` takes it. */ + readonly reverb?: ReverbOptions; + /** What each bar's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */ + readonly master?: (clip: Clip) => Clip; + /** The loudest the bars may add up to as the song plays through, every layer on (0.84, −1.5 dB). */ + readonly peak?: number; + /** + * Seconds a bar's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the bars + * after it cover the rest, and a shorter clip is a smaller map. A layer's last bar keeps all of its tail. + */ + readonly tail?: number; + /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */ + readonly quality?: number; + /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. */ + readonly stingers?: Readonly>; +} + +/** A song cut into bars for the game to play bar by bar, with `conduct` and a `Band`: made when the script is built. */ +export interface Stems { + readonly kind: "stems"; + /** Frames a bar lasts. */ + readonly barFrames: number; + readonly bpm: number; + /** Beats a bar. */ + readonly beats: number; + /** The layers' names, in the order `band.on` has them. */ + readonly layers: readonly string[]; + /** The sections, in the order the song plays them: their names and how many bars. */ + readonly sections: readonly SongSection[]; + /** Section `s`'s bar `b` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays none. */ + readonly bars: readonly (readonly (readonly (Clip | null)[])[])[]; + readonly stingers: readonly Clip[]; + /** A section's place in `sections`, by its name: `band.go(stems.section("drop"))`. */ + section(name: string): number; + /** A layer's place in `layers`, by its name: `band.mute(stems.layer("drums"))`. */ + layer(name: string): number; + /** A stinger's place, by its name: `band.sting(stems.stinger("crash"))`. */ + stinger(name: string): number; + /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */ + readonly voices: number; +} + +/** + * A song of sections (`arrange`) cut into bars, a clip each bar of each layer, for music that follows the game: at + * each bar line the program says which section plays and which layers (`Band`, `conduct`). A bar's clip holds the + * notes that start in it, with their tails, echoes and reverb, so a bar overlaps the next and nothing is cut off, + * whichever comes after it; a bar the same as another is the same clip, one file in the map. Each layer is mixed as + * `render` mixes the song, but with no master and no limiter between the parts — the game adds the clips up — so + * all of them are turned down together until the song played through stays under `peak`. + */ +export function stems(s: Song, options: StemsOptions): Stems { + const parts = partsOf.get(s), score = scores.get(s); + if (!parts || !score) throw new Error("stems() cuts a song made of sections, by music.arrange."); + const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2; + if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a bar, more than 0, not ${tail}.`); + if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`); + const layerOf = new Map(); + layers.forEach((name, l) => { + for (const p of options.layers[name]) { + const found = parts.find((x) => x.name === p); + if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(", ")}.`); + if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`); + layerOf.set(found.track, l); + } + }); + const bar = s.barFrames, barSeconds = bar * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0); + // The notes of each bar of each layer, by part. + const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map())); + for (const n of score) { + const l = layerOf.get(n.track); + if (l === undefined) continue; + const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / bar)); + const m = cells[g][l], list = m.get(n.track); + if (list) list.push(n); else m.set(n.track, [n]); + } + const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); }; + // Each layer's last bar with notes: it rings out whole, as the song ends. + const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; }); + // A bar of a layer, mixed: each part's notes from the bar line, through what the part goes through, and the reverb. + const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => { + const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = []; + // Where the bar's last note ends, its release and all: a riser four bars long is kept whole. + let end = barSeconds; + for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * bar) * FRAME + clipOf(t, n).seconds); + for (const [t, notes] of m) { + const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx); + const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * bar) * FRAME })); + if (apart.length) { + // A section's effects take the whole section: this bar's notes where they fall in it, then the bar cut out. + const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * bar - from) * FRAME; + const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time); + entries.push({ clip: whole.trim(into, whole.seconds), at: 0 }); + } + let part = mixdown(entries, undefined, rate); + if (t.fx) part = t.fx(part); + if (t.volume !== 1) part = part.gain(t.volume); + if (t.pan !== 0) part = part.pan(t.pan); + out.push({ clip: part, at: 0 }); + if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 }); + } + if (out.length === 0) return null; + if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 }); + let mixed = mixdown(out, undefined, rate); + if (options.master) mixed = options.master(mixed); + // What rings on after the bar's notes, kept for `tail` seconds and faded over the last of them — the bars after + // cover the rest — but all of it after the layer's last bar. + const keep = end + tail; + return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed; + })); + // All of them turned down together, as the song plays through; the stingers on their own. + const placed: { clip: Clip; at: number; g: number; l: number }[] = []; + raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); })); + if (placed.length === 0) throw new Error("stems(): the layers play no notes."); + const fitted = together(placed, { peak: options.peak ?? 0.84 }); + const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null)); + const quality = options.quality ?? 4; + placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); }); + const stingerNames = Object.keys(options.stingers ?? {}); + const stingers = stingerNames.map((k) => options.stingers![k]); + let start = 0; + const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); }); + const lookup = (what: string, list: readonly string[], name: string) => { + const k = list.indexOf(name); + if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(", ") || "none"}.`); + return k; + }; + let voices = -1; + return Object.freeze({ + kind: "stems" as const, barFrames: bar, bpm: s.bpm, beats: Math.round(((s.bpm * bar * FRAME) / 60) * 1000) / 1000, + layers: Object.freeze(layers), sections: s.sections, bars: Object.freeze(bars), stingers: Object.freeze(stingers), + section: (name: string) => lookup("section", s.sections.map((x) => x.name), name), + layer: (name: string) => lookup("layer", layers, name), + stinger: (name: string) => lookup("stinger", stingerNames, name), + get voices() { + if (voices < 0) { + voices = 0; + for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); } + } + return voices; + }, + }); +} + +/** + * The music as the game steers it: which section plays, which bar of it, which layers. Make one inside the program + * that steers the music and hand it to `conduct`, which plays a bar at each bar line as it says: + * + * import { music, Band } from "trigscript/music"; + * const band = new Band(); + * start(music.conduct(stems, band)); + * band.go(stems.section("drop")); // at the next bar line + * band.mute(stems.layer("drums")); + * + * With nothing said, the song plays through its sections in order and starts again. + */ +export class Band { + /** The section playing, its place in `stems.sections`, and the bar of it, from 0. */ + section = 0; + bar = 0; + /** Bars played since it started. */ + count = 0; + /** The game's frame the bar playing began on: how far into the bar it is, for what moves with the music. */ + at = 0; + /** The section to go to, and whether at the next bar line (`go`) or when this one ends (`queue`); −1 for none. */ + next = -1; + now = false; + /** Whether a section plays again when it ends, instead of going on to the next. */ + loop = false; + /** Which layers play, by their place in `stems.layers`. */ + on = [true, true, true, true, true, true, true, true]; + /** A stinger to play on the next bar line, by its place; −1 for none. */ + stung = -1; + /** Whether it plays: false stops it at the next bar line (what sounds rings out). */ + playing = true; + + /** Go to a section at the next bar line, from its first bar. */ + go(section: number) { + this.next = section; + this.now = true; + } + /** Go to a section when the one playing ends. */ + queue(section: number) { + this.next = section; + this.now = false; + } + /** Turn a layer off, or on, from the next bar line. */ + mute(layer: number) { + this.on[layer] = false; + } + unmute(layer: number) { + this.on[layer] = true; + } + /** Play a stinger on the next bar line. */ + sting(stinger: number) { + this.stung = stinger; + } + /** Stop at the next bar line. */ + stop() { + this.playing = false; + } +} + +/** Play `stems` as `band` says, bar by bar, until it stops: `start(music.conduct(stems, band))` beside the program. */ +export const conduct = game(async (s: Stems, band: Band) => { + band.playing = true; + while (band.playing) { + // On the bar line: where to be. + if (band.next >= 0 && (band.now || band.bar === 0)) { + band.section = band.next; + band.bar = 0; + band.next = -1; + } + band.at = memory.game.frame; + // The bar's clip of each layer that is on. (Which clip, by test: a clip is no value a program holds.) + for (let k = 0; k < s.sections.length; k++) if (band.section === k) { + for (let b = 0; b < s.sections[k].bars; b++) if (band.bar === b) { + for (let l = 0; l < s.layers.length; l++) if (s.bars[k][b][l]) if (band.on[l]) playWav(s.bars[k][b][l]!, 0); + } + } + for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0); + band.stung = -1; + await sleep(frames(s.barFrames)); + band.count++; + band.bar++; + // A section ended: again, or on to the next (after the last, the first). + for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.bar >= s.sections[k].bars) { + band.bar = 0; + if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0; + } + } +}); + /** * Play a song, note by note, inside an async program: \`await music.play(song)\` resolves at its end, or * \`start(music.play(song))\` plays it beside the program. @@ -859,5 +1116,5 @@ export const loop = game(async (s: Song) => { /** Everything above as one name: import { music } from "trigscript/music"; music.play(song). */ export const music = { sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop, - part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, + part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct, }; diff --git a/tests/music.test.ts b/tests/music.test.ts index 0a04490..52beb04 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -284,6 +284,73 @@ if (!(music.impact().peak > 0.3)) fail("impact");`, }); }); +describe("music that follows the game", () => { + const SONG = `const lead = music.synth({ volume: 0.5, release: 0.05 }), kit = music.kit({ k: tone(60, 0.1, { volume: 0.9 }), h: noise(0.02, { volume: 0.2 }) }); +const parts = { lead: music.part(lead, { reverb: 0.3 }), drums: music.part(kit), pad: music.part(music.synth({ volume: 0.3, release: 0.5 })) }; +const a = music.section(1, { lead: "C4 . E4 . G4 . . .", drums: "k h k h", pad: "A3+C4+E4 - - - - - - -" }, { name: "a" }); +const b = music.section(2, { lead: "A4 - - - . . . .", drums: "k k h h" }, { name: "b" }); +const song = music.arrange({ bpm: 120, steps: 2 }, parts, a, b); +const st = music.stems(song, { layers: { drums: ["drums"], tunes: ["lead", "pad"] }, stingers: { crash: noise(0.5) } });`; + + it("cuts a song into a clip a bar a layer, the same bar one clip, its tail kept, and names what it holds", () => { + const r = check(SONG, + `if (st.barFrames !== 48 || st.layers.join() !== "drums,tunes" || st.sections.map((x) => x.name).join() !== "a,b") fail(\`\${st.barFrames} \${st.layers} \${st.sections.map((x) => x.name)}\`); +if (st.bars.length !== 2 || st.bars[1].length !== 2 || st.bars[1][0][0] !== st.bars[1][1][0]) fail("a bar the same as another is the same clip"); +// The tunes' last bar rings out whole; the one before is cut two seconds after the bar (its notes end in it). +const before = st.bars[1][0][1]!, lastBar = st.bars[1][1][1]!; +if (!(lastBar.seconds > before.seconds) || Math.abs(before.seconds - (48 * 0.042 + 2)) > 1e-3) fail(\`\${before.seconds} \${lastBar.seconds}\`); +if (st.bars[0][0][1] === st.bars[1][0][1]) fail("different bars"); +const bar = 48 * 0.042; +if (!(st.bars[0][0][1]!.seconds > bar)) fail(\`a tail: \${st.bars[0][0][1]!.seconds}\`); +if (st.section("b") !== 1 || st.layer("tunes") !== 1 || st.stinger("crash") !== 0) fail("by name"); +if (st.voices < 2 || st.voices > 6) fail(String(st.voices));`, + ); + expect(errors(r)).toEqual([]); + expect(errors(check(SONG, `st.section("c");`)).join("\n")).toMatch(/There is no section "c": there are "a", "b"/); + expect(errors(check(SONG, `music.stems(song, { layers: { x: ["lede"] } });`)).join("\n")).toMatch(/The layer x names "lede", which is no part of the song: its parts are lead, drums, pad/); + expect(errors(check(SONG, `music.stems(music.song({ bpm: 120 }, music.track(lead, "C4")), { layers: { x: ["lead"] } });`)).join("\n")).toMatch(/a song made of sections, by music.arrange/); + }); + + it("turns every bar down together, so the song played through stays under its peak", () => { + const r = check(SONG, + `const pieces: { clip: Clip; at: number }[] = []; +let g = 0; +for (const sec of st.bars) for (const row of sec) { for (const c of row) if (c) pieces.push({ clip: c, at: g * 48 * 0.042 }); g++; } +const sum = mixdown(pieces), loud = music.stems(song, { layers: { drums: ["drums"], tunes: ["lead", "pad"] }, peak: 1 }); +if (!(sum.peak <= 0.8401 && sum.peak > 0.6)) fail(\`\${sum.peak}\`); +if (!(loud.bars[0][0][0]!.peak > st.bars[0][0][0]!.peak)) fail("peak");`, + ); + expect(errors(r)).toEqual([]); + }); + + it("plays a bar at each bar line as the band says: through the song, to a section, a layer off, a stinger, again and again", () => { + const r = compile(`import { music, Band } from "trigscript/music"; +${SONG} +program(async () => { + const band = new Band(); + start(music.conduct(st, band)); + await sleep(frames(100)); + band.go(st.section("a")); + band.mute(st.layer("drums")); + band.sting(st.stinger("crash")); + await sleep(frames(100)); + band.loop = true; + band.unmute(st.layer("drums")); +}); +`); + expect(errors(r)).toEqual([]); + const sim = new ProgramSimulation(r.ir, { strings: r.strings }); + sim.run(400); + const paths = r.sounds.map((s) => s.path); + const played = sim.events.filter((e) => e.action.type === ActionType.PlayWav).map((e) => [e.frame, paths.indexOf((r.strings[e.action.wav - 1] as { text: string }).text)]); + // Clips: 0 a's drums, 1 a's tunes, 2 b's drums, 3 and 4 b's tunes (its last bar rings out whole), 5 the crash. + // Bars every 48 frames from frame 1. + expect(played).toEqual([[1, 0], [1, 1], [49, 2], [49, 3], [97, 2], [97, 4], [145, 1], [145, 5], [193, 3], [241, 2], [241, 4], [289, 2], [289, 3], [337, 2], [337, 4], [385, 2], [385, 3]]); + // The bars Ogg Vorbis however short; the crash, half a second, a WAV. + expect(r.sounds.map((s) => s.path.slice(-3))).toEqual(["ogg", "ogg", "ogg", "ogg", "ogg", "wav"]); + }); +}); + describe("what the library is made of", () => { it("decides if (clip) when the script is built: a rest (null) plays nothing, a clip plays", () => { const r = compile(`const NOTES = [tone("A4", 0.1), null, tone("B4", 0.1)];\nprogram(async () => { for (const n of NOTES) { if (n) playWav(n, 0); await nextFrame(); } });\n`); diff --git a/tests/sound.test.ts b/tests/sound.test.ts index d1bb1b5..783d523 100644 --- a/tests/sound.test.ts +++ b/tests/sound.test.ts @@ -9,7 +9,7 @@ import { defaultScriptNames } from "../compiler/names"; import { defaultLib } from "../bundle/lib.mjs"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { adpcmFidelity, adpcmWav, clipChannels, clipFile, fm, periodicity, pluck, clipSamples, decodeAdpcm, describeSound, encodeAdpcm, hertz, LIMIT_CEILING, makeClip, mixdown, noise, readSound, silence, tone, writeSound } from "../compiler/sound"; +import { adpcmFidelity, adpcmWav, clipChannels, clipFile, fm, periodicity, pluck, clipSamples, decodeAdpcm, describeSound, encodeAdpcm, hertz, LIMIT_CEILING, makeClip, mixdown, noise, readSound, silence, together, tone, writeSound } from "../compiler/sound"; import { finishSounds, vorbisEncoder } from "../compiler/vorbis"; import { describeMedia, isMediaFile } from "../compiler/media"; import { buildScript, isBuiltSound, readFiles, withFiles, withSounds } from "../script"; @@ -167,6 +167,30 @@ describe("two channels, the limiter and resampling", () => { expect([right.gain(0.5), right.fade(0.01, 0.01), right.trim(0, 0.1), right.reverse(), right.pitch(3), right.repeat(2), right.envelope([0, 1], [0.2, 0])].map((c) => c.channels)).toEqual([2, 2, 2, 2, 2, 2, 2]); }); + it("turns clips that play together down by one gain, the same clip one clip, and never up", () => { + const a = tone("A3", 1, { volume: 0.7 }), b = tone("E4", 1, { volume: 0.7 }); + const [x, y, z] = together([{ clip: a, at: 0 }, { clip: b, at: 0.5 }, { clip: a, at: 1 }]); + expect(x).toBe(z); + const sum = mixdown([{ clip: x, at: 0 }, { clip: y, at: 0.5 }, { clip: z, at: 1 }]); + expect(sum.peak).toBeLessThanOrEqual(LIMIT_CEILING + 1e-6); + expect(sum.peak).toBeGreaterThan(0.8); + expect(y.peak / b.peak).toBeCloseTo(x.peak / a.peak, 5); + const [quiet] = together([{ clip: tone("A3", 1, { volume: 0.1 }), at: 0 }]); + expect(quiet.peak).toBeCloseTo(0.1, 3); + }); + + it("puts a clip made ogg() in the map as Ogg Vorbis at its quality, however short", () => { + const short = tone("A4", 0.5); + expect(clipFile(short, false).path).toMatch(/\.wav$/); + const q4 = clipFile(short.ogg(4)), q5 = clipFile(short.ogg()); + expect(q4.path).toMatch(/\.ogg$/); + expect(q4.vorbis?.quality).toBe(4); + expect(q5.vorbis?.quality).toBe(5); + expect(q4.path).not.toBe(q5.path); + expect(() => short.ogg(11)).toThrow(/−1 to 10/); + expect(() => short.wav().ogg()).toThrow(/a WAV/); + }); + it("limits a mix under its ceiling, smoothly, and leaves a quiet one alone", () => { const loud = mixdown(Array.from({ length: 6 }, (_, k) => ({ clip: tone(220 * (k + 1), 0.5, { volume: 0.4 }), at: k * 0.02 }))); expect(loud.peak).toBeGreaterThan(1); From ead7eabb4dc9eaad292bf0b0320fb8fe5be87f1d Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 18:04:05 -0600 Subject: [PATCH 11/14] Sound: an Ogg goes in with two channels, and stingers fit under the bars The game plays an Ogg of one channel 2.4 dB quieter than it was mixed, so one channel is written twice. together() takes clips that may play on top at any of their times and leaves room for them; stems passes its stingers at every bar line, turned down with the bars. Step 4 as played. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/declarations.ts | 5 +++-- compiler/generated/stdlib.ts | 2 +- compiler/sound.ts | 36 +++++++++++++++++++++++++++++------- docs/v4-plan.md | 15 +++++++++++++++ probes/korobeiniki-band.ts | 2 +- stdlib/music.ts | 10 ++++++---- tests/sound.test.ts | 8 ++++++++ 7 files changed, 63 insertions(+), 15 deletions(-) diff --git a/compiler/declarations.ts b/compiler/declarations.ts index eb108b1..7d68626 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -648,9 +648,10 @@ ${kw}function mixdown(parts: readonly { readonly clip: Clip; readonly at: number * Clips the game will play together, each from its time — the bars of a song played one after another and the layers * over them — each turned down by one gain, so what they add up to never goes over \`peak\` (0.84, −1.5 dB), between * the samples too: what a limiter does for a mix, for sounds the game adds up itself. Never turned up. The same clip - * given twice comes back as one. When the script is built: \`const [a, b] = together([{ clip: x, at: 0 }, { clip: y, at: 1 }])\` + * given twice comes back as one. \`over\`: clips that may play on top, each at any of its times (a stinger on any bar + * line), room left for them too; they come back after the parts, turned down with them. When the script is built: \`const [a, b] = together([{ clip: x, at: 0 }, { clip: y, at: 1 }])\` */ -${kw}function together(parts: readonly { readonly clip: Clip; readonly at: number }[], options?: { readonly peak?: number }): Clip[]; +${kw}function together(parts: readonly { readonly clip: Clip; readonly at: number }[], options?: { readonly peak?: number; readonly over?: readonly { readonly clip: Clip; readonly at: readonly number[] }[] }): Clip[]; /** A brush of cover(): which of the shapes (0 the first), centred on tile (x, y) of the picture. */ ${kw}interface Brush { readonly x: number; readonly y: number; readonly shape: number } /** diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index b680948..505d070 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next bar line:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into bars, played bar by bar ── */\n\n/** How a song of sections is cut for the game to play bar by bar (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off at a bar line. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each bar's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the bars may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a bar's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the bars\n * after it cover the rest, and a shorter clip is a smaller map. A layer's last bar keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into bars for the game to play bar by bar, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /** Section `s`'s bar `b` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays none. */\n readonly bars: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n}\n\n/**\n * A song of sections (`arrange`) cut into bars, a clip each bar of each layer, for music that follows the game: at\n * each bar line the program says which section plays and which layers (`Band`, `conduct`). A bar's clip holds the\n * notes that start in it, with their tails, echoes and reverb, so a bar overlaps the next and nothing is cut off,\n * whichever comes after it; a bar the same as another is the same clip, one file in the map. Each layer is mixed as\n * `render` mixes the song, but with no master and no limiter between the parts — the game adds the clips up — so\n * all of them are turned down together until the song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a bar, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const bar = s.barFrames, barSeconds = bar * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0);\n // The notes of each bar of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / bar));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last bar with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A bar of a layer, mixed: each part's notes from the bar line, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the bar's last note ends, its release and all: a riser four bars long is kept whole.\n let end = barSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * bar) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * bar) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this bar's notes where they fall in it, then the bar cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * bar - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the bar's notes, kept for `tail` seconds and faded over the last of them — the bars after\n // cover the rest — but all of it after the layer's last bar.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through; the stingers on their own.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n const fitted = together(placed, { peak: options.peak ?? 0.84 });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingerNames = Object.keys(options.stingers ?? {});\n const stingers = stingerNames.map((k) => options.stingers![k]);\n let start = 0;\n const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: bar, bpm: s.bpm, beats: Math.round(((s.bpm * bar * FRAME) / 60) * 1000) / 1000,\n layers: Object.freeze(layers), sections: s.sections, bars: Object.freeze(bars), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which bar of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a bar at each bar line as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // at the next bar line\n * band.mute(stems.layer(\"drums\"));\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`, and the bar of it, from 0. */\n section = 0;\n bar = 0;\n /** Bars played since it started. */\n count = 0;\n /** The game's frame the bar playing began on: how far into the bar it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether at the next bar line (`go`) or when this one ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next bar line, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it at the next bar line (what sounds rings out). */\n playing = true;\n\n /** Go to a section at the next bar line, from its first bar. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from the next bar line. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next bar line. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop at the next bar line. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, bar by bar, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n while (band.playing) {\n // On the bar line: where to be.\n if (band.next >= 0 && (band.now || band.bar === 0)) {\n band.section = band.next;\n band.bar = 0;\n band.next = -1;\n }\n band.at = memory.game.frame;\n // The bar's clip of each layer that is on. (Which clip, by test: a clip is no value a program holds.)\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let b = 0; b < s.sections[k].bars; b++) if (band.bar === b) {\n for (let l = 0; l < s.layers.length; l++) if (s.bars[k][b][l]) if (band.on[l]) playWav(s.bars[k][b][l]!, 0);\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n await sleep(frames(s.barFrames));\n band.count++;\n band.bar++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.bar >= s.sections[k].bars) {\n band.bar = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next bar line:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into bars, played bar by bar ── */\n\n/** How a song of sections is cut for the game to play bar by bar (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off at a bar line. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each bar's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the bars may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a bar's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the bars\n * after it cover the rest, and a shorter clip is a smaller map. A layer's last bar keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. Turned down with the bars, which leave room for one on any bar line. */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into bars for the game to play bar by bar, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /** Section `s`'s bar `b` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays none. */\n readonly bars: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n}\n\n/**\n * A song of sections (`arrange`) cut into bars, a clip each bar of each layer, for music that follows the game: at\n * each bar line the program says which section plays and which layers (`Band`, `conduct`). A bar's clip holds the\n * notes that start in it, with their tails, echoes and reverb, so a bar overlaps the next and nothing is cut off,\n * whichever comes after it; a bar the same as another is the same clip, one file in the map. Each layer is mixed as\n * `render` mixes the song, but with no master and no limiter between the parts — the game adds the clips up — so\n * all of them are turned down together until the song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a bar, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const bar = s.barFrames, barSeconds = bar * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0);\n // The notes of each bar of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / bar));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last bar with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A bar of a layer, mixed: each part's notes from the bar line, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the bar's last note ends, its release and all: a riser four bars long is kept whole.\n let end = barSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * bar) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * bar) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this bar's notes where they fall in it, then the bar cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * bar - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the bar's notes, kept for `tail` seconds and faded over the last of them — the bars after\n // cover the rest — but all of it after the layer's last bar.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through; the stingers on their own.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n // The stingers may play on any bar line: turned down with the bars, and room left for them over the loudest.\n const stingerNames = Object.keys(options.stingers ?? {});\n const lines = Array.from({ length: total }, (_, g) => g * barSeconds);\n const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingers = stingerNames.map((_, k) => fitted[placed.length + k]);\n let start = 0;\n const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: bar, bpm: s.bpm, beats: Math.round(((s.bpm * bar * FRAME) / 60) * 1000) / 1000,\n layers: Object.freeze(layers), sections: s.sections, bars: Object.freeze(bars), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which bar of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a bar at each bar line as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // at the next bar line\n * band.mute(stems.layer(\"drums\"));\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`, and the bar of it, from 0. */\n section = 0;\n bar = 0;\n /** Bars played since it started. */\n count = 0;\n /** The game's frame the bar playing began on: how far into the bar it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether at the next bar line (`go`) or when this one ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next bar line, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it at the next bar line (what sounds rings out). */\n playing = true;\n\n /** Go to a section at the next bar line, from its first bar. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from the next bar line. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next bar line. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop at the next bar line. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, bar by bar, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n while (band.playing) {\n // On the bar line: where to be.\n if (band.next >= 0 && (band.now || band.bar === 0)) {\n band.section = band.next;\n band.bar = 0;\n band.next = -1;\n }\n band.at = memory.game.frame;\n // The bar's clip of each layer that is on. (Which clip, by test: a clip is no value a program holds.)\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let b = 0; b < s.sections[k].bars; b++) if (band.bar === b) {\n for (let l = 0; l < s.layers.length; l++) if (s.bars[k][b][l]) if (band.on[l]) playWav(s.bars[k][b][l]!, 0);\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n await sleep(frames(s.barFrames));\n band.count++;\n band.bar++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.bar >= s.sections[k].bars) {\n band.bar = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/compiler/sound.ts b/compiler/sound.ts index ad1def7..fa8e966 100644 --- a/compiler/sound.ts +++ b/compiler/sound.ts @@ -703,10 +703,11 @@ export function mixdown(parts: unknown, seconds?: unknown, rate?: number): Clip * Clips that play together, each from its time — the bars of a song the game plays one after another and the layers * over them — each turned down by one gain, so what they add up to never goes over `peak` (0.84, −1.5 dB), between the * samples too, however loud each is: what a limiter does for a mix, for sounds the game adds up itself. Never turned - * up. A clip given several times is one clip back, and one file in the map. The gain is worked out when a build needs - * the samples, from the whole mix. + * up. A clip given several times is one clip back, and one file in the map. `over`: clips that may play on top, each at + * any of its times (a stinger on any bar line) — the gain leaves room for them too, and they come back after the parts, + * turned down with them. The gain is worked out when a build needs the samples, from the whole mix. */ -export function together(parts: unknown, options?: { readonly peak?: number }): Clip[] { +export function together(parts: unknown, options?: { readonly peak?: number; readonly over?: readonly { readonly clip: Clip; readonly at: readonly number[] }[] }): Clip[] { if (!Array.isArray(parts) || parts.length === 0) throw new Error("together() takes { clip, at } parts, one at least."); const list = parts.map((p, k) => { const c = (p as { clip?: unknown })?.clip, at = (p as { at?: unknown })?.at ?? 0; @@ -716,7 +717,11 @@ export function together(parts: unknown, options?: { readonly peak?: number }): }); const peak = options?.peak ?? LIMIT_CEILING; if (!(num(peak, "together()'s peak") > 0 && peak <= 1)) throw new RangeError(`together()'s peak is above 0 and 1 at most, not ${peak}.`); - const whole = recipe("together", [list.map((p) => [p.clip, p.at]), peak]); + const over = (options?.over ?? []).map((o, k) => { + if (!isClip(o?.clip) || !Array.isArray(o.at)) throw new Error(`together()'s over ${k + 1} is { clip, at: [seconds, …] }.`); + return { clip: o.clip, at: o.at.map((t) => num(t, "an over's time")) }; + }); + const whole = recipe("together", [list.map((p) => [p.clip, p.at]), peak, over.map((o) => [o.clip, o.at])]); let gain: number | undefined; const turn = () => { if (gain === undefined) { @@ -725,12 +730,26 @@ export function together(parts: unknown, options?: { readonly peak?: number }): for (const side of sum) for (let i = 0; i < side.length; i++) { const x = side[i] < 0 ? -side[i] : side[i]; if (x > top) top = x; } // The wave between the samples, near the loudest ones. for (const side of sum) { const tp = truePeaks(side, top); for (let i = 0; i < tp.length; i++) if (tp[i] > top) top = tp[i]; } + // A clip that may play over them, at each time it may: the two together, between the samples too. + const rate = list[0].clip.rate; + for (const o of over) { + const extra = channelsAt(o.clip, rate, sum.length as 1 | 2); + for (const t of o.at) { + const from = Math.round(t * rate); + for (let side = 0; side < sum.length; side++) { + const both = new Float32Array(extra[side].length); + for (let i = 0; i < both.length; i++) both[i] = extra[side][i] + (sum[side][from + i] ?? 0); + const tp = truePeaks(both, top); + for (let i = 0; i < tp.length; i++) if (tp[i] > top) top = tp[i]; + } + } + } gain = top > peak ? peak / top : 1; } return gain; }; const back = new Map(); - return list.map(({ clip }) => { + return [...list, ...over].map(({ clip }) => { let c = back.get(clip); if (!c) { const own = inside.get(clip)!; @@ -779,9 +798,12 @@ export interface ClipFile { export function clipFile(c: Clip, contents = true): ClipFile { const own = inside.get(c)!; if (!own.wav && !own.adpcm && (c.seconds >= VORBIS_SECONDS || own.quality !== undefined)) { + // Two channels always: the game plays an Ogg of one channel 2.4 dB quieter than it was (a WAV of one at its level). const quality = own.quality ?? VORBIS_QUALITY; - const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|vorbis ${quality}|${keyOf(c)}`)}.ogg`; - return contents ? { path, vorbis: { rate: c.rate, channels: samplesOf(c), quality } } : { path }; + const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|vorbis ${quality} stereo|${keyOf(c)}`)}.ogg`; + if (!contents) return { path }; + const chans = samplesOf(c); + return { path, vorbis: { rate: c.rate, channels: chans.length === 1 ? [chans[0], chans[0]] : chans, quality } }; } const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|wav|${keyOf(c)}`)}.wav`; if (!contents) return { path }; diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 2ce12d8..63ab595 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1812,6 +1812,21 @@ User after step 3 ("B sounds amazing"): "go for step 4". - **Probe** `probes/korobeiniki-band.ts` (step 3's B as stems, 4 layers; keys 1–7 sections, D B H M layers, L loop, Space crash): build 18 s, map 5.6 MB, check 1.1 s, lifted game clean. +- **Played 2026-10-19** (recording 17.53.58.41; the untouched pass from 3.180 s, then keys). Each bar's start + against the bars summed at exact times, by correlation of its first 0.35 s (all 40 clear, 0.53–1.00): 0, 5.3, 10.6, + 16.0 ms and round again — the mixer's 21.3 ms grid (1,024 samples at 48 kHz): a bar is 78.75 of its buffers, so each + bar lands a quarter later in one and every fourth wraps; mean 9.1, sd 7.3, never more than one buffer. The frames + themselves are steady. A bar of 32 frames (1,344 ms, 63 buffers) would land the same each time, but only at 48 kHz + — the grid is the player's device's. The level: 60 s within 0.05 dB of the file, seconds within 0.7 dB; the + untouched pass peaked at 0.824 (the file 0.831). **The bass bars — the first short Oggs — start on the grid as the + rest do** (lows: lag 5.3 / 10.7 ms, correlation 1.00), but alone in the intro the lows came 2.3–2.4 dB under the + file with the same shape: **the game plays an Ogg of one channel 2.4 dB quieter** (step 1's one-channel WAV came at + its level). Fixed: an Ogg always goes in with two channels ("stereo" in its name). **Steered, it reached 0.998 once** + (144.3 s, a riser then the drop's hit): the stinger, crash + impact, played at its own level over the drop. Fixed: + `together(…, { over })` leaves room for a clip that may play on top at any of its times and turns it down with the + rest; `stems` passes the stingers at every bar line. With that stinger the song went to −19.0 LUFS; the probe's is + now the crash alone at 0.7: −16.6 LUFS, the worst bar line with a crash 0.811. Rebuilt the map (5.6 MB). + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/probes/korobeiniki-band.ts b/probes/korobeiniki-band.ts index 6306788..e50a97c 100644 --- a/probes/korobeiniki-band.ts +++ b/probes/korobeiniki-band.ts @@ -148,7 +148,7 @@ const st = music.stems(step3, { layers: { drums: ["kit", "hats", "fx"], bass: ["bass"], harmony: ["pad", "strum", "keys"], melody: ["lead", "saw", "bell", "acid"] }, reverb: ROOM, master: (c) => c.filter("highshelf", 9000, { gain: 1.5 }), - stingers: { crash: crash.mix(music.impact({ volume: 0.45 })) }, + stingers: { crash: crash.gain(0.7) }, }); const NAMES = st.sections.map((x) => x.name); const KEYS = ["1", "2", "3", "4", "5", "6", "7"] as const; diff --git a/stdlib/music.ts b/stdlib/music.ts index 5ee4abe..1227067 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -866,7 +866,7 @@ export interface StemsOptions { readonly tail?: number; /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */ readonly quality?: number; - /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. */ + /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. Turned down with the bars, which leave room for one on any bar line. */ readonly stingers?: Readonly>; } @@ -966,12 +966,14 @@ export function stems(s: Song, options: StemsOptions): Stems { const placed: { clip: Clip; at: number; g: number; l: number }[] = []; raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); })); if (placed.length === 0) throw new Error("stems(): the layers play no notes."); - const fitted = together(placed, { peak: options.peak ?? 0.84 }); + // The stingers may play on any bar line: turned down with the bars, and room left for them over the loudest. + const stingerNames = Object.keys(options.stingers ?? {}); + const lines = Array.from({ length: total }, (_, g) => g * barSeconds); + const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) }); const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null)); const quality = options.quality ?? 4; placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); }); - const stingerNames = Object.keys(options.stingers ?? {}); - const stingers = stingerNames.map((k) => options.stingers![k]); + const stingers = stingerNames.map((_, k) => fitted[placed.length + k]); let start = 0; const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); }); const lookup = (what: string, list: readonly string[], name: string) => { diff --git a/tests/sound.test.ts b/tests/sound.test.ts index 783d523..1fc6660 100644 --- a/tests/sound.test.ts +++ b/tests/sound.test.ts @@ -175,6 +175,11 @@ describe("two channels, the limiter and resampling", () => { expect(sum.peak).toBeLessThanOrEqual(LIMIT_CEILING + 1e-6); expect(sum.peak).toBeGreaterThan(0.8); expect(y.peak / b.peak).toBeCloseTo(x.peak / a.peak, 5); + // Room left for a clip that may play on top at any of its times: it comes back after the parts, turned down too. + const hit = noise(0.3, { volume: 0.5 }); + const [p, q, h] = together([{ clip: a, at: 0 }, { clip: b, at: 0.5 }], { over: [{ clip: hit, at: [0, 0.5, 1] }] }); + for (const t of [0, 0.5, 1]) expect(mixdown([{ clip: p, at: 0 }, { clip: q, at: 0.5 }, { clip: h, at: t }]).peak).toBeLessThanOrEqual(LIMIT_CEILING + 1e-6); + expect(h.peak / hit.peak).toBeCloseTo(p.peak / a.peak, 5); const [quiet] = together([{ clip: tone("A3", 1, { volume: 0.1 }), at: 0 }]); expect(quiet.peak).toBeCloseTo(0.1, 3); }); @@ -187,6 +192,9 @@ describe("two channels, the limiter and resampling", () => { expect(q4.vorbis?.quality).toBe(4); expect(q5.vorbis?.quality).toBe(5); expect(q4.path).not.toBe(q5.path); + // One channel goes in as two: the game plays an Ogg of one 2.4 dB quieter. + expect(q4.vorbis?.channels.length).toBe(2); + expect(q4.vorbis?.channels[0]).toBe(q4.vorbis?.channels[1]); expect(() => short.ogg(11)).toThrow(/−1 to 10/); expect(() => short.wav().ogg()).toThrow(/a WAV/); }); From 41c873a5d4edb8e78832350826c5ecb82557b6bb Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 18:11:39 -0600 Subject: [PATCH 12/14] Music: stingers on the next beat, and songs cut every few beats conduct wakes every beat: a stinger plays on the next one. stems takes every (beats a piece), so a change can wait half a bar instead of a whole one, for more files and more sounds at once. The probe cuts in half bars. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/generated/stdlib.ts | 2 +- docs/v4-plan.md | 8 ++ probes/korobeiniki-band.ts | 14 +-- stdlib/music.ts | 184 +++++++++++++++++++++-------------- tests/music.test.ts | 47 +++++++-- 5 files changed, 165 insertions(+), 90 deletions(-) diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index 505d070..546f793 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next bar line:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into bars, played bar by bar ── */\n\n/** How a song of sections is cut for the game to play bar by bar (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off at a bar line. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each bar's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the bars may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a bar's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the bars\n * after it cover the rest, and a shorter clip is a smaller map. A layer's last bar keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. Turned down with the bars, which leave room for one on any bar line. */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into bars for the game to play bar by bar, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /** Section `s`'s bar `b` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays none. */\n readonly bars: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n}\n\n/**\n * A song of sections (`arrange`) cut into bars, a clip each bar of each layer, for music that follows the game: at\n * each bar line the program says which section plays and which layers (`Band`, `conduct`). A bar's clip holds the\n * notes that start in it, with their tails, echoes and reverb, so a bar overlaps the next and nothing is cut off,\n * whichever comes after it; a bar the same as another is the same clip, one file in the map. Each layer is mixed as\n * `render` mixes the song, but with no master and no limiter between the parts — the game adds the clips up — so\n * all of them are turned down together until the song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a bar, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const bar = s.barFrames, barSeconds = bar * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0);\n // The notes of each bar of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / bar));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last bar with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A bar of a layer, mixed: each part's notes from the bar line, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the bar's last note ends, its release and all: a riser four bars long is kept whole.\n let end = barSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * bar) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * bar) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this bar's notes where they fall in it, then the bar cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * bar - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the bar's notes, kept for `tail` seconds and faded over the last of them — the bars after\n // cover the rest — but all of it after the layer's last bar.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through; the stingers on their own.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n // The stingers may play on any bar line: turned down with the bars, and room left for them over the loudest.\n const stingerNames = Object.keys(options.stingers ?? {});\n const lines = Array.from({ length: total }, (_, g) => g * barSeconds);\n const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingers = stingerNames.map((_, k) => fitted[placed.length + k]);\n let start = 0;\n const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: bar, bpm: s.bpm, beats: Math.round(((s.bpm * bar * FRAME) / 60) * 1000) / 1000,\n layers: Object.freeze(layers), sections: s.sections, bars: Object.freeze(bars), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which bar of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a bar at each bar line as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // at the next bar line\n * band.mute(stems.layer(\"drums\"));\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`, and the bar of it, from 0. */\n section = 0;\n bar = 0;\n /** Bars played since it started. */\n count = 0;\n /** The game's frame the bar playing began on: how far into the bar it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether at the next bar line (`go`) or when this one ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next bar line, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it at the next bar line (what sounds rings out). */\n playing = true;\n\n /** Go to a section at the next bar line, from its first bar. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from the next bar line. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next bar line. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop at the next bar line. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, bar by bar, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n while (band.playing) {\n // On the bar line: where to be.\n if (band.next >= 0 && (band.now || band.bar === 0)) {\n band.section = band.next;\n band.bar = 0;\n band.next = -1;\n }\n band.at = memory.game.frame;\n // The bar's clip of each layer that is on. (Which clip, by test: a clip is no value a program holds.)\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let b = 0; b < s.sections[k].bars; b++) if (band.bar === b) {\n for (let l = 0; l < s.layers.length; l++) if (s.bars[k][b][l]) if (band.on[l]) playWav(s.bars[k][b][l]!, 0);\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n await sleep(frames(s.barFrames));\n band.count++;\n band.bar++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.bar >= s.sections[k].bars) {\n band.bar = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next beat:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * if (cleared) band.sting(cut.stinger(\"crash\")); // on the next beat\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, beats, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into pieces, played piece by piece ── */\n\n/** How a song of sections is cut for the game to play piece by piece (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off where a piece begins. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /**\n * Beats a piece: where the game can go to another section or turn a layer on or off (a bar's beats when left out).\n * Two in a bar of four halves the wait for a change, for about half as much map again and more sounds at once.\n */\n readonly every?: number;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each piece's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the pieces may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a piece's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the\n * pieces after it cover the rest, and a shorter clip is a smaller map. A layer's last piece keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the pieces' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /**\n * Sounds to play on the next beat when the game asks (`band.sting`): a crash for a cleared line, a hit for a boss.\n * Turned down with the pieces, which leave room for one on any beat.\n */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into pieces for the game to play piece by piece, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar, and beats a piece. */\n readonly beats: number;\n readonly every: number;\n /** Frames each beat of a piece lasts, whole frames (beats of a bar not a whole number of frames take turns). */\n readonly waits: readonly number[];\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /**\n * Section `s`'s piece `p` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays\n * none. A section of `bars` bars has bars × beats / every pieces.\n */\n readonly pieces: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n}\n\n/**\n * A song of sections (`arrange`) cut into pieces — a bar each, or `every` beats — a clip each piece of each layer, for\n * music that follows the game: where each piece begins the program says which section plays and which layers (`Band`,\n * `conduct`), and a stinger plays on the next beat. A piece's clip holds the notes that start in it, with their tails,\n * echoes and reverb, so it overlaps the next and nothing is cut off, whichever comes after it; a piece the same as\n * another is the same clip, one file in the map. Each layer is mixed as `render` mixes the song, but with no master\n * and no limiter between the parts — the game adds the clips up — so all of them are turned down together until the\n * song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a piece, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const beats = s.beats, every = options.every ?? beats, piece = (s.barFrames * every) / beats;\n if (!Number.isInteger(every) || every < 1 || every > beats || beats % every !== 0) throw new Error(`stems() cuts every few beats of a bar's ${beats}, a number that divides it — not every ${every}.`);\n if (!Number.isInteger(piece)) throw new Error(`A piece of ${every} beats would be ${piece.toFixed(2)} frames: the game plays a sound on a frame. Make it a bar (every: ${beats}), or a tempo whose bar divides into whole frames.`);\n const perBar = beats / every;\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const pieceSeconds = piece * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0) * perBar;\n // The notes of each piece of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / piece));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last piece with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A piece of a layer, mixed: each part's notes from where it begins, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the piece's last note ends, its release and all: a riser four bars long is kept whole.\n let end = pieceSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * piece) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * piece) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this piece's notes where they fall in it, then the piece cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * piece - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the piece's notes, kept for `tail` seconds and faded over the last of them — the pieces\n // after cover the rest — but all of it after the layer's last piece.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * pieceSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n // The stingers may play on any beat: turned down with the pieces, and room left for them over the loudest.\n const waits = Array.from({ length: every }, (_, j) => Math.round(((j + 1) * piece) / every) - Math.round((j * piece) / every));\n const stingerNames = Object.keys(options.stingers ?? {});\n const lines: number[] = [];\n for (let g = 0; g < total; g++) { let at = g * piece; for (const w of waits) { lines.push(at * FRAME); at += w; } }\n const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingers = stingerNames.map((_, k) => fitted[placed.length + k]);\n let start = 0;\n const pieces = s.sections.map((sec) => { const n = sec.bars * perBar, rows = grid.slice(start, start + n).map((row) => Object.freeze(row)); start += n; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: s.barFrames, bpm: s.bpm, beats, every, waits: Object.freeze(waits),\n layers: Object.freeze(layers), sections: s.sections, pieces: Object.freeze(pieces), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which piece of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a piece where each begins as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // where the next piece begins\n * band.mute(stems.layer(\"drums\"));\n * band.sting(stems.stinger(\"crash\")); // on the next beat\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`; the piece of it (a bar, unless `stems.every` says), from 0; the beat of the piece. */\n section = 0;\n piece = 0;\n beat = 0;\n /** Pieces played since it started. */\n count = 0;\n /** The game's frame the beat playing began on: how far into the beat it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether where the next piece begins (`go`) or when this section ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next beat, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it where the next piece would begin (what sounds rings out). */\n playing = true;\n\n /** Go to a section where the next piece begins, from its first piece. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from where the next piece begins. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next beat. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop where the next piece would begin. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, beat by beat, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n band.beat = 0;\n while (band.playing || band.beat > 0) {\n band.at = memory.game.frame;\n if (band.beat === 0) {\n // Where a piece begins: where to be, then the piece's clip of each layer that is on. (Which clip, by test: a\n // clip is no value a program holds.)\n if (band.next >= 0 && (band.now || band.piece === 0)) {\n band.section = band.next;\n band.piece = 0;\n band.next = -1;\n }\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let p = 0; p < s.pieces[k].length; p++) if (band.piece === p) {\n for (let l = 0; l < s.layers.length; l++) if (s.pieces[k][p][l]) if (band.on[l]) playWav(s.pieces[k][p][l]!, 0);\n }\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n for (let j = 0; j < s.every; j++) if (band.beat === j) await sleep(frames(s.waits[j]));\n band.beat++;\n if (band.beat >= s.every) {\n band.beat = 0;\n band.count++;\n band.piece++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.piece >= s.pieces[k].length) {\n band.piece = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n if (!band.playing) band.beat = 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 63ab595..c52b4b9 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1827,6 +1827,14 @@ User after step 3 ("B sounds amazing"): "go for step 4". rest; `stems` passes the stingers at every bar line. With that stinger the song went to −19.0 LUFS; the probe's is now the crash alone at 0.7: −16.6 LUFS, the worst bar line with a crash 0.811. Rebuilt the map (5.6 MB). +- **User after the second play: "it felt great - waiting for the next bar line took a bit too long imo if + sometime important happens but it wasnt bad".** A sound cannot be stopped, so a change cannot land mid-piece (the + piece's remaining beats would sound over the new section). Two answers: `conduct` now wakes every beat and plays a + stinger on the next one (≤ 0.42 s at 143); `stems({ every })` cuts every few beats (a number dividing the bar's; + its frames whole) — `Band.piece` / `beat`, `Stems.pieces` / `every` / `waits`, `Song.beats`. Korobeiniki: bars 114 + files, 5.5 MB, 13 sounds at once; half bars 190, 7.4 MB, 20 (12 left of 32), build 18 s. The half-bar map is + Maps/TrigScript/trigscript-korobeiniki-halfbar.scx beside the bar one. + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/probes/korobeiniki-band.ts b/probes/korobeiniki-band.ts index e50a97c..bfd1b21 100644 --- a/probes/korobeiniki-band.ts +++ b/probes/korobeiniki-band.ts @@ -1,13 +1,14 @@ // Step 4 of the music library's quality work, heard in StarCraft: Remastered: Korobeiniki (a Russian folk song, in // the public domain; the melody only — the rest is this file's own), step 3's arrangement cut into bars and layers -// (`music.stems`) and played bar by bar as the program says (`Band`, `music.conduct`): music that follows the game. -// Left alone it plays the song through, bar by bar, and starts again; the keys steer it. Host it as a multiplayer +// (`music.stems`) and played piece by piece as the program says (`Band`, `music.conduct`): music that follows the game. +// Cut in half bars (`every: 2`), so a change waits at most 0.84 s; a stinger plays on the next beat (0.42 s). Left +// alone it plays the song through and starts again; the keys steer it. Host it as a multiplayer // game, alone, with the game's music off. // -// 1 … 7 go to a section at the next bar line: intro, verse, build, drop, break, last verse, outro -// D B H M the drums, the bass, the harmony (pad, arpeggio, piano), the melody: off or on from the next bar line +// 1 … 7 go to a section at the next half bar: intro, verse, build, drop, break, last verse, outro +// D B H M the drums, the bass, the harmony (pad, arpeggio, piano), the melody: off or on from the next half bar // L a section plays again when it ends (or goes on to the next) -// Space a crash on the next bar line +// Space a crash on the next beat // // npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx // npx tsx scripts/build-fixture.mts probes/korobeiniki-band.ts fixtures/eud/korobeiniki-band.scx fixtures/eud/sound-base.scx @@ -149,6 +150,7 @@ const st = music.stems(step3, { reverb: ROOM, master: (c) => c.filter("highshelf", 9000, { gain: 1.5 }), stingers: { crash: crash.gain(0.7) }, + every: 2, }); const NAMES = st.sections.map((x) => x.name); const KEYS = ["1", "2", "3", "4", "5", "6", "7"] as const; @@ -168,7 +170,7 @@ program(async () => { start(music.conduct(st, band)); let shown = -1; while (true) { - for (let k = 0; k < KEYS.length; k++) if (keyPressed(P1, KEYS[k])) { band.go(k); displayText(`\x04→ ${NAMES[k]} at the next bar`); } + for (let k = 0; k < KEYS.length; k++) if (keyPressed(P1, KEYS[k])) { band.go(k); displayText(`\x04→ ${NAMES[k]} at the next half bar`); } for (let l = 0; l < LAYER_KEYS.length; l++) if (keyPressed(P1, LAYER_KEYS[l])) { band.on[l] = !band.on[l]; if (band.on[l]) displayText(`\x04${LAYERS[l]} on`); else displayText(`\x04${LAYERS[l]} off`); diff --git a/stdlib/music.ts b/stdlib/music.ts index 1227067..eeb8955 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -38,13 +38,14 @@ * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro); * * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers - * bar by bar — which section, which layers, a stinger on the next bar line: + * bar by bar — which section, which layers, a stinger on the next beat: * * const cut = music.stems(song, { layers: { drums: ["drums"], rest: ["keys", "bass"] } }); * program(async () => { * const band = new Band(); // import { music, Band } from "trigscript/music" * start(music.conduct(cut, band)); * if (danger) band.go(cut.section("chorus")); // at the next bar line + * if (cleared) band.sting(cut.stinger("crash")); // on the next beat * }); * * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so @@ -617,6 +618,8 @@ export interface Song { readonly stepFrames: number; /** Frames a bar lasts, a whole number. */ readonly barFrames: number; + /** Beats a bar. */ + readonly beats: number; /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */ readonly bpm: number; /** Steps, the longest track's. */ @@ -717,7 +720,7 @@ function compose(options: SongOptions, tracks: readonly Track[], sections: reado return t; }; const made: Song = Object.freeze({ - stepFrames, barFrames, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]), + stepFrames, barFrames, beats, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]), get frames() { return tables().frames; }, get seconds() { return tables().frames * FRAME; }, get clips() { return playable().clips; }, @@ -842,48 +845,62 @@ export function render(s: Song, options: { readonly rate?: number; readonly limi return limit === false ? mixed : mixed.limit(limit === true ? {} : limit); } -/* ── Music that follows the game: a song cut into bars, played bar by bar ── */ +/* ── Music that follows the game: a song cut into pieces, played piece by piece ── */ -/** How a song of sections is cut for the game to play bar by bar (`stems`). */ +/** How a song of sections is cut for the game to play piece by piece (`stems`). */ export interface StemsOptions { /** * Which parts each layer holds, by name — `{ drums: ["kit", "hats"], bass: ["bass"], melody: ["lead", "saw"] }` — a - * layer the game can turn on and off at a bar line. A part in no layer is left out. + * layer the game can turn on and off where a piece begins. A part in no layer is left out. */ readonly layers: Readonly>; + /** + * Beats a piece: where the game can go to another section or turn a layer on or off (a bar's beats when left out). + * Two in a bar of four halves the wait for a change, for about half as much map again and more sounds at once. + */ + readonly every?: number; /** Samples a second (44 100). */ readonly rate?: number; /** The reverb's room, as `render` takes it. */ readonly reverb?: ReverbOptions; - /** What each bar's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */ + /** What each piece's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */ readonly master?: (clip: Clip) => Clip; - /** The loudest the bars may add up to as the song plays through, every layer on (0.84, −1.5 dB). */ + /** The loudest the pieces may add up to as the song plays through, every layer on (0.84, −1.5 dB). */ readonly peak?: number; /** - * Seconds a bar's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the bars - * after it cover the rest, and a shorter clip is a smaller map. A layer's last bar keeps all of its tail. + * Seconds a piece's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the + * pieces after it cover the rest, and a shorter clip is a smaller map. A layer's last piece keeps all of its tail. */ readonly tail?: number; - /** The Ogg Vorbis quality of the bars' clips (4: a little smaller than a song's 5, as many play at once). */ + /** The Ogg Vorbis quality of the pieces' clips (4: a little smaller than a song's 5, as many play at once). */ readonly quality?: number; - /** Sounds to play on a bar line when the game asks (`band.sting`): a crash for a cleared line, a fill. Turned down with the bars, which leave room for one on any bar line. */ + /** + * Sounds to play on the next beat when the game asks (`band.sting`): a crash for a cleared line, a hit for a boss. + * Turned down with the pieces, which leave room for one on any beat. + */ readonly stingers?: Readonly>; } -/** A song cut into bars for the game to play bar by bar, with `conduct` and a `Band`: made when the script is built. */ +/** A song cut into pieces for the game to play piece by piece, with `conduct` and a `Band`: made when the script is built. */ export interface Stems { readonly kind: "stems"; /** Frames a bar lasts. */ readonly barFrames: number; readonly bpm: number; - /** Beats a bar. */ + /** Beats a bar, and beats a piece. */ readonly beats: number; + readonly every: number; + /** Frames each beat of a piece lasts, whole frames (beats of a bar not a whole number of frames take turns). */ + readonly waits: readonly number[]; /** The layers' names, in the order `band.on` has them. */ readonly layers: readonly string[]; /** The sections, in the order the song plays them: their names and how many bars. */ readonly sections: readonly SongSection[]; - /** Section `s`'s bar `b` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays none. */ - readonly bars: readonly (readonly (readonly (Clip | null)[])[])[]; + /** + * Section `s`'s piece `p` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays + * none. A section of `bars` bars has bars × beats / every pieces. + */ + readonly pieces: readonly (readonly (readonly (Clip | null)[])[])[]; readonly stingers: readonly Clip[]; /** A section's place in `sections`, by its name: `band.go(stems.section("drop"))`. */ section(name: string): number; @@ -896,19 +913,24 @@ export interface Stems { } /** - * A song of sections (`arrange`) cut into bars, a clip each bar of each layer, for music that follows the game: at - * each bar line the program says which section plays and which layers (`Band`, `conduct`). A bar's clip holds the - * notes that start in it, with their tails, echoes and reverb, so a bar overlaps the next and nothing is cut off, - * whichever comes after it; a bar the same as another is the same clip, one file in the map. Each layer is mixed as - * `render` mixes the song, but with no master and no limiter between the parts — the game adds the clips up — so - * all of them are turned down together until the song played through stays under `peak`. + * A song of sections (`arrange`) cut into pieces — a bar each, or `every` beats — a clip each piece of each layer, for + * music that follows the game: where each piece begins the program says which section plays and which layers (`Band`, + * `conduct`), and a stinger plays on the next beat. A piece's clip holds the notes that start in it, with their tails, + * echoes and reverb, so it overlaps the next and nothing is cut off, whichever comes after it; a piece the same as + * another is the same clip, one file in the map. Each layer is mixed as `render` mixes the song, but with no master + * and no limiter between the parts — the game adds the clips up — so all of them are turned down together until the + * song played through stays under `peak`. */ export function stems(s: Song, options: StemsOptions): Stems { const parts = partsOf.get(s), score = scores.get(s); if (!parts || !score) throw new Error("stems() cuts a song made of sections, by music.arrange."); const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2; - if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a bar, more than 0, not ${tail}.`); + if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a piece, more than 0, not ${tail}.`); if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`); + const beats = s.beats, every = options.every ?? beats, piece = (s.barFrames * every) / beats; + if (!Number.isInteger(every) || every < 1 || every > beats || beats % every !== 0) throw new Error(`stems() cuts every few beats of a bar's ${beats}, a number that divides it — not every ${every}.`); + if (!Number.isInteger(piece)) throw new Error(`A piece of ${every} beats would be ${piece.toFixed(2)} frames: the game plays a sound on a frame. Make it a bar (every: ${beats}), or a tempo whose bar divides into whole frames.`); + const perBar = beats / every; const layerOf = new Map(); layers.forEach((name, l) => { for (const p of options.layers[name]) { @@ -918,31 +940,31 @@ export function stems(s: Song, options: StemsOptions): Stems { layerOf.set(found.track, l); } }); - const bar = s.barFrames, barSeconds = bar * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0); - // The notes of each bar of each layer, by part. + const pieceSeconds = piece * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0) * perBar; + // The notes of each piece of each layer, by part. const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map())); for (const n of score) { const l = layerOf.get(n.track); if (l === undefined) continue; - const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / bar)); + const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / piece)); const m = cells[g][l], list = m.get(n.track); if (list) list.push(n); else m.set(n.track, [n]); } const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); }; - // Each layer's last bar with notes: it rings out whole, as the song ends. + // Each layer's last piece with notes: it rings out whole, as the song ends. const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; }); - // A bar of a layer, mixed: each part's notes from the bar line, through what the part goes through, and the reverb. + // A piece of a layer, mixed: each part's notes from where it begins, through what the part goes through, and the reverb. const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => { const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = []; - // Where the bar's last note ends, its release and all: a riser four bars long is kept whole. - let end = barSeconds; - for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * bar) * FRAME + clipOf(t, n).seconds); + // Where the piece's last note ends, its release and all: a riser four bars long is kept whole. + let end = pieceSeconds; + for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * piece) * FRAME + clipOf(t, n).seconds); for (const [t, notes] of m) { const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx); - const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * bar) * FRAME })); + const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * piece) * FRAME })); if (apart.length) { - // A section's effects take the whole section: this bar's notes where they fall in it, then the bar cut out. - const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * bar - from) * FRAME; + // A section's effects take the whole section: this piece's notes where they fall in it, then the piece cut out. + const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * piece - from) * FRAME; const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time); entries.push({ clip: whole.trim(into, whole.seconds), at: 0 }); } @@ -957,25 +979,27 @@ export function stems(s: Song, options: StemsOptions): Stems { if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 }); let mixed = mixdown(out, undefined, rate); if (options.master) mixed = options.master(mixed); - // What rings on after the bar's notes, kept for `tail` seconds and faded over the last of them — the bars after - // cover the rest — but all of it after the layer's last bar. + // What rings on after the piece's notes, kept for `tail` seconds and faded over the last of them — the pieces + // after cover the rest — but all of it after the layer's last piece. const keep = end + tail; return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed; })); - // All of them turned down together, as the song plays through; the stingers on their own. + // All of them turned down together, as the song plays through. const placed: { clip: Clip; at: number; g: number; l: number }[] = []; - raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * barSeconds, g, l }); })); + raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * pieceSeconds, g, l }); })); if (placed.length === 0) throw new Error("stems(): the layers play no notes."); - // The stingers may play on any bar line: turned down with the bars, and room left for them over the loudest. + // The stingers may play on any beat: turned down with the pieces, and room left for them over the loudest. + const waits = Array.from({ length: every }, (_, j) => Math.round(((j + 1) * piece) / every) - Math.round((j * piece) / every)); const stingerNames = Object.keys(options.stingers ?? {}); - const lines = Array.from({ length: total }, (_, g) => g * barSeconds); + const lines: number[] = []; + for (let g = 0; g < total; g++) { let at = g * piece; for (const w of waits) { lines.push(at * FRAME); at += w; } } const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) }); const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null)); const quality = options.quality ?? 4; placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); }); const stingers = stingerNames.map((_, k) => fitted[placed.length + k]); let start = 0; - const bars = s.sections.map((sec) => { const rows = grid.slice(start, start + sec.bars).map((row) => Object.freeze(row)); start += sec.bars; return Object.freeze(rows); }); + const pieces = s.sections.map((sec) => { const n = sec.bars * perBar, rows = grid.slice(start, start + n).map((row) => Object.freeze(row)); start += n; return Object.freeze(rows); }); const lookup = (what: string, list: readonly string[], name: string) => { const k = list.indexOf(name); if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(", ") || "none"}.`); @@ -983,8 +1007,8 @@ export function stems(s: Song, options: StemsOptions): Stems { }; let voices = -1; return Object.freeze({ - kind: "stems" as const, barFrames: bar, bpm: s.bpm, beats: Math.round(((s.bpm * bar * FRAME) / 60) * 1000) / 1000, - layers: Object.freeze(layers), sections: s.sections, bars: Object.freeze(bars), stingers: Object.freeze(stingers), + kind: "stems" as const, barFrames: s.barFrames, bpm: s.bpm, beats, every, waits: Object.freeze(waits), + layers: Object.freeze(layers), sections: s.sections, pieces: Object.freeze(pieces), stingers: Object.freeze(stingers), section: (name: string) => lookup("section", s.sections.map((x) => x.name), name), layer: (name: string) => lookup("layer", layers, name), stinger: (name: string) => lookup("stinger", stingerNames, name), @@ -999,38 +1023,40 @@ export function stems(s: Song, options: StemsOptions): Stems { } /** - * The music as the game steers it: which section plays, which bar of it, which layers. Make one inside the program - * that steers the music and hand it to `conduct`, which plays a bar at each bar line as it says: + * The music as the game steers it: which section plays, which piece of it, which layers. Make one inside the program + * that steers the music and hand it to `conduct`, which plays a piece where each begins as it says: * * import { music, Band } from "trigscript/music"; * const band = new Band(); * start(music.conduct(stems, band)); - * band.go(stems.section("drop")); // at the next bar line + * band.go(stems.section("drop")); // where the next piece begins * band.mute(stems.layer("drums")); + * band.sting(stems.stinger("crash")); // on the next beat * * With nothing said, the song plays through its sections in order and starts again. */ export class Band { - /** The section playing, its place in `stems.sections`, and the bar of it, from 0. */ + /** The section playing, its place in `stems.sections`; the piece of it (a bar, unless `stems.every` says), from 0; the beat of the piece. */ section = 0; - bar = 0; - /** Bars played since it started. */ + piece = 0; + beat = 0; + /** Pieces played since it started. */ count = 0; - /** The game's frame the bar playing began on: how far into the bar it is, for what moves with the music. */ + /** The game's frame the beat playing began on: how far into the beat it is, for what moves with the music. */ at = 0; - /** The section to go to, and whether at the next bar line (`go`) or when this one ends (`queue`); −1 for none. */ + /** The section to go to, and whether where the next piece begins (`go`) or when this section ends (`queue`); −1 for none. */ next = -1; now = false; /** Whether a section plays again when it ends, instead of going on to the next. */ loop = false; /** Which layers play, by their place in `stems.layers`. */ on = [true, true, true, true, true, true, true, true]; - /** A stinger to play on the next bar line, by its place; −1 for none. */ + /** A stinger to play on the next beat, by its place; −1 for none. */ stung = -1; - /** Whether it plays: false stops it at the next bar line (what sounds rings out). */ + /** Whether it plays: false stops it where the next piece would begin (what sounds rings out). */ playing = true; - /** Go to a section at the next bar line, from its first bar. */ + /** Go to a section where the next piece begins, from its first piece. */ go(section: number) { this.next = section; this.now = true; @@ -1040,49 +1066,57 @@ export class Band { this.next = section; this.now = false; } - /** Turn a layer off, or on, from the next bar line. */ + /** Turn a layer off, or on, from where the next piece begins. */ mute(layer: number) { this.on[layer] = false; } unmute(layer: number) { this.on[layer] = true; } - /** Play a stinger on the next bar line. */ + /** Play a stinger on the next beat. */ sting(stinger: number) { this.stung = stinger; } - /** Stop at the next bar line. */ + /** Stop where the next piece would begin. */ stop() { this.playing = false; } } -/** Play `stems` as `band` says, bar by bar, until it stops: `start(music.conduct(stems, band))` beside the program. */ +/** Play `stems` as `band` says, beat by beat, until it stops: `start(music.conduct(stems, band))` beside the program. */ export const conduct = game(async (s: Stems, band: Band) => { band.playing = true; - while (band.playing) { - // On the bar line: where to be. - if (band.next >= 0 && (band.now || band.bar === 0)) { - band.section = band.next; - band.bar = 0; - band.next = -1; - } + band.beat = 0; + while (band.playing || band.beat > 0) { band.at = memory.game.frame; - // The bar's clip of each layer that is on. (Which clip, by test: a clip is no value a program holds.) - for (let k = 0; k < s.sections.length; k++) if (band.section === k) { - for (let b = 0; b < s.sections[k].bars; b++) if (band.bar === b) { - for (let l = 0; l < s.layers.length; l++) if (s.bars[k][b][l]) if (band.on[l]) playWav(s.bars[k][b][l]!, 0); + if (band.beat === 0) { + // Where a piece begins: where to be, then the piece's clip of each layer that is on. (Which clip, by test: a + // clip is no value a program holds.) + if (band.next >= 0 && (band.now || band.piece === 0)) { + band.section = band.next; + band.piece = 0; + band.next = -1; + } + for (let k = 0; k < s.sections.length; k++) if (band.section === k) { + for (let p = 0; p < s.pieces[k].length; p++) if (band.piece === p) { + for (let l = 0; l < s.layers.length; l++) if (s.pieces[k][p][l]) if (band.on[l]) playWav(s.pieces[k][p][l]!, 0); + } } } for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0); band.stung = -1; - await sleep(frames(s.barFrames)); - band.count++; - band.bar++; - // A section ended: again, or on to the next (after the last, the first). - for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.bar >= s.sections[k].bars) { - band.bar = 0; - if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0; + for (let j = 0; j < s.every; j++) if (band.beat === j) await sleep(frames(s.waits[j])); + band.beat++; + if (band.beat >= s.every) { + band.beat = 0; + band.count++; + band.piece++; + // A section ended: again, or on to the next (after the last, the first). + for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.piece >= s.pieces[k].length) { + band.piece = 0; + if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0; + } + if (!band.playing) band.beat = 0; } } }); diff --git a/tests/music.test.ts b/tests/music.test.ts index 52beb04..29be639 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -295,13 +295,13 @@ const st = music.stems(song, { layers: { drums: ["drums"], tunes: ["lead", "pad" it("cuts a song into a clip a bar a layer, the same bar one clip, its tail kept, and names what it holds", () => { const r = check(SONG, `if (st.barFrames !== 48 || st.layers.join() !== "drums,tunes" || st.sections.map((x) => x.name).join() !== "a,b") fail(\`\${st.barFrames} \${st.layers} \${st.sections.map((x) => x.name)}\`); -if (st.bars.length !== 2 || st.bars[1].length !== 2 || st.bars[1][0][0] !== st.bars[1][1][0]) fail("a bar the same as another is the same clip"); +if (st.pieces.length !== 2 || st.pieces[1].length !== 2 || st.pieces[1][0][0] !== st.pieces[1][1][0]) fail("a bar the same as another is the same clip"); // The tunes' last bar rings out whole; the one before is cut two seconds after the bar (its notes end in it). -const before = st.bars[1][0][1]!, lastBar = st.bars[1][1][1]!; +const before = st.pieces[1][0][1]!, lastBar = st.pieces[1][1][1]!; if (!(lastBar.seconds > before.seconds) || Math.abs(before.seconds - (48 * 0.042 + 2)) > 1e-3) fail(\`\${before.seconds} \${lastBar.seconds}\`); -if (st.bars[0][0][1] === st.bars[1][0][1]) fail("different bars"); +if (st.pieces[0][0][1] === st.pieces[1][0][1]) fail("different bars"); const bar = 48 * 0.042; -if (!(st.bars[0][0][1]!.seconds > bar)) fail(\`a tail: \${st.bars[0][0][1]!.seconds}\`); +if (!(st.pieces[0][0][1]!.seconds > bar)) fail(\`a tail: \${st.pieces[0][0][1]!.seconds}\`); if (st.section("b") !== 1 || st.layer("tunes") !== 1 || st.stinger("crash") !== 0) fail("by name"); if (st.voices < 2 || st.voices > 6) fail(String(st.voices));`, ); @@ -315,10 +315,10 @@ if (st.voices < 2 || st.voices > 6) fail(String(st.voices));`, const r = check(SONG, `const pieces: { clip: Clip; at: number }[] = []; let g = 0; -for (const sec of st.bars) for (const row of sec) { for (const c of row) if (c) pieces.push({ clip: c, at: g * 48 * 0.042 }); g++; } +for (const sec of st.pieces) for (const row of sec) { for (const c of row) if (c) pieces.push({ clip: c, at: g * 48 * 0.042 }); g++; } const sum = mixdown(pieces), loud = music.stems(song, { layers: { drums: ["drums"], tunes: ["lead", "pad"] }, peak: 1 }); if (!(sum.peak <= 0.8401 && sum.peak > 0.6)) fail(\`\${sum.peak}\`); -if (!(loud.bars[0][0][0]!.peak > st.bars[0][0][0]!.peak)) fail("peak");`, +if (!(loud.pieces[0][0][0]!.peak > st.pieces[0][0][0]!.peak)) fail("peak");`, ); expect(errors(r)).toEqual([]); }); @@ -344,13 +344,44 @@ program(async () => { const paths = r.sounds.map((s) => s.path); const played = sim.events.filter((e) => e.action.type === ActionType.PlayWav).map((e) => [e.frame, paths.indexOf((r.strings[e.action.wav - 1] as { text: string }).text)]); // Clips: 0 a's drums, 1 a's tunes, 2 b's drums, 3 and 4 b's tunes (its last bar rings out whole), 5 the crash. - // Bars every 48 frames from frame 1. - expect(played).toEqual([[1, 0], [1, 1], [49, 2], [49, 3], [97, 2], [97, 4], [145, 1], [145, 5], [193, 3], [241, 2], [241, 4], [289, 2], [289, 3], [337, 2], [337, 4], [385, 2], [385, 3]]); + // Bars every 48 frames from frame 1, beats every 12: the crash, asked for on frame 101, on the next beat. + expect(played).toEqual([[1, 0], [1, 1], [49, 2], [49, 3], [97, 2], [97, 4], [109, 5], [145, 1], [193, 3], [241, 2], [241, 4], [289, 2], [289, 3], [337, 2], [337, 4], [385, 2], [385, 3]]); // The bars Ogg Vorbis however short; the crash, half a second, a WAV. expect(r.sounds.map((s) => s.path.slice(-3))).toEqual(["ogg", "ogg", "ogg", "ogg", "ogg", "wav"]); }); }); +describe("music that follows the game, in half bars", () => { + it("cuts every two beats, so a change waits half a bar", () => { + const r = compile(`import { music, Band } from "trigscript/music"; +const lead = music.synth({ volume: 0.5, release: 0.05 }); +const parts = { lead: music.part(lead) }; +const song = music.arrange({ bpm: 120, steps: 2 }, parts, music.section(1, { lead: "C4 . D4 . E4 . F4 ." }, { name: "a" }), music.section(1, { lead: "A4 . B4 . C5 . D5 ." }, { name: "b" })); +const st = music.stems(song, { layers: { lead: ["lead"] }, every: 2 }); +const fail = (what: string) => { throw new Error(what); }; +if (st.every !== 2 || st.waits.join() !== "12,12" || st.pieces[0].length !== 2) fail(\`\${st.every} \${st.waits} \${st.pieces[0].length}\`); +program(async () => { + const band = new Band(); + band.loop = true; + start(music.conduct(st, band)); + await sleep(frames(30)); + band.go(st.section("b")); +}); +`); + expect(errors(r)).toEqual([]); + const sim = new ProgramSimulation(r.ir, { strings: r.strings }); + sim.run(100); + const paths = r.sounds.map((s) => s.path); + const played = sim.events.filter((e) => e.action.type === ActionType.PlayWav).map((e) => [e.frame, paths.indexOf((r.strings[e.action.wav - 1] as { text: string }).text)]); + // a's halves on frames 1 and 25; asked for b on frame 31, it begins on 49 (not 97, a bar later); then b again. + expect(played).toEqual([[1, 0], [25, 1], [49, 2], [73, 3], [97, 2]]); + const bad = compile(`import { music } from "trigscript/music"; +const song = music.arrange({ bpm: 120, steps: 2 }, { lead: music.part(music.synth()) }, music.section(1, { lead: "C4" })); +music.stems(song, { layers: { lead: ["lead"] }, every: 3 });`); + expect(errors(bad).join("\n")).toMatch(/every few beats of a bar's 4, a number that divides it — not every 3/); + }); +}); + describe("what the library is made of", () => { it("decides if (clip) when the script is built: a rest (null) plays nothing, a clip plays", () => { const r = compile(`const NOTES = [tone("A4", 0.1), null, tone("B4", 0.1)];\nprogram(async () => { for (const n of NOTES) { if (n) playWav(n, 0); await nextFrame(); } });\n`); From 8229c695d3cf0a1069536385b0a182250cba9b09 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 18:20:33 -0600 Subject: [PATCH 13/14] Sounds: the tools and the recipe to make the Korobeiniki work again scripts/sound-dump.mts writes the sounds a build makes (or names them); scripts/recording-check.mts checks a recording of a probe against them, piece by piece too; stems.playthrough is the song as the game plays it. The probes' answer sheets beside them, and the plan says how it all goes together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- compiler/generated/stdlib.ts | 2 +- docs/v4-plan.md | 41 ++++++++ probes/answers/korobeiniki-ab.txt | 32 ++++++ probes/answers/korobeiniki-band-bars.txt | 30 ++++++ probes/answers/korobeiniki-band.txt | 13 +++ probes/answers/korobeiniki-form.txt | 29 ++++++ probes/answers/korobeiniki.txt | 26 +++++ scripts/recording-check.mts | 119 +++++++++++++++++++++++ scripts/sound-dump.mts | 48 +++++++++ stdlib/music.ts | 5 + 10 files changed, 344 insertions(+), 1 deletion(-) create mode 100755 probes/answers/korobeiniki-ab.txt create mode 100755 probes/answers/korobeiniki-band-bars.txt create mode 100755 probes/answers/korobeiniki-band.txt create mode 100755 probes/answers/korobeiniki-form.txt create mode 100755 probes/answers/korobeiniki.txt create mode 100644 scripts/recording-check.mts create mode 100644 scripts/sound-dump.mts diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts index 546f793..bd2c35c 100644 --- a/compiler/generated/stdlib.ts +++ b/compiler/generated/stdlib.ts @@ -3,7 +3,7 @@ export const STDLIB_SOURCES: Readonly> = { "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = walk(raider, 1200, 640);\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", - "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next beat:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * if (cleared) band.sting(cut.stinger(\"crash\")); // on the next beat\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, beats, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into pieces, played piece by piece ── */\n\n/** How a song of sections is cut for the game to play piece by piece (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off where a piece begins. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /**\n * Beats a piece: where the game can go to another section or turn a layer on or off (a bar's beats when left out).\n * Two in a bar of four halves the wait for a change, for about half as much map again and more sounds at once.\n */\n readonly every?: number;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each piece's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the pieces may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a piece's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the\n * pieces after it cover the rest, and a shorter clip is a smaller map. A layer's last piece keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the pieces' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /**\n * Sounds to play on the next beat when the game asks (`band.sting`): a crash for a cleared line, a hit for a boss.\n * Turned down with the pieces, which leave room for one on any beat.\n */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into pieces for the game to play piece by piece, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar, and beats a piece. */\n readonly beats: number;\n readonly every: number;\n /** Frames each beat of a piece lasts, whole frames (beats of a bar not a whole number of frames take turns). */\n readonly waits: readonly number[];\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /**\n * Section `s`'s piece `p` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays\n * none. A section of `bars` bars has bars × beats / every pieces.\n */\n readonly pieces: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n}\n\n/**\n * A song of sections (`arrange`) cut into pieces — a bar each, or `every` beats — a clip each piece of each layer, for\n * music that follows the game: where each piece begins the program says which section plays and which layers (`Band`,\n * `conduct`), and a stinger plays on the next beat. A piece's clip holds the notes that start in it, with their tails,\n * echoes and reverb, so it overlaps the next and nothing is cut off, whichever comes after it; a piece the same as\n * another is the same clip, one file in the map. Each layer is mixed as `render` mixes the song, but with no master\n * and no limiter between the parts — the game adds the clips up — so all of them are turned down together until the\n * song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a piece, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const beats = s.beats, every = options.every ?? beats, piece = (s.barFrames * every) / beats;\n if (!Number.isInteger(every) || every < 1 || every > beats || beats % every !== 0) throw new Error(`stems() cuts every few beats of a bar's ${beats}, a number that divides it — not every ${every}.`);\n if (!Number.isInteger(piece)) throw new Error(`A piece of ${every} beats would be ${piece.toFixed(2)} frames: the game plays a sound on a frame. Make it a bar (every: ${beats}), or a tempo whose bar divides into whole frames.`);\n const perBar = beats / every;\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const pieceSeconds = piece * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0) * perBar;\n // The notes of each piece of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / piece));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last piece with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A piece of a layer, mixed: each part's notes from where it begins, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the piece's last note ends, its release and all: a riser four bars long is kept whole.\n let end = pieceSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * piece) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * piece) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this piece's notes where they fall in it, then the piece cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * piece - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the piece's notes, kept for `tail` seconds and faded over the last of them — the pieces\n // after cover the rest — but all of it after the layer's last piece.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * pieceSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n // The stingers may play on any beat: turned down with the pieces, and room left for them over the loudest.\n const waits = Array.from({ length: every }, (_, j) => Math.round(((j + 1) * piece) / every) - Math.round((j * piece) / every));\n const stingerNames = Object.keys(options.stingers ?? {});\n const lines: number[] = [];\n for (let g = 0; g < total; g++) { let at = g * piece; for (const w of waits) { lines.push(at * FRAME); at += w; } }\n const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingers = stingerNames.map((_, k) => fitted[placed.length + k]);\n let start = 0;\n const pieces = s.sections.map((sec) => { const n = sec.bars * perBar, rows = grid.slice(start, start + n).map((row) => Object.freeze(row)); start += n; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: s.barFrames, bpm: s.bpm, beats, every, waits: Object.freeze(waits),\n layers: Object.freeze(layers), sections: s.sections, pieces: Object.freeze(pieces), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which piece of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a piece where each begins as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // where the next piece begins\n * band.mute(stems.layer(\"drums\"));\n * band.sting(stems.stinger(\"crash\")); // on the next beat\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`; the piece of it (a bar, unless `stems.every` says), from 0; the beat of the piece. */\n section = 0;\n piece = 0;\n beat = 0;\n /** Pieces played since it started. */\n count = 0;\n /** The game's frame the beat playing began on: how far into the beat it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether where the next piece begins (`go`) or when this section ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next beat, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it where the next piece would begin (what sounds rings out). */\n playing = true;\n\n /** Go to a section where the next piece begins, from its first piece. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from where the next piece begins. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next beat. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop where the next piece would begin. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, beat by beat, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n band.beat = 0;\n while (band.playing || band.beat > 0) {\n band.at = memory.game.frame;\n if (band.beat === 0) {\n // Where a piece begins: where to be, then the piece's clip of each layer that is on. (Which clip, by test: a\n // clip is no value a program holds.)\n if (band.next >= 0 && (band.now || band.piece === 0)) {\n band.section = band.next;\n band.piece = 0;\n band.next = -1;\n }\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let p = 0; p < s.pieces[k].length; p++) if (band.piece === p) {\n for (let l = 0; l < s.layers.length; l++) if (s.pieces[k][p][l]) if (band.on[l]) playWav(s.pieces[k][p][l]!, 0);\n }\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n for (let j = 0; j < s.every; j++) if (band.beat === j) await sleep(frames(s.waits[j]));\n band.beat++;\n if (band.beat >= s.every) {\n band.beat = 0;\n band.count++;\n band.piece++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.piece >= s.pieces[k].length) {\n band.piece = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n if (!band.playing) band.beat = 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next beat:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * if (cleared) band.sting(cut.stinger(\"crash\")); // on the next beat\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, beats, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into pieces, played piece by piece ── */\n\n/** How a song of sections is cut for the game to play piece by piece (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off where a piece begins. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /**\n * Beats a piece: where the game can go to another section or turn a layer on or off (a bar's beats when left out).\n * Two in a bar of four halves the wait for a change, for about half as much map again and more sounds at once.\n */\n readonly every?: number;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each piece's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the pieces may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a piece's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the\n * pieces after it cover the rest, and a shorter clip is a smaller map. A layer's last piece keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the pieces' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /**\n * Sounds to play on the next beat when the game asks (`band.sting`): a crash for a cleared line, a hit for a boss.\n * Turned down with the pieces, which leave room for one on any beat.\n */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into pieces for the game to play piece by piece, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar, and beats a piece. */\n readonly beats: number;\n readonly every: number;\n /** Frames each beat of a piece lasts, whole frames (beats of a bar not a whole number of frames take turns). */\n readonly waits: readonly number[];\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /**\n * Section `s`'s piece `p` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays\n * none. A section of `bars` bars has bars × beats / every pieces.\n */\n readonly pieces: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n /** The pieces added up as the game plays the song through, every layer on, each on its frame: to hear it, or compare. */\n readonly playthrough: Clip;\n}\n\n/**\n * A song of sections (`arrange`) cut into pieces — a bar each, or `every` beats — a clip each piece of each layer, for\n * music that follows the game: where each piece begins the program says which section plays and which layers (`Band`,\n * `conduct`), and a stinger plays on the next beat. A piece's clip holds the notes that start in it, with their tails,\n * echoes and reverb, so it overlaps the next and nothing is cut off, whichever comes after it; a piece the same as\n * another is the same clip, one file in the map. Each layer is mixed as `render` mixes the song, but with no master\n * and no limiter between the parts — the game adds the clips up — so all of them are turned down together until the\n * song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a piece, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const beats = s.beats, every = options.every ?? beats, piece = (s.barFrames * every) / beats;\n if (!Number.isInteger(every) || every < 1 || every > beats || beats % every !== 0) throw new Error(`stems() cuts every few beats of a bar's ${beats}, a number that divides it — not every ${every}.`);\n if (!Number.isInteger(piece)) throw new Error(`A piece of ${every} beats would be ${piece.toFixed(2)} frames: the game plays a sound on a frame. Make it a bar (every: ${beats}), or a tempo whose bar divides into whole frames.`);\n const perBar = beats / every;\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const pieceSeconds = piece * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0) * perBar;\n // The notes of each piece of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / piece));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last piece with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A piece of a layer, mixed: each part's notes from where it begins, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the piece's last note ends, its release and all: a riser four bars long is kept whole.\n let end = pieceSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * piece) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * piece) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this piece's notes where they fall in it, then the piece cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * piece - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the piece's notes, kept for `tail` seconds and faded over the last of them — the pieces\n // after cover the rest — but all of it after the layer's last piece.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * pieceSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n // The stingers may play on any beat: turned down with the pieces, and room left for them over the loudest.\n const waits = Array.from({ length: every }, (_, j) => Math.round(((j + 1) * piece) / every) - Math.round((j * piece) / every));\n const stingerNames = Object.keys(options.stingers ?? {});\n const lines: number[] = [];\n for (let g = 0; g < total; g++) { let at = g * piece; for (const w of waits) { lines.push(at * FRAME); at += w; } }\n const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingers = stingerNames.map((_, k) => fitted[placed.length + k]);\n let start = 0;\n const pieces = s.sections.map((sec) => { const n = sec.bars * perBar, rows = grid.slice(start, start + n).map((row) => Object.freeze(row)); start += n; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: s.barFrames, bpm: s.bpm, beats, every, waits: Object.freeze(waits),\n layers: Object.freeze(layers), sections: s.sections, pieces: Object.freeze(pieces), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get playthrough() {\n return mixdown(placed.map((p) => ({ clip: grid[p.g][p.l]!, at: p.at })));\n },\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which piece of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a piece where each begins as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // where the next piece begins\n * band.mute(stems.layer(\"drums\"));\n * band.sting(stems.stinger(\"crash\")); // on the next beat\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`; the piece of it (a bar, unless `stems.every` says), from 0; the beat of the piece. */\n section = 0;\n piece = 0;\n beat = 0;\n /** Pieces played since it started. */\n count = 0;\n /** The game's frame the beat playing began on: how far into the beat it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether where the next piece begins (`go`) or when this section ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next beat, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it where the next piece would begin (what sounds rings out). */\n playing = true;\n\n /** Go to a section where the next piece begins, from its first piece. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from where the next piece begins. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next beat. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop where the next piece would begin. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, beat by beat, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n band.beat = 0;\n while (band.playing || band.beat > 0) {\n band.at = memory.game.frame;\n if (band.beat === 0) {\n // Where a piece begins: where to be, then the piece's clip of each layer that is on. (Which clip, by test: a\n // clip is no value a program holds.)\n if (band.next >= 0 && (band.now || band.piece === 0)) {\n band.section = band.next;\n band.piece = 0;\n band.next = -1;\n }\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let p = 0; p < s.pieces[k].length; p++) if (band.piece === p) {\n for (let l = 0; l < s.layers.length; l++) if (s.pieces[k][p][l]) if (band.on[l]) playWav(s.pieces[k][p][l]!, 0);\n }\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n for (let j = 0; j < s.every; j++) if (band.beat === j) await sleep(frames(s.waits[j]));\n band.beat++;\n if (band.beat >= s.every) {\n band.beat = 0;\n band.count++;\n band.piece++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.piece >= s.pieces[k].length) {\n band.piece = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n if (!band.playing) band.beat = 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n while (true) await play(s);\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", diff --git a/docs/v4-plan.md b/docs/v4-plan.md index c52b4b9..f5e328f 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1835,6 +1835,47 @@ User after step 3 ("B sounds amazing"): "go for step 4". files, 5.5 MB, 13 sounds at once; half bars 190, 7.4 MB, 20 (12 left of 32), build 18 s. The half-bar map is Maps/TrigScript/trigscript-korobeiniki-halfbar.scx beside the bar one. +### Sounds: how to make the Korobeiniki work again (2026-10-19) + +User after the half-bar map: "half bars is fine … map size isnt a problem. we're going for polish and wow factor. +make sure we know how to reproduce this track and work". Half bars (`every: 2`) are the choice for the showpiece. The +showpiece (step 5: the falling-block game restyled, the music following it, the visualizer) waits until the language +work is done. + +**The track.** Korobeiniki is a Russian folk song in the public domain: the melody only is the folk song's; the bass, +chords, arpeggios, counter-lines, drums and form are ours. Never Hirokazu Tanaka's Game Boy bass or countermelody or +any official arrangement; no "Tetris" in a map's or video's name (a description may say "the Tetris theme"); no +official Tetris look (a 10 × 20 well, the official pieces or colours — Tetris Holding v. Xio). The StarCraft race-theme +remix is only for a video: never committed, never in the registry or on scmjs.dev, no download linked. + +| Probe (`probes/`) | What it is | Its sheet (`probes/answers/`) | +| --- | --- | --- | +| `korobeiniki-ab.ts` | step 1: the same notes before (22 kHz mono WAV) and after (44.1 kHz stereo, limiter, Ogg) | `korobeiniki-ab.txt` | +| `korobeiniki.ts` | step 2: A = step 1's mix, B = the first arrangement (presets, drums, effects; 34 bars, 63 s) | `korobeiniki.txt` | +| `korobeiniki-form.ts` | step 3: A = step 2's B as sections; B = **the track** (40 bars, 73 s: intro, verse, build, drop, break, last verse a tone up, outro) | `korobeiniki-form.txt` | +| `korobeiniki-band.ts` | step 4: the track cut in half bars, four layers, steered by keys (1–7, D B H M, L, Space) | `korobeiniki-band.txt` (`-bars.txt`: the whole-bar build) | + +**Making a map** (a scm-js checkout beside this one with its tileset data, and plugin-eudplib, as for every probe): + + npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx # the base map, once + npx tsx scripts/build-fixture.mts probes/korobeiniki-band.ts fixtures/eud/korobeiniki-band.scx fixtures/eud/sound-base.scx + SCM_SIM_DIR=../scm-sim-eud npx tsx scripts/game-check.mts probes/korobeiniki-band.ts --frames=3800 fixtures/eud/sound-base.scx + +and it goes in Remastered's maps folder, `TrigScript/`, with its sheet. Played as a multiplayer game, alone, the +game's music off, recorded. + +**The sounds as the map gets them**, to listen to or compare: `npx tsx scripts/sound-dump.mts ` (the +names alone: `--check`). The song as the game plays it piece by piece, every layer on: +`--append 'program(async () => { playWav(st.playthrough.ogg(5), 0); });'` on the band probe — the largest file. +A file's name says how it was made (`ENGINE` in sound.ts); since the true-peak limiter ("sound 3") the names differ +from the ones the recordings of steps 1–3 heard, and the bytes a little. + +**A recording checked:** `npx tsx scripts/recording-check.mts …` finds each reference after +the one before and says where it starts, how far after the last (in frames), the level against the file second by +second, the lag at every 10 s (drift), how close below and above 6 kHz, the worst second, L − R, peaks; +`--pieces=0.84` (half bars at 143) adds each piece's start against the playthrough. What the recordings gave is under +each step above; the recorder's AAC is what blurs the top (7–10 dB above 6 kHz), not the game. + ### Closures and the missing methods (built 2026-10-17) The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): diff --git a/probes/answers/korobeiniki-ab.txt b/probes/answers/korobeiniki-ab.txt new file mode 100755 index 0000000..a29a62b --- /dev/null +++ b/probes/answers/korobeiniki-ab.txt @@ -0,0 +1,32 @@ +TrigScript sounds, step 1: Korobeiniki, A then B +================================================ + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help for B. +It plays A (32 s), a pause, B (32 s), a pause, and repeats. Start it once, quit when A comes round again. +A recording is all I need; a sentence or two of what you heard is the rest. + +A: how a mixed song went in before: 22 kHz, one channel, a WAV, turned down so it does not clip. +B: the same notes made at 44 kHz, the parts spread left and right, through the limiter, as Ogg Vorbis. + (Lead a little left, the low lead and the arpeggio right, the chords split both ways, drums and bass in the middle.) + +The two files are beside this sheet too (korobeiniki-A-before.wav, korobeiniki-B-after.ogg), to compare outside the game. + +1. Does B sound better than A? Brighter / clearer top, wider? ............ +2. Anything wrong in B: crackle, clipping, distortion, a dropout? ............ +3. Does B start right when its line of text appears, or late? (The recording tells me too.) ............ +4. Any hitch in the game when B starts (it is a 559 KB Ogg)? ............ +5. B is about 2 dB louder than A at the same peak: too loud next to the game's own sounds? ............ + +The instruments are still the plain ones (square, saw, triangle, noise drums); making them sound good is step 2. +This round is about the format, the stereo and the limiter. + +Measured from the recording (StarCraft 2026.10.19 - 13.59.06.34.mp4), 2026-10-19 +----------------------------------------------------------------------------- +- A started at 6.771 s, B at 41.715 s, A again at 76.659 s: 34.944 s apart both times, the 832 frames the script + waits to the millisecond. The Ogg starts with the same timing as the WAV, and B cost the game no time (no hitch). +- B as heard against the file the build wrote: the same within 31.9 dB over the whole 32 s (what the recording's + own AAC allows; A, a WAV, came back at 24.5), the worst 100 ms still 19.5 dB: no dropout anywhere. +- Left and right kept: the recording's L−R follows the file's at 0.996. +- No clipping: the loudest peaks came back at 0.97 of full scale. The whole is heard 1.08× the file (0.7 dB), the + peaks only 1.00–1.05×: something after the game rounds the very top off a little, gently. +- Q1 and Q5 (does it sound better; too loud?) are for your ears. diff --git a/probes/answers/korobeiniki-band-bars.txt b/probes/answers/korobeiniki-band-bars.txt new file mode 100755 index 0000000..d40ceca --- /dev/null +++ b/probes/answers/korobeiniki-band-bars.txt @@ -0,0 +1,30 @@ +TrigScript sounds, step 4: Korobeiniki, steered by the game +=========================================================== + +(Second build, 18:03: the bass now as loud in the game as it was mixed, and the Space crash turned down with the +music so it can never push it to clipping. The first build's recording went within 0.2% of full scale once.) + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help. Record it. + +This is step 3's B cut into 160 bars (1.68 s each) in four layers: drums, bass, harmony (pad, arpeggio, piano), +melody. The game plays one bar of each layer at every bar line, as the program says. Nothing is one long song any +more: each bar is its own sound, starting on its frame, and the bars overlap so nothing is cut off. + +1. FIRST, let it play once all the way through WITHOUT touching anything (about 70 s, until "intro" shows again). + I will compare that pass against the song bar by bar, to measure how evenly the game starts the bars. + +2. THEN play with it: + 1 2 3 4 5 6 7 jump to a section at the next bar line: intro, verse, build, drop, break, last verse, outro + D B H M drums, bass, harmony, melody: off / on from the next bar line + L loop: a section plays again when it ends (press again: on through the song) + Space a crash on the next bar line + Try: loop the verse (2, L), drop out the drums and bring them back, jump to the drop from anywhere (4), + hit Space a few times, jump to the break mid-phrase (5), and let the outro end it (7, L off). + +What to listen for: +1. The first pass: does it sound like step 3's B, or can you hear the bars — a stutter, a flam, a click, a gap? ............ +2. Jumps: does a jump land cleanly on the bar line? Does the old section's tail ring into the new one nicely? ............ +3. Layers on and off: clean? Does the mix still sound full with one off? ............ +4. How quick it feels: a jump waits for the next bar line (up to 1.7 s). Too slow for a game? ............ +5. It is about 4 dB quieter than B was: the game adds the bars up itself, with no limiter, so they are turned down + to leave room. Too quiet? ............ diff --git a/probes/answers/korobeiniki-band.txt b/probes/answers/korobeiniki-band.txt new file mode 100755 index 0000000..7deef99 --- /dev/null +++ b/probes/answers/korobeiniki-band.txt @@ -0,0 +1,13 @@ +TrigScript sounds, step 4 again: Korobeiniki in half bars +========================================================= + +The same as trigscript-korobeiniki-band.scx, cut every half bar instead of every bar, for your "waiting for the next +bar line took a bit too long": a jump or a layer change now waits at most 0.84 s (it was 1.68), and Space's crash +plays on the next beat (at most 0.42 s). The cost: 7.5 MB instead of 5.6, and up to 20 sounds at once of the game's 32 +(it was 13). Host it as a multiplayer game, alone, game music OFF, and record it if you like. + +Keys as before: 1-7 a section, D B H M a layer, L loop, Space a crash. + +1. Does a change feel quick enough now for "something important happens"? ............ +2. Can you hear the half-bar seams anywhere (a stutter, a flam)? ............ +3. Half bars (quicker, bigger map) or whole bars (slower, smaller), for the showpiece? ............ diff --git a/probes/answers/korobeiniki-form.txt b/probes/answers/korobeiniki-form.txt new file mode 100755 index 0000000..f04619a --- /dev/null +++ b/probes/answers/korobeiniki-form.txt @@ -0,0 +1,29 @@ +TrigScript sounds, step 3: Korobeiniki as a song of sections +============================================================ + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help. +It plays A (63 s: step 2's B, exactly the file you heard last time), a pause, then B (about 73 s), and repeats. +Start it once and quit when A comes round again. A recording is all I need, plus what your ears say. +B is beside this sheet as korobeiniki-step3.ogg too, to play outside the game. + +B, section by section (143 beats a minute, a bar 1.68 s): + 0:00 intro, 4 bars: the pad and the plucked arpeggio behind a filter that opens; hats creep in; a reversed crash + 0:07 verse, 8 bars: the melody on the warm lead, a crash on the first beat, the groove, held back a little + 0:20 build, 4 bars: the bell plays the first phrase, a riser, the snare roll speeding up and getting louder, + the filters opening, half a bar of silence before... + 0:27 drop, 8 bars: the supersaw melody and the bell, four on the floor, an impact, the pad, bass and arpeggio + pumping with the beat + 0:40 break, 4 bars: electric piano, the bell plays the second phrase, the kick comes back, a short riser + 0:47 last verse, a tone up (B minor): the supersaw, the bell, the acid line, pumping + 1:00 outro: the last chord, an impact, the pad closing down into the reverb + +What to listen for: +1. Does the shape work: does the build build, does the drop hit, does the key change lift? ............ +2. The drums: better now (they are about 2 dB quieter against the rest than in A)? Still too much? ............ +3. The riser, the impacts, the reversed crash, the pumping: good, cheesy, too much? ............ +4. Anything wrong: crackle, clipping, a sound that cuts off, a hitch in the game? ............ + +What is new (step 3), all for writing songs, not new sounds: a song made of sections that fill parts by name and +repeat a pattern to fill themselves; a section changed for its second time round; chord names (Am, E, Dm7, C/E) and +templates that play a chord's notes by number, with voice leading; transpose, stretch, accents ("@0.5" on a step), +swing, effects and volumes for one section only (the filter sweeps, the pumping), risers, impacts and a crash. diff --git a/probes/answers/korobeiniki.txt b/probes/answers/korobeiniki.txt new file mode 100755 index 0000000..757d46b --- /dev/null +++ b/probes/answers/korobeiniki.txt @@ -0,0 +1,26 @@ +TrigScript sounds, step 2: Korobeiniki, arranged +================================================ + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help. +It plays A (32 s, step 1: plain waves, the notes you heard last time), a pause, then B (about 63 s), and repeats. +Start it once and quit when A comes round again. A recording is all I need, plus what your ears say. +B is beside this sheet as korobeiniki-step2.ogg too, to play outside the game. + +B, bar by bar (143 beats a minute, a bar 1.68 s): + 0:00 intro, 4 bars: a slow pad and a plucked arpeggio; hats come in, then the kick and a snare build + 0:07 A1, 8 bars: the melody on a warm filtered lead with a dotted echo; bass, drums + 0:20 A2, 8 bars: the melody on seven detuned saws, a bell an octave up, the pluck arpeggio, claps and open hats + 0:34 break, 4 bars: electric piano (FM), the bell, the bass holding, no drums + 0:40 A3, 8 bars: everything, with an acid line under the melody; a drum fill at the end + 0:54 the last chord, ringing out into the reverb + +What to listen for: +1. Does B sound like real produced music now, or still like a toy? What sounds best, what sounds worst? ............ +2. The mix: is anything too loud or buried (melody, bass, kick, hats, bell, acid)? ............ +3. The reverb and echoes: too much, too little, about right? ............ +4. Anything wrong: crackle, clipping, a sound that cuts off, a hitch in the game? ............ +5. Which instruments would you want more of for the falling-block showpiece? ............ + +What is new (step 2): filters that open and close with each note, detuned stacks of voices (supersaw, pad), FM (the +bell and the electric piano), plucked strings, a synthesized drum kit (kick, snare, clap, hats, tom, rim), drive, +echo, chorus, a plate reverb shared by the tracks, a compressor on the drums, the bass and the whole mix. diff --git a/scripts/recording-check.mts b/scripts/recording-check.mts new file mode 100644 index 0000000..a2d7b52 --- /dev/null +++ b/scripts/recording-check.mts @@ -0,0 +1,119 @@ +/** + * A recording of a probe played in StarCraft: Remastered, checked against the sounds that went in + * (`scripts/sound-dump.mts`): where each starts, how far apart, whether it drifts, how loud against the file, how close + * below and above 6 kHz, the worst second, the sides, the peaks — and, for a song the game plays piece by piece, when + * each piece starts against the song played through. + * + * npx tsx scripts/recording-check.mts "/mnt/c/Users/…/StarCraft 2026.10.19 - 16.56.34.39.mp4" a.ogg b.ogg + * npx tsx scripts/recording-check.mts rec.mp4 playthrough.ogg --pieces=1.68 + * + * The references are found in the recording in the order given, each after the one before. The game's audio is + * digital silence between sounds, so a sound's start is readable to the sample; the recording's own coding (AAC) blurs + * the top — 9 to 10 dB above 6 kHz is the recorder, not the game. Needs ffmpeg. + */ +import { spawnSync } from "node:child_process"; + +const R = 48000; +const args = process.argv.slice(2); +const pieces = Number(args.find((a) => a.startsWith("--pieces="))?.split("=")[1] ?? 0); +const [recording, ...refs] = args.filter((a) => !a.startsWith("--")); +if (!recording || refs.length === 0) { console.error("usage: recording-check … [--pieces=]"); process.exit(2); } + +/** A file's sound as 48 kHz, two channels, interleaved. */ +function decode(path: string): Float32Array { + const out = spawnSync("ffmpeg", ["-v", "error", "-i", path, "-vn", "-ac", "2", "-ar", String(R), "-f", "f32le", "-"], { maxBuffer: 2 ** 31 }); + if (out.status !== 0) throw new Error(`ffmpeg could not read ${path}: ${out.stderr.toString()}`); + const b = out.stdout; + return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.length)); +} +const db = (x: number) => 10 * Math.log10(x + 1e-20); +const rec = decode(recording), n = rec.length / 2; + +// Where sound starts after silence: the first sample over 0.01 after 0.5 s under 0.003. +const onsets: number[] = []; +{ + // Armed by half a second under 0.003; what is between the two (a fade's start) neither starts nor disarms. + let quiet = R, armed = true; + for (let i = 0; i < n; i++) { + const loud = Math.max(Math.abs(rec[2 * i]), Math.abs(rec[2 * i + 1])); + quiet = loud < 0.003 ? quiet + 1 : 0; + if (quiet >= R / 2) armed = true; + if (loud > 0.01 && armed) { onsets.push(i); armed = false; } + } +} +console.log(`${recording}: ${(n / R).toFixed(1)} s; sound starts at ${onsets.map((o) => (o / R).toFixed(3)).join(", ")} s`); + +/** Correlation of the recording from `at` with `ref` from `from`, over `len` frames, both channels. */ +function corr(ref: Float32Array, at: number, from: number, len: number): number { + let s = 0, a = 0, b = 0; + for (let i = 0; i < len; i++) for (let c = 0; c < 2; c++) { const x = rec[2 * (at + i) + c] ?? 0, y = ref[2 * (from + i) + c] ?? 0; s += x * y; a += x * x; b += y * y; } + return s / Math.sqrt(a * b + 1e-20); +} +/** The lag (frames) within ±`span` of `at` where `ref` from `from` fits best. */ +function fit(ref: Float32Array, at: number, from: number, len: number, span: number): { lag: number; c: number } { + let best = { lag: 0, c: -2 }; + for (let k = -span; k <= span; k += 8) { const c = corr(ref, at + k, from, len); if (c > best.c) best = { lag: k, c }; } + for (let k = best.lag - 8; k <= best.lag + 8; k++) { const c = corr(ref, at + k, from, len); if (c > best.c) best = { lag: k, c }; } + return best; +} + +let after = 0, previous = -1; +for (const path of refs) { + const ref = decode(path), m0 = ref.length / 2; + let first = 0; + while (first < m0 && Math.abs(ref[2 * first]) < 0.01 && Math.abs(ref[2 * first + 1]) < 0.01) first++; + // The first onset after the last reference's where its first two seconds fit (0.8), or else the one they fit best. + let start = -1, best = -2; + for (const o of onsets.filter((x) => x >= after)) { + const f = fit(ref, o - first, 0, Math.min(2 * R, m0), 480); + if (f.c > best) { best = f.c; start = o - first + f.lag; } + if (f.c >= 0.8) break; + } + if (start < 0) { console.log(`\n${path}: not found after ${(after / R).toFixed(1)} s`); continue; } + const m = Math.min(m0, n - start); + let ee = 0, rr = 0, peak = 0, full = 0; + for (let i = 0; i < m; i++) for (let c = 0; c < 2; c++) { const y = ref[2 * i + c], x = rec[2 * (start + i) + c]; ee += y * y; rr += x * x; const a = Math.abs(x); if (a > peak) peak = a; if (a >= 0.999) full++; } + const gain = Math.sqrt(rr / ee); + // Drift: the lag of each ten seconds, fitted again. + const lags: number[] = []; + for (let t = 2 * R; t + R < m; t += 10 * R) lags.push(fit(ref, start + t, t, R, 240).lag); + // Second by second: level against the file, and how close (below 6 kHz, above, all). + const a6 = 1 - Math.exp((-2 * Math.PI * 6000) / R); + let lo = 0, loE = 0, hi = 0, hiE = 0, worst = Infinity, worstAt = 0, level = [Infinity, -Infinity]; + const s1 = [0, 0], s2 = [0, 0]; + for (let sec = 0; (sec + 1) * R <= m; sec++) { + let a = 0, b = 0, e = 0; + for (let i = sec * R; i < (sec + 1) * R; i++) for (let c = 0; c < 2; c++) { + const y = ref[2 * i + c] * gain, x = rec[2 * (start + i) + c]; + s1[c] += a6 * (y - s1[c]); s2[c] += a6 * (x - s2[c]); + lo += s1[c] ** 2; loE += (s2[c] - s1[c]) ** 2; hi += (y - s1[c]) ** 2; hiE += (x - s2[c] - (y - s1[c])) ** 2; + a += y * y; b += x * x; e += (x - y) ** 2; + } + if (a / (2 * R) < 1e-7) continue; + const d = db(b / a); + level = [Math.min(level[0], d), Math.max(level[1], d)]; + if (db(a / e) < worst) { worst = db(a / e); worstAt = sec; } + } + let side = 0, sideR = 0, sideE = 0; + for (let i = 0; i < m; i++) { const x = rec[2 * (start + i)] - rec[2 * (start + i) + 1], y = ref[2 * i] - ref[2 * i + 1]; side += x * y; sideR += x * x; sideE += y * y; } + console.log(`\n${path} (${(m0 / R).toFixed(2)} s, compared ${(m / R).toFixed(1)} s)`); + console.log(` starts at ${(start / R).toFixed(4)} s${previous >= 0 ? `, ${((start - previous) / R).toFixed(4)} s after the one before (${((start - previous) / R / 0.042).toFixed(1)} frames)` : ""}; fit ${best.toFixed(3)}`); + console.log(` heard ${db(gain * gain).toFixed(2)} dB against the file; each second ${level[0].toFixed(1)} … ${level[1].toFixed(1)} dB of that`); + console.log(` drift: the lag at every 10 s ${lags.join(", ")} frames of 48 kHz`); + console.log(` close: ${db(lo / loE).toFixed(1)} dB below 6 kHz, ${db(hi / hiE).toFixed(1)} above; the worst second ${worst.toFixed(1)} dB at ${worstAt} s`); + console.log(` sides: L − R follows the file's at ${(side / Math.sqrt(sideR * sideE + 1e-20)).toFixed(3)}; peak ${peak.toFixed(3)}, ${full} samples at full scale`); + if (pieces > 0) { + const rows: string[] = [], good: number[] = []; + for (let g = 0; (g + 0.3) * pieces * R < m; g++) { + const at = Math.round(g * pieces * R), f = fit(ref, start + at, at, Math.round(Math.min(0.35, pieces * 0.8) * R), 1440); + rows.push(`${g}:${(f.lag / 48).toFixed(1)}`); + if (f.c > 0.5) good.push(f.lag / 48); + } + const mean = good.reduce((a, b) => a + b, 0) / good.length, sd = Math.sqrt(good.reduce((a, b) => a + (b - mean) ** 2, 0) / good.length); + console.log(` pieces of ${pieces} s, each start's lag in ms: ${rows.join(" ")}`); + console.log(` ${good.length} of ${rows.length} clear: mean ${mean.toFixed(1)} ms, sd ${sd.toFixed(1)}, ${Math.min(...good).toFixed(1)} … ${Math.max(...good).toFixed(1)}`); + console.log(" (played piece by piece, the sample match and the drift above follow each piece's start, and say little)"); + } + after = start + Math.round(m0 * 0.9); + previous = start; +} diff --git a/scripts/sound-dump.mts b/scripts/sound-dump.mts new file mode 100644 index 0000000..72276c9 --- /dev/null +++ b/scripts/sound-dump.mts @@ -0,0 +1,48 @@ +/** + * The sounds a script's build puts in its map, written to a folder: each as the map gets it (Ogg Vorbis encoded, WAVs + * as they are), under its own name. For listening outside the game and for comparing a recording of a probe with what + * went in (`scripts/recording-check.mts`). + * + * npx tsx scripts/sound-dump.mts probes/korobeiniki-form.ts out/ # every sound the build makes + * npx tsx scripts/sound-dump.mts probes/korobeiniki-band.ts out/ --append 'program(async () => { playWav(st.playthrough.ogg(5), 0); });' + * npx tsx scripts/sound-dump.mts probes/korobeiniki.ts out/ --check # the names a check gives, no samples + * + * `--append` adds a line to the script first — a clip it does not play, made a sound of the build (the bars of a song + * cut for the game, summed as the game plays them). `--raw` writes each Ogg's samples beside it as interleaved 32-bit + * floats, before encoding. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { compileScript } from "../compiler/compiler"; +import { defaultScriptNames } from "../compiler/names"; +import { finishSounds, vorbisEncoder } from "../compiler/vorbis"; +import { defaultLib } from "../bundle/lib.mjs"; +import { scriptFiles } from "./script-files.mts"; + +const args = process.argv.slice(2); +const flag = (name: string) => args.includes(name); +const value = (name: string) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; +const [scriptPath, outDir] = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--append"); +if (!scriptPath || !outDir) { console.error("usage: sound-dump [--append ] [--raw] [--check]"); process.exit(2); } +const files = scriptFiles(scriptPath); +const extra = value("--append"); +if (extra) files["main.ts"] += `\n${extra}\n`; +const t0 = performance.now(); +const r = compileScript(ts, files, defaultScriptNames(), { lib: defaultLib(), sounds: !flag("--check") }); +for (const d of r.diagnostics) console.error(`${d.file}:${d.line}:${d.column} ${d.message}`); +if (r.diagnostics.length) process.exit(1); +console.log(`${flag("--check") ? "check" : "build"} ${(performance.now() - t0).toFixed(0)} ms, ${r.sounds.length} sounds`); +if (flag("--check")) { for (const s of r.sounds) console.log(s.path); process.exit(0); } +mkdirSync(outDir, { recursive: true }); +if (flag("--raw")) for (const s of r.sounds) if (s.vorbis) { + const [l, rr] = s.vorbis.channels, n = l.length, out = new Float32Array(2 * n); + for (let i = 0; i < n; i++) { out[2 * i] = l[i]; out[2 * i + 1] = (rr ?? l)[i]; } + writeFileSync(join(outDir, `${s.path.split("\\").pop()}.f32`), new Uint8Array(out.buffer)); +} +const t1 = performance.now(); +finishSounds(r.sounds, vorbisEncoder(readFileSync(join(import.meta.dirname, "..", "node_modules", "wasm-media-encoders", "wasm", "ogg.wasm")))); +console.log(`encoded ${(performance.now() - t1).toFixed(0)} ms`); +let bytes = 0; +for (const s of r.sounds) { writeFileSync(join(outDir, s.path.split("\\").pop()!), s.bytes!); bytes += s.bytes!.length; } +console.log(`${r.sounds.length} files, ${(bytes / 1048576).toFixed(2)} MB in ${outDir}`); diff --git a/stdlib/music.ts b/stdlib/music.ts index eeb8955..69f69e5 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -910,6 +910,8 @@ export interface Stems { stinger(name: string): number; /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */ readonly voices: number; + /** The pieces added up as the game plays the song through, every layer on, each on its frame: to hear it, or compare. */ + readonly playthrough: Clip; } /** @@ -1012,6 +1014,9 @@ export function stems(s: Song, options: StemsOptions): Stems { section: (name: string) => lookup("section", s.sections.map((x) => x.name), name), layer: (name: string) => lookup("layer", layers, name), stinger: (name: string) => lookup("stinger", stingerNames, name), + get playthrough() { + return mixdown(placed.map((p) => ({ clip: grid[p.g][p.l]!, at: p.at }))); + }, get voices() { if (voices < 0) { voices = 0; From 02dec7825d631dc19d29b26eca70d33a19c34934 Mon Sep 17 00:00:00 2001 From: Jeany Date: Mon, 19 Oct 2026 18:21:35 -0600 Subject: [PATCH 14/14] Tests: stems.playthrough is the pieces added up at their times Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUvpRKSfm2yqit18wN5muG --- tests/music.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/music.test.ts b/tests/music.test.ts index 29be639..f7c4fa5 100644 --- a/tests/music.test.ts +++ b/tests/music.test.ts @@ -318,6 +318,7 @@ let g = 0; for (const sec of st.pieces) for (const row of sec) { for (const c of row) if (c) pieces.push({ clip: c, at: g * 48 * 0.042 }); g++; } const sum = mixdown(pieces), loud = music.stems(song, { layers: { drums: ["drums"], tunes: ["lead", "pad"] }, peak: 1 }); if (!(sum.peak <= 0.8401 && sum.peak > 0.6)) fail(\`\${sum.peak}\`); +if (Math.abs(st.playthrough.peak - sum.peak) > 1e-6 || Math.abs(st.playthrough.seconds - sum.seconds) > 1e-6) fail(\`playthrough \${st.playthrough.peak} \${sum.peak}\`); if (!(loud.pieces[0][0][0]!.peak > st.pieces[0][0][0]!.peak)) fail("peak");`, ); expect(errors(r)).toEqual([]);