/** * 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, ranges = true): FakeServer { const server: FakeServer = { asked: [] }; vi.stubGlobal('fetch', (url: string, init?: { headers?: Record }) => { 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; } }, ); }