The last structural promise in the design doc. No level gets a chord any
more. Every bed in the game is derived from a real number belonging to the
era it plays under, and src/score.js says which one and why in a comment
above each line.
coldboot/stack 1000 Hz, the reference tone every piece of telecoms test
gear on earth agrees on. The fortress is tuned to a
calibration signal.
timewait 50 Hz mains and its harmonics. The room's own hum was
always the entire score; now the bed agrees with it.
war the 1800 Hz V.32 carrier folded down, with dial-up hiss
under it, because everybody on EFnet was on a modem.
smash 0x90 is the NOP. Read as a number it is 144, so the sled
has a pitch and the level is tuned to the byte you slide
down.
worm seven partials, and the seventh one is detuned. One in
seven, as a chord.
cuckoo 45.45 baud — the Model 33 rate, 22 ms a bit. Fifty of
them in a basement is what he actually heard all night.
board the 1200 Hz V.22bis carrier folded four times, which is
the doc's exact phrase: a modem carrier detuned into a
pad.
shell the 750 Hz terminal bell over Helsinki's 50 Hz mains.
handshake silence. The fight is the score; anything underneath is
noise competing with the only thing in this game that
has to be listened to.
copper 20 Hz ringing current, 50 Hz mains, and Strowger stepping
at ten pulses a second. Three real clocks, and the
cathedral is the beat they make against each other.
bluebox the six MF tones at once. That is literally what is
inside a blue box.
climb all of it, layered.
root 2600 Hz folded six times until it is a low room tone.
The number the whole game turns on, finally at rest.
ESTABLISHED gets its own cue rather than a bed: 350 and 440, which is the
dial tone the handshake arena opened on and called the sound a line makes
when nothing is happening — with something finally on it.
Fixed while building: the pulse gate was driven by a bare square
oscillator swinging -1..+1, so its off half was not silence, it was the
same tone phase-inverted. Tremolo where a clock was wanted. DC offset now.
All 14 hops swept clean, and no voices leak between them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
9.2 KiB
JavaScript
182 lines
9.2 KiB
JavaScript
// 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;
|