haxtory/src/score.js
type-two cb22239363 The soundtrack is built out of the signal
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>
2026-08-02 20:17:18 +10:00

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;