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>
165 lines
6.8 KiB
TypeScript
165 lines
6.8 KiB
TypeScript
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}`;
|
||
}
|