Files
feuerwerk/tests/viewport/fakeStream.ts
T
adminandClaude Opus 5.5 2764919bb8 Stream long sounds from an Opus copy instead of decoding them (#349)
A sound was decoded whole as soon as the project opened, in the editor
and in a released game: five minutes of stereo held some 115 MB for as
long as the page was open. A sound with Stream on now plays from a copy
as Opus in Ogg, fetched a piece at a time and decoded a few seconds
ahead by WebCodecs, so a game holds a few megabytes for it.

- The Stream switch is a row of the sound, off by default. The Sound
  tab suggests it from a minute on and says how far the copy has got.
- StreamKeeper makes the copies in the browser, a moment after the
  last change, one at a time: the sound's own and one per clip of its
  cut, fades and Reverse baked in. A copy that is no longer wanted is
  taken off and deleted once the project is saved without it.
- streamTable says which played names stream from which copy. A clip
  without a fitting copy, a Sample and a Reverb impulse stay decoded.
  A sound with Stream on is never decoded ahead.
- The page fetches by Range and takes the whole file from a server
  that ignores it, as the CI's PHP server does. It keeps the bytes
  and the first two seconds decoded, the rest goes four seconds ahead.
- A jump decodes 32 packets before the place first. Measured in
  Chrome, what follows is then the same to the bit as decoding from
  the start. With 4 packets it was 0.06 off.
- The stream-player on the FX worklet reads the samples at the rate
  of the context along a Catmull-Rom curve. What comes late is silent
  and skipped once it is there, so a clip keeps its beat.
- The opening stretch goes with the player as it is made. Sent by
  message it came a few milliseconds late, and 557 samples of the
  attack were lost.
- Pitch on a streamed sound is a shifter after the player, as on a
  bus: the length stays and it is heard 42.7 ms late.
- Bakes of patterns and songs (#340) always stream now.
- The song timeline draws a streamed sound from its copy decoded at
  8 kHz, keeping only the peaks.

Tests:
- Gate: CHECK EXIT 0, 280 files, 4312 tests (31 new: ogg +3,
  stream-queue 6, streams 11, streaming 6, sound-library +5).
- e2e 34: ALL PASSED, 106 checks (7 new). Five minutes of mono,
  4.8 MB as WAV, 3.6 MB as its copy: streamed the renderer grows by
  5.8 MB, decoded by 69.6 MB. Offline at 48 kHz, start, loop seam and
  a reversed clip are the copy decoded whole to the sample (0).
- Counter-checks red as they should be: no pre-roll, no catch-up of
  late samples at the end, no opening stretch, a streamed sound
  decoded ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:27:32 +02:00

106 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What streaming (#349) needs of a browser, stubbed: a copy as Opus in Ogg served with or without
* `Range`, and an `AudioDecoder` whose every sample is where it lies in the stream – so a check sees
* to the sample whether what is played is what it should be.
*/
import { vi } from 'vitest';
import { oggOpus } from '../../src/core/music/ogg';
export const PRE_SKIP = 312;
/** Bytes of each packet: big enough that a copy of a few seconds comes in several pieces. */
const PACKET_BYTES = 4000;
/** A copy of `seconds` seconds: CELT packets of 20 ms. */
export function fakeCopy(seconds: number, channels = 2): { bytes: Uint8Array; frames: number } {
const count = Math.ceil((seconds * 48000 + PRE_SKIP) / 960);
const packets = Array.from({ length: count }, () => {
const data = new Uint8Array(PACKET_BYTES);
data[0] = 31 << 3;
return { data, samples: 960 };
});
const frames = Math.round(seconds * 48000);
return { bytes: oggOpus({ channels, preSkip: PRE_SKIP, frames, packets }), frames };
}
export interface FakeServer {
/** Every request, as the range it asked for ('' for the whole file). */
asked: string[];
}
/** Serves these files by URL ending; `ranges` false answers every request with the whole file. */
export function serveCopies(files: Record<string, Uint8Array>, ranges = true): FakeServer {
const server: FakeServer = { asked: [] };
vi.stubGlobal('fetch', (url: string, init?: { headers?: Record<string, string> }) => {
const name = Object.keys(files).find((file) => url.endsWith(file));
if (!name) return Promise.resolve({ ok: false, status: 404 });
const bytes = files[name]!;
const range = init?.headers?.Range ?? '';
server.asked.push(range);
const [from, to] = range ? range.replace('bytes=', '').split('-').map(Number) : [0, bytes.length - 1];
if (!ranges || !range) return Promise.resolve(answer(200, bytes, null));
if (from! >= bytes.length) return Promise.resolve(answer(416, new Uint8Array(0), null));
const part = bytes.slice(from, Math.min(bytes.length, to! + 1));
return Promise.resolve(answer(206, part, `bytes ${from}-${from! + part.length - 1}/${bytes.length}`));
});
return server;
}
function answer(status: number, bytes: Uint8Array, contentRange: string | null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => (name.toLowerCase() === 'content-range' ? contentRange : null) },
arrayBuffer: () => Promise.resolve(bytes.slice().buffer),
};
}
/** Every decoder made, with the timestamps of what it was given and whether it was closed. */
export const decoders: { fed: number[]; closed: boolean; channels: number }[] = [];
/** An `AudioDecoder` that gives back, for each packet, 960 samples counting up from where it starts. */
export function stubDecoder(): void {
decoders.length = 0;
vi.stubGlobal(
'EncodedAudioChunk',
class {
readonly timestamp: number;
constructor(init: { timestamp: number }) {
this.timestamp = init.timestamp;
}
},
);
vi.stubGlobal(
'AudioDecoder',
class {
state = 'unconfigured';
private readonly made = { fed: [] as number[], closed: false, channels: 0 };
constructor(private readonly init: { output: (data: unknown) => void }) {
decoders.push(this.made);
}
configure(config: { numberOfChannels: number }) {
this.state = 'configured';
this.made.channels = config.numberOfChannels;
}
decode(chunk: { timestamp: number }) {
const at = Math.round((chunk.timestamp * 48000) / 1e6);
this.made.fed.push(at);
queueMicrotask(() => {
if (this.made.closed) return;
this.init.output({
numberOfFrames: 960,
numberOfChannels: this.made.channels,
copyTo: (into: Float32Array, { planeIndex }: { planeIndex: number }) => {
for (let i = 0; i < into.length; i++) into[i] = at + i + planeIndex * 1e6;
},
close: () => {},
});
});
}
close() {
this.state = 'closed';
this.made.closed = true;
}
},
);
}