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:
+75
-8
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user