feat(audio): client — split Content into Visual+Audio controls + audio crossfade layer

- index.html: Content <select> -> Visual toggle + Audio source selector; A/B <audio>
- app.js: controls() emits visual+audio; update() posts altitude_index and reads
  render.video/render.audio; A/B gain-crossfade applyAudio(); one-time tap-to-start
  gesture (autoplay policy); mediaUrl() passes absolute /media urls through
- style.css: #start-gesture overlay
- smoke: page serves controls + audio elements; soundtrack url couples to altitude;
  noise + soundtrack media serve 200

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-26 07:48:03 -07:00
parent 29c6d822bd
commit 68f9d7342a
3 changed files with 97 additions and 17 deletions
+75 -8
View File
@@ -96,7 +96,10 @@ let mediaVersions = {}; // file -> content-hash token (from /api/media-version
// changes with its bytes (permanent cache-bust). The blob cache, when present,
// wins — those bytes are already the current ones for this session.
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) { return mediaBlobs[file] || mediaNetUrl(file); }
function mediaUrl(file) {
if (file.startsWith("/media/")) return file; // already a resolved absolute url (audio layer)
return mediaBlobs[file] || mediaNetUrl(file);
}
// Every media file the ring can show: each scale-pool clip's base footage plus the
// per-edge transition clips. This is the full preload set (~two dozen files).
@@ -492,12 +495,14 @@ function renderScaleReadout() {
}
function controls() {
// One bipolar Mood dial (-4 dark .. 0 neutral .. +4 light) maps onto the engine's
// two poles. The calibration gains are pinned to their locked 1.0 (full dial = full
// effect), so the simulator sends no calibration — the server uses DEFAULT_CALIBRATION.
// Visual (on/off) and Audio (off/soundtrack/white_noise) are orthogonal dials
// (audio spec §2). One bipolar Mood dial (-4 dark .. +4 light) maps onto the
// engine's two poles. Calibration gains are pinned to 1.0, so the simulator
// sends no calibration — the server uses DEFAULT_CALIBRATION.
const mood = +$("mood").value;
return {
content: $("content").value,
visual: $("visual").checked ? "on" : "off",
audio: $("audio").value,
left: +$("left").value, right: +$("right").value,
dark: mood < 0 ? -mood : 0, light: mood > 0 ? mood : 0,
volume: 2, brightness: 2,
@@ -509,12 +514,13 @@ async function update() {
if (busy) return;
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls() }),
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
readout.textContent = JSON.stringify(data, null, 2);
if (!data.content.video) { black.style.opacity = "1"; black.classList.remove("hidden"); return; }
applyAudio(data.render.audio); // reconcile the audio layer
if (!data.render.video.shown) { black.style.opacity = "1"; black.classList.remove("hidden"); return; }
black.classList.add("hidden");
try {
ensureClipMedia();
@@ -894,6 +900,66 @@ function devLiveReload() {
}, 1000);
}
// --- Audio layer: two <audio> elements, gain-crossfaded (audio spec §7/§8) ---
const audA = $("audA"), audB = $("audB");
let audActive = audA, audIdle = audB; // which element is currently audible
let audUrl = null; // the url currently playing (null = silence)
let audioReady = false; // unlocked after the first user gesture (autoplay policy)
const XFADE_MS = 600; // ~0.6s gain crossfade — "live", no busy ack (§7)
function fadeVolume(el, to, ms) {
const from = el.volume, t0 = performance.now();
return new Promise((resolve) => {
function tick(now) {
const k = Math.min(1, (now - t0) / ms);
el.volume = from + (to - from) * k;
if (k < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
}
// Reconcile the audio layer to render.audio: crossfade the active element to the
// new url (or fade to silence for off). White-noise and soundtrack are the same
// mechanism — only the url differs; an unchanged url is a no-op so altitude
// re-rolls on the same scale don't restart the bed.
async function applyAudio(audio) {
const url = audio ? audio.url : null;
if (url === audUrl) return;
audUrl = url;
if (!audioReady) return; // deferred until the start gesture (§8)
if (!url) { // fade current out to silence
await fadeVolume(audActive, 0, XFADE_MS);
audActive.pause();
return;
}
audIdle.src = mediaUrl(url); // absolute /media/... passes through mediaUrl
audIdle.volume = 0;
await audIdle.play().catch(() => {});
await Promise.all([fadeVolume(audIdle, 1, XFADE_MS), fadeVolume(audActive, 0, XFADE_MS)]);
audActive.pause();
[audActive, audIdle] = [audIdle, audActive]; // swap roles
}
// Browsers block autoplay until a user gesture. The renderer is launched
// full-screen by the operator; a one-time tap unlocks audio, then it follows
// state (audio spec §8 — the documented launch step).
function showStartGesture() {
const ov = document.createElement("div");
ov.id = "start-gesture";
ov.textContent = "▶ tap to start sound";
ov.addEventListener("click", async () => {
audioReady = true;
// prime both elements within the gesture so later play() calls are allowed
for (const el of [audA, audB]) { try { await el.play(); el.pause(); } catch (_) {} }
ov.remove();
audUrl = null; // force the next applyAudio to (re)load
update(); // re-apply current state, now with audio
}, { once: true });
document.body.appendChild(ov);
}
async function main() {
devLiveReload();
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
@@ -903,9 +969,10 @@ async function main() {
buildDial(); // draw the altitude knob from the ring's scales
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
renderScaleReadout();
for (const id of ["content", "left", "right", "mood"]) {
for (const id of ["visual", "audio", "left", "right", "mood"]) {
$(id).addEventListener("input", debounced);
}
showStartGesture(); // one-time tap unlocks audio (browser autoplay policy, §8)
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
dial.addEventListener("pointerdown", onDialDown);
window.addEventListener("pointermove", onDialMove);
+12 -9
View File
@@ -12,6 +12,8 @@
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<audio id="audA" loop preload="auto"></audio>
<audio id="audB" loop preload="auto"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
@@ -22,15 +24,16 @@
<section class="panel">
<fieldset>
<legend>Content dial</legend>
<select id="content">
<option value="video">video</option>
<option value="audio_video">audio + video</option>
<option value="music_video">music + video</option>
<option value="off">off (black)</option>
<option value="white_noise">white noise (no video)</option>
<option value="music">music (no video)</option>
<option value="audio_track">audio track (no video)</option>
<legend>Visual</legend>
<label class="toggle"><input type="checkbox" id="visual" checked /> show video</label>
</fieldset>
<fieldset>
<legend>Audio</legend>
<select id="audio">
<option value="off">off (silence)</option>
<option value="soundtrack">soundtrack (follows altitude)</option>
<option value="white_noise">white noise</option>
</select>
</fieldset>
+10
View File
@@ -107,3 +107,13 @@ input[type=range], select { width: 100%; }
.dev-anno .anno-key.measure { color: #ffd79a; }
.dev-anno .anno-meta { color: #678; }
.dev-anno .anno-empty { color: #567; font-style: italic; }
/* One-time tap-to-start overlay unlocks audio under the browser autoplay
policy (audio spec §8). Removed after the first gesture. */
#start-gesture {
position: fixed; inset: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.82); color: #fff;
font: 600 28px/1.2 system-ui, sans-serif; letter-spacing: 0.02em;
cursor: pointer; user-select: none;
}