Files
feuerwerk/src/editor/sound/StreamKeeper.ts
T
adminandClaude Opus 5.5 82e96e680f
game-maker / gate (push) Successful in 4m3s
game-maker / suite (push) Failing after 14m58s
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>
2026-10-09 20:53:49 +02:00

165 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SoundAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { clipsOf, cutName, cutChannels, cutRange, parseCutName, soundRefName } from '../../core/model/sound';
import { STREAM_RATE } from '../../core/music/streamQueue';
import { type StreamJob, streamUploadName, streamWork } from '../../core/music/streams';
import { cutOf, streamsOf } from '../../core/properties/sounds';
import type { SoundStore } from '../../core/store/SoundStore';
import { BakeCancelled, encodeOpus } from '../../viewport/bakeRender';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { dropWhenSaved } from '../music/dropWhenSaved';
import type { EditorState } from '../state';
/** Milliseconds the project has to stay as it is before a copy is made: a clip dragged about is one. */
const SETTLE_MS = 600;
/**
* Makes the copies sounds and clips are played from (#349). A moment after the project last changed,
* each sound with `stream` on gets a copy of its file as Opus, and each clip of any sound one of its
* cut, one at a time, from the file decoded for it and let go again. A copy no longer wanted is taken
* off its sound and deleted once the project has been saved without it. Not while the game runs.
*/
export class StreamKeeper {
private job: { job: StreamJob; share: number } | null = null;
private timer: ReturnType<typeof setTimeout> | null = null;
/** Copies that could not be made, so one broken file is not tried over and over. */
private readonly failed = new Set<string>();
private waiting: (() => void)[] = [];
constructor(
private readonly state: EditorState,
private readonly sounds: SoundStore,
) {
for (const event of ['project', 'load', 'mode'] as const) state.on(event, () => this.later());
this.later();
}
/** How far the copy of that sound, or of one of its clips, has got, 0 to 1; null while none is made. */
making(assetId: string): number | null {
return this.job?.job.assetId === assetId ? this.job.share : null;
}
/** Done once there is nothing left to make (test hook). */
idle(): Promise<void> {
if (!this.job && this.timer === null && this.pending().length === 0) return Promise.resolve();
return new Promise((resolve) => this.waiting.push(resolve));
}
private later(wait = SETTLE_MS): void {
if (this.timer !== null) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
void this.work();
}, wait);
}
private pending(): StreamJob[] {
return streamWork(this.state.project).jobs.filter((job) => !this.failed.has(keyOf(job)));
}
private async work(): Promise<void> {
if (this.job || this.state.mode === 'play') return;
for (const drop of streamWork(this.state.project).drops) {
const old = this.ownerOf(drop.assetId, drop.clipId)?.stream?.file;
this.state.setStream(drop.assetId, drop.clipId, null);
if (old && !this.named(old)) dropWhenSaved(this.state, this.sounds, old);
}
const next = this.pending()[0];
if (!next) {
for (const resolve of this.waiting.splice(0)) resolve();
return;
}
this.job = { job: next, share: 0 };
try {
await this.make(this.job);
} catch (error: unknown) {
// Called off because what it was made of changed: the next round makes the right one.
if (error instanceof BakeCancelled) return this.later(0);
this.failed.add(keyOf(next));
this.state.setNotice(
`No copy of ${this.label(next)}: ${error instanceof Error ? error.message : String(error)}`,
'error',
);
} finally {
this.job = null;
}
this.later(0);
}
private async make(running: { job: StreamJob; share: number }): Promise<void> {
const { job } = running;
const label = this.label(job);
const { file, cut } = parseCutName(job.from);
const whole = await soundLibrary.decodeApart(file, STREAM_RATE);
let channels = Array.from({ length: Math.min(2, whole.numberOfChannels) }, (_, c) =>
whole.getChannelData(c),
);
if (cut) channels = cutChannels(channels, cutRange(whole.length, whole.sampleRate, cut));
const data = await encodeOpus(channels, {
progress: (share) => {
const before = Math.round(running.share * 100);
running.share = share;
const now = Math.round(share * 100);
if (now !== before) this.state.setNotice(`Making a copy of ${label}: ${now}%`, 'quiet');
},
cancelled: () => !this.stillWanted(job),
});
if (!this.stillWanted(job)) return;
const uploaded = await this.sounds.upload(data.slice().buffer, 'audio/ogg', streamUploadName(label));
// Nothing names the file yet, so it can go at once.
if (!this.stillWanted(job)) return void this.sounds.delete(uploaded.file).catch(() => {});
const old = this.ownerOf(job.assetId, job.clipId)?.stream?.file;
const frames = channels[0]?.length ?? 0;
// Done: what the Sound tab reads when the copy lands is that it streams, not how far it got.
this.job = null;
this.state.setStream(job.assetId, job.clipId, {
file: uploaded.file,
from: job.from,
frames,
bytes: uploaded.bytes,
});
if (old && old !== uploaded.file && !this.named(old)) dropWhenSaved(this.state, this.sounds, old);
this.state.setNotice(`Made a copy of ${label}`, 'quiet');
}
/** Whether the sound, or its clip, still plays what the copy is being made of – a sound still streams. */
private stillWanted(job: StreamJob): boolean {
const asset = this.sound(job.assetId);
if (!asset) return false;
if (job.clipId === null) return streamsOf(asset.props) && asset.file === job.from;
const clip = clipsOf(asset).find((c) => c.id === job.clipId);
return clip !== undefined && cutName(asset.file, cutOf(clip.props)) === job.from;
}
/** Whether a copy is still named by another sound or clip – a sound and its duplicate share one. */
private named(file: string): boolean {
return this.state.project.assets.some(
(asset) =>
asset.type === 'sound' &&
(asset.stream?.file === file || clipsOf(asset).some((clip) => clip.stream?.file === file)),
);
}
private sound(id: string): SoundAsset | null {
const asset = findAsset(this.state.project, id);
return asset?.type === 'sound' ? asset : null;
}
private ownerOf(assetId: string, clipId: string | null): { stream?: SoundAsset['stream'] } | null {
const asset = this.sound(assetId);
if (!asset) return null;
return clipId === null ? asset : (clipsOf(asset).find((clip) => clip.id === clipId) ?? null);
}
private label(job: StreamJob): string {
const asset = this.sound(job.assetId);
if (!asset) return 'a sound';
const clip = clipsOf(asset).find((c) => c.id === job.clipId) ?? null;
return soundRefName({ asset, clip });
}
}
function keyOf(job: StreamJob): string {
return `${job.assetId}:${job.clipId ?? ''}:${job.from}`;
}