// 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));