// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft: slow // attacks, long fades, no deep bass, and a short room reverb under it all so sounds melt away // instead of stopping. Each sound belongs to a mixer group the player can turn down on its own. // Browsers keep audio locked until the first click or key press; unlock() resumes it. // Mixer groups, in the order the mixer shows them. export const MIX_GROUPS = ["stamp", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "countdown", "click", "ending"]; const GROUP_OF = { stampYes: "stamp", stampNo: "stamp", paper: "paper", page: "page", sign: "sign", telegram: "telegram", alert: "alert", chime: "chime", tick: "tick", tock: "tick", clockTick: "countdown", clockTock: "countdown", click: "click", fall: "ending", fanfare: "ending", }; export function createAudio({ enabled = () => true, volume = () => 0.6, mix = () => ({}) } = {}) { let ctx = null; let master = null; let noiseBuf = null; let out = null; // where the sound being built sends its notes (a gain node per play) function context() { if (ctx) return ctx; const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext; if (!AC) return null; try { ctx = new AC(); } catch { return null; } // master -> no deep bass -> no harsh treble -> gentle limiter -> speakers, plus a soft room send master = ctx.createGain(); const lowCut = ctx.createBiquadFilter(); lowCut.type = "highpass"; lowCut.frequency.value = 140; const highCut = ctx.createBiquadFilter(); highCut.type = "lowpass"; highCut.frequency.value = 4800; highCut.Q.value = 0.4; const limiter = ctx.createDynamicsCompressor(); limiter.threshold.value = -22; limiter.knee.value = 24; limiter.ratio.value = 6; limiter.attack.value = 0.01; limiter.release.value = 0.35; master.connect(lowCut).connect(highCut).connect(limiter).connect(ctx.destination); const room = ctx.createConvolver(); const len = Math.floor(ctx.sampleRate * 1.5); const ir = ctx.createBuffer(2, len, ctx.sampleRate); for (let ch = 0; ch < 2; ch++) { const d = ir.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6); } room.buffer = ir; const wet = ctx.createGain(); wet.gain.value = 0.28; const roomTone = ctx.createBiquadFilter(); roomTone.type = "lowpass"; roomTone.frequency.value = 2600; highCut.connect(wet).connect(room).connect(roomTone).connect(limiter); 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 wave with an attack and an exponential fade. function tone(c, at, { freq, to, type = "sine", dur = 0.3, gain = 0.1, attack = 0.02 }) { 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(out); o.start(at); o.stop(at + dur + 0.05); } // Filtered noise: paper, pages, soft impacts and ticks. function noise(c, at, { dur = 0.2, gain = 0.06, type = "bandpass", freq = 2000, to, q = 0.6, attack = 0.03 }) { 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(out); src.start(at); src.stop(at + dur + 0.05); } const SOUNDS = { // A padded "thup" rather than a thud: mid-range, slow to start, with a soft paper tail. stampYes: (c, t) => { tone(c, t, { freq: 230, to: 130, dur: 0.32, gain: 0.2, attack: 0.016 }); noise(c, t, { type: "lowpass", freq: 1100, dur: 0.18, gain: 0.09, attack: 0.014, q: 0.4 }); noise(c, t + 0.02, { freq: 2000, to: 1400, dur: 0.32, gain: 0.025, attack: 0.03 }); }, stampNo: (c, t) => { tone(c, t, { freq: 190, to: 108, dur: 0.34, gain: 0.2, attack: 0.016 }); noise(c, t, { type: "lowpass", freq: 900, dur: 0.2, gain: 0.09, attack: 0.014, q: 0.4 }); noise(c, t + 0.02, { freq: 1700, to: 1200, dur: 0.32, gain: 0.025, attack: 0.03 }); }, paper: (c, t) => noise(c, t, { freq: 2400, to: 1100, dur: 0.36, gain: 0.075, attack: 0.09 }), page: (c, t) => { noise(c, t, { freq: 450, to: 2800, dur: 0.52, gain: 0.085, attack: 0.13 }); noise(c, t + 0.4, { freq: 2200, dur: 0.18, gain: 0.035, attack: 0.03 }); }, sign: (c, t) => { noise(c, t, { freq: 3200, to: 2400, dur: 0.42, gain: 0.04, q: 2, attack: 0.11 }); tone(c, t + 0.44, { freq: 200, to: 125, dur: 0.28, gain: 0.15, attack: 0.02 }); noise(c, t + 0.44, { type: "lowpass", freq: 1000, dur: 0.14, gain: 0.06, attack: 0.012, q: 0.4 }); }, telegram: (c, t) => { for (let i = 0; i < 4; i++) noise(c, t + i * 0.09, { type: "highpass", freq: 3200, dur: 0.045, gain: 0.05, attack: 0.007 }); tone(c, t + 0.44, { freq: 1320, dur: 1.6, gain: 0.07, attack: 0.04 }); tone(c, t + 0.44, { freq: 1980, dur: 1.1, gain: 0.022, attack: 0.04 }); }, alert: (c, t) => { tone(c, t, { freq: 233, dur: 0.95, gain: 0.12, type: "triangle", attack: 0.16 }); tone(c, t + 0.32, { freq: 175, dur: 1.25, gain: 0.12, type: "triangle", attack: 0.16 }); }, chime: (c, t) => { tone(c, t, { freq: 659, dur: 1.4, gain: 0.08, attack: 0.06 }); tone(c, t + 0.22, { freq: 880, dur: 1.8, gain: 0.065, attack: 0.06 }); }, tick: (c, t) => tone(c, t, { freq: 1000, to: 850, dur: 0.07, gain: 0.02, attack: 0.007 }), tock: (c, t) => tone(c, t, { freq: 760, to: 650, dur: 0.07, gain: 0.02, attack: 0.007 }), // The countdown on the day before a new proposal: a small wooden clock. clockTick: (c, t) => { tone(c, t, { freq: 1500, to: 1150, dur: 0.1, gain: 0.05, attack: 0.005 }); noise(c, t, { freq: 2800, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 }); }, clockTock: (c, t) => { tone(c, t, { freq: 1150, to: 880, dur: 0.1, gain: 0.05, attack: 0.005 }); noise(c, t, { freq: 2200, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 }); }, click: (c, t) => { tone(c, t, { freq: 800, to: 600, dur: 0.09, gain: 0.045, attack: 0.007 }); noise(c, t, { type: "highpass", freq: 3000, dur: 0.035, gain: 0.02, attack: 0.005 }); }, fall: (c, t) => { [262, 220, 196, 165].forEach((f, i) => tone(c, t + i * 0.6, { freq: f, dur: 1.9, gain: 0.09, attack: 0.16 })); }, fanfare: (c, t) => { [392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.2, { freq: f, dur: 1.9, gain: 0.07, attack: 0.07 })); }, }; 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 } const level = Math.max(0, Math.min(1, mix()[GROUP_OF[name]] ?? 1)); if (level < 0.005) return; master.gain.value = Math.max(0, Math.min(1, volume())); out = c.createGain(); out.gain.value = level; out.connect(master); try { make(c, c.currentTime + 0.005); } catch { /* a failing sound never stops the game */ } } return { play, unlock, names: Object.keys(SOUNDS), groupOf: (name) => GROUP_OF[name] }; }