A cut between two videos flashed white: the sprite switched on had no picture yet, because its player only opened then or had been dropped meanwhile. A driven video now waits muted at its time while its sprite is off, in the editor and in a run. - A disabled sprite with Video clock Driven asks for its player without drawing it. A Free video still opens when shown. - A run's draw list keeps disabled driven sprites, marked as off. Before, collectWorldItems dropped every disabled object, so the game view never held a player for them. - VideoLibrary.wear() counts as asking. The sweeper dropped a held player after 2 to 4 s and it was loading again at the cut. - The speed of the asked time is read over a window that grows to 500 ms. Over 100 ms one 1/60 s step more or less read as 17 % off, the playback rate jumped and the sound dropped out. - A stream feeds every packet it has loaded to the decoder at once and waits once. One round trip per 20 ms packet let the decoder fall behind on a busy main thread, which made e2e 34 flaky in CI. Tests: - Unit streaming, video-library, video-follow: green, 4 new - e2e 34 + 66 (atx): ALL PASSED, stream skipped 0, 66m new - e2e 66 (atx), two runs: ALL PASSED, 25 checks, 66n new - Gate (atx): CHECK EXIT 0, 297 files, 4557 tests - Counter-checks: without the draw list fix 66n finds no player, without the wear() fix and with the old speed reading the new unit tests turn red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
/**
|
|
* Streaming a sound (#349) on the page's side: the copy is fetched a piece at a time, read page by
|
|
* page, and decoded by WebCodecs a few seconds ahead of what is heard; the samples go to a
|
|
* `stream-player` on the worklet and are let go there once played. Only the compressed bytes and the
|
|
* first seconds of each copy are kept, so a long sound weighs a few megabytes rather than a hundred.
|
|
*/
|
|
import { OggOpusReader, type PlacedPacket } from '../core/music/ogg';
|
|
import { STREAM_RATE } from '../core/music/streamQueue';
|
|
import { StreamCursor, type StreamPlan } from '../core/music/streams';
|
|
|
|
/** Bytes fetched at a time, some 13 s at 160 kbit/s. */
|
|
const CHUNK_BYTES = 256 * 1024;
|
|
/** Samples of each copy decoded once it is named, so it starts at once (`StreamFile.ready`). */
|
|
const HEAD = 2 * STREAM_RATE;
|
|
/**
|
|
* Samples decoded before a place jumped to and thrown away: measured in Chrome, 32 packets of 20 ms
|
|
* make what follows the same to the bit as decoding from the start, 4 come out 0.06 off.
|
|
*/
|
|
const PREROLL = 32 * 960;
|
|
/** Samples kept sent ahead of what the worklet has read, and the most decoded at once. */
|
|
const LEAD = 4 * STREAM_RATE;
|
|
const BATCH = STREAM_RATE;
|
|
|
|
/** One copy, shared by every voice streaming it: its bytes as they come, and its first seconds decoded. */
|
|
export class StreamFile {
|
|
readonly reader = new OggOpusReader();
|
|
/** Samples per channel without the pre-skip, as the asset says. */
|
|
head: Float32Array[] | null = null;
|
|
failed = false;
|
|
private fetched = 0;
|
|
private complete = false;
|
|
private fetching: Promise<void> | null = null;
|
|
private heading: Promise<boolean> | null = null;
|
|
|
|
constructor(
|
|
readonly url: string,
|
|
readonly frames: number,
|
|
) {}
|
|
|
|
get channels(): number {
|
|
return this.reader.channels ?? 2;
|
|
}
|
|
|
|
get preSkip(): number {
|
|
return this.reader.preSkip ?? 0;
|
|
}
|
|
|
|
/** Bytes fetched so far (test hook). */
|
|
get bytes(): number {
|
|
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<boolean> {
|
|
return (this.heading ??= (async () => {
|
|
try {
|
|
this.head = await decodeSpan(this, 0, Math.min(HEAD, this.frames));
|
|
return true;
|
|
} catch (error: unknown) {
|
|
this.failed = true;
|
|
console.error(`[sounds] cannot stream ${this.url}`, error);
|
|
return false;
|
|
}
|
|
})());
|
|
}
|
|
|
|
/** Fetches until the packet holding stream sample `sample` is there, or the copy has ended. */
|
|
async reach(sample: number): Promise<void> {
|
|
while (!this.complete && this.lastEnd() <= sample) await this.more();
|
|
}
|
|
|
|
/** Every byte of the copy, fetched if it is not yet: what the picture of it is decoded from. */
|
|
async whole(): Promise<ArrayBuffer> {
|
|
const response = await fetch(this.url);
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return response.arrayBuffer();
|
|
}
|
|
|
|
private lastEnd(): number {
|
|
const last = this.reader.packets.at(-1);
|
|
return last ? last.at + last.samples : 0;
|
|
}
|
|
|
|
private more(): Promise<void> {
|
|
return (this.fetching ??= this.fetchMore().finally(() => {
|
|
this.fetching = null;
|
|
}));
|
|
}
|
|
|
|
/** The next piece by `Range`; a server that sends the whole file instead is taken at its word. */
|
|
private async fetchMore(): Promise<void> {
|
|
const response = await fetch(this.url, {
|
|
headers: { Range: `bytes=${this.fetched}-${this.fetched + CHUNK_BYTES - 1}` },
|
|
});
|
|
if (response.status === 416) {
|
|
this.complete = true;
|
|
return;
|
|
}
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
if (response.status === 200 && this.fetched > 0) throw new Error('the server sent all of it again');
|
|
this.reader.push(bytes);
|
|
this.fetched += bytes.length;
|
|
const total = Number(response.headers.get('content-range')?.split('/')[1]);
|
|
if (response.status === 200 || bytes.length === 0 || (Number.isFinite(total) && this.fetched >= total))
|
|
this.complete = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decodes stream samples on one decoder, reading on from where it stopped while it is asked for what
|
|
* follows, and settling first wherever it is asked to jump.
|
|
*/
|
|
class StreamDecoder {
|
|
private decoder: AudioDecoder | null = null;
|
|
/** The next packet to hand it, and the stream sample the next sample it gives back is at. */
|
|
private next = 0;
|
|
private outAt = 0;
|
|
/** What it has given back and not yet been asked for, from `outAt` back. */
|
|
private spare: Float32Array[] = [];
|
|
private outputs: Float32Array[][] = [];
|
|
private fed = 0;
|
|
private given = 0;
|
|
private wake: (() => void) | null = null;
|
|
private failure: Error | null = null;
|
|
|
|
constructor(private readonly file: StreamFile) {}
|
|
|
|
/** The samples of the copy from `from` to `to`, counted without its pre-skip. */
|
|
async read(from: number, to: number): Promise<Float32Array[]> {
|
|
const { file } = this;
|
|
const start = from + file.preSkip;
|
|
const end = to + file.preSkip;
|
|
const held = () => this.outAt - (this.spare[0]?.length ?? 0);
|
|
if (!this.decoder || start !== held()) await this.jump(start);
|
|
const spareFrom = held();
|
|
const parts: Float32Array[][] = [this.spare];
|
|
let have = this.outAt;
|
|
this.spare = [];
|
|
while (have < end) {
|
|
await file.reach(have);
|
|
const packets = file.reader.packets;
|
|
if (this.next >= packets.length) break;
|
|
// All of it that is fetched goes in at once: waiting on the page for each packet of 20 ms, a
|
|
// busy page decoded slower than it played and the player went short (CI tasks 638, 640, 651).
|
|
let upTo = have;
|
|
while (this.next < packets.length && upTo < end) {
|
|
const packet = packets[this.next]!;
|
|
this.feed(packet);
|
|
upTo = packet.at + packet.samples;
|
|
}
|
|
const got = await this.take();
|
|
parts.push(got);
|
|
have += got[0]?.length ?? 0;
|
|
}
|
|
this.outAt = have;
|
|
const joined = join(parts, file.channels, have - spareFrom);
|
|
const offset = start - spareFrom;
|
|
const kept = joined.map((side) => side.slice(offset, offset + (end - start)));
|
|
this.spare = joined.map((side) => side.slice(offset + (end - start)));
|
|
return kept;
|
|
}
|
|
|
|
close(): void {
|
|
if (this.decoder && this.decoder.state !== 'closed') this.decoder.close();
|
|
this.decoder = null;
|
|
}
|
|
|
|
/** A new decoder, from far enough before `sample` that it has settled there; what came before goes. */
|
|
private async jump(sample: number): Promise<void> {
|
|
this.close();
|
|
const { file } = this;
|
|
await file.reach(sample);
|
|
const packets = file.reader.packets;
|
|
let index = 0;
|
|
while (index + 1 < packets.length && packets[index + 1]!.at <= sample - PREROLL) index += 1;
|
|
this.next = index;
|
|
this.outAt = packets[index]?.at ?? 0;
|
|
this.spare = [];
|
|
this.outputs = [];
|
|
this.fed = this.given = 0;
|
|
this.failure = null;
|
|
const decoder = new AudioDecoder({
|
|
output: (data) => {
|
|
const sides = Array.from({ length: file.channels }, (_, c) => {
|
|
const side = new Float32Array(data.numberOfFrames);
|
|
data.copyTo(side, { planeIndex: Math.min(c, data.numberOfChannels - 1), format: 'f32-planar' });
|
|
return side;
|
|
});
|
|
data.close();
|
|
this.outputs.push(sides);
|
|
this.given += 1;
|
|
this.wake?.();
|
|
},
|
|
error: (error) => {
|
|
this.failure = error;
|
|
this.wake?.();
|
|
},
|
|
});
|
|
// No description: Chrome would apply the pre-skip by itself, and only to the first packet it gets.
|
|
decoder.configure({ codec: 'opus', sampleRate: STREAM_RATE, numberOfChannels: file.channels });
|
|
this.decoder = decoder;
|
|
// Every packet that ends before where it is asked for settles it and is thrown away; `read` cuts
|
|
// the rest off the one it starts in.
|
|
while (this.next < packets.length && packets[this.next]!.at + packets[this.next]!.samples <= sample)
|
|
this.feed(packets[this.next]!);
|
|
await this.take();
|
|
this.outAt = packets[this.next]?.at ?? this.outAt;
|
|
}
|
|
|
|
private feed(packet: PlacedPacket): void {
|
|
const decoder = this.decoder!;
|
|
decoder.decode(
|
|
new EncodedAudioChunk({
|
|
type: 'key',
|
|
timestamp: Math.round((packet.at * 1e6) / STREAM_RATE),
|
|
data: packet.data,
|
|
}),
|
|
);
|
|
this.next += 1;
|
|
this.fed += 1;
|
|
}
|
|
|
|
/** The samples of the packets handed over since the last call, once the decoder has given them back. */
|
|
private async take(): Promise<Float32Array[]> {
|
|
while (this.given < this.fed && !this.failure)
|
|
await new Promise<void>((resolve) => {
|
|
this.wake = resolve;
|
|
});
|
|
this.wake = null;
|
|
if (this.failure) throw this.failure;
|
|
const out = this.outputs.splice(0);
|
|
return join(
|
|
out,
|
|
this.file.channels,
|
|
out.reduce((sum, part) => sum + (part[0]?.length ?? 0), 0),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** The samples of a copy from `from` to `to` on a decoder of their own: what a head is. */
|
|
export async function decodeSpan(file: StreamFile, from: number, to: number): Promise<Float32Array[]> {
|
|
await file.reach(0);
|
|
if (file.reader.channels === null) throw new Error('no Opus in it');
|
|
const decoder = new StreamDecoder(file);
|
|
try {
|
|
return await decoder.read(from, to);
|
|
} finally {
|
|
decoder.close();
|
|
}
|
|
}
|
|
|
|
function join(parts: readonly Float32Array[][], channels: number, length: number): Float32Array[] {
|
|
return Array.from({ length: channels }, (_, c) => {
|
|
const side = new Float32Array(Math.max(0, length));
|
|
let at = 0;
|
|
for (const part of parts) {
|
|
const data = part[c] ?? part[0];
|
|
if (!data) continue;
|
|
side.set(data.subarray(0, Math.max(0, length - at)), at);
|
|
at += data.length;
|
|
}
|
|
return side;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Keeps one `stream-player` fed along its plan: the head where the plan is in it, decoded samples
|
|
* after that, never more than `LEAD` ahead of what it has read. Tells `ended` once it has played out.
|
|
*/
|
|
export class StreamFeed {
|
|
private readonly cursor: StreamCursor;
|
|
private readonly decoder: StreamDecoder;
|
|
private node: AudioWorkletNode | null = null;
|
|
private sent = 0;
|
|
private used = 0;
|
|
private pumping = false;
|
|
private stopped = false;
|
|
private finished = false;
|
|
/** Samples that came too late to be heard, as the worklet last said (test hook). */
|
|
skipped = 0;
|
|
|
|
constructor(
|
|
private readonly file: StreamFile,
|
|
plan: StreamPlan,
|
|
private readonly ended: () => void,
|
|
) {
|
|
this.cursor = new StreamCursor(plan);
|
|
this.decoder = new StreamDecoder(file);
|
|
}
|
|
|
|
/**
|
|
* What the player starts with where its plan starts in the head: handed over as it is made, so it
|
|
* is there for the first sample heard. Sent by message, it came a few milliseconds late, and a
|
|
* sound started at once lost its attack. Empty where the plan starts past the head.
|
|
*/
|
|
opening(): Float32Array[] {
|
|
const at = this.cursor.peek();
|
|
const head = this.file.head;
|
|
if (at === null || !head || at >= (head[0]?.length ?? 0)) return [];
|
|
const span = this.cursor.next(head[0]!.length - at)!;
|
|
this.sent += span.to - span.from;
|
|
return head.map((side) => side.slice(span.from, span.to));
|
|
}
|
|
|
|
/** Feeds that player from now on. */
|
|
attach(node: AudioWorkletNode): void {
|
|
this.node = node;
|
|
node.port.onmessage = (event: MessageEvent) => {
|
|
const data: unknown = event.data;
|
|
if (data === 'ended') {
|
|
this.close();
|
|
this.ended();
|
|
} else if (typeof data === 'object' && data !== null && 'used' in data) {
|
|
const report = data as { used: number; skipped: number };
|
|
this.used = report.used;
|
|
this.skipped = report.skipped;
|
|
void this.pump();
|
|
}
|
|
};
|
|
void this.pump();
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.stopped) return;
|
|
this.close();
|
|
if (!this.node) return;
|
|
this.node.port.postMessage('stop');
|
|
this.node.port.close();
|
|
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;
|
|
}
|
|
|
|
/** Read anew each time: a stop comes in while a stretch is being decoded. */
|
|
private halted(): boolean {
|
|
return this.stopped;
|
|
}
|
|
|
|
private close(): void {
|
|
this.stopped = true;
|
|
if (this.node) this.node.port.onmessage = null;
|
|
this.decoder.close();
|
|
}
|
|
|
|
private async pump(): Promise<void> {
|
|
const node = this.node;
|
|
if (!node || this.pumping || this.stopped || this.finished) return;
|
|
this.pumping = true;
|
|
try {
|
|
while (!this.halted() && this.sent - this.used < LEAD) {
|
|
const at = this.cursor.peek();
|
|
if (at === null) {
|
|
this.finished = true;
|
|
node.port.postMessage('end');
|
|
break;
|
|
}
|
|
const head = this.file.head;
|
|
const inHead = head !== null && at < (head[0]?.length ?? 0);
|
|
const span = this.cursor.next(inHead ? head[0]!.length - at : BATCH)!;
|
|
const samples = inHead
|
|
? head.map((side) => side.slice(span.from, span.to))
|
|
: await this.decoder.read(span.from, span.to);
|
|
if (this.halted()) break;
|
|
node.port.postMessage(
|
|
samples,
|
|
samples.map((side) => side.buffer),
|
|
);
|
|
this.sent += span.to - span.from;
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error(`[sounds] streaming ${this.file.url} broke off`, error);
|
|
if (!this.halted()) node.port.postMessage('end');
|
|
this.finished = true;
|
|
} finally {
|
|
this.pumping = false;
|
|
}
|
|
}
|
|
}
|