/* Optional soundscape for the player screen — the "flutes and drums" the scenario keeps describing. Silent until the player clicks to enable it (browsers block audio without a gesture). All synthesized; no asset files. */ (function () { "use strict"; let ctx = null; let master = null; let droneGain = null; // Pre-rendered ffmpeg SFX (see sfx/generate.sh). Decoded into buffers on // enable; if a file fails to load we fall back to the synth below. const FILES = { pulse: "sfx/pulse.mp3", communion: "sfx/communion.mp3", whisper: "sfx/whisper.mp3", relay: "sfx/relay.wav", }; const buffers = {}; function loadBuffers() { for (const [name, url] of Object.entries(FILES)) { fetch(new URL(url, location.href)) .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject())) .then((data) => ctx.decodeAudioData(data)) .then((buf) => (buffers[name] = buf)) .catch(() => {}); } } function play(name) { const buf = buffers[name]; if (!buf || !ctx) return false; const src = ctx.createBufferSource(); src.buffer = buf; src.connect(master); src.start(); return true; } function enable() { if (ctx) return true; const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false; ctx = new AC(); master = ctx.createGain(); master.gain.value = 0.8; master.connect(ctx.destination); // A near-subaudible drone that swells as communion nears (see setDread). droneGain = ctx.createGain(); droneGain.gain.value = 0; droneGain.connect(master); const drone = ctx.createOscillator(); drone.type = "sine"; drone.frequency.value = 33; const drone2 = ctx.createOscillator(); drone2.type = "sine"; drone2.frequency.value = 33.4; // beat against the first for unease drone.connect(droneGain); drone2.connect(droneGain); drone.start(); drone2.start(); // Mains hum: this is a high-voltage 1964 nixie device, so it's never truly // silent. A 60 Hz transformer hum with harmonics plus a faint HV whine, // synthesized live so it loops seamlessly. Constant while sound is on. const humGain = ctx.createGain(); humGain.gain.value = 0.07; humGain.connect(master); const humLfo = ctx.createOscillator(); // slow amplitude waver humLfo.frequency.value = 0.5; const humLfoGain = ctx.createGain(); humLfoGain.gain.value = 0.012; humLfo.connect(humLfoGain).connect(humGain.gain); humLfo.start(); const humLp = ctx.createBiquadFilter(); humLp.type = "lowpass"; humLp.frequency.value = 500; humLp.connect(humGain); [[60, 0.5], [120, 0.32], [180, 0.12]].forEach(([f, g]) => { const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = f; const og = ctx.createGain(); og.gain.value = g; o.connect(og).connect(humLp); o.start(); }); const whine = ctx.createOscillator(); // high-voltage supply whine whine.type = "sine"; whine.frequency.value = 15000; const whineGain = ctx.createGain(); whineGain.gain.value = 0.015; whine.connect(whineGain).connect(humGain); whine.start(); loadBuffers(); return true; } function enabled() { return !!ctx; } // Low drum thump + a thin flute whistle: one "pulse" of Azathoth's court. function synthPulse() { if (!ctx) return; const t = ctx.currentTime; const drum = ctx.createOscillator(); const dg = ctx.createGain(); drum.type = "sine"; drum.frequency.setValueAtTime(64, t); drum.frequency.exponentialRampToValueAtTime(34, t + 0.5); dg.gain.setValueAtTime(0.0001, t); dg.gain.exponentialRampToValueAtTime(0.9, t + 0.02); dg.gain.exponentialRampToValueAtTime(0.0001, t + 0.7); drum.connect(dg).connect(master); drum.start(t); drum.stop(t + 0.75); const flute = ctx.createOscillator(); const fg = ctx.createGain(); flute.type = "triangle"; flute.frequency.setValueAtTime(1900, t); flute.frequency.exponentialRampToValueAtTime(3300, t + 0.6); fg.gain.setValueAtTime(0.0001, t); fg.gain.exponentialRampToValueAtTime(0.12, t + 0.05); fg.gain.exponentialRampToValueAtTime(0.0001, t + 0.65); flute.connect(fg).connect(master); flute.start(t); flute.stop(t + 0.7); } function noise(dur) { const len = Math.floor(ctx.sampleRate * dur); const buf = ctx.createBuffer(1, len, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1; return buf; } // The full extrusion: a rising roar and noise swell that detonate into a // sub-bass impact, then a cluster of screaming flutes over a void drone. // Timed so the impact lands with the visual blast (~1.6s in). function synthCommunion() { if (!ctx) return; const t = ctx.currentTime; const blast = t + 1.6; // rising roar into the blast const roar = ctx.createOscillator(); const rg = ctx.createGain(); roar.type = "sawtooth"; roar.frequency.setValueAtTime(26, t); roar.frequency.exponentialRampToValueAtTime(64, blast); roar.frequency.exponentialRampToValueAtTime(30, blast + 2.6); rg.gain.setValueAtTime(0.0001, t); rg.gain.exponentialRampToValueAtTime(0.5, blast); rg.gain.exponentialRampToValueAtTime(0.0001, blast + 3.2); roar.connect(rg).connect(master); roar.start(t); roar.stop(blast + 3.4); // reverse-cymbal noise swell sucking up into the blast const sw = ctx.createBufferSource(); sw.buffer = noise(2); const swf = ctx.createBiquadFilter(); swf.type = "highpass"; swf.frequency.setValueAtTime(200, t); swf.frequency.exponentialRampToValueAtTime(9000, blast); const swg = ctx.createGain(); swg.gain.setValueAtTime(0.0001, t); swg.gain.exponentialRampToValueAtTime(0.22, blast); swg.gain.exponentialRampToValueAtTime(0.0001, blast + 0.4); sw.connect(swf).connect(swg).connect(master); sw.start(t); sw.stop(blast + 0.5); // THE IMPACT: a plunging sub-bass body + a broadband burst const imp = ctx.createOscillator(); const ig = ctx.createGain(); imp.type = "sine"; imp.frequency.setValueAtTime(82, blast); imp.frequency.exponentialRampToValueAtTime(18, blast + 0.8); ig.gain.setValueAtTime(0.0001, blast); ig.gain.exponentialRampToValueAtTime(0.95, blast + 0.03); ig.gain.exponentialRampToValueAtTime(0.0001, blast + 1.4); imp.connect(ig).connect(master); imp.start(blast); imp.stop(blast + 1.5); const burst = ctx.createBufferSource(); burst.buffer = noise(1); const bf = ctx.createBiquadFilter(); bf.type = "bandpass"; bf.frequency.value = 1100; bf.Q.value = 0.4; const bg = ctx.createGain(); bg.gain.setValueAtTime(0.55, blast); bg.gain.exponentialRampToValueAtTime(0.0001, blast + 0.6); burst.connect(bf).connect(bg).connect(master); burst.start(blast); burst.stop(blast + 0.7); // screaming flute cluster after the rupture [1700, 2050, 2390, 2900, 3550, 4200].forEach((f, i) => { const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = "triangle"; o.frequency.setValueAtTime(f, blast); o.frequency.linearRampToValueAtTime(f * 1.22, blast + 2.6); g.gain.setValueAtTime(0.0001, blast + i * 0.06); g.gain.exponentialRampToValueAtTime(0.06, blast + 0.3 + i * 0.06); g.gain.exponentialRampToValueAtTime(0.0001, blast + 3); o.connect(g).connect(master); o.start(blast + i * 0.06); o.stop(blast + 3.2); }); // void drone tail (two detuned sines, beating) const vg = ctx.createGain(); vg.gain.setValueAtTime(0.0001, blast + 0.2); vg.gain.exponentialRampToValueAtTime(0.3, blast + 1); vg.gain.exponentialRampToValueAtTime(0.0001, blast + 4); vg.connect(master); [40, 40.5].forEach((f) => { const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = f; o.connect(vg); o.start(blast); o.stop(blast + 4.2); }); } // level 0..1 — swells the drone with tension, but cuts cleanly to silence // once tension is spent so the deep pulse doesn't hang around. function setDread(level) { if (!ctx || !droneGain) return; const clamped = Math.max(0, Math.min(1, level)); const now = ctx.currentTime; if (clamped <= 0.08) { droneGain.gain.cancelScheduledValues(now); droneGain.gain.setTargetAtTime(0, now, 0.3); return; } droneGain.gain.setTargetAtTime(clamped * 0.22, now, 1.5); } // File first, synth as fallback. function pulse() { if (!play("pulse")) synthPulse(); } function communion() { if (!play("communion")) synthCommunion(); } function message() { play("whisper"); } // Per-second stepping relay — consistent level, no variation. Optionally // scheduled at a future AudioContext time (`when`) so ticks keep firing while // the tab is backgrounded. Pending clicks are tracked so they can be canceled. let pendingTicks = []; function step(when) { if (!ctx) return; const at = when || ctx.currentTime; const g = ctx.createGain(); g.gain.value = 0.4; g.connect(master); let node; if (buffers.relay) { node = ctx.createBufferSource(); node.buffer = buffers.relay; node.connect(g); node.start(at); } else { node = ctx.createOscillator(); node.type = "square"; node.frequency.value = 1800; const e = ctx.createGain(); e.gain.setValueAtTime(0.0001, at); e.gain.exponentialRampToValueAtTime(0.4, at + 0.002); e.gain.exponentialRampToValueAtTime(0.0001, at + 0.03); node.connect(e).connect(g); node.start(at); node.stop(at + 0.04); } pendingTicks.push({ node, at }); if (pendingTicks.length > 64) { pendingTicks = pendingTicks.filter((p) => p.at > ctx.currentTime - 1); } } // Stop any clicks scheduled for the future (e.g. on pause or a clock jump). function cancelTicks() { if (!ctx) return; const t = ctx.currentTime; for (const p of pendingTicks) { if (p.at > t) { try { p.node.stop(t); } catch (_) {} } } pendingTicks = pendingTicks.filter((p) => p.at > t); } function audioNow() { return ctx ? ctx.currentTime : 0; } window.OESound = { enable, enabled, pulse, communion, message, step, cancelTicks, now: audioNow, setDread, }; })();