diff --git a/compile.ts b/compile.ts index e9f650f..f1f46e0 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 @@ -62,18 +66,23 @@ export const allowlistUrl = (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; 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 +98,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 +110,14 @@ 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 }) }); + // 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; + mod.finishSounds(result.sounds, encoder); + } + postMessage({ id, result }); } catch (err) { postMessage({ id, error: String((err && err.message) || err) }); } @@ -199,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. */ @@ -258,8 +274,9 @@ 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 req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}) }; + 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) => { if (err.message === RETRY) return compileInBackground(input, dist); diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 8b15744..08a5cfd 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; 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[]; /** The programs' variables, in declaration order; a function's copies and results are left out. */ variables: VariableInfo[]; @@ -183,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 }; @@ -383,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 }; @@ -540,7 +551,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..7d68626 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -483,20 +483,55 @@ ${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. * 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,13 +555,85 @@ ${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), 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()\`. + */ + /** + * 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; + /** 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)\`. */ @@ -534,8 +641,17 @@ ${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; +/** + * 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. \`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; 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 } /** @@ -1097,6 +1213,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/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/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..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. 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; \"@\" 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/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/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(); diff --git a/compiler/runtime.ts b/compiler/runtime.ts index ddca8c6..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, isClip, mixdown, noise, silence, tone } 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; @@ -217,8 +217,10 @@ 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(); + /** 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. */ @@ -303,8 +305,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.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)}.`); @@ -628,6 +632,9 @@ 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)."); }; /* ── The game's tables ── */ @@ -806,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", "__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 00c39c4..fa8e966 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, @@ -18,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"; @@ -110,15 +112,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; } @@ -206,12 +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; - /** The loudest sample, 0 … 1 (more than 1 clips when the map's file is written). */ + /** 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). Reading it works the samples out. */ readonly peak: number; trim(from: number, to?: number): Clip; gain(factor: number): Clip; @@ -222,9 +231,33 @@ 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; + 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; + 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 { samples: Float32Array; adpcm?: Uint8Array } +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; + /** 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; +} const inside = new WeakMap(); export const isClip = (v: unknown): v is Clip => typeof v === "object" && v !== null && (v as Clip).__trigscript === "clip"; @@ -234,66 +267,204 @@ const num = (v: unknown, what: string): number => { return v; }; -/** `samples` at `rate`, as a rate of its own, by straight lines between samples. */ +/** 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 + * 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; +})(); + +/** 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.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)}.`); - 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); +/** 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 = 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)}.`); + const seconds = frames / rate, wav = extra.wav === true; + const at = (t: number) => Math.max(0, Math.min(frames, Math.round(t * rate))); + /** 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, - peak, + channels, + 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 makeClip(samples.slice(at(from), at(to)), rate); + 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((s) => s * 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, 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 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 makeClip(samples.slice().reverse(), rate); }, + 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 makeClip(resample(samples, rate * ratio, rate), rate); + return derive("pitch", [semitones], resampled(frames, rate * ratio, rate), channels, (cs) => cs.map((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)) 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 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) 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, @@ -301,32 +472,204 @@ export function makeClip(samples: Float32Array, rate: number, adpcm?: Uint8Array 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."); - let k = 0; - return map((v, 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; - }); + 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}.`); - 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 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 l = Math.min(1, 1 - position), r = Math.min(1, 1 + position); + return derive("pan", [position], frames, 2, (cs) => [scale(cs[0], l), scale(cs[cs.length - 1], r)]); + }, + mono() { + if (channels === 1) return clip; + 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."); + 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 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)!; + 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)); }, }; - inside.set(clip, { samples, ...(adpcm ? { adpcm } : {}) }); + 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. `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, 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), peaks = chans.map((s) => truePeaks(s, ceiling)); + for (let i = 0; i < n; i++) { + let m = 0; + 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. + 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. (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; + 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: what a song - * sounds like when it is mixed when the script is built. `seconds` makes it that long at least. + * 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): Clip { +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 +677,146 @@ 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); - 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]; - } - return makeClip(out, rate); + if (rate !== undefined) checkRate(rate); + const to = rate ?? list[0].c.rate; + 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.`); + 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 out; + })); +} + +/** + * 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. `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; 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; + 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 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) { + 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]; } + // 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, ...over].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 (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; +/** 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); + 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[] => 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 `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. */ +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 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 { + path: string; + bytes?: Uint8Array; + vorbis?: { rate: number; channels: readonly Float32Array[]; quality: number }; +} -/** 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; - 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 }; +/** + * 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 || 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} 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 }; + // 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 = 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); + } + 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). */ @@ -399,36 +858,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/compiler/vorbis.ts b/compiler/vorbis.ts new file mode 100644 index 0000000..d2a5983 --- /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, s.vorbis.quality); + 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..f5e328f 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -1641,6 +1641,241 @@ 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. +- **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). + +### 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. +- **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. + +### 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. + +- **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 + 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). + +### 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. + +- **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). + +- **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. + +### 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/editor.ts b/editor.ts index 13f43b6..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, @@ -1396,12 +1397,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 +1434,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; @@ -1711,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; @@ -1742,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 }) @@ -1801,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/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/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/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/probes/korobeiniki-band.ts b/probes/korobeiniki-band.ts new file mode 100644 index 0000000..bfd1b21 --- /dev/null +++ b/probes/korobeiniki-band.ts @@ -0,0 +1,189 @@ +// 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 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 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 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 +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.gain(0.7) }, + every: 2, +}); +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 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`); + } + 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/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/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 9378bb3..af84c2a 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` (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. */ -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; } @@ -133,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/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/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/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/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/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/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 146fa05..69f69e5 100644 --- a/stdlib/music.ts +++ b/stdlib/music.ts @@ -21,17 +21,40 @@ * * 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); + * + * 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 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 * 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, mixdown, type Clip } 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; @@ -41,8 +64,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. */ @@ -54,19 +80,19 @@ 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); } /** 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; }; } @@ -77,58 +103,529 @@ function remembering(make: (name: string, seconds: number) => Clip): (name: stri * 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)); }), }); } +/** 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) => { - 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]); + 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 ** ((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; + 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 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, 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 { + 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]); + 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). */ 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)}.`); 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(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 { + 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 = 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 = noteNumber(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(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 - - -" + * — 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; 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; + /** 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 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; "@" 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: Pattern, options: PartOptions = {}): Track { + return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, "track") }); } -/** 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 { - const text = typeof pattern === "string" ? pattern : pattern.join(" "); - return Object.freeze({ instrument, steps: Object.freeze(text.split(/\s+/).filter((w) => w !== "" && w !== "|")) }); +/** 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 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 + * 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); }); + 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. */ +function placed(t: Track, made: Map, c: Clip): Clip { + if (t.pan === 0 && t.volume === 1 && !t.fx) return c; + let p = made.get(c); + if (!p) { + 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); + } + return p; +} + +/** 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. */ 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; + /** 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. */ 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; @@ -137,66 +634,498 @@ 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; } +/** 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). 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), `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 }, ...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: 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; - const notes: { at: number; clip: Clip }[] = []; - let steps = 0; + // 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, effects = false; for (const t of tracks) { - const ws = t.steps; + 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(", ")}).`); - notes.push({ at: s * stepFrames, clip: 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("+")) { - midi(name); - notes.push({ at: s * stepFrames, clip: t.instrument.note(name, length * stepSeconds) }); + const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME; + for (const name of body.split("+")) { + noteNumber(name); + score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds }); } }); } - 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.`); + 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.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) => { + 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 = () => { + 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, 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; }, + get at() { return playable().at; }, + get which() { return playable().which; }, + get voices() { return tables().voices; }, + }); + scores.set(made, score); + 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 } + +/** + * 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 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, 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 { + 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) { + 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); + 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); +} + +/* ── 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 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 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 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 pieces may add up to as the song plays through, every layer on (0.84, −1.5 dB). */ + readonly peak?: number; + /** + * 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 pieces' clips (4: a little smaller than a song's 5, as many play at once). */ + readonly quality?: number; + /** + * 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 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, 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 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; + /** 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; + /** 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; +} + +/** + * 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 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]) { + 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 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))); + 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) / 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 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 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 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 * piece) * FRAME })); + if (apart.length) { + // 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 }); + } + 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 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. + 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 * pieceSeconds, g, l }); })); + if (placed.length === 0) throw new Error("stems(): the layers play no notes."); + // 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: 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 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"}.`); + return k; + }; + let voices = -1; return 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, + 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), + get playthrough() { + return mixdown(placed.map((p) => ({ clip: grid[p.g][p.l]!, at: p.at }))); + }, + 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; + }, }); } -/** 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); +/** + * 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")); // 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`; the piece of it (a bar, unless `stems.every` says), from 0; the beat of the piece. */ + section = 0; + piece = 0; + beat = 0; + /** Pieces played since it started. */ + count = 0; + /** 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 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 beat, by its place; −1 for none. */ + stung = -1; + /** Whether it plays: false stops it where the next piece would begin (what sounds rings out). */ + playing = true; + + /** Go to a section where the next piece begins, from its first piece. */ + 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 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 beat. */ + sting(stinger: number) { + this.stung = stinger; + } + /** Stop where the next piece would begin. */ + stop() { + this.playing = false; + } } +/** 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; + band.beat = 0; + while (band.playing || band.beat > 0) { + band.at = memory.game.frame; + 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; + 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; + } + } +}); + /** * 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. @@ -226,4 +1155,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, 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, midi, stems, conduct, +}; diff --git a/tests/music.test.ts b/tests/music.test.ts index 9687f21..f7c4fa5 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/); }); @@ -95,6 +97,290 @@ 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("instruments, effects and the mix", () => { + 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", "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");`, + ); + 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("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("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.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.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.pieces[0][0][1] === st.pieces[1][0][1]) fail("different bars"); +const bar = 48 * 0.042; +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));`, + ); + 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.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([]); + }); + + 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, 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", () => { 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/tests/sound.test.ts b/tests/sound.test.ts index 5f590bc..1fc6660 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, 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, 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); }); @@ -132,12 +135,177 @@ 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); }); }); +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("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); + // 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); + }); + + 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); + // 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/); + }); + + 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); + // 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); + }); + + 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 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]{16}\.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("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)]); +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, 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])], [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"); + }); +}); + 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 +338,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)); }); @@ -183,3 +351,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); + }); +}); 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;