Decode a clip from a file of its own, not its whole sound (#349)
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:
1 parent
2764919bb8
commit
82e96e680f
10 files changed
+332
-42
No files matched your search
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user