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>
122 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|