observer effect content

This commit is contained in:
Ellie 2026-06-15 21:22:41 -07:00
parent 625a847a81
commit e4ee25295c
23 changed files with 2982 additions and 0 deletions

View file

@ -0,0 +1,438 @@
/* Olympian Holobeam Array Master Chronometer.
Atomic-age console: brushed steel, nixie tubes, warning lamps. 1964. */
:root {
--bg: #0a0b0c;
--steel-hi: #4c5054;
--steel: #2c2f32;
--steel-lo: #16181a;
--engrave: #0c0d0e;
--label: #c9cdd1;
--nixie: #ff7a1e;
--nixie-core: #ffe2bc;
--nixie-glow: #ff6a00;
--amber: #ffb347;
--red: #ff3b30;
--green: #46e06a;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body.screen {
display: grid;
place-items: center;
min-height: 100%;
padding: clamp(0.5rem, 3vw, 3rem);
background:
radial-gradient(120% 120% at 50% 30%, #15171a 0%, var(--bg) 70%);
color: var(--label);
font-family: "Helvetica Neue", Arial, system-ui, sans-serif;
overflow: hidden;
}
/* --- the console body ----------------------------------------------------- */
.console {
width: min(92vw, 880px);
}
.bezel {
position: relative;
padding: clamp(1rem, 3.5vw, 2.4rem);
border-radius: 14px;
background:
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.035) 0 1px,
transparent 1px 3px
),
linear-gradient(180deg, var(--steel-hi) -20%, var(--steel) 45%, var(--steel-lo));
border: 2px solid #0d0e0f;
box-shadow:
inset 0 2px 0 rgba(255, 255, 255, 0.12),
inset 0 -3px 8px rgba(0, 0, 0, 0.6),
0 22px 60px rgba(0, 0, 0, 0.7);
}
/* corner screws */
.bezel::before {
content: "";
position: absolute;
inset: 10px;
border-radius: 8px;
pointer-events: none;
background:
radial-gradient(circle at 0 0, #555 1.5px, #1a1a1a 3px, transparent 4px) 6px 6px / 100% 100% no-repeat,
radial-gradient(circle at 100% 0, #555 1.5px, #1a1a1a 3px, transparent 4px) -6px 6px / 100% 100% no-repeat,
radial-gradient(circle at 0 100%, #555 1.5px, #1a1a1a 3px, transparent 4px) 6px -6px / 100% 100% no-repeat,
radial-gradient(circle at 100% 100%, #555 1.5px, #1a1a1a 3px, transparent 4px) -6px -6px / 100% 100% no-repeat;
}
/* --- engraved plates ------------------------------------------------------ */
.plate {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 700;
letter-spacing: 0.18em;
font-size: clamp(0.6rem, 1.6vw, 0.9rem);
color: var(--label);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8), 0 -1px 0 rgba(255, 255, 255, 0.06);
}
.stencil {
font-weight: 800;
}
.subplate {
margin: 0.35rem 0 1.1rem;
text-align: center;
letter-spacing: 0.3em;
font-size: clamp(0.5rem, 1.3vw, 0.72rem);
color: #8b9094;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
}
.plate--bottom {
margin-top: 1.2rem;
justify-content: center;
letter-spacing: 0.22em;
font-size: clamp(0.45rem, 1.2vw, 0.66rem);
color: #7d8286;
}
.rec {
display: inline-flex;
align-items: center;
gap: 0.4em;
color: #9aa0a4;
}
/* --- nixie readout -------------------------------------------------------- */
.readout {
display: flex;
align-items: stretch;
justify-content: center;
gap: clamp(0.15rem, 0.8vw, 0.5rem);
padding: clamp(0.6rem, 2vw, 1.2rem) clamp(0.4rem, 2vw, 1rem);
border-radius: 10px;
background:
radial-gradient(120% 160% at 50% 0%, #141312 0%, #070605 100%);
border: 1px solid #000;
box-shadow: inset 0 6px 18px rgba(0, 0, 0, 0.9), inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
.tube {
position: relative;
display: grid;
place-items: center;
width: clamp(2.6rem, 9vw, 5.2rem);
padding: clamp(0.3rem, 1.5vw, 0.9rem) 0;
border-radius: 40% 40% 38% 38% / 14% 14% 12% 12%;
background:
linear-gradient(180deg, rgba(80, 70, 55, 0.16), rgba(20, 16, 10, 0.05) 30%, rgba(0, 0, 0, 0.25));
box-shadow:
inset 0 2px 6px rgba(255, 200, 140, 0.06),
inset 0 -8px 14px rgba(0, 0, 0, 0.6);
}
/* curvature vignette behind the digit */
.tube::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120% 130% at 50% 45%, transparent 52%, rgba(0, 0, 0, 0.45) 100%);
}
/* curved-glass glare in front (offset to a corner so it doesn't wash the digit) */
.tube::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(55% 32% at 32% 15%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.06) 45%, transparent 70%),
radial-gradient(38% 22% at 72% 82%, rgba(255, 255, 255, 0.06), transparent 60%);
}
.digit {
position: relative;
z-index: 2;
font-size: clamp(2.4rem, 9vw, 5.4rem);
font-weight: 400;
line-height: 1;
transform: scaleY(1.18);
color: var(--nixie-core);
text-shadow:
0 0 4px var(--nixie),
0 0 14px var(--nixie-glow),
0 0 34px var(--nixie-glow),
0 0 60px rgba(255, 90, 0, 0.55);
/* per-tube flicker via vars, so the communion rupture can fully override it */
animation: tube-flicker var(--flick-dur, 6s) var(--flick-delay, 0s) infinite steps(40);
}
/* afterglow: the outgoing numeral lingers and fades (neon de-ionization) */
.ghost {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
opacity: 0;
font-size: clamp(2.4rem, 9vw, 5.4rem);
font-weight: 400;
line-height: 1;
transform: scaleY(1.18);
color: var(--nixie-core);
text-shadow: 0 0 4px var(--nixie), 0 0 16px var(--nixie-glow);
pointer-events: none;
}
@keyframes tube-flicker {
0%, 97%, 100% { opacity: 1; }
98% { opacity: 0.86; }
99% { opacity: 0.95; }
}
.colon {
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(0.5rem, 2vw, 1.2rem);
padding: 0 clamp(0.05rem, 0.5vw, 0.3rem);
}
.colon i {
width: clamp(0.3rem, 1vw, 0.6rem);
height: clamp(0.3rem, 1vw, 0.6rem);
border-radius: 50%;
background: var(--nixie-core);
box-shadow: 0 0 6px var(--nixie), 0 0 16px var(--nixie-glow);
}
.srtime {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
/* --- lamps ---------------------------------------------------------------- */
.lamps {
margin-top: 1.3rem;
display: flex;
align-items: center;
justify-content: center;
gap: clamp(1rem, 5vw, 3rem);
}
.lampcell {
display: flex;
align-items: center;
gap: 0.5em;
letter-spacing: 0.16em;
font-size: clamp(0.55rem, 1.4vw, 0.78rem);
color: #8b9094;
}
.lamp {
width: 0.85em;
height: 0.85em;
border-radius: 50%;
background: #2a2a2a;
box-shadow: inset 0 0 3px #000, 0 1px 0 rgba(255, 255, 255, 0.08);
transition: background 0.08s, box-shadow 0.08s;
}
.lamp--rec {
width: 0.7em;
height: 0.7em;
}
.lamp--on {
background: var(--green);
box-shadow: 0 0 8px var(--green), 0 0 16px rgba(70, 224, 106, 0.6);
}
/* REC indicator: steady recording lamp (flavor). */
.lamp--rec {
background: var(--red);
box-shadow: 0 0 5px var(--red);
}
/* --- pulse shudder --------------------------------------------------------- */
body.pulsing #cellPulse .lamp {
background: var(--amber);
box-shadow: 0 0 10px var(--amber), 0 0 24px rgba(255, 179, 71, 0.8);
}
body.pulsing .bezel {
animation: shudder 0.45s ease-out;
}
body.pulsing .digit {
text-shadow:
0 0 6px var(--nixie-core),
0 0 20px var(--nixie),
0 0 50px var(--nixie-glow),
0 0 90px rgba(255, 120, 0, 0.8);
}
@keyframes shudder {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-3px, 1px); }
40% { transform: translate(3px, -2px); }
60% { transform: translate(-2px, -1px); }
80% { transform: translate(2px, 1px); }
}
/* --- dread wash (driven by --tension, 0..1) -------------------------------- */
.screen::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: radial-gradient(120% 120% at 50% 50%, transparent 38%, rgba(120, 0, 0, 0.6) 100%);
opacity: calc(var(--tension, 0) * 0.95);
z-index: 5;
}
/* once the menace is high, the wash starts to crawl */
body.agitated::before {
animation: wash-flicker 0.4s infinite;
}
@keyframes wash-flicker {
0%, 100% { opacity: calc(var(--tension, 0) * 0.95); }
50% { opacity: calc(var(--tension, 0) * 0.7); }
}
/* --- communion: Azathoth breaks through ------------------------------------ */
/* A ~6s sequence over three layers: the void (roiling black), the flash (the
blue-white detonation), plus a console quake and digit rupture. */
.void {
position: fixed;
inset: 0;
z-index: 21;
pointer-events: none;
opacity: 0;
background: #000;
}
.flash {
position: fixed;
inset: 0;
z-index: 22;
pointer-events: none;
opacity: 0;
mix-blend-mode: screen;
background: radial-gradient(circle at 50% 45%, #fff 0%, #dce9ff 12%, #6fb0ff 34%, #163a8f 68%, transparent 100%);
}
body.communion .console { animation: quake 2.3s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
body.communion .void { animation: thevoid 6s ease-out forwards; }
body.communion .flash { animation: detonate 6s ease-out forwards; }
body.communion .digit { animation: digit-rupture 6s steps(60) forwards; }
body.communion .lamp { animation: panic-lamp 0.18s steps(1) 12; }
@keyframes quake {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
5% { transform: translate(-14px, 8px) rotate(-0.6deg); }
10% { transform: translate(13px, -10px) rotate(0.5deg); }
15% { transform: translate(-16px, -6px) rotate(0.4deg); }
21% { transform: translate(15px, 11px) rotate(-0.5deg); }
27% { transform: translate(-19px, 7px) rotate(0.7deg); }
33% { transform: translate(18px, -9px) rotate(-0.6deg); }
41% { transform: translate(-11px, 7px) rotate(0.3deg); }
52% { transform: translate(8px, -5px) rotate(-0.2deg); }
64% { transform: translate(-5px, 3px); }
78% { transform: translate(3px, -2px); }
}
@keyframes detonate {
0% { opacity: 0; }
18% { opacity: 0.12; }
22% { opacity: 0; }
26% { opacity: 1; }
30% { opacity: 0.7; }
34% { opacity: 1; }
44% { opacity: 0.2; }
60% { opacity: 0.06; }
78%, 100% { opacity: 0; }
}
@keyframes thevoid {
0%, 30% { opacity: 0; }
45% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes digit-rupture {
0% { color: var(--nixie-core); text-shadow: 0 0 4px var(--nixie), 0 0 14px var(--nixie-glow); }
10% { color: #fff; text-shadow: -3px 0 #ff003c, 3px 0 #00e5ff, 0 0 18px #fff; }
20% { text-shadow: 4px 0 #ff003c, -4px 0 #00e5ff, 0 0 22px #fff; }
26% { color: #eaf3ff; text-shadow: -6px 0 #ff003c, 6px 0 #00e5ff, 0 0 30px #fff; }
34% { opacity: 0.2; }
40% { opacity: 1; color: #b9c6ff; text-shadow: -5px 0 #ff003c, 5px 0 #00e5ff; }
55% { opacity: 0.5; text-shadow: -3px 0 #6a00ff, 3px 0 #00d0ff; }
70% { opacity: 0.12; }
82% { opacity: 0.04; }
100% { opacity: 1; color: var(--nixie-core); text-shadow: 0 0 4px var(--nixie), 0 0 14px var(--nixie-glow); }
}
@keyframes panic-lamp {
0% { background: var(--red); box-shadow: 0 0 10px var(--red); }
50% { background: #1a0000; box-shadow: inset 0 0 3px #000; }
}
/* --- message overlay ------------------------------------------------------- */
.message {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
font-weight: 800;
letter-spacing: 0.22em;
font-size: clamp(1.6rem, 7vw, 4.5rem);
color: #fff;
text-shadow: 0 0 12px #6fb0ff, 0 0 40px #2a6bff;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
visibility: hidden;
transition: opacity 0.4s, visibility 0.4s;
}
.message.show {
opacity: 1;
visibility: visible;
}
/* --- offline -------------------------------------------------------------- */
body.offline .bezel {
filter: saturate(0.5) brightness(0.85);
}
/* --- sound button --------------------------------------------------------- */
.sound {
position: fixed;
right: 0.8rem;
bottom: 0.8rem;
z-index: 40;
padding: 0.4rem 0.8rem;
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.14em;
color: #9aa0a4;
background: #1b1d1f;
border: 1px solid #000;
border-radius: 6px;
cursor: pointer;
}
.sound.on {
color: var(--green);
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.digit,
body.agitated::before,
body.communion .console,
body.communion .digit,
body.communion .lamp {
animation: none !important;
}
}

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Warner Center Radio Array — Master Chronometer</title>
<link rel="stylesheet" href="clock.css" />
</head>
<body class="screen">
<main class="console" id="console">
<div class="bezel">
<header class="plate plate--top">
<span class="stencil">WARNER CENTER RADIO ARRAY</span>
<span class="rec"><i class="lamp lamp--rec"></i> REC</span>
</header>
<div class="subplate">MASTER CHRONOMETER</div>
<div class="readout" id="tubes" aria-hidden="true"></div>
<p class="srtime" aria-live="off"><span id="srtime">17:00:00</span> local time</p>
<div class="lamps">
<div class="lampcell">
<i class="lamp lamp--green" id="lampLink"></i><span>ARRAY</span>
</div>
<div class="lampcell" id="cellPulse">
<i class="lamp lamp--amber"></i><span>SIGNAL</span>
</div>
</div>
<footer class="plate plate--bottom">
PROPERTY OF THE U.S. DEPARTMENT OF ENERGY · MCMLXIV · AUTHORIZED PERSONNEL ONLY
</footer>
</div>
</main>
<div class="void" id="void" aria-hidden="true"></div>
<div class="flash" id="flash" aria-hidden="true"></div>
<div class="message" id="message" aria-hidden="true"></div>
<button class="sound" id="soundBtn" type="button">◌ SOUND OFF</button>
<script src="common.js"></script>
<script src="sound.js"></script>
<script src="clock.js"></script>
</body>
</html>

View file

@ -0,0 +1,215 @@
/* Player screen. Read-only and deliberately ignorant of the scenario's
structure no countdown, no iteration counter, nothing the in-world DOE
inspectors couldn't see. It shows the Array's current time and reacts to
whatever the Handler chooses to push: signal pulses, the catastrophe, text.
Dread builds from the pulses the Handler fires, not from any visible clock. */
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
const body = document.body;
const reduceMotion =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Build six nixie tubes as HH MM SS with two colons; keep digit + ghost refs.
const tubeWrap = $("tubes");
const digits = [];
const ghosts = [];
"HH:MM:SS".split("").forEach((ch) => {
if (ch === ":") {
const c = document.createElement("span");
c.className = "colon";
c.innerHTML = "<i></i><i></i>";
tubeWrap.appendChild(c);
} else {
const tube = document.createElement("span");
tube.className = "tube";
// afterglow layer: holds the outgoing numeral as it de-ionizes
const g = document.createElement("span");
g.className = "ghost";
const d = document.createElement("span");
d.className = "digit";
d.textContent = "0";
// each tube flickers on its own rhythm (via CSS vars, so the communion
// rupture animation can still fully override the flicker)
d.style.setProperty("--flick-dur", (5 + Math.random() * 4).toFixed(2) + "s");
d.style.setProperty("--flick-delay", (-Math.random() * 6).toFixed(2) + "s");
tube.appendChild(g);
tube.appendChild(d);
tubeWrap.appendChild(tube);
digits.push(d);
ghosts.push(g);
}
});
function renderDigits(str) {
const order = [0, 1, 3, 4, 6, 7]; // skip the colon positions
const glitching = body.classList.contains("communion");
order.forEach((pos, i) => {
const ch = str[pos];
if (digits[i].textContent !== ch) {
// leave a fading ghost of the outgoing numeral (neon persistence)
if (!glitching && !reduceMotion) {
ghosts[i].textContent = digits[i].textContent;
ghosts[i].animate([{ opacity: 0.7 }, { opacity: 0 }], {
duration: 160,
easing: "ease-out",
});
}
digits[i].textContent = ch;
}
});
}
// cathode-poisoning exercise: a rapid slot-machine cycle through 0-9,
// staggered per tube, run only while the clock sits idle
function renderSpin(now) {
for (let i = 0; i < digits.length; i++) {
digits[i].textContent = ((Math.floor(now / 45) + i * 2) % 10).toString();
}
}
// --- dread / tension -------------------------------------------------------
// 0..1, nudged up by each pulse and decaying slowly. Drives the red wash and
// the audio drone, so the Handler paces the menace by firing pulses.
let tension = 0;
function bumpTension(amount) {
tension = Math.min(1, tension + amount);
}
// --- effects ---------------------------------------------------------------
let pulseTimer = null;
function flashPulse() {
body.classList.add("pulsing");
clearTimeout(pulseTimer);
pulseTimer = setTimeout(() => body.classList.remove("pulsing"), 750);
bumpTension(0.2);
if (window.OESound) OESound.pulse();
}
let communionTimer = null;
let scrambleUntil = 0;
function communionSeq() {
body.classList.remove("communion");
void body.offsetWidth; // restart the whole sequence
body.classList.add("communion");
if (window.OESound && OESound.cancelTicks) OESound.cancelTicks();
tension = 1;
scrambleUntil = performance.now() + 2300; // digits possessed through the blast
clearTimeout(communionTimer);
communionTimer = setTimeout(() => body.classList.remove("communion"), 6200);
if (window.OESound) OESound.communion();
}
function renderScramble() {
for (const d of digits) d.textContent = ((Math.random() * 10) | 0).toString();
}
const msgEl = $("message");
function showMessage(text) {
msgEl.textContent = text;
msgEl.classList.add("show");
}
function hideMessage() {
msgEl.classList.remove("show");
}
// --- model + connection ----------------------------------------------------
const model = OE.ClockModel();
let nextTickTime = null; // next scheduled relay click, in AudioContext time
const conn = OE.connect(OE.defaultWsUrl(), {
onstatus(s) {
$("lampLink").classList.toggle("lamp--on", s === "open");
body.classList.toggle("offline", s !== "open");
},
onstate(state) {
model.apply(state);
// drop queued clicks and realign — so pausing stops ticks promptly and a
// jump/rate change re-aligns instead of replaying a backlog
if (window.OESound && OESound.cancelTicks) OESound.cancelTicks();
nextTickTime = null;
},
onfx(fx) {
if (fx.fx === "pulse") flashPulse();
else if (fx.fx === "communion") communionSeq();
else if (fx.fx === "msg") {
if (fx.text) {
showMessage(fx.text);
if (window.OESound && OESound.enabled()) OESound.message();
} else hideMessage();
}
},
});
window.addEventListener("beforeunload", () => conn.close());
// --- tick scheduler --------------------------------------------------------
// Schedule relay clicks ahead of time on the AudioContext clock, so they keep
// ticking precisely even when the tab is backgrounded (requestAnimationFrame
// is paused there, but Web Audio scheduling is not). Each click is aligned to
// the in-world second boundary — i.e. the digit changeover.
const TICK_LOOKAHEAD = 2.5; // seconds of clicks kept queued
function scheduleTicks() {
if (!window.OESound || !OESound.enabled()) return (nextTickTime = null);
const m = model.now();
if (!m.running || body.classList.contains("communion")) return (nextTickTime = null);
const aNow = OESound.now();
const rate = m.rate || 1;
if (nextTickTime === null) {
const nextSec = Math.floor(m.inworld) + 1;
nextTickTime = aNow + (nextSec - m.inworld) / rate;
}
const stepGap = Math.max(1 / rate, 0.07); // never buzz at high rates
while (nextTickTime < aNow + TICK_LOOKAHEAD) {
if (nextTickTime > aNow) OESound.step(nextTickTime);
nextTickTime += stepGap;
}
}
setInterval(scheduleTicks, 250);
// --- render loop -----------------------------------------------------------
let last = performance.now();
let spinUntil = 0;
const SPIN_IDLE = 90000; // exercise the tubes after this long sitting idle
let nextSpinAt = performance.now() + SPIN_IDLE;
function frame(now) {
const dt = (now - last) / 1000;
last = now;
const s = model.now();
// cathode-poisoning spin, but only while the clock sits idle (paused)
if (s.running || body.classList.contains("communion") || reduceMotion) {
nextSpinAt = now + SPIN_IDLE;
} else if (now >= nextSpinAt && now >= spinUntil) {
spinUntil = now + 1600;
nextSpinAt = now + SPIN_IDLE;
}
if (now < scrambleUntil) renderScramble();
else if (now < spinUntil) renderSpin(now);
else renderDigits(OE.clock(s.inworld));
$("srtime").textContent = OE.clock(s.inworld);
// (relay ticks are scheduled on the audio clock above, not here)
// decay tension and reflect it on the console — quick enough that the
// drone and red wash settle within several seconds, not minutes
tension = Math.max(0, tension - dt * 0.12);
body.style.setProperty("--tension", tension.toFixed(3));
body.classList.toggle("agitated", tension > 0.6);
if (window.OESound && OESound.enabled()) OESound.setDread(tension);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// --- sound toggle ----------------------------------------------------------
const soundBtn = $("soundBtn");
soundBtn.addEventListener("click", () => {
if (OESound.enable()) {
soundBtn.textContent = "◉ SOUND ON";
soundBtn.classList.add("on");
soundBtn.disabled = true;
}
});
})();

View file

@ -0,0 +1,134 @@
/* Observer Effect shared clock logic for the player screen and the Handler's
panel. Classic script (no modules) so the pages also open straight from disk
for a quick look. Everything hangs off the global `OE`. */
(function () {
"use strict";
// In-world second-of-day for the moment Azathoth achieves communion.
const COMMUNION = 79417; // 22:03:37
// The scenario's beats, in second-of-day. `pulse: true` marks the live
// on-site shudders the player screen reacts to; the rest are scene markers
// and useful jump targets for the Handler.
const EVENTS = [
{ t: 36000, label: "Array activated", time: "10:00:00" },
{ t: 55735, label: "Power surge", time: "15:28:55" },
{ t: 59682, label: "Phantom signal", time: "16:34:42" },
{ t: 61200, label: "Agents arrive", time: "17:00:00" },
{ t: 63629, label: "Pulse", time: "17:40:29", pulse: true },
{ t: 67576, label: "Takagawa awakens", time: "18:46:16", pulse: true },
{ t: 71523, label: "Klinger appears", time: "19:52:03", pulse: true },
{ t: 75470, label: "Klinger's rampage", time: "20:57:50", pulse: true },
{ t: COMMUNION, label: "COMMUNION", time: "22:03:37", communion: true },
];
const RESET_POINTS = { 1: 61200, 2: 67576, 3: 75470, 4: COMMUNION };
const ROMAN = { 1: "I", 2: "II", 3: "III", 4: "IV" };
function pad(n) {
return String(Math.floor(n)).padStart(2, "0");
}
// second-of-day (may exceed a day or go negative) -> "HH:MM:SS", clamped 0..24h.
function clock(sec) {
let s = Math.max(0, Math.min(86399, Math.floor(sec)));
return pad(s / 3600) + ":" + pad((s % 3600) / 60) + ":" + pad(s % 60);
}
// A signed duration in seconds -> "H:MM:SS".
function duration(sec) {
const neg = sec < 0;
let s = Math.abs(Math.floor(sec));
const h = Math.floor(s / 3600);
return (neg ? "-" : "") + h + ":" + pad((s % 3600) / 60) + ":" + pad(s % 60);
}
/* Holds the latest authoritative snapshot and free-runs from it locally, so
the digits keep ticking between the Handler's actions. */
function ClockModel() {
let base = RESET_POINTS[1];
let baseAt = performance.now();
let running = false;
let rate = 1;
let iteration = 1;
return {
apply(state) {
base = state.inworld;
baseAt = performance.now();
running = state.running;
rate = state.rate;
iteration = state.iteration;
},
now() {
const live = running
? base + ((performance.now() - baseAt) / 1000) * rate
: base;
return {
inworld: live,
remaining: COMMUNION - live,
running,
rate,
iteration,
};
},
};
}
/* Auto-reconnecting WebSocket. `onstate`/`onfx` get parsed messages;
`onstatus` gets "open"/"closed" for the link lamp. */
function connect(url, handlers) {
let ws;
let closed = false;
function open() {
ws = new WebSocket(url);
ws.onopen = () => handlers.onstatus && handlers.onstatus("open");
ws.onclose = () => {
handlers.onstatus && handlers.onstatus("closed");
if (!closed) setTimeout(open, 1000);
};
ws.onerror = () => ws.close();
ws.onmessage = (e) => {
let m;
try {
m = JSON.parse(e.data);
} catch (_) {
return;
}
if (m.type === "state") handlers.onstate && handlers.onstate(m);
else if (m.type === "fx") handlers.onfx && handlers.onfx(m);
};
}
open();
return {
send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
},
close() {
closed = true;
if (ws) ws.close();
},
};
}
// Default WS endpoint: same host, /ws, matching page's TLS.
function defaultWsUrl() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return proto + "//" + location.host + "/ws";
}
window.OE = {
COMMUNION,
EVENTS,
RESET_POINTS,
ROMAN,
clock,
duration,
pad,
ClockModel,
connect,
defaultWsUrl,
};
})();

View file

@ -0,0 +1,260 @@
/* Array Control the Handler's cockpit. Same steel as the chronometer,
but laid out as a dense bank of switches and push-buttons. */
:root {
--bg: #0a0b0c;
--steel-hi: #4c5054;
--steel: #2c2f32;
--steel-lo: #16181a;
--label: #c9cdd1;
--amber: #ffb347;
--red: #ff3b30;
--green: #46e06a;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body.panel {
min-height: 100vh;
padding: clamp(0.6rem, 2.5vw, 2rem);
background: radial-gradient(120% 120% at 50% 0%, #15171a, var(--bg) 70%);
color: var(--label);
font-family: "Helvetica Neue", Arial, system-ui, sans-serif;
-webkit-text-size-adjust: 100%;
}
.rig {
width: min(96vw, 760px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* engraved header */
.plate {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 800;
letter-spacing: 0.16em;
font-size: clamp(0.7rem, 2vw, 0.95rem);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
}
.link {
display: inline-flex;
align-items: center;
gap: 0.4em;
color: #9aa0a4;
font-size: 0.78rem;
}
.lamp {
width: 0.8em;
height: 0.8em;
border-radius: 50%;
background: #2a2a2a;
box-shadow: inset 0 0 3px #000;
transition: background 0.1s;
}
.lamp--on {
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
/* --- mirror --------------------------------------------------------------- */
.mirror {
padding: clamp(0.7rem, 2.5vw, 1.2rem);
border-radius: 12px;
background: radial-gradient(120% 160% at 50% 0%, #141312, #070605);
border: 1px solid #000;
box-shadow: inset 0 5px 16px rgba(0, 0, 0, 0.9);
}
.mirror-time {
display: flex;
align-items: baseline;
justify-content: center;
gap: 1rem;
}
.big {
font-variant-numeric: tabular-nums;
font-size: clamp(2.4rem, 12vw, 4.5rem);
font-weight: 400;
letter-spacing: 0.04em;
color: #ffe2bc;
text-shadow: 0 0 6px #ff7a1e, 0 0 22px #ff6a00, 0 0 50px rgba(255, 90, 0, 0.5);
}
.run {
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #7d8286;
}
.run.live {
color: var(--green);
text-shadow: 0 0 8px rgba(70, 224, 106, 0.6);
}
.mirror-meta {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem 1.4rem;
font-size: clamp(0.6rem, 1.6vw, 0.8rem);
letter-spacing: 0.12em;
color: #8b9094;
}
.mirror-meta b {
color: var(--amber);
}
/* --- banks ---------------------------------------------------------------- */
.bank {
padding: 0.8rem clamp(0.7rem, 2.5vw, 1.1rem) 1rem;
border-radius: 12px;
background: linear-gradient(180deg, var(--steel-hi) -40%, var(--steel) 50%, var(--steel-lo));
border: 1px solid #0d0e0f;
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.1), inset 0 -3px 8px rgba(0, 0, 0, 0.5);
}
.bank-label,
.sub {
display: block;
letter-spacing: 0.18em;
font-size: 0.62rem;
font-weight: 700;
color: #8b9094;
margin-bottom: 0.6rem;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
}
.sub {
display: inline;
margin: 0 0.4rem 0 0;
align-self: center;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.row + .row {
margin-top: 0.6rem;
}
/* --- buttons -------------------------------------------------------------- */
.btn {
font: inherit;
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.8rem;
color: #e7eaed;
padding: 0.55rem 0.9rem;
border-radius: 7px;
border: 1px solid #000;
background: linear-gradient(180deg, #3c4044, #23262a);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 2px 4px rgba(0, 0, 0, 0.5);
cursor: pointer;
transition: transform 0.05s, filter 0.1s;
}
.btn:hover {
filter: brightness(1.15);
}
.btn:active {
transform: translateY(1px);
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.6);
}
.btn--wide {
flex: 1;
}
.btn.active {
color: #0a0b0c;
background: linear-gradient(180deg, #ffd98a, var(--amber));
box-shadow: 0 0 10px rgba(255, 179, 71, 0.6);
}
.btn--amber {
color: #2a1c00;
background: linear-gradient(180deg, #ffd98a, var(--amber));
}
.btn--red {
color: #fff;
background: linear-gradient(180deg, #ff6a60, var(--red));
}
.btn--big {
flex: 1;
font-size: 1rem;
padding: 0.9rem;
letter-spacing: 0.16em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
margin-bottom: 0.7rem;
}
.jump {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: flex-start;
text-align: left;
padding: 0.5rem 0.7rem;
}
.jump b {
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.jump span {
font-weight: 400;
font-size: 0.62rem;
letter-spacing: 0.06em;
color: #aeb3b7;
}
.jump.btn--amber span,
.jump.btn--red span {
color: rgba(0, 0, 0, 0.65);
}
.iters .iter {
flex: 1;
min-width: 90px;
}
/* --- inputs --------------------------------------------------------------- */
input {
font: inherit;
font-size: 0.85rem;
color: #e7eaed;
background: #101214;
border: 1px solid #000;
border-radius: 6px;
padding: 0.5rem 0.6rem;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.6);
}
input:focus {
outline: 1px solid var(--amber);
}
#token,
#msg {
flex: 1;
min-width: 8rem;
}
.hint {
font-size: 0.7rem;
color: var(--amber);
letter-spacing: 0.08em;
}
.foot {
text-align: center;
font-size: 0.72rem;
letter-spacing: 0.1em;
color: #7d8286;
}
.foot a {
color: var(--amber);
}

View file

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Array Control</title>
<link rel="stylesheet" href="control.css" />
</head>
<body class="panel">
<main class="rig">
<header class="plate">
<span class="stencil">ARRAY CONTROL · HANDLER ONLY</span>
<span class="link"><i class="lamp" id="lampLink"></i> LINK</span>
</header>
<!-- live mirror of what the players see -->
<section class="mirror">
<div class="mirror-time">
<span class="big" id="mClock">17:00:00</span>
<span class="run" id="mRun">PAUSED</span>
</div>
<div class="mirror-meta">
<span>COMMUNION IN <b id="mCountdown">—:—:—</b></span>
<span>ITERATION <b id="mIter">I</b></span>
<span>NEXT <b id="mNext"></b></span>
</div>
</section>
<!-- token -->
<section class="bank">
<label class="bank-label" for="token">CONTROL KEY</label>
<div class="row">
<input id="token" type="password" autocomplete="off" placeholder="shared token" />
<button class="btn" id="saveToken" type="button">SET</button>
<span class="hint" id="tokenHint"></span>
</div>
</section>
<!-- transport -->
<section class="bank">
<span class="bank-label">TRANSPORT</span>
<div class="row">
<button class="btn btn--wide" data-cmd="play" type="button">▶ RUN</button>
<button class="btn btn--wide" data-cmd="pause" type="button">⏸ HOLD</button>
</div>
<div class="row rates" id="rates">
<span class="sub">RATE</span>
<button class="btn rate" data-rate="1" type="button">×1</button>
<button class="btn rate" data-rate="6" type="button">×6</button>
<button class="btn rate" data-rate="30" type="button">×30</button>
<button class="btn rate" data-rate="60" type="button">×60</button>
<button class="btn rate" data-rate="300" type="button">×300</button>
</div>
</section>
<!-- timeline jumps -->
<section class="bank">
<span class="bank-label">JUMP TO BEAT</span>
<div class="grid" id="jumps"></div>
<div class="row">
<span class="sub">SET</span>
<input id="manualTime" type="time" step="1" value="17:00:00" />
<button class="btn" id="manualJump" type="button">JUMP</button>
</div>
</section>
<!-- iterations -->
<section class="bank">
<span class="bank-label">ITERATION — resets reality to its wake point</span>
<div class="row iters" id="iters">
<button class="btn iter" data-iter="1" type="button">I · 17:00</button>
<button class="btn iter" data-iter="2" type="button">II · 18:46</button>
<button class="btn iter" data-iter="3" type="button">III · 20:57</button>
<button class="btn iter" data-iter="4" type="button">IV · SINGULARITY</button>
</div>
</section>
<!-- effects -->
<section class="bank">
<span class="bank-label">EFFECTS — fire on the players' screen</span>
<div class="row">
<button class="btn btn--amber btn--big" data-cmd="pulse" type="button">◉ PULSE</button>
<button class="btn btn--red btn--big" data-cmd="communion" type="button">☢ COMMUNION</button>
</div>
<div class="row">
<input id="msg" type="text" maxlength="60" placeholder="broadcast a line of text…" />
<button class="btn" id="sendMsg" type="button">SHOW</button>
<button class="btn" id="clearMsg" type="button">CLEAR</button>
</div>
</section>
<footer class="foot">
Player screen: <a id="clockLink" href="clock.html" target="_blank" rel="noopener">open the chronometer ↗</a>
</footer>
</main>
<script src="common.js"></script>
<script src="control.js"></script>
</body>
</html>

View file

@ -0,0 +1,120 @@
/* Handler's control panel. Sends authenticated commands to the relay and
mirrors the live clock so the Handler sees exactly what the players see. */
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
// --- control key (persisted locally) --------------------------------------
const tokenInput = $("token");
tokenInput.value = localStorage.getItem("oe-token") || "";
function saveToken() {
localStorage.setItem("oe-token", tokenInput.value.trim());
flashHint("key set");
}
$("saveToken").addEventListener("click", saveToken);
tokenInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") saveToken();
});
let hintTimer = null;
function flashHint(text) {
const el = $("tokenHint");
el.textContent = text;
clearTimeout(hintTimer);
hintTimer = setTimeout(() => (el.textContent = ""), 1500);
}
// --- connection -----------------------------------------------------------
const model = OE.ClockModel();
const conn = OE.connect(OE.defaultWsUrl(), {
onstatus(s) {
$("lampLink").classList.toggle("lamp--on", s === "open");
},
onstate(state) {
model.apply(state);
syncRateButtons(state.rate);
},
});
window.addEventListener("beforeunload", () => conn.close());
function send(obj) {
const token = (localStorage.getItem("oe-token") || "").trim();
if (!token) {
flashHint("no control key set");
tokenInput.focus();
return;
}
conn.send(Object.assign({ token }, obj));
}
// --- wire fixed-command buttons (play/pause/pulse/communion) ---------------
document.querySelectorAll("[data-cmd]").forEach((btn) => {
btn.addEventListener("click", () => send({ cmd: btn.dataset.cmd }));
});
// --- rate -----------------------------------------------------------------
document.querySelectorAll(".rate").forEach((btn) => {
btn.addEventListener("click", () => send({ cmd: "rate", rate: Number(btn.dataset.rate) }));
});
function syncRateButtons(rate) {
document.querySelectorAll(".rate").forEach((b) => {
b.classList.toggle("active", Number(b.dataset.rate) === rate);
});
}
// --- iterations -----------------------------------------------------------
document.querySelectorAll(".iter").forEach((btn) => {
btn.addEventListener("click", () => send({ cmd: "iteration", n: Number(btn.dataset.iter) }));
});
// --- timeline jumps -------------------------------------------------------
const jumps = $("jumps");
OE.EVENTS.forEach((ev) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn jump" + (ev.communion ? " btn--red" : ev.pulse ? " btn--amber" : "");
b.innerHTML = "<b>" + ev.time + "</b><span>" + ev.label + "</span>";
b.addEventListener("click", () => send({ cmd: "set", inworld: ev.t }));
jumps.appendChild(b);
});
// manual time -> seconds-of-day
$("manualJump").addEventListener("click", () => {
const parts = $("manualTime").value.split(":").map(Number);
if (parts.length >= 2 && parts.every((n) => !Number.isNaN(n))) {
const sec = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0);
send({ cmd: "set", inworld: sec });
}
});
// --- broadcast message ----------------------------------------------------
$("sendMsg").addEventListener("click", () => {
const text = $("msg").value.trim();
if (text) send({ cmd: "msg", text });
});
$("clearMsg").addEventListener("click", () => send({ cmd: "msg", text: null }));
$("msg").addEventListener("keydown", (e) => {
if (e.key === "Enter") $("sendMsg").click();
});
// player-screen link points at clock.html next to this page
$("clockLink").href = new URL("clock.html", location.href).href;
// --- mirror loop ----------------------------------------------------------
function nextBeat(inworld) {
const upcoming = OE.EVENTS.filter((e) => e.t > inworld + 0.5);
return upcoming.length ? upcoming[0] : null;
}
function frame() {
const s = model.now();
$("mClock").textContent = OE.clock(s.inworld);
$("mIter").textContent = OE.ROMAN[s.iteration] || s.iteration;
$("mCountdown").textContent = s.remaining <= 0 ? "00:00:00" : OE.duration(s.remaining);
$("mRun").textContent = s.running ? "RUNNING" : "PAUSED";
$("mRun").classList.toggle("live", s.running);
const nb = nextBeat(s.inworld);
$("mNext").textContent = nb ? nb.time + " " + nb.label : "—";
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();

Binary file not shown.

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Synthesize the Observer Effect SFX with ffmpeg. Pure lavfi synthesis — no
# samples. Re-run to regenerate the .mp3s in this directory.
#
# nix-shell -p ffmpeg --run ./generate.sh
#
# Expressions are deliberately comma-free (logistic gates 1/(1+exp(-k*(t-T)))
# and exp() envelopes instead of between()/min()/max()) so nothing needs
# escaping inside the filtergraph.
set -euo pipefail
cd "$(dirname "$0")"
SR=48000
MP3=(-ac 1 -c:a libmp3lame -b:a 128k)
# Per-second clicks use WAV: mp3 adds a few ms of priming silence at the buffer
# start, which smears the transient against the digit changeover.
WAV=(-ac 1 -c:a pcm_s16le)
# --- pulse: a deep drum thump, a rising flute whistle, a metallic shimmer ----
PULSE='exp(-5*t)*0.9*sin(2*PI*(90*t-25*t*t))'
PULSE+=' + (1-exp(-25*t))*exp(-3*t)*0.16*sin(2*PI*(1500*t+850*t*t))'
PULSE+=' + exp(-2.2*t)*0.05*sin(2*PI*2700*t)*(1+0.5*sin(2*PI*7*t))'
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${PULSE}:s=${SR}:d=1.4" \
-af "aecho=0.8:0.7:45|110:0.35|0.2,alimiter=limit=0.95,aresample=44100" \
"${MP3[@]}" pulse.mp3
# --- communion: rising roar + noise swell -> sub-bass impact -> screaming
# flutes over a beating void drone -------------------------------------
ROAR='(1-exp(-2.5*t))*exp(-0.28*t)*0.28*(sin(2*PI*(60*t-2.5*t*t))+0.5*sin(4*PI*(60*t-2.5*t*t))+0.33*sin(6*PI*(60*t-2.5*t*t)))'
IMPACT='(1/(1+exp(-30*(t-1.6))))*exp(-3.2*(t-1.6))*0.95*sin(2*PI*(80*(t-1.6)-35*(t-1.6)*(t-1.6)))'
FLUTES='(1/(1+exp(-30*(t-1.7))))*exp(-0.9*(t-1.7))*0.07*(sin(2*PI*1700*t)+sin(2*PI*2050*t)+sin(2*PI*2390*t)+sin(2*PI*2900*t)+sin(2*PI*3550*t))*(1+0.4*sin(2*PI*5*t))'
DRONE='(1/(1+exp(-25*(t-1.7))))*exp(-0.5*(t-1.7))*0.22*(sin(2*PI*40*t)+sin(2*PI*40.5*t))'
TONAL="${ROAR} + ${IMPACT} + ${FLUTES} + ${DRONE}"
SWELL="volume=volume='0.4*(1-exp(-2.2*t))*(1/(1+exp(8*(t-1.95))))*(0.6+0.4*sin(2*PI*0.9*t))':eval=frame"
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${TONAL}:s=${SR}:d=6.5" \
-f lavfi -i "anoisesrc=color=white:amplitude=0.6:r=${SR}:d=6.5" \
-filter_complex "[1:a]highpass=f=3000,${SWELL}[n];[0:a][n]amix=inputs=2:normalize=0,aecho=0.8:0.85:55|150:0.35|0.2,alimiter=limit=0.96,aresample=44100[out]" \
-map "[out]" "${MP3[@]}" communion.mp3
# --- whisper: a brief eerie transmission for broadcast text ------------------
WHISP='(1-exp(-8*t))*exp(-2*t)*0.12*(sin(2*PI*620*t)+sin(2*PI*623*t))'
WHISP+=' + exp(-3*t)*0.04*sin(2*PI*(2200*t-300*t*t))'
WHISP+=' + 0.03*exp(-1.5*t)*sin(2*PI*1240*t)*(1+0.6*sin(2*PI*11*t))'
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${WHISP}:s=${SR}:d=1.6" \
-f lavfi -i "anoisesrc=color=white:amplitude=0.25:r=${SR}:d=1.6" \
-filter_complex "[1:a]bandpass=f=1500:width_type=h:w=900,volume=volume='0.18*exp(-2.5*t)*(0.5+0.5*sin(2*PI*13*t))':eval=frame[n];[0:a][n]amix=inputs=2:normalize=0,volume=12dB,aecho=0.8:0.6:70:0.3,alimiter=limit=0.9,aresample=44100[out]" \
-map "[out]" "${MP3[@]}" whisper.mp3
# --- relay: a dry electromechanical seconds step (armature snap + body) -------
# The seconds advance is a stepping relay, not a pendulum: a sharp broadband
# snap with a faint low "thunk" and almost no tonal tail.
RELAY='exp(-120*t)*0.3*sin(2*PI*430*t) + exp(-220*t)*0.32*sin(2*PI*1700*t)'
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "aevalsrc=${RELAY}:s=${SR}:d=0.09" \
-f lavfi -i "anoisesrc=color=white:amplitude=0.6:r=${SR}:d=0.09" \
-filter_complex "[1:a]bandpass=f=2400:width_type=h:w=2400,volume=volume='0.6*exp(-230*t)':eval=frame[n];[0:a][n]amix=inputs=2:normalize=0,alimiter=limit=0.95,aresample=44100[out]" \
-map "[out]" "${WAV[@]}" relay.wav
echo "generated:"
for f in pulse.mp3 communion.mp3 whisper.mp3 relay.wav; do
printf ' %-14s %s\n' "$f" "$(du -h "$f" | cut -f1)"
done

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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,
};
})();