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 -3
View File
@@ -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;
}
+25 -8
View File
@@ -1,9 +1,9 @@
/**
* 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.
* Opus in Ogg, made in the browser. Every clip of every sound has a copy of its own cut, so no clip
* needs the whole file: streamed where its sound streams, decoded where it does not. 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 tables here say which of those names to play from which copy.
*/
import type { SoundAsset, SoundClip, SoundStream } from '../model/Asset';
import type { Project } from '../model/Project';
@@ -97,6 +97,23 @@ function heldCopy(stream: SoundStream | undefined, played: string): StreamedFile
return stream && stream.from === played ? { file: stream.file, frames: stream.frames } : null;
}
/**
* The cut each clip plays, with the copy it is decoded from instead of its sound's whole file – of a
* sound that streams too, for where a stream cannot be had. A clip whose copy does not fit is left out.
*/
export function clipCopyTable(project: Project): Map<string, string> {
const table = new Map<string, string>();
for (const asset of project.assets) {
if (asset.type !== 'sound' || !asset.file) continue;
for (const clip of clipsOf(asset)) {
const played = cutName(asset.file, cutOf(clip.props));
const copy = played === asset.file ? null : heldCopy(clip.stream, played);
if (copy) table.set(played, copy.file);
}
}
return table;
}
/** A copy to make: of a sound, or of one of its clips, from what it plays now. */
export interface StreamJob {
assetId: string;
@@ -104,7 +121,7 @@ export interface StreamJob {
from: string;
}
/** A copy that is to go: its sound no longer streams, or a clip of it has become the whole file. */
/** A copy that is to go: its sound no longer streams or has no file, or a clip is the whole file. */
export interface StreamDrop {
assetId: string;
clipId: string | null;
@@ -116,16 +133,16 @@ export function streamWork(project: Project): { jobs: StreamJob[]; drops: Stream
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);
keep(asset, null, streamsOf(asset.props) && asset.file !== '' ? asset.file : null);
// A clip has its copy whether its sound streams or not.
for (const clip of clipsOf(asset)) {
const played = cutName(asset.file, cutOf(clip.props));
keep(clip, clip.id, on && played !== asset.file ? played : null);
keep(clip, clip.id, asset.file !== '' && played !== asset.file ? played : null);
}
}
return { jobs, drops };