// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft // (slow attacks, low levels, filtered noise) so it stays in the background of the desk. // Browsers keep audio locked until the first click or key press; unlock() resumes it. export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) { let ctx = null; let master = null; let noiseBuf = null; function context() { if (!ctx) { const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext; if (!AC) return null; try { ctx = new AC(); } catch { return null; } master = ctx.createGain(); master.connect(ctx.destination); } return ctx; } function unlock() { const c = context(); if (c && c.state === "suspended") c.resume?.().catch?.(() => {}); } for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true }); // One note: a sine (or other) wave with an attack and an exponential fade. function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) { const o = c.createOscillator(); const g = c.createGain(); o.type = type; o.frequency.setValueAtTime(freq, at); if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur); g.gain.setValueAtTime(0.0001, at); g.gain.linearRampToValueAtTime(gain, at + attack); g.gain.exponentialRampToValueAtTime(0.0001, at + dur); o.connect(g).connect(master); o.start(at); o.stop(at + dur + 0.05); } // Filtered noise: paper, pages, thumps and clicks. function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) { noiseBuf ??= (() => { const b = c.createBuffer(1, c.sampleRate, c.sampleRate); const d = b.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; return b; })(); const src = c.createBufferSource(); src.buffer = noiseBuf; src.loop = true; const f = c.createBiquadFilter(); f.type = type; f.Q.value = q; f.frequency.setValueAtTime(freq, at); if (to) f.frequency.exponentialRampToValueAtTime(to, at + dur); const g = c.createGain(); g.gain.setValueAtTime(0.0001, at); g.gain.linearRampToValueAtTime(gain, at + attack); g.gain.exponentialRampToValueAtTime(0.0001, at + dur); src.connect(f).connect(g).connect(master); src.start(at); src.stop(at + dur + 0.05); } const SOUNDS = { stampYes: (c, t) => { tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 }); noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 }); noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 }); }, stampNo: (c, t) => { tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 }); noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 }); noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 }); }, paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }), page: (c, t) => { noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 }); noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 }); }, sign: (c, t) => { noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 }); tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 }); }, telegram: (c, t) => { for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 }); tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 }); tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 }); }, alert: (c, t) => { tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 }); tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 }); }, chime: (c, t) => { tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 }); tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 }); }, tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }), tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }), click: (c, t) => { tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 }); noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 }); }, fall: (c, t) => { [330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" })); }, fanfare: (c, t) => { [392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 })); }, }; function play(name) { if (!enabled()) return; const make = SOUNDS[name]; const c = context(); if (!make || !c) return; if (c.state === "suspended") { c.resume?.().catch?.(() => {}); return; // still locked: nothing to hear yet } master.gain.value = Math.max(0, Math.min(1, volume())); try { make(c, c.currentTime + 0.005); } catch { /* a failing sound never stops the game */ } } return { play, unlock, names: Object.keys(SOUNDS) }; }