327 lines
10 KiB
JavaScript
327 lines
10 KiB
JavaScript
|
|
/* 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,
|
||
|
|
};
|
||
|
|
})();
|