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
@@ -1,9 +1,12 @@
|
||||
import { validBake } from '../music/bake';
|
||||
import { streamsOf } from '../properties/sounds';
|
||||
import { clipCopyTable } from '../music/streams';
|
||||
import { cutOf, streamsOf } from '../properties/sounds';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
import type { SoundAsset } from './Asset';
|
||||
import { isFontFile } from './font';
|
||||
import { celFilesOf } from './pixelart';
|
||||
import type { Project } from './Project';
|
||||
import { isSoundFile } from './sound';
|
||||
import { clipsOf, cutName, isSoundFile } from './sound';
|
||||
import { isTextureFile } from './texture';
|
||||
|
||||
/** The stored files a project draws and plays, per library – what is loaded before anything asks. */
|
||||
@@ -20,17 +23,57 @@ export interface ProjectFiles {
|
||||
* 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. 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.
|
||||
* A sound only played through its clips has their copies loaded rather than its whole file.
|
||||
*/
|
||||
export function projectFiles(project: Project): ProjectFiles {
|
||||
const files: ProjectFiles = { textures: [], fonts: [], cels: [], sounds: [] };
|
||||
const copies = clipCopyTable(project);
|
||||
let named: ReadonlySet<string> | null = null;
|
||||
const namedWhole = (asset: SoundAsset) => (named ??= namedStrings(project)).has(asset.id);
|
||||
for (const asset of project.assets) {
|
||||
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) && !streamsOf(asset.props))
|
||||
files.sounds.push(asset.file);
|
||||
files.sounds.push(...decodedAhead(asset, copies, namedWhole));
|
||||
else if ((asset.type === 'pattern' || asset.type === 'song') && validBake(project, asset))
|
||||
files.sounds.push(asset.bake!.file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* What of a sound is decoded ahead: its file where something may play it whole – a sound without
|
||||
* clips, one the project names itself, one with a clip whose copy is not there yet – else the cut of
|
||||
* each clip, which the sound library plays from its copy.
|
||||
*/
|
||||
function decodedAhead(
|
||||
asset: SoundAsset,
|
||||
copies: ReadonlyMap<string, string>,
|
||||
namedWhole: (asset: SoundAsset) => boolean,
|
||||
): string[] {
|
||||
const cuts = clipsOf(asset).map((clip) => cutName(asset.file, cutOf(clip.props)));
|
||||
const whole = cuts.length === 0 || cuts.some((cut) => !copies.has(cut)) || namedWhole(asset);
|
||||
return whole ? [asset.file] : [...new Set(cuts)];
|
||||
}
|
||||
|
||||
/** What holds no reference to a sound, and holds a lot: not walked for one. */
|
||||
const UNNAMING: ReadonlySet<AssetType> = new Set(['sound', 'texture', 'pixelart', 'tilemap', 'font']);
|
||||
|
||||
/**
|
||||
* Every text the project holds outside the assets that cannot name a sound. Where a sound is played
|
||||
* whole – a sound field, an item of a song, a Sample, a Reverb's impulse – its id is one of them; a
|
||||
* clip has an id of its own. Looked at as data, not field by field, so no place is missed: one that
|
||||
* only looks like a reference loads the file as before.
|
||||
*/
|
||||
function namedStrings(project: Project): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const walk = (value: unknown): void => {
|
||||
if (typeof value === 'string') seen.add(value);
|
||||
else if (Array.isArray(value)) value.forEach(walk);
|
||||
else if (typeof value === 'object' && value !== null && !ArrayBuffer.isView(value))
|
||||
Object.values(value).forEach(walk);
|
||||
};
|
||||
for (const asset of project.assets) if (!UNNAMING.has(asset.type)) walk(asset);
|
||||
return seen;
|
||||
}
|
||||
Reference in new issue
Block a user