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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 20:27:32 +02:00
1 parent 84cd36f902
commit 2764919bb8
30 files changed
+2443 -146

No files matched your search

+17
View File
@@ -317,6 +317,21 @@ export interface SoundAsset extends AssetBase {
file: string;
/** Absent for a sound without clips. */
clips?: SoundClip[];
/** The copy it is streamed from while its `stream` is on (#349). */
stream?: SoundStream;
}
/**
* A sound or a clip of it as Opus in Ogg (#349), what it is streamed from rather than decoded whole.
* Made in the browser from what it plays; `from` says what that was – the file, or the name of the
* cut a clip is – and once it is something else the copy is stale and made again.
*/
export interface SoundStream {
file: string;
from: string;
/** Samples per channel at 48 kHz, and the size of the file, to show. */
frames: number;
bytes: number;
}
/**
@@ -329,6 +344,8 @@ export interface SoundClip {
id: string;
name: string;
props: PropertyBag;
/** Its cut baked to a copy of its own, while its sound streams (#349). */
stream?: SoundStream;
}
/**
+5 -2
View File
@@ -1,4 +1,5 @@
import { validBake } from '../music/bake';
import { streamsOf } from '../properties/sounds';
import { isFontFile } from './font';
import { celFilesOf } from './pixelart';
import type { Project } from './Project';
@@ -17,7 +18,8 @@ export interface ProjectFiles {
* Every file an asset names, used or not: which frame an animation or a tile reaches next is only
* known while it plays, and a file fetched then shows as a gap. A name that is no stored file –
* an upload that failed – is left out, as the libraries would never get it anyway. So is a bake that
* no longer holds (#340): it would never be played, and decoded it weighs as much as the music.
* no longer holds (#340): it would never be played. So is a sound that streams (#349): the sound
* library fetches the start of its copy instead, and decoded ahead it would weigh what streaming saves.
*/
export function projectFiles(project: Project): ProjectFiles {
const files: ProjectFiles = { textures: [], fonts: [], cels: [], sounds: [] };
@@ -25,7 +27,8 @@ export function projectFiles(project: Project): ProjectFiles {
if (asset.type === 'texture' && isTextureFile(asset.file)) files.textures.push(asset.file);
else if (asset.type === 'font' && isFontFile(asset.file)) files.fonts.push(asset.file);
else if (asset.type === 'pixelart') files.cels.push(...celFilesOf(asset));
else if (asset.type === 'sound' && isSoundFile(asset.file)) files.sounds.push(asset.file);
else if (asset.type === 'sound' && isSoundFile(asset.file) && !streamsOf(asset.props))
files.sounds.push(asset.file);
else if ((asset.type === 'pattern' || asset.type === 'song') && validBake(project, asset))
files.sounds.push(asset.bake!.file);
}
+98 -25
View File
@@ -140,38 +140,111 @@ export interface ReadOpus {
pages: number;
}
/** Reads a file `oggOpus` wrote, checking every page; throws at the first that is not right. */
/** Reads a whole file `oggOpus` wrote, checking every page; throws at the first that is not right. */
export function readOggOpus(bytes: Uint8Array): ReadOpus {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const all: Uint8Array[] = [];
let open: Uint8Array[] = [];
let granule = 0;
let pages = 0;
for (let at = 0; at < bytes.length; pages++) {
if (text(bytes.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
const count = bytes[at + 26]!;
const lacing = bytes.subarray(at + 27, at + 27 + count);
const size = 27 + count + lacing.reduce((sum, value) => sum + value, 0);
const page = bytes.slice(at, at + size);
const crc = view.getUint32(at + 22, true);
page.fill(0, 22, 26);
if (oggCrc(page) !== crc) throw new Error(`page ${pages} has a wrong checksum`);
granule = view.getUint32(at + 6, true) + view.getUint32(at + 10, true) * 2 ** 32;
let from = at + 27 + count;
const reader = new OggOpusReader();
reader.push(bytes);
if (reader.channels === null || reader.preSkip === null) throw new Error('no OpusHead');
return {
channels: reader.channels,
preSkip: reader.preSkip,
granule: reader.granule,
packets: reader.packets.map((packet) => packet.data),
pages: reader.pages,
};
}
/** A packet of a stream (#349) and where it lies, in samples counted from the first, pre-skip and all. */
export interface PlacedPacket {
data: Uint8Array;
at: number;
samples: number;
}
/**
* Reads Ogg Opus as its bytes come in (#349), a page once all of it is there, checking each; throws
* at the first that is not right. The packets after the tags are placed by the samples each stands for.
*/
export class OggOpusReader {
channels: number | null = null;
preSkip: number | null = null;
/** The granule position of the last page read, and whether it was the last of the stream. */
granule = 0;
ended = false;
pages = 0;
readonly packets: PlacedPacket[] = [];
private held: Uint8Array = new Uint8Array(0);
private open: Uint8Array[] = [];
private headers = 0;
private next = 0;
push(bytes: Uint8Array): void {
let data = this.held.length > 0 ? concat([this.held, bytes]) : bytes;
let at = 0;
while (data.length - at >= 27) {
if (text(data.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
const count = data[at + 26]!;
if (data.length - at < 27 + count) break;
const lacing = data.subarray(at + 27, at + 27 + count);
const size = 27 + count + lacing.reduce((sum, value) => sum + value, 0);
if (data.length - at < size) break;
this.page(data.subarray(at, at + size), lacing);
at += size;
}
data = data.subarray(at);
this.held = data.length > 0 ? data.slice() : new Uint8Array(0);
}
private page(page: Uint8Array, lacing: Uint8Array): void {
const view = new DataView(page.buffer, page.byteOffset, page.byteLength);
const crc = view.getUint32(22, true);
const blank = page.slice();
blank.fill(0, 22, 26);
if (oggCrc(blank) !== crc) throw new Error(`page ${this.pages} has a wrong checksum`);
this.granule = view.getUint32(6, true) + view.getUint32(10, true) * 2 ** 32;
this.ended = (page[5]! & 0x04) !== 0;
this.pages += 1;
let from = 27 + lacing.length;
for (const value of lacing) {
open.push(bytes.subarray(from, from + value));
this.open.push(page.subarray(from, from + value));
from += value;
if (value < 255) {
all.push(concat(open));
open = [];
this.packet(concat(this.open));
this.open = [];
}
}
at += size;
}
const head = all[0];
const preSkip = head ? preSkipOf(head) : null;
if (!head || preSkip === null) throw new Error('no OpusHead');
return { channels: head[9]!, preSkip, granule, packets: all.slice(2), pages };
private packet(data: Uint8Array): void {
if (this.headers === 0) {
this.preSkip = preSkipOf(data);
if (this.preSkip === null) throw new Error('no OpusHead');
this.channels = data[9]!;
}
if (this.headers < 2) {
this.headers += 1;
return;
}
const samples = opusPacketSamples(data);
this.packets.push({ data, at: this.next, samples });
this.next += samples;
}
}
/** The samples per channel at 48 kHz an Opus packet stands for, from its TOC byte (RFC 6716 3.1). */
export function opusPacketSamples(packet: Uint8Array): number {
if (packet.length === 0) return 0;
const toc = packet[0]!;
const config = toc >> 3;
const size =
config < 12
? [480, 960, 1920, 2880][config & 3]!
: config < 16
? [480, 960][config & 1]!
: [120, 240, 480, 960][config & 3]!;
const code = toc & 3;
const frames = code === 0 ? 1 : code === 3 ? (packet[1] ?? 0) & 63 : 2;
return size * frames;
}
function ascii(value: string): Uint8Array {
+144
View File
@@ -0,0 +1,144 @@
/**
* What a streamed sound (#349) is played from on the audio thread: the samples the page decoded, in
* the order they are heard, at `STREAM_RATE`. It reads them out at the rate of the context, `step`
* of its samples per sample heard, between two of them along a cubic curve. Time runs on whatever
* has arrived: a stretch that comes late is silent where it would have been heard and skipped once it
* is there, so a sound in a song stays on its beat.
*/
export const STREAM_RATE = 48000;
export class StreamQueue {
private readonly chunks: Float32Array[][] = [];
/** Where in the first chunk the next sample is, and how far between it and the one after. */
private index = 0;
private fraction = 0;
/** The sample of each channel read last, for the curve. */
private readonly last: number[];
private finished = false;
/** Samples of the stream read so far, heard or skipped, and those that were due but not there. */
used = 0;
owed = 0;
/** Samples that came too late to be heard (test hook). */
skipped = 0;
constructor(
readonly channels: number,
private readonly step: number,
) {
this.last = Array.from({ length: channels }, () => 0);
}
push(chunk: Float32Array[]): void {
if (chunk.length === 0 || (chunk[0]?.length ?? 0) === 0) return;
this.chunks.push(chunk);
this.payOwed();
}
/** Nothing more comes: it ends once what is held has been read. */
end(): void {
this.finished = true;
this.payOwed();
}
/** Samples held and not yet read. */
get held(): number {
let count = -this.index;
for (const chunk of this.chunks) count += chunk[0]!.length;
return Math.max(0, count);
}
/** Whether all of it has been read. */
get done(): boolean {
return this.finished && this.held === 0;
}
/**
* Fills `out` from what is held; what is not there yet is silence. Says whether it goes on: false
* once it has ended and been read to the end.
*/
render(out: Float32Array[]): boolean {
const length = out[0]?.length ?? 0;
for (let i = 0; i < length; i++) {
if (!this.ahead(2)) {
if (this.finished && this.held === 0) {
for (const channel of out) channel.fill(0, i);
return false;
}
if (!this.finished) {
for (const channel of out) channel[i] = 0;
this.owe();
continue;
}
}
const t = this.fraction;
for (let c = 0; c < out.length; c++) {
const side = Math.min(c, this.channels - 1);
out[c]![i] = cubic(this.last[side]!, this.at(side, 0), this.at(side, 1), this.at(side, 2), t);
}
this.advance(this.step);
}
return !this.done;
}
/** Whether `count` samples past the next one are held, or the stream ends before them. */
private ahead(count: number): boolean {
if (this.finished) return this.held > 0;
return this.held > count;
}
/** The sample `k` places after the next one, 0 past the end. */
private at(side: number, k: number): number {
let offset = this.index + k;
for (const chunk of this.chunks) {
const data = chunk[Math.min(side, chunk.length - 1)]!;
if (offset < data.length) return data[offset]!;
offset -= data.length;
}
return 0;
}
private advance(by: number): void {
this.fraction += by;
while (this.fraction >= 1) {
this.fraction -= 1;
this.take();
}
}
/** Moves past the next sample, keeping it for the curve. */
private take(): void {
for (let side = 0; side < this.channels; side++) this.last[side] = this.at(side, 0);
this.index += 1;
this.used += 1;
while (this.chunks.length > 0 && this.index >= this.chunks[0]![0]!.length) {
this.index -= this.chunks[0]![0]!.length;
this.chunks.shift();
}
}
/** A sample was due and not there: skipped once it is. */
private owe(): void {
this.fraction += this.step;
while (this.fraction >= 1) {
this.fraction -= 1;
this.owed += 1;
}
}
private payOwed(): void {
while (this.owed > 0 && this.held > 0) {
this.take();
this.owed -= 1;
this.skipped += 1;
}
}
}
/** Catmull-Rom between `p1` and `p2`, `t` of the way; at 0 it is `p1` to the bit. */
function cubic(p0: number, p1: number, p2: number, p3: number, t: number): number {
if (t === 0) return p1;
const a = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3;
const b = p0 - 2.5 * p1 + 2 * p2 - 0.5 * p3;
const c = -0.5 * p0 + 0.5 * p2;
return ((a * t + b) * t + c) * t + p1;
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Sounds streamed rather than decoded whole (#349). A sound with `stream` on is played from a copy as
* Opus in Ogg, made in the browser, and each of its clips from a copy of its own cut, so no clip needs
* the whole file either. A bake (#340) is Opus already and always streams. What a game plays is still
* named as before – the file, or the cut a clip is – and the table here says which of those names to
* stream from which copy.
*/
import type { SoundAsset, SoundClip, SoundStream } from '../model/Asset';
import type { Project } from '../model/Project';
import { clipsOf, cutName, isSoundFile } from '../model/sound';
import { cutOf, streamsOf } from '../properties/sounds';
import { STREAM_RATE } from './streamQueue';
/** Seconds from which the Sound tab suggests streaming. */
export const STREAM_SUGGESTED = 60;
/** What a name is streamed from: the copy, and how many samples per channel it holds at `STREAM_RATE`. */
export interface StreamedFile {
file: string;
frames: number;
}
/** What a sound or a clip played streamed is read as (#349), in samples of the copy at `STREAM_RATE`. */
export interface StreamPlan {
/** Where it starts. */
from: number;
/** Where its first run ends: the end of the copy, or of the head of a bake. */
until: number;
/** What it runs round after that; null to end there. */
loop: { start: number; end: number } | null;
}
/** A sound or a clip played from `offset` seconds in, round and round where it loops. */
export function playedPlan(frames: number, offset: number, loop: boolean): StreamPlan {
const at = Math.max(0, Math.round(offset * STREAM_RATE));
const from = loop && frames > 0 ? at % frames : Math.min(at, frames);
return { from, until: frames, loop: loop && frames > 0 ? { start: 0, end: frames } : null };
}
/** Walks a plan in stretches of the copy, each read straight through; a loop starts a new one. */
export class StreamCursor {
private at: number;
private end: number;
constructor(private readonly plan: StreamPlan) {
this.at = plan.from;
this.end = plan.until;
}
/** Where the next stretch starts; null once all of it has been read. */
peek(): number | null {
if (this.at < this.end) return this.at;
const loop = this.plan.loop;
return loop && loop.end > loop.start ? loop.start : null;
}
/** The next stretch, at most `most` samples; null once all of it has been read. */
next(most: number): { from: number; to: number } | null {
if (this.at >= this.end) {
const loop = this.plan.loop;
if (!loop || loop.end <= loop.start) return null;
this.at = loop.start;
this.end = loop.end;
}
const from = this.at;
this.at = Math.min(this.end, from + Math.max(1, most));
return { from, to: this.at };
}
}
/**
* Every name a game streams, with its copy: each sound with `stream` on whose copy is of its file,
* each clip of one whose copy is of its cut – a clip without one plays decoded until it is there – and
* every bake, which streams from itself.
*/
export function streamTable(project: Project): Map<string, StreamedFile> {
const table = new Map<string, StreamedFile>();
for (const asset of project.assets) {
if (asset.type === 'sound') {
if (!streamsOf(asset.props) || !asset.file) continue;
const own = heldCopy(asset.stream, asset.file);
if (own) table.set(asset.file, own);
for (const clip of clipsOf(asset)) {
const played = cutName(asset.file, cutOf(clip.props));
const copy = played === asset.file ? own : heldCopy(clip.stream, played);
if (copy) table.set(played, copy);
}
} else if ((asset.type === 'pattern' || asset.type === 'song') && asset.bake) {
const bake = asset.bake;
table.set(bake.file, { file: bake.file, frames: bake.head + bake.round });
}
}
return table;
}
function heldCopy(stream: SoundStream | undefined, played: string): StreamedFile | null {
return stream && stream.from === played ? { file: stream.file, frames: stream.frames } : null;
}
/** A copy to make: of a sound, or of one of its clips, from what it plays now. */
export interface StreamJob {
assetId: string;
clipId: string | null;
from: string;
}
/** A copy that is to go: its sound no longer streams, or a clip of it has become the whole file. */
export interface StreamDrop {
assetId: string;
clipId: string | null;
}
/** What is missing and what is left over, sounds in their order, each sound before its clips. */
export function streamWork(project: Project): { jobs: StreamJob[]; drops: StreamDrop[] } {
const jobs: StreamJob[] = [];
const drops: StreamDrop[] = [];
for (const asset of project.assets) {
if (asset.type !== 'sound') continue;
const on = streamsOf(asset.props) && asset.file !== '';
const keep = (owner: SoundAsset | SoundClip, clipId: string | null, from: string | null) => {
if (from === null) {
if (owner.stream) drops.push({ assetId: asset.id, clipId });
} else if (owner.stream?.from !== from) jobs.push({ assetId: asset.id, clipId, from });
};
keep(asset, null, on ? asset.file : null);
for (const clip of clipsOf(asset)) {
const played = cutName(asset.file, cutOf(clip.props));
keep(clip, clip.id, on && played !== asset.file ? played : null);
}
}
return { jobs, drops };
}
/** Held to what a copy may be, else none. */
export function heldStream(raw: unknown): SoundStream | null {
if (typeof raw !== 'object' || raw === null) return null;
const s = raw as Record<string, unknown>;
const whole = (value: unknown) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
if (typeof s.file !== 'string' || !isSoundFile(s.file) || typeof s.from !== 'string' || s.from === '')
return null;
if (!whole(s.frames) || !whole(s.bytes)) return null;
return { file: s.file, from: s.from, frames: s.frames as number, bytes: s.bytes as number };
}
/** What a copy is uploaded as: like a bake's, behind a mark the sound picker leaves out by. */
const STREAM_MARK = '[stream] ';
export function streamUploadName(name: string): string {
return `${STREAM_MARK}${name.replace(/[/\\]/g, '-')}.ogg`;
}
export function isStreamUpload(name: string): boolean {
return name.startsWith(STREAM_MARK);
}
+7 -1
View File
@@ -103,7 +103,11 @@ import {
} from './particles';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
import {
LOOP_KEY as SOUND_LOOP_KEY,
STREAM_KEY as SOUND_STREAM_KEY,
VOLUME_KEY as SOUND_VOLUME_KEY,
} from './sounds';
import {
DEFAULT_BARS,
DEFAULT_GRID,
@@ -692,6 +696,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
sound: [
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
// A long one is streamed from a copy as Opus rather than decoded whole (#349).
{ key: SOUND_STREAM_KEY, label: 'Stream', kind: 'boolean', default: false },
FX_ROW,
],
// The effects are the asset's graph (see SoundFxAsset), not a property.
+6
View File
@@ -7,6 +7,8 @@ import type { PropertyDef } from './PropertySchema';
export const VOLUME_KEY = 'volume';
/** Whether this sound is music: it repeats until something stops it. */
export const LOOP_KEY = 'loop';
/** Whether a game streams it from a copy as Opus rather than decoding all of it first (#349). */
export const STREAM_KEY = 'stream';
/** Where in its file a clip starts and ends, in seconds; an end of 0 is the end of the file (#343). */
export const START_KEY = 'start';
export const END_KEY = 'end';
@@ -47,6 +49,10 @@ export function loopsOf(bag: PropertyBag): boolean {
return booleanOf(bag[LOOP_KEY]);
}
export function streamsOf(bag: PropertyBag): boolean {
return booleanOf(bag[STREAM_KEY]);
}
/** The stretch of its file a clip is, its fades in seconds. */
export function cutOf(bag: PropertyBag): SoundCut {
const seconds = (key: string, fallback: number) => Math.max(0, coerceAs(bag[key], 'number', fallback));
+13 -1
View File
@@ -22,6 +22,7 @@ import { isShaderNodeDef } from '../shader/nodes';
import { isSoundFxNodeDef } from '../soundfx/nodes';
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
import { heldBake } from '../music/bake';
import { heldStream } from '../music/streams';
import { heldPatternNotes } from '../music/pattern';
import { isEmbeddable } from '../music/embedded';
import { heldSongItems, heldSongTracks } from '../music/song';
@@ -263,7 +264,16 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
} else if (type === 'sound') {
const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : '';
const clips = normalizeClips(a.clips, takenIds);
assets.push({ id, type, name, props, file, ...(clips.length > 0 && { clips }) });
const stream = heldStream(a.stream);
assets.push({
id,
type,
name,
props,
file,
...(clips.length > 0 && { clips }),
...(stream && { stream }),
});
} else if (type === 'tile') {
// A picture naming nothing is kept, like an animation's dangling root: it reads as missing
// in the tab and comes back when the texture it named is restored by an undo.
@@ -763,10 +773,12 @@ function normalizeClips(raw: unknown, takenIds: Set<string>): SoundClip[] {
const id = typeof c.id === 'string' ? c.id : '';
if (!id || takenIds.has(id)) continue;
takenIds.add(id);
const stream = heldStream(c.stream);
clips.push({
id,
name: typeof c.name === 'string' ? c.name : '',
props: normalizeBag(c.props, CLIP_ROWS, true),
...(stream && { stream }),
});
}
return clips;