haxtory/src/audio.js
type-two 696b0c65e8 HANDSHAKE — 13 playable hops, one renderer
You are SYN. You want ACK. Root is the only one who can give it to you.

Three load-bearing ideas, all working:
  - TTL is the health bar and it only goes down. 64 hops, no healing.
  - Bandwidth is resolution. One render target, one shader; at 1200 baud
    and below the 3D scene renders as ASCII glyphs.
  - Every header you drop is a piece of clothing. Seven wireframe shells,
    one per OSI layer, shed on the way down.

Every frequency is the real frequency, synthesised, nothing sampled:
350+440 dial tone, the DTMF matrix, R1 MF pairs at real KP/digit timing,
2600 Hz, ANSam at 2100 reversing phase every 450 ms, Bell 103 FSK.

Thirteen hops, each a different genre over one renderer — FP tutorial,
OSI descent platformer, walking sim, EFnet twin-stick, 2D buffer overflow,
Morris worm RTS, LBL terminal stealth, a whole BBS with a playable LORD,
the five-boss handshake arena, the copper, a playable blue box, the climb,
and an empty room with an ACK in it.

No engine, no build step, one vendored three.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:34:58 +10:00

295 lines
13 KiB
JavaScript

// audio.js — every frequency in this game is the real frequency.
// Nothing here is a sample. It is all oscillators, and that is the point.
let ctx = null, master = null, musicBus = null, sfxBus = null;
export function boot() {
if (ctx) return ctx;
ctx = new (window.AudioContext || window.webkitAudioContext)();
master = ctx.createGain(); master.gain.value = 0.55; master.connect(ctx.destination);
musicBus = ctx.createGain(); musicBus.gain.value = 0.6; musicBus.connect(master);
sfxBus = ctx.createGain(); sfxBus.gain.value = 1.0; sfxBus.connect(master);
return ctx;
}
export function resume() { if (ctx && ctx.state === 'suspended') ctx.resume(); }
export const now = () => (ctx ? ctx.currentTime : 0);
export const AC = () => ctx;
export const music = () => musicBus;
export const sfx = () => sfxBus;
export function setMaster(v) { if (master) master.gain.setTargetAtTime(v, ctx.currentTime, 0.05); }
// ─────────────────────────────────────────────────────────────── primitives
// A sustained pair of sine tones. This is 90% of telephony.
export function pair(f1, f2, { gain = 0.16, dest = null, attack = 0.004 } = {}) {
const t = ctx.currentTime;
const g = ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(gain, t + attack);
g.connect(dest || sfxBus);
const oscs = [f1, f2].filter(Boolean).map(f => {
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t); return o;
});
return {
oscs, gain: g,
freq(i, f, glide = 0) {
const o = oscs[i]; if (!o) return;
if (glide > 0) o.frequency.setTargetAtTime(f, ctx.currentTime, glide); else o.frequency.setValueAtTime(f, ctx.currentTime);
},
level(v, tau = 0.02) { g.gain.setTargetAtTime(v, ctx.currentTime, tau); },
stop(rel = 0.03) {
const tt = ctx.currentTime;
g.gain.cancelScheduledValues(tt);
g.gain.setValueAtTime(g.gain.value, tt);
g.gain.linearRampToValueAtTime(0, tt + rel);
oscs.forEach(o => o.stop(tt + rel + 0.02));
}
};
}
// One-shot burst of a tone pair.
export function burst(f1, f2, dur = 0.12, gain = 0.18, dest = null) {
const p = pair(f1, f2, { gain, dest });
setTimeout(() => p.stop(0.012), dur * 1000);
return p;
}
export function noise(seconds = 2) {
const n = ctx.sampleRate * seconds;
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true;
return src;
}
// ─────────────────────────────────────────────────────────── call progress
export const DIAL_TONE = [350, 440]; // precise tone plan, North America
export const RINGBACK = [440, 480]; // 2s on, 4s off
export const BUSY = [480, 620]; // 0.5 on, 0.5 off
export const REORDER = [480, 620]; // 0.25 on, 0.25 off — "fast busy"
export const ANS = 2100; // answer tone. the bong.
export const SEIZE = 2600; // the one that mattered
export function dialTone() { return pair(DIAL_TONE[0], DIAL_TONE[1], { gain: 0.10 }); }
// Cadenced call-progress tone. Returns a handle with .stop()
export function cadence(freqs, onSec, offSec, gain = 0.13) {
let live = true, handle = null;
const step = (on) => {
if (!live) return;
if (on) { handle = pair(freqs[0], freqs[1], { gain }); }
else if (handle) { handle.stop(0.01); handle = null; }
setTimeout(() => step(!on), (on ? onSec : offSec) * 1000);
};
step(true);
return { stop() { live = false; if (handle) handle.stop(0.01); } };
}
export const ringback = () => cadence(RINGBACK, 2, 4);
export const busySignal = () => cadence(BUSY, 0.5, 0.5);
export const reorder = () => cadence(REORDER, 0.25, 0.25);
// ───────────────────────────────────────────────────────────────── DTMF
export const DTMF_ROW = [697, 770, 852, 941];
export const DTMF_COL = [1209, 1336, 1477, 1633];
export const DTMF_KEYS = [
['1','2','3','A'],
['4','5','6','B'],
['7','8','9','C'],
['*','0','#','D'],
];
export function dtmfOf(key) {
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++)
if (DTMF_KEYS[r][c] === key) return [DTMF_ROW[r], DTMF_COL[c]];
return null;
}
export function dtmf(key, dur = 0.11) {
const f = dtmfOf(String(key)); if (!f) return null;
return burst(f[0], f[1], dur, 0.20);
}
export async function dialString(s, digitMs = 90, gapMs = 70) {
for (const ch of s) {
if (ch === ' ' || ch === '-') { await wait(gapMs); continue; }
dtmf(ch, digitMs / 1000);
await wait(digitMs + gapMs);
}
}
// ──────────────────────────────────────────────── MF — the blue box tones
// R1 multi-frequency inter-office signalling. Six tones, two at a time.
// This is what you were actually playing when you played a blue box.
export const MF_TONES = [700, 900, 1100, 1300, 1500, 1700];
export const MF = {
'1': [700, 900], '2': [700, 1100], '3': [900, 1100],
'4': [700, 1300], '5': [900, 1300], '6': [1100, 1300],
'7': [700, 1500], '8': [900, 1500], '9': [1100, 1500],
'0': [1300, 1500],
'KP': [1100, 1700], 'ST': [1500, 1700], 'KP2': [1300, 1700],
'11': [700, 1700], '12': [900, 1700],
};
// KP is 100ms, everything else 60ms. Real timing. It matters to the ear.
export function mf(sym, durOverride = null) {
const f = MF[sym]; if (!f) return null;
const dur = durOverride ?? (sym === 'KP' ? 0.100 : 0.060);
return burst(f[0], f[1], dur, 0.22);
}
export async function mfSeq(syms, gapMs = 60) {
for (const s of syms) { mf(s); await wait((MF[s] && s === 'KP' ? 100 : 60) + gapMs); }
}
// 2600 Hz. Hold it and the far end drops and the near end keeps thinking
// you are still on the call. You are now standing in a hole in the network.
export function seize(gain = 0.17) { return pair(SEIZE, null, { gain }); }
// ─────────────────────────────────────────────────────── the handshake
// ANSam: 2100 Hz answer tone with a phase reversal every 450ms.
// The reversals are what disable the echo cancellers. The boss flips on them.
export function ansam({ reversals = true, gain = 0.15 } = {}) {
const t = ctx.currentTime;
const g = ctx.createGain(); g.gain.value = 0; g.connect(sfxBus);
g.gain.linearRampToValueAtTime(gain, t + 0.05);
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = ANS;
const inv = ctx.createGain(); inv.gain.value = 1;
o.connect(inv); inv.connect(g); o.start(t);
let phase = 1, timer = null;
const listeners = [];
if (reversals) {
timer = setInterval(() => {
phase = -phase;
inv.gain.setValueAtTime(phase, ctx.currentTime);
listeners.forEach(fn => fn(phase));
}, 450);
}
return {
onReverse(fn) { listeners.push(fn); },
get phase() { return phase; },
stop() { clearInterval(timer); const tt = ctx.currentTime; g.gain.linearRampToValueAtTime(0, tt + 0.06); o.stop(tt + 0.1); }
};
}
// Bell 103 — 300 baud FSK. Originate 1070/1270, answer 2025/2225.
export const BELL103 = { origin: { space: 1070, mark: 1270 }, answer: { space: 2025, mark: 2225 } };
export function fsk(side = 'answer', bitrate = 300, gain = 0.10) {
const m = BELL103[side === 'answer' ? 'answer' : 'origin'];
const p = pair(m.mark, null, { gain });
const iv = setInterval(() => p.freq(0, Math.random() < 0.5 ? m.mark : m.space), 1000 / bitrate * 8);
return { stop() { clearInterval(iv); p.stop(0.05); } };
}
// V.8bis probe scream — the "screeeee". Rapid capability probes.
export function scream(gain = 0.12) {
const p = pair(1200, 2400, { gain });
let i = 0;
const iv = setInterval(() => {
i++;
p.freq(0, 900 + (i * 271) % 1600, 0.001);
p.freq(1, 1800 + (i * 433) % 2200, 0.001);
}, 28);
return { stop() { clearInterval(iv); p.stop(0.06); } };
}
// The carrier. Scrambled data as filtered noise + a phase-jittering pair.
export function carrier(gain = 0.09) {
const src = noise(3);
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 1.1;
const g = ctx.createGain(); g.gain.value = 0;
src.connect(bp); bp.connect(g); g.connect(sfxBus); src.start();
g.gain.setTargetAtTime(gain, ctx.currentTime, 0.15);
const iv = setInterval(() => bp.frequency.setTargetAtTime(1400 + Math.random() * 900, ctx.currentTime, 0.02), 60);
return {
level(v) { g.gain.setTargetAtTime(v, ctx.currentTime, 0.1); },
stop() { clearInterval(iv); g.gain.setTargetAtTime(0, ctx.currentTime, 0.12); setTimeout(() => src.stop(), 500); }
};
}
// The four seconds of a modem failing to train, then silence.
export function noCarrier() {
const s = scream(0.10);
setTimeout(() => { s.stop(); const f = fsk('answer', 300, 0.09); setTimeout(() => f.stop(), 700); }, 900);
const p = pair(ANS, 1800, { gain: 0.05 });
p.gain.gain.setTargetAtTime(0, ctx.currentTime + 1.6, 0.4);
setTimeout(() => p.stop(0.4), 2600);
}
// ─────────────────────────────────────────────────────────────── texture
// 50 Hz strip lighting. TIME_WAIT runs on this and nothing else.
export function striplight(gain = 0.05) {
const p = pair(50, 100, { gain });
const o3 = ctx.createOscillator(); o3.type = 'sine'; o3.frequency.value = 150;
const g3 = ctx.createGain(); g3.gain.value = gain * 0.35; o3.connect(g3); g3.connect(sfxBus); o3.start();
return { stop() { p.stop(0.4); const t = ctx.currentTime; g3.gain.linearRampToValueAtTime(0, t + 0.4); o3.stop(t + 0.5); } };
}
// Strowger step-by-step relay. A click is a click.
export function relayClick(gain = 0.25, pitch = 1) {
const t = ctx.currentTime;
const src = noise(0.06); src.loop = false;
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
bp.frequency.value = 1900 * pitch; bp.Q.value = 6;
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0005, t + 0.035);
src.connect(bp); bp.connect(g); g.connect(sfxBus); src.start(t); src.stop(t + 0.06);
}
// Terminal key. 300 baud sounds like this and nothing else does.
export function blip(f = 1400, dur = 0.014, gain = 0.05) {
const t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = 'square'; o.frequency.value = f;
const g = ctx.createGain(); g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0005, t + dur);
o.connect(g); g.connect(sfxBus); o.start(t); o.stop(t + dur + 0.01);
}
export function thud(f = 70, dur = 0.3, gain = 0.3) {
const t = ctx.currentTime;
const o = ctx.createOscillator(); o.type = 'sine';
o.frequency.setValueAtTime(f * 2, t); o.frequency.exponentialRampToValueAtTime(f * 0.5, t + dur);
const g = ctx.createGain(); g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0005, t + dur);
o.connect(g); g.connect(sfxBus); o.start(t); o.stop(t + dur + 0.02);
}
// ────────────────────────────────────────────────────────────────── music
// Each level's pad is built out of that level's protocol. No exceptions.
let padHandle = null;
export function pad(freqs, { gain = 0.045, detune = 6, type = 'sine' } = {}) {
stopPad();
const t = ctx.currentTime;
const g = ctx.createGain(); g.gain.value = 0; g.connect(musicBus);
g.gain.linearRampToValueAtTime(gain, t + 2.5);
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2200; lp.connect(g);
const oscs = [];
freqs.forEach((f, i) => {
for (const d of [-detune, detune]) {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = f; o.detune.value = d;
const og = ctx.createGain(); og.gain.value = 1 / (freqs.length * 2);
o.connect(og); og.connect(lp); o.start(t + i * 0.08); oscs.push(o);
}
});
// slow breathing so it never sits still
const lfo = ctx.createOscillator(); lfo.frequency.value = 0.06;
const lfoG = ctx.createGain(); lfoG.gain.value = 400;
lfo.connect(lfoG); lfoG.connect(lp.frequency); lfo.start(t);
padHandle = { oscs, g, lfo, lp };
return padHandle;
}
export function stopPad(rel = 1.8) {
if (!padHandle) return;
const h = padHandle; padHandle = null;
const t = ctx.currentTime;
h.g.gain.cancelScheduledValues(t);
h.g.gain.setValueAtTime(h.g.gain.value, t);
h.g.gain.linearRampToValueAtTime(0, t + rel);
h.oscs.forEach(o => o.stop(t + rel + 0.1));
h.lfo.stop(t + rel + 0.1);
}
export const wait = ms => new Promise(r => setTimeout(r, ms));