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>
106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
},
|
||
);
|
||
}
|