diff --git a/README.md b/README.md
index deeeb65..f73cf56 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,31 @@ every frequency is the real one: 350+440 dial tone, the DTMF matrix, R1 MF pairs
real 100 ms KP / 60 ms digit timing, 2600 Hz, ANSam at 2100 Hz reversing phase every
450 ms, Bell 103 FSK. About 300 lines and zero megabytes.
+## The score
+
+[`src/score.js`](src/score.js) — no level gets a chord somebody picked because it
+sounded nice. Every bed is derived from a real number belonging to its era:
+
+| hop | built from |
+|---|---|
+| coldboot / stack | **1000 Hz**, the reference tone every piece of telecoms test gear agrees on, folded down |
+| timewait | **50 Hz** mains and its harmonics — the room's own hum is the whole score |
+| war | the **1800 Hz** V.32 carrier, folded, with dial-up hiss under it |
+| smash | **0x90** — the NOP byte read as 144 Hz, so the sled has a pitch |
+| worm | seven partials, and the **seventh one is wrong** |
+| cuckoo | **45.45 baud**, the Model 33 teletype rate, as a clock |
+| board | the **1200 Hz** V.22bis carrier, folded four times — the doc's own instruction |
+| shell | the **750 Hz** terminal bell over Helsinki's 50 Hz mains |
+| handshake | *nothing.* The fight is the score and anything under it is noise |
+| copper | **20 Hz** ringing current, **50 Hz** mains, **10 pps** Strowger stepping — three real clocks beating against each other |
+| bluebox | the **six MF tones**. Literally what a blue box contains |
+| climb | all of it at once |
+| root | **2600 Hz**, folded six times until it is a low room tone, finally at rest |
+
+Some come out beautiful and some come out ugly. That's correct — a 1980 exchange
+wasn't tuned to A=440, and pretending otherwise would be the same lie as setting the
+ANSI art in a proportional font.
+
## Layout
```
@@ -124,5 +149,4 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee
- **Multiplayer (`CHECKSUM`)** — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to.
- **Playing both fragments** — fragmenting costs TTL, it doesn't split you into two packets you alternate between. Add when a level has a corridor narrow enough to need it.
- **The 200 philes** — 74 written in [`src/philes.js`](src/philes.js), all readable with `P`. Adding the next 126 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found.
-- **Era-generated music** — the design doc says each level's pad is built from that era's protocol. Right now they're chords.
- Levels run 3–8 minutes against a design doc that asks for 5–20. The genres, mechanics and set pieces are all in — the *duration* is still short. See [STAGES.md](STAGES.md) for a per-hop breakdown of what's in and what's next.
diff --git a/STAGES.md b/STAGES.md
index 8871947..70963e3 100644
--- a/STAGES.md
+++ b/STAGES.md
@@ -359,7 +359,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1.
| | |
|---|---|
| **The philes** | 75 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
-| **Audio** | Every tone is real but the *music* isn't built — the design says each level's pad is generated from that era's protocol and right now they're just chords. |
+| ~~**Audio**~~ | **DONE** — `src/score.js`. Every bed derived from a real number: 1 kHz reference tone, 50 Hz mains, 0x90 as a pitch, 45.45 baud, the 1200 Hz carrier, 20 Hz ringing current, the six MF tones, 2600 folded six times. The handshake is scored silent on purpose. |
| ~~**The retrain**~~ | **DONE** — `src/retrain.js`. Fires once per run after hop 51, drops you a notch, and the recovery is the 450 ms ANSam beat from hop 47. Terminal levels opt out. |
| **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. |
| **The 64 hops** | 13 levels covering 64 hop numbers. Either fill in more levels or make the existing ones properly own their ranges. |
diff --git a/src/levels/climb.js b/src/levels/climb.js
index de28bc4..2c378f5 100644
--- a/src/levels/climb.js
+++ b/src/levels/climb.js
@@ -146,8 +146,6 @@ export default {
camera.position.set(0, 0, 0);
camera.far = 1400; camera.updateProjectionMatrix();
-
- A.pad([55, 82.41, 110, 164.81, 220], { gain: 0.05, type: 'sawtooth' });
S.hiss = A.carrier(0.05);
ctx.hint('A Dsteer SPACEseize Euse');
@@ -275,7 +273,7 @@ export default {
if (S.z > END_AT && !S.done) {
S.done = true;
(async () => {
- S.hiss.stop(); A.stopPad();
+ S.hiss.stop();
ctx.game.flags.climbClean = S.passed;
ctx.game.flags.climbGates = S.gates.length;
if (S.passed >= 6) ctx.phile('climb');
@@ -483,7 +481,6 @@ export default {
ctx.link.unsplit(ctx.camera);
S.hiss?.stop();
S.gates?.forEach(g => g.seizing?.stop(0.05));
- A.stopPad();
ctx.link.glitch = 0;
document.body.classList.remove('ink-dark');
ctx.hint('');
diff --git a/src/levels/coldboot.js b/src/levels/coldboot.js
index 37d1db1..aefeff8 100644
--- a/src/levels/coldboot.js
+++ b/src/levels/coldboot.js
@@ -103,8 +103,6 @@ export default {
look = new Look();
look.yaw = Math.PI / 2; // stand in your slot and look along the curve
camera.near = 0.05; camera.far = 1200; camera.updateProjectionMatrix();
-
- A.pad([174.61, 261.63, 329.63], { gain: 0.03, type: 'sine' }); // sterile. major. nobody's home.
ctx.hint('CLICKlook around');
S.line = 0;
@@ -169,7 +167,6 @@ export default {
S.phase = 'dma'; S.dma = 0;
ctx.hint('');
ctx.hush?.();
- A.stopPad();
A.thud(48, 1.2, 0.4);
streaks.visible = true;
S.camY = ctx.camera.position.y;
@@ -215,7 +212,6 @@ export default {
async unmount(ctx) {
if (S.sc) { S.sc.stop(); S.sc = null; }
- A.stopPad();
ctx.link.glitch = 0;
In.unlock();
},
diff --git a/src/levels/handshake.js b/src/levels/handshake.js
index fe2bdf0..2b5af9e 100644
--- a/src/levels/handshake.js
+++ b/src/levels/handshake.js
@@ -526,7 +526,6 @@ export default {
async unmount(ctx) {
const cur = BOSS[ORDER[S.i]];
if (cur && cur.exit) cur.exit();
- A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},
diff --git a/src/levels/root.js b/src/levels/root.js
index a5c7bd6..32a10c8 100644
--- a/src/levels/root.js
+++ b/src/levels/root.js
@@ -6,6 +6,7 @@
import * as THREE from '../../vendor/three.module.js';
import * as A from '../audio.js';
+import * as Score from '../score.js';
import * as In from '../input.js';
import { Body, Look, sprite, glow, damp, clamp } from '../kit.js';
import { TOTAL as PHILE_TOTAL } from '../philes.js';
@@ -62,8 +63,6 @@ export default {
S.ackMesh.position.set(0, 1.2, -220);
S.ackMesh.visible = false;
scene.add(S.ackMesh);
-
- A.pad([220, 329.63, 440], { gain: 0.028 });
ctx.hint('WASDwalk
CLICKcapture mouse');
await ctx.card('ROOT', 'hop 64 · one TTL remaining', 3400);
ctx.say('you are here.', 3600);
@@ -103,7 +102,6 @@ export default {
async theAck(ctx) {
S.phase = 'ack';
ctx.hush();
- A.stopPad();
await ctx.sleep(3000);
S.ackMesh.visible = true;
@@ -120,8 +118,7 @@ export default {
await ctx.sleep(4200);
ctx.say('somewhere in all of that — somebody answered.', 4600);
await ctx.sleep(4800);
-
- A.pad([261.63, 329.63, 392, 523.25], { gain: 0.05 });
+ Score.established();
await ctx.flash('ESTABLISHED', 'est', 2600);
ctx.say('', 0); ctx.hush();
@@ -134,7 +131,7 @@ export default {
ctx.hush();
// and then, because that is what TCP is and always was
- A.stopPad();
+ Score.stop(2.2);
for (const [f, label] of [[440, 'FIN'], [523.25, 'FIN-ACK'], [392, 'ACK'], [0, 'CLOSED']]) {
if (f) A.burst(f, f * 1.5, 0.2, 0.12);
ctx.say(label, 1400);
@@ -240,7 +237,6 @@ export default {
},
async unmount(ctx) {
- A.stopPad();
In.unlock();
ctx.hint('');
},
diff --git a/src/levels/smash.js b/src/levels/smash.js
index 19baa0e..6e60240 100644
--- a/src/levels/smash.js
+++ b/src/levels/smash.js
@@ -187,8 +187,6 @@ export default {
camera.position.set(0, 4, 15);
camera.far = 400; camera.updateProjectionMatrix();
-
- A.pad([98, 146.83, 196], { gain: 0.03 });
ctx.hint('A Dmove
SPACEjump');
await ctx.card('SMASHING THE STACK', 'hop 17 · for fun and profit', 3000);
ctx.say('you are the input. walk right.', 4000, 'ALEPH ONE');
@@ -399,7 +397,6 @@ export default {
},
async unmount(ctx) {
- A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},
diff --git a/src/levels/stack.js b/src/levels/stack.js
index 5e4b24c..77bfb40 100644
--- a/src/levels/stack.js
+++ b/src/levels/stack.js
@@ -221,8 +221,6 @@ export default {
S.look.yaw = 0.4;
S.spawn = S.body.pos.clone();
camera.far = 900; camera.updateProjectionMatrix();
-
- A.pad([110, 164.81, 220, 329.63], { gain: 0.032 });
ctx.hint('WASDmove
SPACEjump
CLICKcapture mouse');
await ctx.card('7 APPLICATION', 'hop 2 · the descent begins', 2400);
ctx.say(LAYERS[0].note, 4200);
@@ -478,5 +476,5 @@ export default {
setTimeout(() => { S.missing = false; }, 400);
},
- async unmount(ctx) { A.stopPad(); In.unlock(); ctx.hint(''); },
+ async unmount(ctx) { In.unlock(); ctx.hint(''); },
};
diff --git a/src/levels/war.js b/src/levels/war.js
index 740de64..f149fc5 100644
--- a/src/levels/war.js
+++ b/src/levels/war.js
@@ -120,8 +120,6 @@ export default {
// ── the rival crew. they show up after the first rejoin and they want the
// same empty room you do.
S.rivals = [];
-
- A.pad([146.83, 220, 293.66], { gain: 0.03, type: 'sawtooth' });
ctx.hint('WASDmove
Etake ops / possess
1–4use it while it works');
await ctx.card('THE WAR', 'hop 10 · efnet, 1998', 2800);
ctx.say('nobody owns this room. that has never once stopped anybody.', 4600);
@@ -436,7 +434,6 @@ export default {
},
async unmount(ctx) {
- A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},
diff --git a/src/levels/worm.js b/src/levels/worm.js
index 5312f13..d910889 100644
--- a/src/levels/worm.js
+++ b/src/levels/worm.js
@@ -100,8 +100,6 @@ export default {
camera.position.set(0, 42, 30);
camera.lookAt(0, 0, 0);
camera.far = 300; camera.updateProjectionMatrix();
-
- A.pad([65.41, 98, 130.81], { gain: 0.035, type: 'triangle' });
ctx.hint('1 2 3choose vector');
await ctx.card('2 NOVEMBER 1988', 'hop 21 · approximately 60,000 machines', 3400);
ctx.say('you are the worm now. you get three ways in and one bug you did not write.', 5400);
@@ -214,7 +212,6 @@ export default {
S.done = true;
ctx.link.glitch = 0;
(async () => {
- A.stopPad();
await ctx.sleep(1400);
ctx.spendTTL(1);
ctx.game.flags.wormSaved = S.cured;
@@ -288,7 +285,6 @@ export default {
},
async unmount(ctx) {
- A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},
diff --git a/src/main.js b/src/main.js
index e8a67e2..0268b9d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -8,6 +8,7 @@ import { Link } from './link.js';
import { World } from './kit.js';
import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js';
import * as Retrain from './retrain.js';
+import * as Score from './score.js';
import coldboot from './levels/coldboot.js';
import stack from './levels/stack.js';
@@ -156,6 +157,7 @@ export async function goto(id, { restart = false } = {}) {
HUD.hush();
await HUD.veil(1, 420);
A.stopPad();
+ Score.stop(0.6);
if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } }
In.releaseText(); In.clearRemap(); In.unlock();
@@ -195,6 +197,7 @@ export async function goto(id, { restart = false } = {}) {
const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })();
HUD.veil(0, 520);
await mounting;
+ Score.play(id);
Retrain.arm(ctx, nextLevel);
refreshHud();
saveGame();
diff --git a/src/score.js b/src/score.js
new file mode 100644
index 0000000..eb569cf
--- /dev/null
+++ b/src/score.js
@@ -0,0 +1,181 @@
+// 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;