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>
295 lines
13 KiB
JavaScript
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));
|