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
}