home-server/services/observer-effect/site/sound.js

327 lines
10 KiB
JavaScript
Raw Normal View History

2026-06-15 21:22:41 -07:00
/* 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,
};
})();