From 3f7eb0314368a26382f5b1e0989f5d1d45288437 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 19:01:00 +0200 Subject: [PATCH] Check what a streamed sound holds by the library's own count (#378) The e2e check that a streamed sound costs less than a decoded one compared how much the renderer process grew. Alone, module 34 saw it grow by 1 to 8 MB while streaming. After the 33 modules before it in CI it grew by 25 to 66 MB, and suite went red (CI tasks 611, 614 and 617, the last with 66.7 MB streamed against 95.9 MB decoded). - SoundLibrary.stats tells decodedBytes, the samples of every file decoded whole, and streaming.held, every head of a copy and what is sent ahead to the worklet. - StreamFile.headBytes and StreamFeed.ahead give their share. - The e2e check now asks the library: streamed it holds less than 4 MB more than before and nothing decoded whole, decoded it holds more than 40 MB, the five minutes being 50 MB at 44.1 kHz. - The growth of the renderer stays in the log of that check. The counter-proof that the renderer grows by more than 40 MB when the sound is decoded stays as it was. Tests: - Unit sound-library: the streaming test also checks the bytes held. - e2e 34 (melektaus-atx): 136 checks. Streamed 0.76 MB more held and none decoded whole, decoded 50.5 MB. The renderer grew by 4 MB and 49 MB. - Gate (melektaus-atx): 288 files, 4416 tests. Co-Authored-By: Claude Opus 5.5 --- src/viewport/SoundLibrary.ts | 14 +++++++++++-- src/viewport/streaming.ts | 10 +++++++++ tests/viewport/sound-library.test.ts | 8 ++++++- tools/e2e/34-sound.mjs | 31 +++++++++++++++++++++------- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 1800ce0d..a8baabfe 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -1574,6 +1574,8 @@ export class SoundLibrary implements AudioSink { tuned: number; tunedAt: number | null; decoded: number; + /** Bytes of samples in the files decoded whole: what a long sound costs when it does not stream. */ + decodedBytes: number; failed: number; /** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */ worklet: boolean | null; @@ -1582,9 +1584,10 @@ export class SoundLibrary implements AudioSink { last: LastVoice | null; /** * Streaming (#349): sources playing from a copy now, samples that came too late to be heard, the - * bytes fetched of every copy, and the copies whose head is decoded. + * bytes fetched of every copy, the copies whose head is decoded, and the bytes of samples held for + * streaming: every head and what is sent ahead to the worklet. */ - streaming: { playing: number; skipped: number; fetched: number; heads: number }; + streaming: { playing: number; skipped: number; fetched: number; heads: number; held: number }; } { const files = [...this.streamFiles.values()]; return { @@ -1594,6 +1597,10 @@ export class SoundLibrary implements AudioSink { tuned: this.tuned, tunedAt: this.tunedAt, decoded: this.buffers.size, + decodedBytes: [...this.buffers.values()].reduce( + (sum, buffer) => sum + buffer.length * buffer.numberOfChannels * Float32Array.BYTES_PER_ELEMENT, + 0, + ), failed: this.failed.size, worklet: this.hasWorklet, vocoders: this.voices.reduce((sum, voice) => sum + vocodersOf(voice), 0), @@ -1603,6 +1610,9 @@ export class SoundLibrary implements AudioSink { skipped: [...this.feeds].reduce((sum, feed) => sum + feed.skipped, this.skippedBefore), fetched: files.reduce((sum, file) => sum + file.bytes, 0), heads: files.filter((file) => file.head !== null).length, + held: + files.reduce((sum, file) => sum + file.headBytes, 0) + + [...this.feeds].reduce((sum, feed) => sum + feed.ahead, 0), }, }; } diff --git a/src/viewport/streaming.ts b/src/viewport/streaming.ts index 20376a0c..a3d60c24 100644 --- a/src/viewport/streaming.ts +++ b/src/viewport/streaming.ts @@ -50,6 +50,11 @@ export class StreamFile { return this.fetched; } + /** Bytes of its head decoded, 0 before (test hook). */ + get headBytes(): number { + return (this.head ?? []).reduce((sum, side) => sum + side.byteLength, 0); + } + /** Its head fetched and decoded; false where it cannot be had. */ ready(): Promise { return (this.heading ??= (async () => { @@ -324,6 +329,11 @@ export class StreamFeed { this.node.disconnect(); } + /** Bytes of samples sent and not yet read by the worklet, at most `LEAD` of them (test hook). */ + get ahead(): number { + return Math.max(0, this.sent - this.used) * this.file.channels * Float32Array.BYTES_PER_ELEMENT; + } + /** Whether it is decoding or sending right now: what an offline clock waits for (test hook). */ get busy(): boolean { return this.pumping; diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index debf86fc..f4b2af5f 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -2079,10 +2079,16 @@ describe('a sound streamed (#349)', () => { expect(wires).toEqual(['stream > gain', ...INTO_MASTER]); expect(decodes).toHaveLength(0); expect(library.stats()).toMatchObject({ decoded: 0, playing: 1, streaming: { playing: 1, heads: 2 } }); + // Held: two heads of 2 s in stereo, and at most 4 s sent ahead to the worklet. Nothing decoded whole. + const head = 96000 * 2 * 4; + const playing = library.stats(); + expect(playing.decodedBytes).toBe(0); + expect(playing.streaming.held).toBeGreaterThan(2 * head); + expect(playing.streaming.held).toBeLessThanOrEqual(2 * head + 192000 * 2 * 4); library.stopVoice(voice); clockTo(); expect(player().posted.at(-1)).toBe('stop'); - expect(library.stats()).toMatchObject({ playing: 0, streaming: { playing: 0 } }); + expect(library.stats()).toMatchObject({ playing: 0, streaming: { playing: 0, held: 2 * head } }); }); it('moves its pitch with a shifter of what it plays, as a bus does', async () => { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 85ee1e8d..26f8d835 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -3518,7 +3518,8 @@ export async function sound(t) { JSON.stringify({ long, copies, streamed }), ); - const decodedBefore = (await stats()).decoded; + const statsBefore = await stats(); + const decodedBefore = statsBefore.decoded; const restStreamed = await restingMb(); const renderersBefore = renderers(); await play(); @@ -3533,6 +3534,7 @@ export async function sound(t) { // After a garbage collection as at rest: play mode makes garbage of its own (#378, CI task 614). const playingStreamed = await restingMb(); const renderersPlaying = renderers(); + const heldStreamed = await stats(); await t.clickToolbar('pause'); await sleep(500); const paused = { clock: await json('__gm.sound.clock()'), state: await json('__gm.sound.clockState()') }; @@ -3582,13 +3584,28 @@ export async function sound(t) { (await json("__gm.streams.copies('sn-long')"))?.sound === null, JSON.stringify({ restDecoded, playingDecoded, decodedNow }), ); - // What play mode makes itself is in both, so it is the difference that tells (#378, CI task 614). - const grownStreamed = playingStreamed - restStreamed; - const grownDecoded = playingDecoded - restDecoded; + // What the sound library holds of it, counted by the library: streamed at most 4 s sent ahead on top + // of what it held before (its head among the heads of every copy), decoded all five minutes: 50 MB + // at 44.1 kHz. + // Not the growth of the renderer: after the modules before this one it grew by 25 to 66 MB while + // streaming, alone by 1 to 8 (#378, CI tasks 611 to 617). That growth stays in the log. + const mb = (bytes) => bytes / 2 ** 20; + const kept = { + before: mb(statsBefore.streaming.held), + streamed: mb(heldStreamed.streaming.held - statsBefore.streaming.held), + streamedWhole: mb(heldStreamed.decodedBytes - statsBefore.decodedBytes), + decodedWhole: mb((decodedNow?.decodedBytes ?? 0) - statsBefore.decodedBytes), + }; check( - 'streamed, the renderer grows by at least 30 MB less than decoded', - grownStreamed + 30 < grownDecoded, - JSON.stringify({ grownStreamed, grownDecoded, renderersBefore, renderersPlaying }), + 'streamed, the page holds a few seconds of its samples and none decoded whole, decoded all five minutes', + kept.streamed > 0 && kept.streamed < 4 && kept.streamedWhole === 0 && kept.decodedWhole > 40, + JSON.stringify({ + kept, + grownStreamed: playingStreamed - restStreamed, + grownDecoded: playingDecoded - restDecoded, + renderersBefore, + renderersPlaying, + }), ); // The Sound tab points a long sound at the switch, and says once it streams.