Commit9704717cRecorded24 Sep 2026Repositorysigil
sigil-wasm-audio: sab rate changes travel in the ring header; tests for the review's gaps
Changed
packages/sigil-wasm-audio/assets/sigil-wasm-audio.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
test/integration/test-wasm-audio-worklet-cues-rate.mjs | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 226 insertions(+), 15 deletions(-)Diff
packages/sigil-wasm-audio/assets/sigil-wasm-audio.jsmodified
@@ -63,8 +63,9 @@
63
// "play cue N now"; the processor mixes the voice into the very next render 64
// quantum (128 frames, 2.7 ms at 48 kHz), whatever any sink's ring holds. It 65
// needs AudioWorkletNode but not a SharedArrayBuffer. Without a worklet, a−66
// play starts an AudioBufferSourceNode on a buffer built once per cue, which is−67
// ring-free too.+66
// play starts an AudioBufferSourceNode on a buffer built once per cue (on its+67
// first play), which is ring-free too; that path pans with a StereoPannerNode+68
// and refuses a play past MAX_CUE_VOICES, where the worklet steals the oldest. 69
// 70
// Atomics work on both SharedArrayBuffer and plain ArrayBuffer (ES2020+), so the 71
// sab path uses a SharedArrayBuffer (shared main<->audio thread) and the other@@ -89,7 +90,12 @@
90
// [2] underruns cumulative starved output frames 91
// [3] volumeMilli per-sink gain in 0..1000 (=0.0..1.0) 92
// [4] underSounding the starved output frames that cut off sound−92
// [5..7] reserved+93
// [5] rateHead sab mode: rate changes published (producer)+94
// [6] rateTail sab mode: rate changes taken by the worklet+95
// [7] reserved+96
// [8..15] sab mode: RATE_SLOTS entries of (at, hz): a change+97
// lands when the read counter reaches its write+98
// counter `at`; slot i is at [8 + 2i], [9 + 2i] 99
// Counters are monotonic int32 and wrap after ~13.5h at 44.1kHz; all distance 100
// math is wrap-safe via `(write - read) | 0` (two's-complement), and ring 101
// indexing uses `& (capacity-1)` (capacity is always a power of two), which is@@ -99,7 +105,11 @@
105
const HDR_UNDERRUNS = 2; 106
const HDR_VOLUME = 3; 107
const HDR_UNDER_SOUNDING = 4;−102
const HEADER_INTS = 8;+108
const HDR_RATE_HEAD = 5;+109
const HDR_RATE_TAIL = 6;+110
const HDR_RATE_SLOTS = 8;+111
const RATE_SLOTS = 4;+112
const HEADER_INTS = 16; 113
const HEADER_BYTES = HEADER_INTS * 4; 114
115
// Render quanta between a worklet-msg processor's reports of its read@@ -378,7 +388,22 @@
388
} 389
const hdr = this.header; 390
st.vol = Atomics.load(hdr, ${HDR_VOLUME}) / 1000;+391
// the write counter BEFORE the rate seq: a frame this quantum can see+392
// was pushed after any change published before it, and that change+393
// is then visible to the seq load below 394
st.write = Atomics.load(hdr, ${HDR_WRITE});+395
const head = Atomics.load(hdr, ${HDR_RATE_HEAD});+396
let tail = Atomics.load(hdr, ${HDR_RATE_TAIL});+397
if (head !== tail) {+398
while (tail !== head) {+399
const slot = ${HDR_RATE_SLOTS} + 2 * (tail & ${RATE_SLOTS - 1});+400
const hz = Atomics.load(hdr, slot + 1);+401
st.rateQ.push({ at: Atomics.load(hdr, slot),+402
ratio: hz === Math.round(sampleRate) ? 1 : hz / sampleRate });+403
tail = (tail + 1) | 0;+404
}+405
Atomics.store(hdr, ${HDR_RATE_TAIL}, tail);+406
} 407
st.read = Atomics.load(hdr, ${HDR_READ}); 408
sigilDrain(st, this.ring, outL, outR, n); 409
Atomics.store(hdr, ${HDR_READ}, st.read);@@ -648,9 +673,12 @@
673
} 674
node.connect(ctx.destination); 675
sink.node = node;−651
// rate changes asked before the node existed, in order−652
for (const c of sink.pendingRates) node.port.postMessage({ type: "rate", at: c.at, ratio: c.ratio });−653
sink.pendingRates = [];+676
// msg mode: rate changes asked before the node existed, in order+677
// (sab mode's travel through the header, published or pending)+678
if (msg) {+679
for (const c of sink.pendingRates) node.port.postMessage({ type: "rate", at: c.at, ratio: c.ratio });+680
sink.pendingRates = [];+681
} 682
if (msg) { 683
// what the ring holds already (pushes before the module was ready) 684
const write = Atomics.load(sink.header, HDR_WRITE);@@ -681,7 +709,19 @@
709
function attachScriptProcessor(sink, ctx) { 710
if (!sink.st) { 711
sink.st = makeDrainState(sink.capacity, sink.channels);−684
// rate changes asked while a worklet was still expected+712
// rate changes asked while a worklet was still expected: one a sab+713
// sink already published in the header, then the pending ones+714
const hdr = sink.header;+715
const ctxRate = Math.round(ctx.sampleRate);+716
let tail = Atomics.load(hdr, HDR_RATE_TAIL);+717
const head = Atomics.load(hdr, HDR_RATE_HEAD);+718
while (tail !== head) {+719
const slot = HDR_RATE_SLOTS + 2 * (tail & (RATE_SLOTS - 1));+720
const hz = Atomics.load(hdr, slot + 1);+721
sink.st.rateQ.push({ at: Atomics.load(hdr, slot), ratio: hz === ctxRate ? 1 : hz / ctxRate });+722
tail = (tail + 1) | 0;+723
}+724
Atomics.store(hdr, HDR_RATE_TAIL, tail); 725
for (const c of sink.pendingRates) sink.st.rateQ.push(c); 726
sink.pendingRates = []; 727
}@@ -721,6 +761,7 @@
761
function sinkPush(id, ptr, nFrames, byteLen) { 762
const sink = sinks.get(id); 763
if (!sink || sink.closed || nFrames <= 0) return 0;+764
if (sink.mode === "worklet-sab" && sink.pendingRates.length > 0) publishRates(sink); 765
const ch = sink.channels; 766
const cap = sink.capacity; 767
const mask = sink.mask;@@ -768,14 +809,39 @@
809
if (!(r > 0)) return 0; 810
if (r > ctxRate) r = ctxRate; 811
const ratio = r === ctxRate ? 1 : r / ctxRate;−771
const change = { at: Atomics.load(sink.header, HDR_WRITE), ratio };+812
const change = { at: Atomics.load(sink.header, HDR_WRITE), ratio, hz: r }; 813
sink.rate = r; 814
if (sink.st) sink.st.rateQ.push(change);−774
else if (sink.node) sink.node.port.postMessage({ type: "rate", at: change.at, ratio });+815
else if (sink.mode === "worklet-sab") {+816
// through the shared header, so the change travels with the frames+817
// rather than behind them on the port (see publishRates)+818
sink.pendingRates.push(change);+819
publishRates(sink);+820
} else if (sink.node) sink.node.port.postMessage({ type: "rate", at: change.at, ratio }); 821
else sink.pendingRates.push(change); 822
return 1; 823
} 824
+825
// sab mode: publish pending rate changes into the header's queue, as many+826
// as it has room for (RATE_SLOTS not yet taken by the worklet). Called at+827
// each change and each push, so a change held back (more than RATE_SLOTS+828
// within one render quantum) is published before the frames after it are+829
// written; it lands late only if the worklet had already read past its+830
// write counter by then.+831
function publishRates(sink) {+832
const hdr = sink.header;+833
let head = Atomics.load(hdr, HDR_RATE_HEAD);+834
while (sink.pendingRates.length > 0 &&+835
((head - Atomics.load(hdr, HDR_RATE_TAIL)) | 0) < RATE_SLOTS) {+836
const c = sink.pendingRates.shift();+837
const slot = HDR_RATE_SLOTS + 2 * (head & (RATE_SLOTS - 1));+838
Atomics.store(hdr, slot, c.at);+839
Atomics.store(hdr, slot + 1, c.hz);+840
head = (head + 1) | 0;+841
Atomics.store(hdr, HDR_RATE_HEAD, head);+842
}+843
}+844
845
function sinkRate(id) { 846
const sink = sinks.get(id); 847
return sink ? sink.rate : 0;@@ -941,8 +1007,9 @@
1007
} 1008
1009
// Frames the last worklet play waited between its post and the quantum that−944
// first mixed it (the message's delay; the device's output latency is on−945
// top). -1 until a worklet play has been mixed.+1010
// first mixed it: the message's delay, plus however stale the main thread's+1011
// view of ctx.currentTime was at the post (up to a quantum or so); the+1012
// device's output latency is on top. -1 until a worklet play has been mixed. 1013
function cueLag(contextId) { 1014
const rec = contexts.get(contextId); 1015
return rec ? rec.cueLag : -1;test/integration/test-wasm-audio-worklet-cues-rate.mjsmodified
@@ -93,10 +93,12 @@ function evalWorkletModule(src) {
93
94
class FakeAudioWorklet { 95
addModule(url) {+96
if (FakeAudioWorklet.fail) return Promise.reject(new Error("addModule refused (test)")); 97
evalWorkletModule(blobRegistry.get(url)); 98
return Promise.resolve(); 99
} 100
}+101
FakeAudioWorklet.fail = false; 102
103
class FakeAudioWorkletNode { 104
constructor(ctx, name, opts) {@@ -104,10 +106,11 @@ class FakeAudioWorkletNode {
106
this.name = name; 107
this.opts = opts; 108
this.toMain = [];+109
// A real port delivers between render quanta, never inside one: the main+110
// thread's posts queue here and reach the processor at the next render.+111
this.inbox = []; 112
this.port = {−108
postMessage: (m) => {−109
if (this._proc.port.onmessage) this._proc.port.onmessage({ data: m });−110
},+113
postMessage: (m) => this.inbox.push(m), 114
onmessage: null, 115
}; 116
this._proc = new (Processors.get(name))(opts || {});@@ -122,6 +125,8 @@ class FakeAudioWorkletNode {
125
this.connected = false; 126
} 127
render(outL, outR) {+128
const inbox = this.inbox.splice(0);+129
for (const m of inbox) if (this._proc.port.onmessage) this._proc.port.onmessage({ data: m }); 130
this._proc.process([], [[outL, outR]]); 131
// deliver the processor's posts to the main thread after the quantum 132
const msgs = this.toMain.splice(0);@@ -632,11 +637,150 @@ async function testRates() {
637
} 638
} 639
+640
// ---- the review's gaps: rate before attach, fallback, a nearly empty ring,+641
// the loudness hold's boundary, voice stealing -------------------------------+642
+643
// rms of out[a, b) against a 440 Hz tone that starts at output frame a+644
function toneError(out, a, b) {+645
let err = 0;+646
for (let t = a; t < b; t++) {+647
const ideal = AMP * Math.sin((2 * Math.PI * TONE * (t - a)) / SAMPLE_RATE);+648
err += (out[t] - ideal) ** 2;+649
}+650
return Math.sqrt(err / (b - a));+651
}+652
+653
function renderL(ctx, n) {+654
const out = [];+655
for (let q = 0; q < n / QUANTUM; q++) {+656
const { L } = ctx.render(QUANTUM);+657
for (let i = 0; i < QUANTUM; i++) out.push(L[i]);+658
}+659
return out;+660
}+661
+662
let gapHeap = 900000;+663
function tonePtr(rate, frames) {+664
const ptr = heapWrite(gapHeap, frames, (i) => {+665
const v = AMP * Math.sin((2 * Math.PI * TONE * i) / rate);+666
return [v, v];+667
});+668
gapHeap += frames * 2 + 16;+669
return ptr;+670
}+671
+672
async function testReviewGaps() {+673
console.log("rate changes the review found untested");+674
for (const mode of Object.keys(MODES)) {+675
// (a) rate: asked before the worklet node exists (open-audio-stream rate:)+676
MODES[mode]();+677
let cid = glue.contextOpen();+678
let ctx = lastCtx;+679
let sid = glue.sinkOpen(cid, 2, 16384);+680
glue.sinkSetRate(sid, 24000); // before the module is ready+681
pushAll(sid, tonePtr(24000, 2400), 2400);+682
await tick();+683
await tick();+684
let out = renderL(ctx, 4096);+685
let e = toneError(out, 0, 4000);+686
check(`${mode}: a rate asked before the node attached is in force from the first frame (rms ${e.toExponential(2)})`,+687
e < 0.01 * AMP, `rms=${e}`);+688
glue.sinkClose(sid);+689
+690
// (b) a change asked when the ring is nearly empty: the frames pushed+691
// after it must not play at the old rate while the change is in flight+692
cid = glue.contextOpen();+693
ctx = lastCtx;+694
sid = glue.sinkOpen(cid, 2, 16384);+695
await tick();+696
await tick();+697
pushAll(sid, SILENCE_PTR, 64);+698
renderL(ctx, 128); // the 64 frames drain; the ring is empty+699
glue.sinkSetRate(sid, 24000);+700
pushAll(sid, tonePtr(24000, 2400), 2400);+701
out = renderL(ctx, 4096);+702
e = toneError(out, 0, 4000);+703
check(`${mode}: a change on a drained ring lands exactly (rms ${e.toExponential(2)})`, e < 0.01 * AMP, `rms=${e}`);+704
glue.sinkClose(sid);+705
}+706
+707
// (c) the worklet fails to load: the sink falls back to a ScriptProcessor+708
// and keeps the rate asked meanwhile+709
for (const mode of ["worklet-sab", "worklet-msg"]) {+710
MODES[mode]();+711
FakeAudioWorklet.fail = true;+712
const origWarn = console.warn;+713
console.warn = () => {};+714
const cid = glue.contextOpen();+715
const ctx = lastCtx;+716
const sid = glue.sinkOpen(cid, 2, 16384);+717
glue.sinkSetRate(sid, 24000);+718
pushAll(sid, tonePtr(24000, 2400), 2400);+719
await tick();+720
await tick();+721
FakeAudioWorklet.fail = false;+722
console.warn = origWarn;+723
check(`${mode} page, worklet refused: the sink fell back to scriptprocessor`, glue.sinkMode(sid) === "scriptprocessor",+724
glue.sinkMode(sid));+725
const out = renderL(ctx, 4096);+726
const e = toneError(out, 0, 4000);+727
check(`${mode} page, worklet refused: the rate asked before the fallback holds (rms ${e.toExponential(2)})`,+728
e < 0.01 * AMP, `rms=${e}`);+729
glue.sinkClose(sid);+730
}+731
+732
console.log("the loudness hold's boundary, and voice stealing");+733
const loudPtr = tonePtr(SAMPLE_RATE, 256);+734
for (const mode of Object.keys(MODES)) {+735
MODES[mode]();+736
const cid = glue.contextOpen();+737
const ctx = lastCtx;+738
const sid = glue.sinkOpen(cid, 2, 4096);+739
await tick();+740
await tick();+741
// a constant loud level, so the last loud frame is known exactly+742
const flat = heapWrite(gapHeap, 64, () => [0.1, 0.1]);+743
gapHeap += 200;+744
pushAll(sid, flat, 64);+745
pushAll(sid, SILENCE_PTR, 127);+746
ctx.render(64 + 127 + 10);+747
check(`${mode}: dry 127 quiet frames after sound is still sounding`,+748
glue.sinkUnderrunsSounding(sid) === 10, `sounding=${glue.sinkUnderrunsSounding(sid)}`);+749
pushAll(sid, flat, 64);+750
pushAll(sid, SILENCE_PTR, 128);+751
ctx.render(64 + 128 + 10);+752
check(`${mode}: dry 128 quiet frames after sound is idle`,+753
glue.sinkUnderrunsSounding(sid) === 10 && glue.sinkUnderruns(sid) === 20,+754
`all=${glue.sinkUnderruns(sid)} sounding=${glue.sinkUnderrunsSounding(sid)}`);+755
glue.sinkClose(sid);+756
void loudPtr;+757
}+758
+759
MODES["worklet-msg"]();+760
const cid = glue.contextOpen();+761
const ctx = lastCtx;+762
const aPtr = heapWrite(gapHeap, 1024, () => [0.01, 0.01]);+763
gapHeap += 2100;+764
const bPtr = heapWrite(gapHeap, 1024, () => [0.5, 0.5]);+765
gapHeap += 2100;+766
const a = glue.cueLoad(cid, aPtr, 1024, 1024 * 8, 2);+767
const b = glue.cueLoad(cid, bPtr, 1024, 1024 * 8, 2);+768
await tick();+769
await tick();+770
for (let i = 0; i < 32; i++) glue.cuePlay(cid, a, 1000, 0);+771
glue.cuePlay(cid, b, 1000, 0); // the 33rd: the oldest A ends, B plays+772
const { L } = ctx.render(QUANTUM);+773
check(`the 33rd play steals the oldest voice (31 x 0.01 + 0.5 = 0.81): ${L[0].toFixed(4)}`,+774
Math.abs(L[0] - (31 * 0.01 + 0.5)) < 1e-4, `L=${L[0]}`);+775
glue.cueStopAll(cid);+776
}+777
778
async function main() { 779
await testCueLatency(); 780
await testCueDetails(); 781
await testUnderrunSplit(); 782
await testRates();+783
await testReviewGaps(); 784
console.log(failures === 0 ? "\nwasm-audio-worklet-cues-rate-ok" : `\n${failures} check(s) failed`); 785
process.exit(failures === 0 ? 0 : 1); 786
}