Files
feuerwerk/tests/core/ogg.test.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

122 lines
4.8 KiB
TypeScript

/**
* Opus in Ogg as a bake is kept (#340): the pages around the packets, their checksums, where each
* page says it has got to, and the pre-skip and length a decoder trims by.
*/
import { describe, expect, it } from 'vitest';
import {
OggOpusReader,
oggCrc,
oggOpus,
opusHead,
opusPacketSamples,
preSkipOf,
readOggOpus,
} from '../../src/core/music/ogg';
const packet = (size: number, fill: number) => ({ data: new Uint8Array(size).fill(fill), samples: 960 });
/** The flags and the granule position of every page, in order. */
function pagesOf(file: Uint8Array): { flags: number; granule: number }[] {
const view = new DataView(file.buffer);
const pages: { flags: number; granule: number }[] = [];
for (let at = 0; at < file.length;) {
pages.push({ flags: file[at + 5]!, granule: view.getUint32(at + 6, true) });
const count = file[at + 26]!;
at += 27 + count + file.subarray(at + 27, at + 27 + count).reduce((s, v) => s + v, 0);
}
return pages;
}
describe('Opus in Ogg', () => {
it('checks a page with the CRC Ogg uses', () => {
expect(oggCrc(Uint8Array.from('123456789', (c) => c.charCodeAt(0)))).toBe(0x89a1897f);
});
it('reads back the head, the packets and the length it was given', () => {
const packets = [packet(3, 1), packet(255, 2), packet(600, 3), packet(0, 4)];
const file = oggOpus({ channels: 2, preSkip: 312, frames: 3000, packets });
const back = readOggOpus(file);
expect(back.channels).toBe(2);
expect(back.preSkip).toBe(312);
expect(back.granule).toBe(312 + 3000);
expect(back.packets.map((p) => [p.length, p[0] ?? -1])).toEqual([
[3, 1],
[255, 2],
[600, 3],
[0, -1],
]);
});
it('opens with the head alone, flags the first and the last page', () => {
const file = oggOpus({ channels: 1, preSkip: 312, frames: 960, packets: [packet(10, 1)] });
expect(String.fromCharCode(...file.subarray(28, 36))).toBe('OpusHead');
expect(pagesOf(file).map((page) => page.flags)).toEqual([0x02, 0x00, 0x04]);
});
it('starts a new page before 255 lacing values, each counting the samples finished on it', () => {
const packets = Array.from({ length: 300 }, (_, i) => packet(100, i % 256));
const file = oggOpus({ channels: 2, preSkip: 312, frames: 300 * 960 - 500, packets });
const back = readOggOpus(file);
expect(back.packets).toHaveLength(300);
expect(back.pages).toBe(4);
expect(pagesOf(file).map((page) => page.granule)).toEqual([0, 0, 255 * 960, 312 + 300 * 960 - 500]);
});
it('refuses a page whose bytes were changed', () => {
const file = oggOpus({ channels: 2, preSkip: 312, frames: 960, packets: [packet(20, 7)] });
file[file.length - 3] = file[file.length - 3]! ^ 0xff;
expect(() => readOggOpus(file)).toThrow(/checksum/);
});
it('reads the pre-skip of a head an encoder hands out', () => {
expect(preSkipOf(opusHead(2, 3840))).toBe(3840);
expect(preSkipOf(new Uint8Array(19))).toBeNull();
});
});
describe('Opus in Ogg as it comes in (#349)', () => {
/** A CELT packet of 20 ms at full band: config 31, one frame. */
const celt = (size: number, fill: number) => {
const data = new Uint8Array(size).fill(fill);
data[0] = 31 << 3;
return { data, samples: 960 };
};
const file = oggOpus({
channels: 2,
preSkip: 312,
frames: 300 * 960 - 312,
packets: Array.from({ length: 300 }, (_, i) => celt(100 + (i % 300), i % 256)),
});
it('reads a file pushed in pieces as it reads it whole, each packet placed where it starts', () => {
const reader = new OggOpusReader();
for (let at = 0, size = 1; at < file.length; at += size, size = (size * 7 + 3) % 4000) {
reader.push(file.subarray(at, at + size));
}
const whole = readOggOpus(file);
expect(reader.packets.map((p) => p.data)).toEqual(whole.packets);
expect(reader.packets.slice(0, 3).map((p) => p.at)).toEqual([0, 960, 1920]);
expect(reader.packets.at(-1)!.at).toBe(299 * 960);
expect([reader.channels, reader.preSkip, reader.ended]).toEqual([2, 312, true]);
});
it('holds a page back until all of it is there', () => {
const reader = new OggOpusReader();
reader.push(file.subarray(0, 4000));
const seen = reader.packets.length;
expect(reader.ended).toBe(false);
reader.push(file.subarray(4000));
expect(reader.packets.length).toBeGreaterThan(seen);
expect(reader.packets).toHaveLength(300);
});
it('counts the samples of a packet from its TOC byte', () => {
expect(opusPacketSamples(Uint8Array.of(31 << 3))).toBe(960);
expect(opusPacketSamples(Uint8Array.of((28 << 3) | 1))).toBe(240);
expect(opusPacketSamples(Uint8Array.of((3 << 3) | 2))).toBe(2 * 2880);
expect(opusPacketSamples(Uint8Array.of((13 << 3) | 3, 3))).toBe(3 * 960);
expect(opusPacketSamples(Uint8Array.of(12 << 3))).toBe(480);
expect(opusPacketSamples(new Uint8Array(0))).toBe(0);
});
});