From cf00f1544731a029191d5efcc89ef86f45d77c99 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 21 Oct 2026 04:32:12 +0000 Subject: [PATCH] the crackle is the room tone: it runs whenever music isn't playing (seed screen, cooking, paused) from the first tap on, and fades out the moment a track starts Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6 --- index.html | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index e865ada..15f6b35 100644 --- a/index.html +++ b/index.html @@ -636,6 +636,8 @@ function makeCrackle() { const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = 0.45; el.preload = "auto"; return el; } +/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */ +function bed() { const want = state.primed && !state.playing && !state.holding; if (want) crackleOn(); else crackleOff(); } function crackleOn() { if (crackle.on || state.holding) return; crackle.el ||= makeCrackle(); crackle.on = true; crackle.el.currentTime = 0; crackle.el.play().catch(() => {}); } function crackleOff() { if (!crackle.on) return; crackle.on = false; const el = crackle.el, t0 = el.volume; let v = t0; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); } /* 50 ms of silence as a wav, built here rather than pasted */ @@ -651,8 +653,8 @@ const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000)); const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; function prime() { if (state.primed || state.current || (state.audio.src && !state.audio.paused)) return; - try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; }).catch(() => {}); } catch {} - try { crackle.el ||= makeCrackle(); if (!crackle.on) { const v = crackle.el.volume; crackle.el.volume = 0; crackle.el.play().then(() => { if (!crackle.on) crackle.el.pause(); crackle.el.volume = v; }).catch(() => { crackle.el.volume = v; }); } } catch {} + try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; bed(); }).catch(() => {}); } catch {} + try { crackle.el ||= makeCrackle(); if (!crackle.on) crackleOn(); } catch {} } for (const ev of ["pointerdown", "touchend", "keydown"]) document.addEventListener(ev, prime, { capture: true, passive: true }); /* iOS routes sound through Web Audio the moment the element is wired to an analyser, and suspends Web Audio when @@ -1093,11 +1095,10 @@ async function playJob(job) { drawCover($("#cover canvas"), spec); if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; } $("#cover").classList.add("cooking"); $("#bar i").style.width = "0"; - if (!job.blob && state.primed) crackleOn(); // the needle is down while it cooks + bed(); // the needle is down while it cooks await job.done; if (state.current !== job) return; // skipped while cooking $("#cover").classList.remove("cooking"); - crackleOff(); if (job.err) { crackleOff(); toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; } if (state.holding && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop job.url = job.url || URL.createObjectURL(job.blob); @@ -1183,7 +1184,7 @@ async function advance() { const t = await shuffled(); if (t) { state.audio.pause(); await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve() }); return; } } - if (!(state.nextJob && state.nextJob.blob) && state.primed) crackleOn(); // nothing ready: needle down while the DJ writes and the oven runs + bed(); // nothing ready: the room tone carries while the DJ writes and the oven runs const job = state.nextJob || await cookNext(); state.nextJob = null; $("#next").textContent = ""; if (!job) { crackleOff(); return; } @@ -1223,7 +1224,8 @@ state.audio.addEventListener("ended", () => { if (state.holding) return; // crackle loop ran out with nothing waiting (loop is on, so this is rare) state.playing = false; setPlayIcon(); vizStop(); swapNow(); advance(); }); -state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } }); +state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); }); +state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } }); state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); }); state.audio.addEventListener("timeupdate", () => { if (state.audio.duration && !$("#bar").classList.contains("drag")) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; }); /* scrubbing: tap or drag along the bar; the readout shows where you'd land */