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.