// score.js — the soundtrack is built out of the signal. // // No level gets a chord that somebody picked because it sounded nice. Every bed in // this game is derived from a real number belonging to the era it plays under: the // mains frequency in the room, the ringing current on the pair, the carrier of the // modem you are talking through, the byte value of a NOP, the baud rate of a // teletype, the six frequencies of an MF keypad. // // Some of them come out beautiful and some of them come out ugly. That is the // correct outcome. A 1980 telephone exchange was not tuned to A=440 and pretending // otherwise would be the same lie as drawing the ANSI art in a proportional font. import * as A from './audio.js'; let VOICES = []; function reg(o) { VOICES.push(o); return o; } // ── builders ────────────────────────────────────────────────────────────── // A stack of detuned oscillators through a slowly-breathing lowpass. Give it real // frequencies and it becomes a pad without ever choosing a note. function drone(freqs, { gain = 0.04, type = 'sine', detune = 5, cut = 1800, breathe = 0.06 } = {}) { const ac = A.AC(); if (!ac) return null; const t = ac.currentTime; const g = ac.createGain(); g.gain.value = 0; g.connect(A.music()); g.gain.linearRampToValueAtTime(gain, t + 2.6); const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cut; lp.connect(g); const oscs = []; freqs.forEach((f, i) => { for (const d of [-detune, detune]) { const o = ac.createOscillator(); o.type = type; o.frequency.value = f; o.detune.value = d; const og = ac.createGain(); og.gain.value = 1 / (freqs.length * 2.2); o.connect(og); og.connect(lp); o.start(t + i * 0.07); oscs.push(o); } }); const lfo = ac.createOscillator(); lfo.frequency.value = breathe; const lg = ac.createGain(); lg.gain.value = cut * 0.35; lfo.connect(lg); lg.connect(lp.frequency); lfo.start(t); oscs.push(lfo); return reg({ g, oscs }); } // A tone gated at a protocol's own rate. The rhythm is not a rhythm, it is a clock // that already existed. function pulse(rateHz, freq, { gain = 0.03, duty = 0.35, type = 'square', cut = 900 } = {}) { const ac = A.AC(); if (!ac) return null; const t = ac.currentTime; const g = ac.createGain(); g.gain.value = 0; g.connect(A.music()); g.gain.linearRampToValueAtTime(gain, t + 1.6); const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cut; lp.connect(g); const o = ac.createOscillator(); o.type = type; o.frequency.value = freq; // The gate is driven by a square oscillator, which swings -1..+1 — so it needs a // DC offset equal to the swing, or the "off" half of the cycle is not silence, it // is the same tone phase-inverted, and you get tremolo where you wanted a clock. const gate = ac.createGain(); gate.gain.value = duty; o.connect(gate); gate.connect(lp); o.start(t); const clk = ac.createOscillator(); clk.type = 'square'; clk.frequency.value = rateHz; const clkG = ac.createGain(); clkG.gain.value = duty; clk.connect(clkG); clkG.connect(gate.gain); clk.start(t); return reg({ g, oscs: [o, clk] }); } // Filtered noise, for the levels where the era's signature sound is a surface. function hiss(centre, { gain = 0.02, q = 0.9, wobble = 0 } = {}) { const ac = A.AC(); if (!ac) return null; const src = A.noise(3); const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = centre; bp.Q.value = q; const g = ac.createGain(); g.gain.value = 0; g.connect(A.music()); src.connect(bp); bp.connect(g); src.start(); g.gain.linearRampToValueAtTime(gain, ac.currentTime + 2.2); let iv = null; if (wobble) iv = setInterval(() => bp.frequency.setTargetAtTime( centre * (1 + (Math.random() - 0.5) * wobble), ac.currentTime, 0.4), 900); return reg({ g, src, iv }); } // f, f/2, f/4 ... — a carrier folded down until it is music. The doc's exact phrase // for the board is "a modem carrier detuned into a pad", and this is that. const sub = (f, n = 4) => Array.from({ length: n }, (_, i) => f / Math.pow(2, i)); // n partials of a fundamental const parts = (f, n) => Array.from({ length: n }, (_, i) => f * (i + 1)); // ── the beds ────────────────────────────────────────────────────────────── const BEDS = { // 1000 Hz is the reference tone every piece of telecoms test gear in the world // agrees on. The fortress at the top of the stack is tuned to a calibration tone. coldboot: () => drone(sub(1000, 4), { gain: 0.030, cut: 2600, breathe: 0.04 }), stack: () => drone(sub(1000, 5), { gain: 0.032, cut: 2200, breathe: 0.05 }), // 50 Hz mains and its harmonics. The room's own hum is the entire score, and // striplight() in the level supplies the top of it. timewait: () => drone([50, 100, 150], { gain: 0.026, cut: 400, breathe: 0.03 }), // V.32 ran a 1800 Hz carrier. 1998 is that, folded down, with the dial-up hiss // still audible underneath because everybody on EFnet was on a modem. war: () => { drone(sub(1800, 5), { gain: 0.028, type: 'sawtooth', cut: 1100, breathe: 0.09 }); return hiss(1800, { gain: 0.012, q: 0.8, wobble: 0.3 }); }, // 0x90 is the NOP. It is also, read as a number, 144 — so the sled has a pitch, // and the whole level is tuned to the byte you slide down. smash: () => drone(parts(144, 5), { gain: 0.028, type: 'triangle', cut: 1500, breathe: 0.07 }), // one in seven. seven partials, and the seventh is the one that is wrong. worm: () => { const f = parts(58, 7); f[6] *= 1.031; return drone(f, { gain: 0.030, cut: 700, breathe: 0.05 }); }, // 45.45 baud — the Baudot rate a Model 33 teletype ran at, which is 22 ms a bit. // He had fifty of them in a basement and they are what he heard all night. cuckoo: () => { pulse(45.45 / 8, 96, { gain: 0.022, duty: 0.3, cut: 500 }); return drone([58, 87], { gain: 0.020, cut: 420, breathe: 0.04 }); }, // V.22bis put 2400 bits a second down a 1200 Hz carrier. The board's theme is // that carrier, folded down four times, which is the doc's own instruction. board: () => drone(sub(1200, 5), { gain: 0.030, cut: 1400, breathe: 0.05 }), // a Unix box has no signature frequency, so it gets the two it actually made: // the terminal bell at 750 Hz, folded, over Helsinki's 50 Hz mains. shell: () => drone([...sub(750, 4), 50], { gain: 0.026, cut: 1600, breathe: 0.045 }), // the handshake is scored by the fight itself. anything under it would be noise // competing with the only thing in the game that has to be listened to. handshake: () => null, // ringing current is 20 Hz at about ninety volts, and the exchange runs on 50 Hz // mains, and a step-by-step switch pulses at ten a second. Three real clocks, and // the cathedral is the beat they make against each other. copper: () => { drone([20, 40, 50, 100], { gain: 0.030, cut: 320, breathe: 0.03 }); return pulse(10 / 8, 62, { gain: 0.018, duty: 0.22, cut: 260 }); }, // the six MF tones, all at once. This is literally what a blue box contains. bluebox: () => drone(A.MF_TONES, { gain: 0.020, cut: 1900, breathe: 0.05, detune: 3 }), // everything, layered: the reference tone, the carrier, the mains and 2600. climb: () => { drone([...sub(1000, 3), ...sub(1200, 3), 50], { gain: 0.030, type: 'sawtooth', cut: 1300, breathe: 0.11 }); return hiss(2600, { gain: 0.010, q: 2.0, wobble: 0.15 }); }, // 2600 Hz, folded all the way down until it is a low room tone. The number the // whole game turns on, finally at rest. root: () => drone(sub(2600, 6), { gain: 0.026, cut: 2000, breathe: 0.03 }), }; // ── cues ────────────────────────────────────────────────────────────────── // ESTABLISHED. The handshake arena opened on 350 + 440 and called it "the sound a // line makes when nothing is happening". This is the same two frequencies with // something finally on them. export function established() { return drone([350, 440, 700, 880, 1320], { gain: 0.055, cut: 2400, breathe: 0.02, detune: 3 }); } // ── control ─────────────────────────────────────────────────────────────── export function play(id) { stop(); const build = BEDS[id]; if (!build) return; try { build(); } catch (e) { console.error('score failed for', id, e); } } export function stop(rel = 1.8) { const ac = A.AC(); const dying = VOICES; VOICES = []; if (!ac) return; const t = ac.currentTime; dying.forEach(v => { if (v.iv) clearInterval(v.iv); try { v.g.gain.cancelScheduledValues(t); v.g.gain.setValueAtTime(v.g.gain.value, t); v.g.gain.linearRampToValueAtTime(0, t + rel); (v.oscs || []).forEach(o => o.stop(t + rel + 0.1)); if (v.src) setTimeout(() => { try { v.src.stop(); } catch (e) {} }, (rel + 0.2) * 1000); } catch (e) { /* already stopped */ } }); } export const voices = () => VOICES.length;