/* Antechamber wait-loop player -- WAITLOOP_SPEC.md, Phase 1. * * Plays the generic loop-bank (/loopbank.json, per-voice) as a low, shuffled * ambient bed while a session is being voiced -- turning the synth wait into * pre-induction. Audio runs through an Audio() object held on `window`, NOT in the * DOM, so it survives the 1.5s HTMX progress-poll swaps untouched. * * Playback is SEQUENTIAL, not crossfaded: each clip plays to its natural end, then a * short pause, then the next. These are spoken sentences -- crossfading would fade out * (cut off) the final words and muddy two voices together. A gentle pause between * clauses also matches the slow hypnotic pacing. Only the handoff to the real session * fades (the whole bed out over END_FADE_MS), which never cuts a single clip's words. * * Lifecycle: * HypnoLoop.start() -- from the "Voice my session" click (the user gesture * that unblocks autoplay). Loads manifest, reveals the * pause/play bar, begins the shuffled bed. * HypnoLoop.toggle() -- pause/resume from the on-page bar button. * HypnoLoop.fadeOutAndHide() -- called when the real session player appears (handoff). * * Graceful: if the manifest is missing/empty or autoplay is blocked, it stays silent * and hidden -- the progress bar still works on its own. */ (function () { "use strict"; // The bank is rendered once per voice (loopbank//), so we ask the route rather than // a fixed static path -- it resolves the voice and falls back to the legacy flat bank if that // voice hasn't been rendered yet. A page that knows its session's voice can set // window.LOOP_VOICE; otherwise the server picks its default. // A page may point the loop at a DIFFERENT bank with window.LOOP_MANIFEST (the questions // page plays the truth bank + the round's own whispers from its qloop.json route). function manifestUrl() { var v = window.LOOP_VOICE || ""; var base = window.LOOP_MANIFEST || "/loopbank.json"; if (!v) return base; return base + (base.indexOf("?") >= 0 ? "&" : "?") + "voice=" + encodeURIComponent(v); } // Re-fetch the manifest while playing so clips that finish rendering after the page // loaded (the per-round whispers) join the loop without a reload. Set by the page // (window.LOOP_REFRESH_MS); 0/undefined = the old fetch-once behaviour. function refreshMs() { return Number(window.LOOP_REFRESH_MS) || 0; } var lastFetch = 0; var VOLUME = 0.5; // ambient bed level var MUSIC_VOL = 0.22; // HypnoMusic level under the wait loop -- lower than the session's, // because the antechamber is mostly SILENCE between spoken clips and // music at session level would dominate those gaps rather than fill // them. Overridden by bed.js the moment the bed starts (same click). var FADEIN_MS = 150; // soft onset so a clip doesn't click in (does NOT cut words) var GAP_MIN = 600; // min silence between clips var GAP_MAX = 1200; // max silence between clips (randomized for an organic feel) var END_FADE_MS = 5000; // slow fade-out of the loop at handoff -- crossfades with the // session voice rising (base.html fadeInAudioEl) for a smooth, // unhurried transition rather than an abrupt cut. var STEP_MS = 50; // volume-ramp granularity var clips = []; // [{url, role}] var queue = []; // upcoming urls (reshuffled when drained) var el = null; // the single Audio element var state = "idle"; // idle | loading | playing | paused | stopped var gapTimer = null; // pending "play next after the gap" timer var inClip = false; // true while a clip is actually sounding (vs. in the gap) var fadeIv = null; // active volume-ramp interval var armed = false; // a one-shot "resume on first gesture" listener is pending function noop() {} // Autoplay without a user gesture is blocked by browsers. On the revisit page the bed // is asked to start on load (no click), so the first play() may reject -- when it does, // arm a one-shot listener that retries on the first pointer/key event. Harmless on the // active page (there a gesture already preceded start(), so play() succeeds and this // never arms). function armGesture() { if (armed) return; armed = true; var h = function () { document.removeEventListener("pointerdown", h, true); document.removeEventListener("keydown", h, true); armed = false; if (state === "playing" && !inClip) playNext(); }; document.addEventListener("pointerdown", h, true); document.addEventListener("keydown", h, true); } function shuffle(a) { a = a.slice(); for (var i = a.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = a[i]; a[i] = a[j]; a[j] = t; } return a; } function fadeVol(target, ms, done) { if (!el) { if (done) done(); return; } if (fadeIv) { clearInterval(fadeIv); fadeIv = null; } var steps = Math.max(1, Math.round(ms / STEP_MS)); var start = el.volume, delta = (target - start) / steps, n = 0; fadeIv = setInterval(function () { n++; el.volume = Math.min(1, Math.max(0, start + delta * n)); if (n >= steps) { clearInterval(fadeIv); fadeIv = null; el.volume = Math.min(1, Math.max(0, target)); if (done) done(); } }, STEP_MS); } function nextUrl() { if (!queue.length) queue = shuffle(clips.map(function (c) { return c.url; })); return queue.shift(); } // Merge newly-arrived clips (by url) into the bank; personal ones are slotted near the // FRONT of the queue so a whisper written for this very round is heard soon, not after // a whole pass of the generic bank. function mergeClips(data) { if (!Array.isArray(data)) return; var have = {}; clips.forEach(function (c) { have[c.url] = true; }); data.forEach(function (d) { if (!d || !d.url || have[d.url]) return; have[d.url] = true; clips.push({ url: d.url, role: d.role }); var at = d.personal ? Math.floor(Math.random() * 2) : queue.length; queue.splice(at, 0, d.url); }); } function maybeRefresh() { var ms = refreshMs(); if (!ms || Date.now() - lastFetch < ms) return; lastFetch = Date.now(); fetch(manifestUrl(), { cache: "no-store" }) .then(function (r) { return r.json(); }) .then(mergeClips) .catch(noop); } function playNext() { if (state !== "playing") return; inClip = true; el.src = nextUrl(); el.volume = 0; el.play().then(function () { fadeVol(VOLUME, FADEIN_MS); }) .catch(function () { inClip = false; armGesture(); }); } function onEnded() { inClip = false; if (state !== "playing") return; maybeRefresh(); var gap = GAP_MIN + Math.floor(Math.random() * (GAP_MAX - GAP_MIN)); gapTimer = setTimeout(playNext, gap); } function ensureEl() { if (el) return; el = new Audio(); el.preload = "auto"; el.volume = 0; el.addEventListener("ended", onEnded); // Duck the music under each spoken clip, for exactly the clip's length. This is why the // antechamber can have music at all: the wait loop is her VOICE, and a bed that stayed at // level through it would put two things in the same register. Ducked, the music instead // fills the deliberate GAPS between clips, which is what the loop was always missing. if (window.HypnoMusic) HypnoMusic.attachVoice(el); } // Music under the antechamber. Started here rather than left to bed.js so a page that runs // the wait loop WITHOUT the ambient bed still gets one; where both run (the session page // starts them in the same click) HypnoMusic is a singleton, so this is the one that builds // the engine and bed.js only re-levels it. function startMusic() { if (!window.HypnoMusic) return; HypnoMusic.start({ mood: window.BED_MUSIC_MOOD || "trance", volume: MUSIC_VOL }); } function bar(show) { var b = document.getElementById("antechamber-bar"); if (b) b.style.display = show ? "flex" : "none"; } function syncBtn() { var t = document.getElementById("ante-toggle"); if (t) t.textContent = (state === "paused") ? "▶ Resume loop" : "❚❚ Pause loop"; } window.HypnoLoop = { start: function () { if (state === "playing" || state === "loading") return; state = "loading"; ensureEl(); // Before the manifest resolves, and regardless of whether it has clips: an install with // no loop bank rendered should still not be a silent room while the synth runs. startMusic(); lastFetch = Date.now(); fetch(manifestUrl(), { cache: "no-store" }) .then(function (r) { return r.json(); }) .then(function (data) { if (!Array.isArray(data) || !data.length) { // An empty bank now may fill in a moment (whispers still rendering): keep // trying on the refresh cadence when the page asked for one. state = "idle"; if (refreshMs()) setTimeout(function () { if (state === "idle") window.HypnoLoop.start(); }, refreshMs()); return; } clips = data.map(function (d) { return { url: d.url, role: d.role }; }); queue = shuffle(clips.map(function (c) { return c.url; })); // Open with a random opener if the bank has one, then the shuffled rest. var openers = clips.filter(function (c) { return c.role === "opener"; }); if (openers.length) { var o = openers[Math.floor(Math.random() * openers.length)].url; queue = [o].concat(queue.filter(function (u) { return u !== o; })); } state = "playing"; bar(true); syncBtn(); playNext(); }) .catch(function () { state = "idle"; }); }, toggle: function () { if (state === "playing") { state = "paused"; if (gapTimer) { clearTimeout(gapTimer); gapTimer = null; } try { el.pause(); } catch (e) {} } else if (state === "paused") { state = "playing"; // Resume mid-clip if one was sounding; otherwise we were in a gap -> start the next. if (inClip && el.src && !el.ended) { try { el.play(); } catch (e) {} } else { playNext(); } } syncBtn(); }, fadeOutAndHide: function () { if (state === "idle" || state === "stopped") { bar(false); return; } state = "stopped"; if (gapTimer) { clearTimeout(gapTimer); gapTimer = null; } fadeVol(0, END_FADE_MS, function () { try { el.pause(); } catch (e) {} }); setTimeout(function () { bar(false); }, END_FADE_MS + 50); } }; })();