Commit 66318b6e by PLN (Algolia)

foundry: the audition loop belongs in the audio thread, not on a 4 Hz poll

The loops panel looped an <audio> element from a `timeupdate` listener:
`if (currentTime >= end) currentTime = start`. `timeupdate` fires at most
about every 250 ms, and analyze_chops offers slices of 0.25-2.0 s
(engine/loops.py:322) — the poll interval was the same length as the thing
being looped. Playback overshot `end` by up to a whole region and only
snapped back on the next tick, and every snap was a seek on a streamed
element, which rebuffers and gaps the seam. Audible result: a stutter at
4 Hz, reported as "play/pause toggle every 200ms". It was.

No poll rate fixes a 250 ms region, so the fix is a deletion. An
AudioBufferSourceNode with loop/loopStart/loopEnd loops in the audio thread:
sample-accurate, gapless, no polling and no seeking, correct for a chop and
for a 30 s loop alike. The AudioBuffer is already decoded because drawWave
needs it, so audition is now instant after the waveform appears, and the
context is resumed on the click that needs it.

tests/test_audition_loop.py is a mechanical guard on the served page: three
of its four assertions fail against the previous version, verified.
parent c6d298eb
"""The audition loop must live in the audio thread, not on a `timeupdate` poll.
THE BUG THIS TEST GUARDS (2026-09-25, mid-rehearsal)
PLN: *"i hear a chopped thing as if play/pause toggle every 200ms?"*
The loops panel looped an `<audio>` element from a `timeupdate` listener:
loopAud.addEventListener("timeupdate", () => {
if (loopRegion && loopAud.currentTime >= loopRegion.end)
loopAud.currentTime = loopRegion.start; });
`timeupdate` fires at most about every 250 ms. `analyze_chops` offers slices
from `min_len_s` up, and those are SUB-SECOND — so the poll interval was the
same length as the thing being looped. Playback overshot `end` by up to a whole
region and only snapped back on the next tick, and each snap was a seek on a
streamed element, which rebuffers and gaps the seam. A 4 Hz stutter, exactly as
described.
There is no poll rate that fixes this, which is why the fix is a deletion: an
`AudioBufferSourceNode` with `loop`/`loopStart`/`loopEnd` loops in the audio
thread, sample-accurate and gapless at any region length.
These are TEXT assertions on the served page, which is the honest thing this
suite can check without a browser: the page is static, hand-written, and served
by `server.py` with `read_bytes()` per request. The point is that the polled
shape cannot come back unnoticed.
cd tools/foundry && python3 -m pytest tests/test_audition_loop.py -q
"""
import re
from pathlib import Path
FOUNDRY = Path(__file__).resolve().parent.parent
UI = (FOUNDRY / "ui" / "index.html").read_text()
# Comments quote the old shape on purpose (that is the scar), so the assertions
# below read CODE only.
CODE = re.sub(r"//[^\n]*", "", UI)
def test_no_loop_is_driven_from_a_timeupdate_listener():
assert "timeupdate" not in CODE, (
"a media-element `timeupdate` fires at ~4 Hz; a sub-second chop cannot "
"be looped by polling it"
)
def test_the_audition_loops_in_the_audio_thread():
for prop in ("loop=true", "loopStart", "loopEnd", "createBufferSource"):
assert prop in CODE, f"the Web Audio loop lost `{prop}`"
def test_nothing_seeks_a_streamed_element_to_loop():
"""`currentTime = start` was the seam gap. No `currentTime` writes at all."""
assert not re.search(r"currentTime\s*=", CODE), (
"a seek on a streamed <audio> rebuffers — that was half the audible glitch"
)
def test_a_chop_is_shorter_than_a_timeupdate_tick():
"""The REASON, asserted rather than remembered.
If chops ever became multi-second the old design would merely have been
sloppy instead of broken, and this test would be worth re-reading.
"""
src = (FOUNDRY / "engine" / "loops.py").read_text()
m = re.search(r"min_len_s\s*=\s*([\d.]+)", src)
assert m, "analyze_chops lost its min_len_s default"
assert float(m.group(1)) < 1.0, (
"chops are sub-second, which is why a 4 Hz poll could never loop them"
)
......@@ -217,10 +217,32 @@ async function render(){
const ROLECOL={drums:"#ff8c00",bass:"#7c5cff",other:"#36c5f0",vocals:"#ff3d7b"};
const AC=new (window.AudioContext||window.webkitAudioContext)();
const BUF={}; // decoded AudioBuffer cache, keyed slug/name
let loopAud=new Audio(); loopAud.preload="auto";
let loopRegion=null, loopBtn=null;
loopAud.addEventListener("timeupdate",()=>{
if(loopRegion && loopAud.currentTime>=loopRegion.end) loopAud.currentTime=loopRegion.start; });
// AUDITION LOOPS IN THE AUDIO THREAD, NOT ON A `timeupdate` POLL (2026-09-25)
// This used to be an <audio> element whose loop was enforced from a listener:
// loopAud.addEventListener("timeupdate", () => {
// if (loopRegion && loopAud.currentTime >= loopRegion.end)
// loopAud.currentTime = loopRegion.start; });
// `timeupdate` fires AT MOST about every 250 ms, and `analyze_chops` offers
// slices of 0.25 s to 2.0 s (engine/loops.py:322) — so the poll interval was
// the same length as the thing being looped. Playback ran past `end` and only
// snapped back on the next tick, and every snap was a SEEK on a streamed
// element, which rebuffers and leaves a gap at the seam. PLN, mid-rehearsal:
// "i hear a chopped thing as if play/pause toggle every 200ms". It was, at 4 Hz.
//
// An AudioBufferSourceNode loops in the audio thread between `loopStart` and
// `loopEnd`: sample-accurate, gapless, no polling and no seeking, and correct
// for a 250 ms chop and a 30 s loop alike. The buffer is already decoded —
// drawWave needs it — so this costs nothing and DELETES the listener rather
// than tuning it. There is no poll rate that fixes a 250 ms region.
let loopSrc=null, loopBtn=null;
// Stop whatever is looping and un-light its button. Idempotent: called on the
// stop press, on a switch to another stem, and on a decode failure.
function loopStop(){
if(loopSrc){ try{ loopSrc.stop(); }catch(_){} try{ loopSrc.disconnect(); }catch(_){} loopSrc=null; }
if(loopBtn){ loopBtn.classList.remove("on"); loopBtn=null; }
}
async function findLoops(slug, btn){
btn.disabled=true; btn.textContent="finding…";
......@@ -319,13 +341,26 @@ function drawWave(cv, buf, s, e, color){
ctx.fillRect(x,y1,1,Math.max(1,y2-y1)); }
}
function audition(slug, name, s, e, btn){
if(loopBtn===btn){ loopAud.pause(); btn.classList.remove("on"); loopBtn=null; loopRegion=null; return; }
if(loopBtn) loopBtn.classList.remove("on");
loopRegion={start:s,end:e}; loopBtn=btn; btn.classList.add("on");
loopAud.src=`/media/${slug}/stems/${name}.wav`;
loopAud.onloadedmetadata=()=>{ try{loopAud.currentTime=s;}catch(_){}};
loopAud.play().catch(()=>{});
async function audition(slug, name, s, e, btn){
const wasMine = (loopBtn===btn);
loopStop();
if(wasMine) return; // second press on the same stem = stop
btn.classList.add("on"); loopBtn=btn;
let buf;
try{ buf=await decodeStem(slug, name); }
catch(_){ loopStop(); return toast(`cannot decode ${name}`,true); }
if(loopBtn!==btn) return; // another stem was pressed while decoding
// An AudioContext built at page load starts suspended until a user gesture.
if(AC.state==="suspended"){ try{ await AC.resume(); }catch(_){} }
if(loopBtn!==btn) return;
const dur=buf.duration;
const a=Math.min(Math.max(0,s), Math.max(0,dur-0.01));
const b=Math.min(dur, Math.max(a+0.01, e));
const src=AC.createBufferSource();
src.buffer=buf; src.loop=true; src.loopStart=a; src.loopEnd=b;
src.connect(AC.destination);
src.start(0, a);
loopSrc=src;
}
async function forge(slug, take, panel, btn){
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment