observer effect content
This commit is contained in:
parent
625a847a81
commit
e4ee25295c
23 changed files with 2982 additions and 0 deletions
326
services/observer-effect/site/sound.js
Normal file
326
services/observer-effect/site/sound.js
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/* 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,
|
||||
};
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue