Decode a clip from a file of its own, not its whole sound (#349)
game-maker / gate (push) Successful in 4m3s
game-maker / suite (push) Failing after 14m58s

A clip was cut from its sound's file, so a game decoded the whole file
for it and held it for as long as the page was open. A long recording
played through a few short clips cost the recording. Every clip now has
a copy of its cut as Opus, with Stream on or off, and a game decodes
that instead.

- StreamKeeper makes the copy of each clip whether its sound streams
  or not. Stream only decides whether a sound gets a copy of its own.
- clipCopyTable says which cut decodes from which copy. The sound
  library loads the copy for a clip unless the whole file is decoded
  already. Then the clip is cut from that as before.
- projectFiles decodes the whole file ahead only where something may
  play the sound itself: no clips, a clip without a fitting copy, or
  the sound's id anywhere in an asset that can name one. That last
  test walks the data, not the fields, so no place is missed.
- A copy that cannot be had falls back to the whole file. A library
  that bakes keeps to the whole files, as the bake's fingerprint does.

Tests:
- Gate: CHECK EXIT 0, 280 files, 4319 tests (7 new: streams +4,
  sound-library +3).
- e2e 34: ALL PASSED, 108 checks (2 new). With Stream off a fresh
  library decodes the ten-second reversed clip's copy and not the
  five-minute file. It is the cut made from the original to within
  0.0091 a sample and 0.013 dB in any 20 ms. Once a node plays the
  sound itself, the whole file is decoded ahead again.
- e2e 65: all passed with this change.
- Counter-checks red as they should be: the library ignoring copies,
  the whole file always decoded ahead, clip copies only with Stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 20:53:49 +02:00
1 parent 2764919bb8
commit 82e96e680f
10 files changed
+332 -42

No files matched your search

+46 -7
View File
@@ -378,6 +378,8 @@ export class SoundLibrary implements AudioSink {
private readonly mixVolumes = new Map<string, number>();
/** The names streamed (#349) and the copies they stream from, by name and by copy. */
private streams: ReadonlyMap<string, StreamedFile> = new Map();
/** The cut of each clip, with the copy it is decoded from rather than its whole file (#349). */
private copies: ReadonlyMap<string, string> = new Map();
private readonly streamFiles = new Map<string, StreamFile>();
private readonly feeds = new Set<StreamFeed>();
/** Every sample streamed so far that came too late to be heard, of the feeds gone (test hook). */
@@ -398,6 +400,21 @@ export class SoundLibrary implements AudioSink {
for (const name of table.keys()) void this.streamed(name)?.ready();
}
/**
* The copies clips are decoded from (#349), each under the cut it plays (`clipCopyTable`). A clip
* whose sound's whole file is decoded anyway is cut from that as before. One that bakes keeps to the
* whole files, as the fingerprint of a bake does.
*/
setCopies(table: ReadonlyMap<string, string>): void {
this.copies = table;
}
/** The copy a clip is decoded from, while it has one that has not failed. */
private copyOf(name: string): string | null {
const copy = this.render ? undefined : this.copies.get(name);
return copy && !this.failed.has(copy) ? copy : null;
}
/** The copy a name streams from, while it streams and the copy has not failed. */
private streamed(name: string): StreamFile | null {
const entry = this.streams.get(name);
@@ -1755,10 +1772,14 @@ export class SoundLibrary implements AudioSink {
/**
* Fetches and decodes once per file; a second play while it is in flight waits on the first. A cut
* (#343) loads its file and is made from it.
* (#343) loads its file and is made from it – the cut of a clip with a copy (#349) loads that
* instead, unless the whole file is there already.
*/
async load(file: string): Promise<AudioBuffer | null> {
if (parseCutName(file).cut) {
const copy = this.copyOf(file);
if (copy && !this.buffers.has(parseCutName(file).file) && (await this.load(copy)))
return this.buffer(file);
const whole = await this.load(parseCutName(file).file);
return whole ? this.buffer(file) : null;
}
@@ -2331,6 +2352,13 @@ export class SoundLibrary implements AudioSink {
};
}
/** A library of its own, served from where this one is, that has decoded nothing yet (test hook). */
aside(): SoundLibrary {
const library = new SoundLibrary(this.guarded);
library.configure(this.base);
return library;
}
/** All of the copy a name streams from, decoded as the feed decodes it (test hook). */
async streamedWhole(name: string): Promise<Float32Array[] | null> {
const file = this.streamed(name);
@@ -2446,10 +2474,16 @@ export class SoundLibrary implements AudioSink {
});
}
/** What a name plays, a cut made from its file the first time; null while the file is not loaded. */
/**
* What a name plays: the copy of a clip (#349) once it is decoded, else a cut made from its file the
* first time; null while neither is loaded.
*/
private buffer(name: string): AudioBuffer | null {
const whole = this.buffers.get(name);
if (whole) return whole;
const copy = this.copyOf(name);
const copied = copy ? this.buffers.get(copy) : undefined;
if (copied) return copied;
const held = this.cuts.get(name);
if (held) {
this.cuts.delete(name);
@@ -2476,12 +2510,17 @@ export class SoundLibrary implements AudioSink {
return made;
}
/** The samples of its loaded file a name plays: all of them, or the stretch a cut keeps. */
/**
* The samples of its loaded file a name plays: all of them, or the stretch a cut keeps – all of a
* clip's copy (#349), which is that stretch already.
*/
private rangeOf(name: string): (CutRange & { whole: AudioBuffer }) | null {
const { file, cut } = parseCutName(name);
const whole = this.buffers.get(file);
const copy = this.copyOf(name);
const copied = copy ? this.buffers.get(copy) : undefined;
const whole = copied ?? this.buffers.get(file);
if (!whole) return null;
if (!cut)
if (!cut || copied)
return {
whole,
from: 0,
@@ -2498,9 +2537,9 @@ export class SoundLibrary implements AudioSink {
/** How long a sound a Reverb takes as its impulse response rings (#328): all it may, while unknown. */
private readonly soundLength = (file: string): number => this.duration(file) ?? REVERB_MAX_DECAY;
/** Whether the file of a name could not be had: a cut of it cannot either. */
/** Whether the file of a name could not be had: a cut of it cannot either, unless it has a copy. */
private hasFailed(name: string): boolean {
return this.failed.has(parseCutName(name).file);
return this.copyOf(name) === null && this.failed.has(parseCutName(name).file);
}
/** Loads the FX worklet (Pitch, Bitcrusher, Chip, Phaser, Flanger) into the context once; says whether it is there. */