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 | null = null; /** Copies that could not be made, so one broken file is not tried over and over. */ private readonly failed = new Set(); 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 { 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 { 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 { 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}`; }