diff --git a/README.md b/README.md index b6b1a5a..639bd25 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,38 @@ your lane only, and only a couple of metres of it, so it has to be aimed. Eggscape on a phone

+## The soundtrack + +There is no audio file in the repository, and there is music. `soundtrack.js` +writes it down — one token per sixteenth, `e2 - . e3+g3+b3` — and `music.js` +plays it on the same AudioContext the sound effects use, on instruments made +of oscillators and noise. Nothing is built until the page has had a touch, +because no browser will make a sound before one. + +It is psytrance — the sequels cut their fights to Juno Reactor — which is a +kick on every beat and a bass rolling the three sixteenths between them, here +in E minor, round Em Em C C Am Am F B. The F is the trick. It sits a semitone above home, where nothing +that belongs in E minor lives, and it is what makes the loop sound like the +code is wrong. + +It builds with depth. The first hundred metres are the kick and the bass. Then +come the hats, the clap and the green rain — square-wave plinks three to a +beat against four, so they never fall the same way twice in a bar — and at +three hundred the pads and the data bleeps, and at six hundred the lead. Every +layer comes in on a bar line, whatever metre it was earned on. An agent +muffles the lot for a second. System failure is a chord that is not in the key +and the power draining out of a saw; between runs it is the pod, the same +chords slowed right down, and a heartbeat. + +The sequencer never plays anything at the moment it is asked to. It puts +notes down a quarter of a second ahead on the audio clock, so a frame that +hitches is not a note that arrives late — and further ahead than that when the +frames are coming slowly, since a phone that is struggling is struggling on +every one of them. A tab that comes back from the background drops what it +missed and stays on the grid, rather than playing a minute of music at once. +`M` mutes it with everything else, and it keeps time while it is off, so it +comes back on the beat. + ## How it holds together The run is a plain object graph with no pixels in it — course, egg, lives, @@ -91,6 +123,8 @@ src/ hud.js The readouts and the panel between runs input.js Keys and swipes → one frame of intent sound.js Four oscillators' worth of arcade + music.js A sequencer that reads its parts out of strings + soundtrack.js Psytrance in E minor, and a heartbeat for the title best.js The only thing that survives a run test/ node:test, including an autopilot that proves seeds are fair ``` @@ -118,7 +152,7 @@ cannot get a few hundred metres down a seed. | `npm run dev` | Vite | | `npm run build` | Bundles to `dist/` | | `npm run preview` | Serves the build | -| `npm test` | `node:test` over the core, the HUD and the page | +| `npm test` | `node:test` over the core, the HUD, the page and the music | | `npm run lint` | ESLint | CI runs the lint, the tests on Node 22.12 and 24, and a build that then has to diff --git a/src/main.js b/src/main.js index 7e480e1..3e7ac30 100644 --- a/src/main.js +++ b/src/main.js @@ -7,7 +7,9 @@ import { createView } from './render/view.js'; import { readBest, writeBest } from './ui/best.js'; import { createHud } from './ui/hud.js'; import { createInput } from './ui/input.js'; +import { createMusic } from './ui/music.js'; import { createSound } from './ui/sound.js'; +import { SOUNDTRACK } from './ui/soundtrack.js'; const seed = () => Math.floor(Math.random() * 1e6); @@ -15,6 +17,7 @@ const stage = createScene(document.getElementById('stage')); const view = createView(stage); const hud = createHud(document, LIVES); const sound = createSound(); +const music = createMusic(SOUNDTRACK, sound); const game = createGame({ seed: seed() }); let best = readBest(); @@ -23,6 +26,7 @@ game.on('start', () => { view.reset(); hud.running(); sound.play('start'); + music.play('run'); }); game.on('jump', () => sound.play('jump')); game.on('land', () => { @@ -42,6 +46,7 @@ game.on('hit', () => { view.kick(2.6); view.jolt(0.9); sound.play('hit'); + music.duck(); }); /** The egg is put down metres further on, so the camera cuts rather than * chases — it used to spend the landing somewhere behind, pointed at void. */ @@ -50,6 +55,7 @@ game.on('over', (snapshot) => { best = writeBest(snapshot.score); hud.over(snapshot, best); sound.play('over'); + music.play('over'); }); function play() { @@ -62,10 +68,17 @@ function play() { const input = createInput(window, { onConfirm: play }); hud.onPlay(play); hud.ready(best); +music.play('title'); + +/** The title has music too, and no browser will play a note of it before + * somebody has touched the page — so the first touch of anything wakes the + * audio, rather than the first run. */ +for (const type of ['pointerup', 'keydown']) addEventListener(type, () => sound.wake(), { once: true }); const mute = document.getElementById('mute'); function setMuted(value) { sound.muted = value; + music.muted = value; mute.textContent = value ? 'audio off' : 'audio on'; mute.setAttribute('aria-pressed', String(value)); } @@ -91,6 +104,7 @@ function frame(now) { last = now; const snapshot = game.advance(dt, input.take()); + music.update(snapshot); view.sync(snapshot, dt, now / 1000); stage.tick(dt); hud.update(snapshot); diff --git a/src/ui/music.js b/src/ui/music.js new file mode 100644 index 0000000..7f6ca1b --- /dev/null +++ b/src/ui/music.js @@ -0,0 +1,498 @@ +/** + * The soundtrack, written down rather than recorded: a step sequencer on the + * page's own AudioContext, reading its parts out of strings and playing them + * on instruments made of oscillators and noise. There is nothing to download, + * which is the rule for everything else here too. + * + * A part is one token per sixteenth: + * + * e2 c#4 bb3 a note + * e3+g3+b3 a chord + * e2! e2? accented, ghosted + * x X o a hit, an accent, a ghost — drums have no pitch + * - hold whatever is sounding for another step + * . nothing + * | a bar line, for the reader; the sequencer skips it + * + * The game never talks to this in time. It says which cue it wants and how + * worked up it is, and the sequencer lands both on the grid: a cue starts on + * the next step, a new layer comes in on the next bar line. + */ + +const LETTERS = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +const NAMES = ['c', 'c#', 'd', 'eb', 'e', 'f', 'f#', 'g', 'ab', 'a', 'bb', 'b']; +const HITS = { x: 1, X: 1.4, o: 0.5 }; +const MARKS = { '!': 1.35, '?': 0.55 }; +const SPACERS = new Set(['.', '-', '|']); + +/** + * How far ahead of the clock notes are put down. A frame is sixteen + * milliseconds and a frame that hitches is a couple of hundred, and a note + * nobody scheduled before the hitch is a note that arrives late. + */ +const AHEAD = 0.25; +/** And the furthest it will ever go, however slow the frames are coming. */ +const REACH = 1; +/** Later than this and a step is dropped rather than played out of time. */ +const LATE = 0.02; +/** A cue asked for now starts this far from now, so its first note is not + * already late by the time it reaches the speakers. */ +const LEAD = 0.05; +/** The master filter, open and ducked. */ +const OPEN = 18000; +const DUCKED = 420; + +export function midi(name) { + const match = /^([a-g])(#|b)?(-?\d)$/.exec(name); + if (!match) return null; + const accidental = match[2] === '#' ? 1 : (match[2] === 'b' ? -1 : 0); + return 12 * (Number(match[3]) + 1) + LETTERS[match[1]] + accidental; +} + +export function noteName(note) { + return `${NAMES[((note % 12) + 12) % 12]}${Math.floor(note / 12) - 1}`; +} + +export const hz = (note) => 440 * 2 ** ((note - 69) / 12); + +/** A note held for `steps`, and `steps` of nothing — for writing parts. */ +export const hold = (word, steps) => [word, ...Array(steps - 1).fill('-')].join(' '); +export const rest = (steps) => Array(steps).fill('.').join(' '); + +function event(word) { + if (word in HITS) return { notes: [], vel: HITS[word], len: 1 }; + const mark = MARKS[word.at(-1)]; + const notes = (mark ? word.slice(0, -1) : word).split('+').map(midi); + if (notes.some((note) => note === null)) return null; + return { notes, vel: mark ?? 1, len: 1 }; +} + +/** One entry per step: an event, or null for a rest or a held step. */ +export function parse(text) { + const steps = []; + let sounding = null; + for (const word of text.trim().split(/\s+/)) { + if (word === '|' || word === '') continue; + if (word === '-' || word === '.') { + if (word === '-' && sounding) sounding.len += 1; + else sounding = null; + steps.push(null); + continue; + } + const found = event(word); + if (!found) throw new Error(`"${word}" is not a note, a hit or a rest`); + steps.push(found); + sounding = found; + } + return steps; +} + +/** Every note in a part put through `map`, rhythm and marks untouched. */ +export function retune(text, map) { + return text.split(/(\s+)/).map((word) => { + if (!word.trim() || word in HITS || SPACERS.has(word)) return word; + const mark = word.at(-1) in MARKS ? word.at(-1) : ''; + const body = mark ? word.slice(0, -1) : word; + return body.split('+').map((name) => noteName(map(midi(name)))).join('+') + mark; + }).join(''); +} + +/** + * Up or down a scale by degrees rather than semitones, which is how a second + * fiddle finds its part: a third below is two steps down the key, and what + * that is in semitones depends on where in the key you started. + */ +export function diatonic(scale, degrees) { + const root = scale[0]; + const steps = scale.map((pc) => (((pc - root) % 12) + 12) % 12).sort((a, b) => a - b); + return (note) => { + const rel = note - root; + const index = steps.indexOf(((rel % 12) + 12) % 12); + if (index < 0) return note - 3; + const degree = Math.floor(rel / 12) * 7 + index + degrees; + return root + Math.floor(degree / 7) * 12 + steps[((degree % 7) + 7) % 7]; + }; +} + +/** Every cue with its parts parsed, and the defaults filled in. */ +export function compile(cues) { + const out = {}; + for (const [name, cue] of Object.entries(cues)) { + const parts = cue.parts.map((part) => ({ + at: 0, until: Infinity, gain: 1, wet: 0, echo: 0, pan: 0, + ...part, + steps: parse(part.notes), + })); + out[name] = { + bar: 16, swing: 0, once: false, then: null, + ...cue, + name, + parts, + length: Math.max(...parts.map((part) => part.steps.length)), + }; + } + return out; +} + +/** A seeded stream of [-1, 1), so the noise is the same noise every time. */ +function stream(seed) { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 2 ** 31 - 1; + }; +} + +function whiteNoise(ctx) { + const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); + const data = buffer.getChannelData(0); + const next = stream(7); + for (let i = 0; i < data.length; i++) data[i] = next(); + return buffer; +} + +/** A room, made the cheap way: two channels of noise dying away. */ +function impulse(ctx, { seconds = 2, decay = 3 } = {}) { + const length = Math.max(1, Math.floor(ctx.sampleRate * seconds)); + const buffer = ctx.createBuffer(2, length, ctx.sampleRate); + for (let channel = 0; channel < 2; channel++) { + const data = buffer.getChannelData(channel); + const next = stream(11 + channel); + for (let i = 0; i < length; i++) data[i] = next() * (1 - i / length) ** decay; + } + return buffer; +} + +/** + * An envelope on a param: up to `peak`, down towards `sustain`, and away once + * the note is over. Returns when it has got there, which is when the voice + * can stop. + */ +export function shape(param, t, dur, { + peak = 1, attack = 0.005, decay = 0.2, sustain = 0, release = 0.08, +} = {}) { + const off = t + Math.max(attack, dur); + param.setValueAtTime(0, t); + param.linearRampToValueAtTime(peak, t + attack); + param.setTargetAtTime(peak * sustain, t + attack, decay / 4); + param.setTargetAtTime(0, off, release / 5); + return off + release; +} + +/** Something struck: there at once, and gone in `seconds`. */ +export function hit(param, t, seconds, peak = 1, attack = 0.002) { + return shape(param, t, seconds, { peak, attack, decay: seconds, release: 0.03 }); +} + +/** + * Plays a soundtrack on whatever context `source.context` has — and never + * builds one, because the page may only do that inside a gesture and the + * music is never what the player just pressed. + */ +export function createMusic(soundtrack, source, { ahead = AHEAD } = {}) { + const cues = compile(soundtrack.cues); + const volume = soundtrack.volume ?? 0.5; + + let ctx = null; + let rig = null; + let voices = null; + let wanted = null; + let current = null; + let level = 0; + let muted = false; + let lastPump = null; + let slack = 0; + const retired = []; + + /** One note's worth of nodes, let go of together once the last one stops. */ + function voice(start) { + const nodes = []; + const sources = []; + const own = (node) => { + nodes.push(node); + return node; + }; + const run = (node, offset) => { + sources.push([own(node), offset]); + return node; + }; + const note = { + osc(type, freq, detune = 0) { + const osc = ctx.createOscillator(); + osc.type = type; + osc.frequency.setValueAtTime(freq, start); + if (detune) osc.detune.setValueAtTime(detune, start); + return run(osc); + }, + noise() { + const src = ctx.createBufferSource(); + src.buffer = rig.noise; + src.loop = true; + return run(src, (rig.scatter() + 1) * 0.45); + }, + buffer(buffer, rate = 1) { + const src = ctx.createBufferSource(); + src.buffer = buffer; + src.playbackRate.setValueAtTime(rate, start); + return run(src); + }, + gain(value = 0) { + const gain = own(ctx.createGain()); + gain.gain.setValueAtTime(value, start); + return gain; + }, + filter(type, freq, q = 0.7) { + const filter = own(ctx.createBiquadFilter()); + filter.type = type; + filter.frequency.setValueAtTime(freq, start); + filter.Q.setValueAtTime(q, start); + return filter; + }, + /** A slow oscillator on a param — vibrato, tremolo, a tape that + * has been played too often — faded in over `delay`. */ + wobble(param, rate, depth, delay = 0) { + const lfo = note.osc('sine', rate); + const amount = note.gain(0); + amount.gain.linearRampToValueAtTime(depth, start + Math.max(0.001, delay)); + lfo.connect(amount).connect(param); + }, + play(end) { + let live = sources.length; + for (const [node, offset] of sources) { + node.onended = () => { + live -= 1; + if (live === 0) for (const each of nodes) each.disconnect(); + }; + node.start(start, offset); + node.stop(end); + } + }, + }; + return note; + } + + function build(audio) { + ctx = audio; + const space = soundtrack.space ?? {}; + + const master = ctx.createGain(); + master.gain.value = muted ? 0 : volume; + /** After the volume, and high: this is a safety on the peaks of eight + * layers at once, not a squeeze on one. A compressor adds back what it + * thinks it took away, so a low threshold would drag every quiet cue up + * to the level of the loudest. */ + const squash = ctx.createDynamicsCompressor(); + squash.threshold.value = -8; + squash.knee.value = 6; + squash.ratio.value = 6; + squash.attack.value = 0.004; + squash.release.value = 0.2; + const tone = ctx.createBiquadFilter(); + tone.type = 'lowpass'; + tone.frequency.value = OPEN; + const mix = ctx.createGain(); + mix.connect(tone).connect(master).connect(squash).connect(ctx.destination); + + const room = ctx.createConvolver(); + room.buffer = impulse(ctx, space.reverb); + const roomOut = ctx.createGain(); + roomOut.gain.value = space.reverb?.level ?? 1; + room.connect(roomOut).connect(mix); + + const echo = space.echo ?? {}; + const delay = ctx.createDelay(4); + const dull = ctx.createBiquadFilter(); + dull.type = 'lowpass'; + dull.frequency.value = echo.tone ?? 2600; + const feedback = ctx.createGain(); + feedback.gain.value = echo.feedback ?? 0.35; + delay.connect(dull).connect(feedback).connect(delay); + dull.connect(mix); + + rig = { master, tone, mix, room, delay, noise: whiteNoise(ctx), scatter: stream(3) }; + voices = soundtrack.instruments({ ctx, voice }); + } + + function link(node, to) { + node.connect(to); + return node; + } + + /** A part's own way in: its level, its place in the stereo, its sends. */ + function wire(part, bus, nodes) { + const input = ctx.createGain(); + input.gain.value = part.gain; + nodes.push(input); + let out = input; + if (part.pan && ctx.createStereoPanner) { + out = input.connect(ctx.createStereoPanner()); + out.pan.value = part.pan; + nodes.push(out); + } + out.connect(bus.dry); + for (const send of ['wet', 'echo']) { + if (!part[send]) continue; + const amount = link(ctx.createGain(), bus[send]); + amount.gain.value = part[send]; + out.connect(amount); + nodes.push(amount); + } + return input; + } + + function begin(request, at) { + const cue = cues[request.name]; + const bpm = request.bpm ?? cue.bpm; + const stepTime = 60 / bpm / 4; + const bus = { + dry: link(ctx.createGain(), rig.mix), + wet: link(ctx.createGain(), rig.room), + echo: link(ctx.createGain(), rig.delay), + }; + const nodes = Object.values(bus); + const parts = cue.parts.map((part) => ({ part, input: wire(part, bus, nodes) })); + /** The frames just after a cue starts are the ones most likely to be + * slow — a new run is when the renderer has the most to build — so the + * opening goes down further ahead than it needs to, just in case. */ + slack = Math.max(slack, 0.5); + /** The echo is in time with whatever is playing, so it moves with it — + * gently, since there is still some of the last cue in the line. */ + const beats = soundtrack.space?.echo?.beats ?? 0.75; + rig.delay.delayTime.setTargetAtTime(Math.min(3.9, (beats * 60) / bpm), at, 0.05); + current = { + request, cue, stepTime, bus, nodes, parts, level, + transpose: request.transpose ?? 0, + step: 0, + next: at, + }; + } + + /** + * Off the clock. A cue that is being replaced fades in a few milliseconds, + * notes it had already put down included; one that simply finished is left + * to ring, and its nodes are let go of once everything in it is over. + */ + function retire(instance, at, fade) { + if (fade) for (const node of Object.values(instance.bus)) node.gain.setTargetAtTime(0, at, 0.02); + retired.push({ nodes: instance.nodes, after: at + (fade ? 1 : 12) }); + } + + function sweep(now) { + for (let i = retired.length - 1; i >= 0; i--) { + if (retired[i].after > now) continue; + for (const node of retired[i].nodes) node.disconnect(); + retired.splice(i, 1); + } + } + + function strike(instance) { + const { cue, step, stepTime } = instance; + const time = instance.next + (step % 2 ? cue.swing * stepTime : 0); + for (const { part, input } of instance.parts) { + if (instance.level < part.at || instance.level >= part.until) continue; + if (cue.once && step >= part.steps.length) continue; + const hit = part.steps[step % part.steps.length]; + if (!hit) continue; + const freqs = hit.notes.map((note) => hz(note + instance.transpose)); + voices[part.play](input, time, hit.len * stepTime, freqs, hit.vel); + } + } + + function pump() { + const now = ctx.currentTime; + /** + * A slow phone is slow on every frame, not on one: at six frames a second + * a quarter of a second of notes runs out between two of them, and the + * music falls to pieces exactly where the game already has. So the notes + * go down far enough ahead to cover the gaps it is really getting, and + * come back in as those shrink. + */ + if (lastPump !== null) slack = Math.max(slack * 0.97, now - lastPump); + lastPump = now; + const horizon = now + Math.min(REACH, Math.max(ahead, slack * 1.5 + 0.05)); + while (current && current.next < horizon) { + const instance = current; + /** A frame that came back late — a hitch, a tab in the background — + * skips the steps it missed and stays on the grid, rather than + * playing a second of music in one go. */ + if (instance.next < now - LATE) { + const missed = Math.ceil((now - instance.next) / instance.stepTime); + instance.step += missed; + instance.next += missed * instance.stepTime; + continue; + } + if (instance.cue.once && instance.step >= instance.cue.length) { + retire(instance, instance.next, false); + current = null; + if (instance.cue.then) begin({ name: instance.cue.then }, instance.next); + continue; + } + if (instance.step % instance.cue.bar === 0) instance.level = level; + if (!muted) strike(instance); + instance.step += 1; + instance.next += instance.stepTime; + } + sweep(now); + } + + function same(a, b) { + return a.name === b.name && a.bpm === b.bpm && (a.transpose ?? 0) === (b.transpose ?? 0); + } + + const music = { + get muted() { return muted; }, + set muted(value) { + muted = Boolean(value); + if (rig) rig.master.gain.setTargetAtTime(muted ? 0 : volume, ctx.currentTime, 0.04); + }, + + /** What is playing, or about to be. */ + get cue() { return (wanted ?? current?.request)?.name ?? null; }, + + get level() { return current?.level ?? level; }, + + play(name, { bpm, transpose = 0 } = {}) { + if (!cues[name]) throw new Error(`there is no cue called "${name}"`); + const request = { name, bpm, transpose }; + const playing = wanted ?? current?.request; + if (playing && same(playing, request)) return; + wanted = request; + music.update(); + }, + + stop() { + wanted = null; + if (current) retire(current, ctx.currentTime, true); + current = null; + }, + + /** The music taking the hit too: everything muffled, and back. */ + duck() { + if (!rig) return; + const t = ctx.currentTime; + const freq = rig.tone.frequency; + freq.cancelScheduledValues(t); + freq.setValueAtTime(Math.min(OPEN, Math.max(DUCKED, freq.value)), t); + freq.exponentialRampToValueAtTime(DUCKED, t + 0.04); + freq.exponentialRampToValueAtTime(OPEN, t + 1.1); + }, + + /** Once a frame. The snapshot is how worked up the game is. */ + update(snapshot) { + if (snapshot && soundtrack.level) level = soundtrack.level(snapshot); + if (!ctx) { + const audio = source.context; + if (!audio) return; + build(audio); + } + if (wanted) { + if (current) retire(current, ctx.currentTime, true); + begin(wanted, ctx.currentTime + LEAD); + wanted = null; + } + if (current) pump(); + }, + }; + + return music; +} diff --git a/src/ui/sound.js b/src/ui/sound.js index a230e63..005a939 100644 --- a/src/ui/sound.js +++ b/src/ui/sound.js @@ -14,11 +14,17 @@ export function createSound() { let ctx = null; let muted = false; - function context() { - if (ctx) return ctx; - const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext; - if (!Ctor) return null; - ctx = new Ctor(); + /** Built on the first call and woken on every one after, which is only + * ever from inside a gesture: the one place a browser will start it. */ + function wake() { + if (!ctx) { + const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext; + if (!Ctor) return null; + ctx = new Ctor(); + } + /** A resume that is refused is a run with no sound in it, not an + * unhandled rejection in everybody's console. */ + if (ctx.state === 'suspended') ctx.resume()?.catch(() => {}); return ctx; } @@ -26,14 +32,17 @@ export function createSound() { get muted() { return muted; }, set muted(value) { muted = Boolean(value); }, + /** Whatever a gesture has built so far, and null until one has. The music + * plays on this and never builds it: it is never what was just pressed. */ + get context() { return ctx; }, + + wake, + play(name) { const voice = VOICES[name]; if (!voice || muted) return; - const audio = context(); + const audio = wake(); if (!audio) return; - /** A resume that is refused is a run with no sound in it, not an - * unhandled rejection in everybody's console. */ - if (audio.state === 'suspended') audio.resume()?.catch(() => {}); const now = audio.currentTime; const osc = audio.createOscillator(); diff --git a/src/ui/soundtrack.js b/src/ui/soundtrack.js new file mode 100644 index 0000000..fc85a18 --- /dev/null +++ b/src/ui/soundtrack.js @@ -0,0 +1,279 @@ +import { hit, hold, rest, shape } from './music.js'; + +/** + * Eggscape the Matrix: psytrance, because the sequels cut their fights to + * Juno Reactor, and because a bass that rolls three sixteenths behind every + * kick is the sound of something that does not stop. + * + * E minor, round a progression that goes Em Em C C Am Am F B — and the F is + * the whole trick. It is a semitone above home, where nothing that belongs in + * E minor lives, and it is what makes the loop sound like the code is wrong. + */ + +const CHORDS = [ + { root: 'e2', pad: 'e3+g3+b3', rain: ['e5', 'g5', 'b5'] }, + { root: 'e2', pad: 'e3+g3+b3', rain: ['e5', 'g5', 'b5'], turn: 'e3' }, + { root: 'c2', pad: 'e3+g3+c4', rain: ['e5', 'g5', 'c6'] }, + { root: 'c2', pad: 'e3+g3+c4', rain: ['e5', 'g5', 'c6'], turn: 'c3' }, + { root: 'a1', pad: 'e3+a3+c4', rain: ['e5', 'a5', 'c6'] }, + { root: 'a1', pad: 'e3+a3+c4', rain: ['e5', 'a5', 'c6'], turn: 'a2' }, + { root: 'f2', pad: 'f3+a3+c4', rain: ['f5', 'a5', 'c6'] }, + { root: 'b1', pad: 'd#3+f#3+b3', rain: ['d#5', 'f#5', 'b5'], turn: 'b2' }, +]; + +const each = (write) => CHORDS.map(write).join(' | '); + +/** Kick, bass, bass, bass — and on a turn the last beat jumps the octave. */ +const rolling = ({ root, turn }) => { + const beat = `. ${root} ${root} ${root}`; + return [beat, beat, beat, turn ? `. ${turn} ${root} ${turn}` : beat].join(' '); +}; + +/** The green rain: three notes against four beats, so it never lands the + * same way twice in a bar. */ +const falling = ({ rain }) => Array.from({ length: 16 }, (_, i) => rain[i % 3] + (i % 4 ? '' : '!')).join(' '); + +const FOUR = 'x . . . x . . . x . . . x . . .'; + +const LEAD = [ + 'b4 - - - e5 - - - g5 - f#5 - e5 - - -', + 'd5 - - - e5 - - - b4 - - - - - - -', + 'c5 - - - e5 - - - g5 - a5 - g5 - - -', + 'e5 - - - g5 - - - c5 - - - - - - -', + 'a4 - - - c5 - - - e5 - d5 - c5 - - -', + 'b4 - - - c5 - - - a4 - - - - - - -', + 'f5 - - - e5 - - - f5 - a5 - f5 - - -', + 'f#5 - - - d#5 - - - b4 - - - - - - -', +].join(' | '); + +/** The title is the pod: the same chords, slowed right down, and a heart. */ +const DRIFT = [ + '. . . . e6 . . . . . b5 . . . . .', + '. . g6 . . . . . . . . . e6 . . .', + '. . . . c6 . . . . . g5 . . . . .', + '. . e6 . . . . . . . . . . . g6 .', + '. . . . a5 . . . . . e6 . . . . .', + '. . c6 . . . . . . . . . a5 . . .', + '. . . . f6 . . . . . c6 . . . . .', + '. . d#6 . . . . . . . . . b5 . . .', +].join(' | '); + +function instruments({ voice }) { + let lastLead = null; + + return { + kick(out, t, _dur, _freqs, vel) { + const note = voice(t); + const body = note.osc('sine', 150); + body.frequency.exponentialRampToValueAtTime(42, t + 0.11); + const amp = note.gain(); + body.connect(amp).connect(out); + const click = note.noise(); + const bite = note.filter('highpass', 2800); + const clickAmp = note.gain(); + click.connect(bite).connect(clickAmp).connect(out); + hit(clickAmp.gain, t, 0.012, 0.3 * vel); + note.play(hit(amp.gain, t, 0.34, 0.95 * vel)); + }, + + /** Three hands, a hair apart, and a room behind them. */ + clap(out, t, _dur, _freqs, vel) { + const note = voice(t); + const src = note.noise(); + const band = note.filter('bandpass', 1500, 0.9); + const amp = note.gain(); + src.connect(band).connect(amp).connect(out); + for (let i = 0; i < 3; i++) { + amp.gain.setValueAtTime(0.8 * vel, t + i * 0.011); + amp.gain.setTargetAtTime(0.1, t + i * 0.011, 0.003); + } + amp.gain.setValueAtTime(0.8 * vel, t + 0.033); + amp.gain.setTargetAtTime(0, t + 0.033, 0.045); + note.play(t + 0.3); + }, + + /** Closed, or open on an accent. */ + hat(out, t, _dur, _freqs, vel) { + const note = voice(t); + const src = note.noise(); + const air = note.filter('highpass', 7200); + const amp = note.gain(); + src.connect(air).connect(amp).connect(out); + note.play(hit(amp.gain, t, vel > 1.2 ? 0.16 : 0.04, 0.5 * Math.min(vel, 1))); + }, + + tick(out, t, _dur, _freqs, vel) { + const note = voice(t); + const src = note.noise(); + const band = note.filter('bandpass', 9500, 1.4); + const amp = note.gain(); + src.connect(band).connect(amp).connect(out); + note.play(hit(amp.gain, t, 0.022, 0.6 * vel)); + }, + + /** Two saws a little apart, and a resonant filter that snaps open on + * every note and shuts again before the next. */ + bass(out, t, dur, [freq], vel) { + const note = voice(t); + const low = note.filter('lowpass', 170, 7); + low.frequency.linearRampToValueAtTime(760 * vel, t + 0.008); + low.frequency.setTargetAtTime(160, t + 0.008, 0.045); + for (const cents of [-9, 9]) note.osc('sawtooth', freq, cents).connect(low); + const body = note.osc('sine', freq); + const bodyAmp = note.gain(0.5); + body.connect(bodyAmp).connect(low); + const amp = note.gain(); + low.connect(amp).connect(out); + note.play(shape(amp.gain, t, dur * 0.85, { + peak: 0.55 * vel, attack: 0.003, decay: 0.1, sustain: 0.6, release: 0.03, + })); + }, + + rain(out, t, _dur, [freq], vel) { + const note = voice(t); + const src = note.osc('square', freq); + const low = note.filter('lowpass', 4200); + const amp = note.gain(); + src.connect(low).connect(amp).connect(out); + note.play(hit(amp.gain, t, 0.13, 0.28 * vel)); + }, + + pad(out, t, dur, freqs, vel) { + const note = voice(t); + const low = note.filter('lowpass', 1300, 1.2); + note.wobble(low.frequency, 0.13, 380); + for (const freq of freqs) { + for (const cents of [-11, 0, 11]) note.osc('sawtooth', freq, cents).connect(low); + } + const amp = note.gain(); + low.connect(amp).connect(out); + note.play(shape(amp.gain, t, dur, { + peak: (0.2 * vel) / freqs.length, attack: 0.55, decay: 1, sustain: 0.85, release: 1.1, + })); + }, + + /** A saw with a square under it, sliding in from wherever it last was. */ + lead(out, t, dur, [freq], vel) { + const note = voice(t); + const low = note.filter('lowpass', 2600, 2); + const from = lastLead && Math.abs(Math.log2(lastLead / freq)) < 1 ? lastLead : freq; + lastLead = freq; + for (const [type, cents] of [['sawtooth', 0], ['square', 7]]) { + const osc = note.osc(type, from, cents); + osc.frequency.exponentialRampToValueAtTime(freq, t + 0.05); + note.wobble(osc.detune, 5.4, 14, 0.25); + osc.connect(low); + } + const amp = note.gain(); + low.connect(amp).connect(out); + note.play(shape(amp.gain, t, dur, { + peak: 0.3 * vel, attack: 0.02, decay: 0.3, sustain: 0.7, release: 0.18, + })); + }, + + bleep(out, t, _dur, [freq], vel) { + const note = voice(t); + const src = note.osc('sine', freq); + const edge = note.osc('square', freq * 2); + const edgeAmp = note.gain(0.12); + edge.connect(edgeAmp); + const amp = note.gain(); + src.connect(amp); + edgeAmp.connect(amp); + amp.connect(out); + note.play(hit(amp.gain, t, 0.05, 0.35 * vel)); + }, + + sub(out, t, dur, [freq], vel) { + const note = voice(t); + const src = note.osc('sine', freq); + const amp = note.gain(); + src.connect(amp).connect(out); + note.play(shape(amp.gain, t, dur, { + peak: 0.6 * vel, attack: 0.5, decay: 1, sustain: 0.9, release: 1.2, + })); + }, + + /** Somebody asleep in a pod. */ + heart(out, t, _dur, _freqs, vel) { + const note = voice(t); + const body = note.osc('sine', 72); + body.frequency.exponentialRampToValueAtTime(44, t + 0.09); + const amp = note.gain(); + body.connect(amp).connect(out); + note.play(hit(amp.gain, t, 0.2, 0.9 * vel, 0.008)); + }, + + /** Everything powering down at once, which is what a failure is. */ + drop(out, t, dur, [freq], vel) { + const note = voice(t); + const src = note.osc('sawtooth', freq); + src.frequency.exponentialRampToValueAtTime(freq / 8, t + dur); + const low = note.filter('lowpass', 2200, 3); + low.frequency.exponentialRampToValueAtTime(140, t + dur); + const amp = note.gain(); + src.connect(low).connect(amp).connect(out); + note.play(shape(amp.gain, t, dur, { + peak: 0.35 * vel, attack: 0.005, decay: dur, sustain: 0.6, release: 0.3, + })); + }, + }; +} + +export const SOUNDTRACK = { + volume: 0.22, + space: { + reverb: { seconds: 2.6, decay: 3.2, level: 0.9 }, + echo: { beats: 0.75, feedback: 0.42, tone: 2400 }, + }, + instruments, + + /** The deeper the egg, the more of the track is playing. */ + level(snapshot) { + if (snapshot.state !== 'running') return 0; + const depth = snapshot.distance; + return depth < 100 ? 0 : depth < 300 ? 1 : depth < 600 ? 2 : 3; + }, + + cues: { + title: { + bpm: 96, + parts: [ + { play: 'pad', gain: 1.25, wet: 0.5, notes: each(({ pad }) => hold(pad, 16)) }, + { play: 'sub', gain: 0.22, notes: each(({ root }) => hold(root, 16)) }, + { play: 'rain', gain: 1.7, echo: 0.55, wet: 0.3, pan: -0.25, notes: DRIFT }, + { play: 'heart', gain: 0.55, notes: 'x . o . . . . . x . o . . . . .' }, + ], + }, + + run: { + bpm: 138, + parts: [ + { play: 'kick', gain: 0.56, notes: FOUR }, + { play: 'bass', gain: 0.27, notes: each(rolling) }, + { play: 'hat', gain: 1.4, at: 1, pan: 0.2, notes: '. . x . . . x . . . x . . . X .' }, + { play: 'clap', gain: 2, at: 1, wet: 0.35, notes: '. . . . x . . . . . . . x . . .' }, + { play: 'rain', gain: 0.66, at: 1, echo: 0.4, pan: -0.3, notes: each(falling) }, + { play: 'tick', gain: 1.3, at: 2, pan: 0.35, notes: 'o o x o o o x o o o x o o x x o' }, + { play: 'pad', gain: 0.8, at: 2, wet: 0.45, notes: each(({ pad }) => hold(pad, 16)) }, + { + play: 'bleep', gain: 1.4, at: 2, echo: 0.5, pan: 0.4, + notes: '. . . . . . . b6 . . . . . . e7 . | . . . g6 . . . . . . . . . . . .', + }, + { play: 'lead', gain: 0.45, at: 3, wet: 0.3, echo: 0.3, notes: LEAD }, + ], + }, + + /** System failure: a chord that is not in the key, and the power going. */ + over: { + bpm: 138, + once: true, + then: 'title', + parts: [ + { play: 'drop', gain: 0.8, wet: 0.4, notes: `${hold('e4', 16)} ${rest(16)}` }, + { play: 'pad', gain: 0.8, wet: 0.6, notes: `${hold('e3+g3+bb3', 16)} ${rest(16)}` }, + { play: 'bleep', gain: 0.4, echo: 0.6, notes: `e6 e6 e6 e6 e6 e6 . . . . . . . . . . ${rest(16)}` }, + ], + }, + }, +}; diff --git a/test/helpers/audio.js b/test/helpers/audio.js new file mode 100644 index 0000000..29b387d --- /dev/null +++ b/test/helpers/audio.js @@ -0,0 +1,164 @@ +/** + * Just enough of an AudioContext to schedule music against and look at what + * was scheduled. Time only moves when a test moves it, and a source only + * ends when time passes its stop — which is when the real thing would let go + * of it too. + */ + +class Param { + constructor(value) { + this.value = value; + this.events = []; + } + + #at(kind, value, time, extra) { + if (!Number.isFinite(value) || !Number.isFinite(time)) { + throw new TypeError(`${kind}(${value}, ${time}) is not a number`); + } + this.events.push({ kind, value, time, extra }); + return this; + } + + setValueAtTime(value, time) { return this.#at('set', value, time); } + linearRampToValueAtTime(value, time) { return this.#at('linear', value, time); } + + exponentialRampToValueAtTime(value, time) { + /** The real one throws on this, and on nothing else that is a number. */ + if (value <= 0) throw new RangeError(`an exponential ramp cannot reach ${value}`); + return this.#at('exponential', value, time); + } + + setTargetAtTime(value, time, constant) { + if (!(constant > 0)) throw new RangeError(`a time constant of ${constant} never arrives`); + return this.#at('target', value, time, constant); + } + + cancelScheduledValues(time) { + this.events = this.events.filter((event) => event.time < time); + return this; + } +} + +class Node { + constructor(ctx, kind) { + this.context = ctx; + this.kind = kind; + this.outputs = new Set(); + ctx.nodes.push(this); + } + + connect(to) { + this.outputs.add(to); + return to; + } + + disconnect() { + this.outputs.clear(); + } +} + +class Source extends Node { + constructor(ctx, kind) { + super(ctx, kind); + this.startedAt = null; + this.stoppedAt = null; + this.onended = null; + } + + start(when = 0) { + if (this.startedAt !== null) throw new Error('a source can only be started once'); + this.startedAt = when; + this.context.started.push(this); + } + + stop(when = 0) { + if (this.startedAt === null) throw new Error('stopped before it was started'); + this.stoppedAt = when; + } +} + +class Buffer { + constructor(channels, length, sampleRate) { + this.numberOfChannels = channels; + this.length = length; + this.sampleRate = sampleRate; + this.duration = length / sampleRate; + this.data = Array.from({ length: channels }, () => new Float32Array(length)); + } + + getChannelData(channel) { + return this.data[channel]; + } +} + +export function fakeAudio({ sampleRate = 8000 } = {}) { + const ctx = { + sampleRate, + currentTime: 0, + state: 'running', + nodes: [], + started: [], + resume: () => Promise.resolve(), + + createGain() { + const node = new Node(ctx, 'gain'); + node.gain = new Param(1); + return node; + }, + createBiquadFilter() { + const node = new Node(ctx, 'filter'); + node.type = 'lowpass'; + node.frequency = new Param(350); + node.Q = new Param(1); + node.gain = new Param(0); + return node; + }, + createOscillator() { + const node = new Source(ctx, 'oscillator'); + node.type = 'sine'; + node.frequency = new Param(440); + node.detune = new Param(0); + return node; + }, + createBufferSource() { + const node = new Source(ctx, 'buffer'); + node.buffer = null; + node.loop = false; + node.playbackRate = new Param(1); + return node; + }, + createBuffer: (channels, length, rate) => new Buffer(channels, length, rate), + createConvolver() { + const node = new Node(ctx, 'convolver'); + node.buffer = null; + return node; + }, + createDelay() { + const node = new Node(ctx, 'delay'); + node.delayTime = new Param(0); + return node; + }, + createDynamicsCompressor() { + const node = new Node(ctx, 'compressor'); + for (const param of ['threshold', 'knee', 'ratio', 'attack', 'release']) node[param] = new Param(0); + return node; + }, + createStereoPanner() { + const node = new Node(ctx, 'panner'); + node.pan = new Param(0); + return node; + }, + + /** Move the clock on, and end whatever has stopped by then. */ + advance(seconds) { + ctx.currentTime += seconds; + for (const source of ctx.started) { + if (source.ended || source.stoppedAt === null || source.stoppedAt > ctx.currentTime) continue; + source.ended = true; + source.onended?.(); + } + }, + }; + ctx.destination = new Node(ctx, 'destination'); + return ctx; +} diff --git a/test/music.test.js b/test/music.test.js new file mode 100644 index 0000000..debd0a0 --- /dev/null +++ b/test/music.test.js @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createMusic, diatonic, hit, hz, midi, noteName, parse, retune, +} from '../src/ui/music.js'; +import { fakeAudio } from './helpers/audio.js'; + +/** 120 a minute: a step is an eighth of a second and a bar is two. */ +const STEP = 0.125; +const BAR = 2; + +const TRACK = { + instruments: ({ voice }) => ({ + beep(out, t, _dur, freqs, vel) { + const note = voice(t); + const osc = note.osc('square', freqs[0] ?? 100); + const amp = note.gain(); + osc.connect(amp).connect(out); + note.play(hit(amp.gain, t, 0.05, 0.3 * vel)); + }, + }), + level: (snapshot) => snapshot.level ?? 0, + cues: { + loop: { + bpm: 120, + parts: [ + { play: 'beep', notes: 'c4 . . . c4 . . . c4 . . . c4 . . .' }, + { play: 'beep', at: 1, notes: 'e5 e5 e5 e5 e5 e5 e5 e5 e5 e5 e5 e5 e5 e5 e5 e5' }, + ], + }, + sting: { + bpm: 120, + once: true, + then: 'loop', + parts: [{ play: 'beep', notes: 'g4 - - - . . . .' }], + }, + }, +}; + +const C4 = hz(60); +const E5 = hz(76); +const G4 = hz(67); + +function rig(track = TRACK) { + const ctx = fakeAudio(); + const source = { context: null }; + const music = createMusic(track, source); + return { ctx, source, music }; +} + +/** Every note that has been put down, as when and what. */ +function heard(ctx, since = 0) { + return ctx.started + .filter((node) => node.kind === 'oscillator' && node.startedAt >= since) + .map((node) => ({ at: node.startedAt, freq: node.frequency.events[0].value })); +} + +/** Run the page's frame loop for a while. */ +function run(ctx, music, seconds, snapshot = {}) { + for (let t = 0; t < seconds; t += 1 / 60) { + ctx.advance(1 / 60); + music.update(snapshot); + } +} + +const near = (a, b) => Math.abs(a - b) < 1e-6; +const onGrid = (at, origin) => near(Math.round((at - origin) / STEP), (at - origin) / STEP); + +describe('notation', () => { + it('reads notes, chords, holds, rests, hits and marks', () => { + const steps = parse('e2 - - . e3+g3+b3! | x X o c#4? . bb3 -'); + assert.equal(steps.length, 12, 'bar lines are for the reader, not the count'); + assert.deepEqual(steps[0], { notes: [40], vel: 1, len: 3 }); + assert.equal(steps[1], null); + assert.deepEqual(steps[4].notes, [52, 55, 59]); + assert.ok(steps[4].vel > 1, 'an accent is louder'); + assert.deepEqual(steps[5], { notes: [], vel: 1, len: 1 }); + assert.ok(steps[6].vel > steps[5].vel); + assert.ok(steps[7].vel < steps[5].vel); + assert.ok(steps[8].vel < 1, 'a ghost is quieter'); + assert.equal(steps[8].notes[0], 61); + assert.equal(steps[10].len, 2); + assert.equal(steps[11], null); + }); + + it('does not let a hold carry across a rest', () => { + const steps = parse('c4 . - -'); + assert.equal(steps[0].len, 1); + }); + + it('refuses what is not music', () => { + assert.throws(() => parse('c4 h2 .'), /h2/); + assert.throws(() => parse('c4 C4'), /C4/); + }); + + it('names notes the way it reads them', () => { + assert.equal(hz(69), 440); + assert.equal(midi('a4'), 69); + assert.equal(midi('bb3'), midi('a#3')); + for (let note = 24; note < 108; note++) assert.equal(midi(noteName(note)), note); + }); + + it('finds a second part a third under the first, in the key', () => { + const G = [7, 9, 11, 0, 2, 4, 6]; + assert.equal(retune('b4 - d5! . g4 f#5 c5', diatonic(G, -2)), 'g4 - b4! . e4 d5 a4'); + assert.equal(retune('c4+e4 x', (note) => note + 12), 'c5+e5 x'); + }); +}); + +describe('music', () => { + it('waits for a context rather than building one', () => { + const { ctx, music } = rig(); + music.play('loop'); + music.update({}); + assert.equal(music.cue, 'loop'); + assert.deepEqual(ctx.nodes.map((node) => node.kind), ['destination']); + }); + + it('puts notes down on the grid, and only a little way ahead', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + const first = heard(ctx); + assert.ok(first.length > 0, 'nothing was scheduled'); + assert.ok(first.every(({ at }) => at > 0 && at <= 1), 'scheduled too far ahead'); + run(ctx, music, 3); + const settled = ctx.currentTime; + const ahead = heard(ctx).filter(({ at }) => at > settled); + assert.ok(ahead.every(({ at }) => at - settled <= 0.3), 'still reaching a long way ahead at 60fps'); + + run(ctx, music, 4); + const all = heard(ctx); + const origin = all[0].at; + assert.ok(all.every(({ at }) => onGrid(at, origin)), 'a note came off the grid'); + const beats = all.filter(({ freq }) => near(freq, C4)).map(({ at }) => at - origin); + assert.ok(beats.length >= 8); + beats.forEach((at, i) => assert.ok(near(at, i * 4 * STEP), `beat ${i} at ${at}`)); + }); + + it('drops what a stall missed instead of playing it all at once', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + run(ctx, music, 1); + const origin = heard(ctx)[0].at; + + ctx.advance(3); + const before = ctx.started.length; + music.update({}); + const burst = heard(ctx).slice(-(ctx.started.length - before)); + assert.ok(burst.length <= 3, `${burst.length} notes landed at once`); + assert.ok(burst.every(({ at }) => at >= ctx.currentTime - 0.02), 'a note was put down in the past'); + assert.ok(burst.every(({ at }) => onGrid(at, origin)), 'the stall knocked it off the grid'); + }); + + it('does not fall apart when the frames come slowly', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + const origin = heard(ctx)[0].at; + /** Two and a half frames a second: slower than the look-ahead. */ + for (let t = 0; t < 6; t += 0.4) { + ctx.advance(0.4); + music.update({}); + } + const beats = heard(ctx).filter(({ freq }) => near(freq, C4)).map(({ at }) => at - origin); + assert.ok(beats.length >= 12, `${beats.length} beats in six seconds`); + beats.forEach((at, i) => assert.ok(near(at, i * 4 * STEP), `beat ${i} was dropped or moved`)); + }); + + it('keeps time while it is muted, and plays nothing', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + const origin = heard(ctx)[0].at; + music.muted = true; + const count = ctx.started.length; + run(ctx, music, 3); + assert.equal(ctx.started.length, count); + + music.muted = false; + run(ctx, music, 1); + const after = heard(ctx).slice(count); + assert.ok(after.length > 0); + assert.ok(after.every(({ at }) => onGrid(at, origin))); + }); + + it('brings a layer in on the next bar line, not the moment it is asked for', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + const origin = heard(ctx)[0].at; + run(ctx, music, 0.9, { level: 0 }); + run(ctx, music, 3, { level: 1 }); + const layer = heard(ctx).filter(({ freq }) => near(freq, E5)); + assert.ok(layer.length > 0, 'the layer never came in'); + assert.ok(near(layer[0].at - origin, BAR), `it came in at ${layer[0].at - origin}s`); + }); + + it('plays a sting once and then what comes after it, on the beat', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('sting'); + run(ctx, music, 2); + const notes = heard(ctx); + const stings = notes.filter(({ freq }) => near(freq, G4)); + assert.equal(stings.length, 1); + const next = notes.find(({ freq }) => near(freq, C4)); + assert.ok(next, 'nothing came after the sting'); + assert.ok(near(next.at - stings[0].at, 8 * STEP), 'the loop did not start where the sting ended'); + assert.equal(music.cue, 'loop'); + }); + + it('does not restart a cue that is already playing', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + run(ctx, music, 0.6); + const origin = heard(ctx)[0].at; + music.play('loop'); + run(ctx, music, 1.5); + assert.ok(heard(ctx).every(({ at }) => onGrid(at, origin)), 'it started again off the grid'); + }); + + it('fades out whatever it is replacing', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + run(ctx, music, 0.5); + const gains = ctx.nodes.filter((node) => node.kind === 'gain'); + music.play('sting'); + assert.ok(gains.some((node) => node.gain.events.some((e) => e.kind === 'target' && e.value === 0 + && near(e.time, ctx.currentTime))), 'the old cue was left playing under the new one'); + }); + + it('lets go of every note once it is over', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + run(ctx, music, 5, { level: 1 }); + music.stop(); + run(ctx, music, 2); + const voices = ctx.started; + assert.ok(voices.length > 20); + assert.ok(voices.every((node) => node.ended), 'a source never ended'); + assert.ok(voices.every((node) => node.outputs.size === 0), 'a note was left wired up'); + }); + + it('muffles itself when the game takes a hit, and comes back', () => { + const { ctx, source, music } = rig(); + source.context = ctx; + music.play('loop'); + music.duck(); + const filter = ctx.nodes.find((node) => node.kind === 'filter'); + const ramps = filter.frequency.events.filter((e) => e.kind === 'exponential'); + assert.equal(ramps.length, 2); + assert.ok(ramps[0].value < 1000, 'it did not muffle'); + assert.ok(ramps[1].value > 10000, 'it did not come back'); + }); + + it('refuses a cue it does not have', () => { + const { music } = rig(); + assert.throws(() => music.play('encore'), /encore/); + }); +}); diff --git a/test/soundtrack.test.js b/test/soundtrack.test.js new file mode 100644 index 0000000..aed4c7e --- /dev/null +++ b/test/soundtrack.test.js @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { compile, createMusic, hz } from '../src/ui/music.js'; +import { SOUNDTRACK } from '../src/ui/soundtrack.js'; +import { fakeAudio } from './helpers/audio.js'; + +/** What `main.js` asks for, and when. */ +const ASKED = ['title', 'run', 'over']; + +const cues = compile(SOUNDTRACK.cues); +const instruments = SOUNDTRACK.instruments({ ctx: fakeAudio(), voice: () => null }); + +function running(distance) { + return { state: 'running', distance }; +} + +describe('soundtrack', () => { + it('has every cue the page asks for', () => { + for (const name of ASKED) assert.ok(cues[name], `no "${name}" cue`); + }); + + it('plays every part on an instrument it has', () => { + for (const cue of Object.values(cues)) { + for (const part of cue.parts) { + assert.equal(typeof instruments[part.play], 'function', `${cue.name} plays "${part.play}"`); + } + } + }); + + it('writes every part in whole bars, and loops them together', () => { + for (const cue of Object.values(cues)) { + for (const [i, part] of cue.parts.entries()) { + const where = `${cue.name}, part ${i} (${part.play})`; + assert.equal(part.steps.length % cue.bar, 0, `${where} is ${part.steps.length} steps`); + assert.equal(cue.length % part.steps.length, 0, `${where} drifts against the rest of the cue`); + } + } + }); + + it('hands every sting on to a cue that exists', () => { + for (const cue of Object.values(cues)) { + if (cue.once && cue.then) assert.ok(cues[cue.then], `${cue.name} hands on to "${cue.then}"`); + } + }); + + it('keeps every note where a speaker can play it', () => { + for (const cue of Object.values(cues)) { + for (const part of cue.parts) { + for (const step of part.steps) { + for (const note of step?.notes ?? []) { + const freq = hz(note); + assert.ok(freq > 30 && freq < 5000, `${cue.name}/${part.play} has a note at ${freq.toFixed(0)}Hz`); + } + } + } + } + }); + + it('builds up the deeper the run gets, and never back down', () => { + let last = 0; + for (let distance = 0; distance < 3000; distance += 10) { + const level = SOUNDTRACK.level(running(distance)); + assert.ok(Number.isInteger(level) && level >= last && level <= 3, `level ${level} at ${distance}m`); + last = level; + } + assert.equal(SOUNDTRACK.level(running(0)), 0); + assert.equal(last, 3); + assert.equal(SOUNDTRACK.level({ state: 'over', distance: 900 }), 0); + }); + + it('has something playing at every level of the run', () => { + for (let level = 0; level <= 3; level++) { + const on = cues.run.parts.filter((part) => part.at <= level && level < part.until); + assert.ok(on.length > 0, `nothing plays at level ${level}`); + if (level > 0) { + assert.ok(cues.run.parts.some((part) => part.at === level), `level ${level} adds nothing`); + } + } + }); + + it('gets through a whole run, all the way up and out, on real parts', () => { + const ctx = fakeAudio(); + const music = createMusic(SOUNDTRACK, { context: ctx }); + const frame = (snapshot) => { + ctx.advance(1 / 30); + music.update(snapshot); + }; + music.play('title'); + for (let t = 0; t < 12; t += 1 / 30) frame({ state: 'ready' }); + music.play('run'); + for (let t = 0; t < 40; t += 1 / 30) frame(running(t * 25)); + assert.equal(music.level, 3); + music.duck(); + music.play('over'); + for (let t = 0; t < 20; t += 1 / 30) frame({ state: 'over' }); + assert.equal(music.cue, 'title', 'the sting did not hand back to the title'); + + const sources = ctx.started; + assert.ok(sources.length > 500, `only ${sources.length} sources in a minute of music`); + for (const source of sources) { + assert.ok(source.stoppedAt > source.startedAt, 'a source was never stopped'); + } + const ended = sources.filter((source) => source.ended); + assert.ok(ended.length > sources.length * 0.9); + assert.ok(ended.every((source) => source.outputs.size === 0), 'a finished note was left wired up'); + }); +});