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>
245 lines
9.8 KiB
TypeScript
245 lines
9.8 KiB
TypeScript
/**
|
|
* Sounds streamed rather than decoded whole (#349): which names a game streams from which copy, what
|
|
* copies are missing or left over, how a played sound is walked through its copy, and that a copy is
|
|
* kept with the project.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CreateAssetCommand,
|
|
CreateSoundCommand,
|
|
SetAssetPropertyCommand,
|
|
} from '../../src/core/commands/assetCommands';
|
|
import { AddClipCommand, SetClipPropsCommand } from '../../src/core/commands/clipCommands';
|
|
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
|
import type { SongAsset, SoundAsset, SoundStream } from '../../src/core/model/Asset';
|
|
import { projectFiles } from '../../src/core/model/projectFiles';
|
|
import { cutName } from '../../src/core/model/sound';
|
|
import { newItem } from '../../src/core/music/song';
|
|
import { STREAM_RATE } from '../../src/core/music/streamQueue';
|
|
import {
|
|
clipCopyTable,
|
|
heldStream,
|
|
isStreamUpload,
|
|
playedPlan,
|
|
StreamCursor,
|
|
streamTable,
|
|
streamUploadName,
|
|
streamWork,
|
|
} from '../../src/core/music/streams';
|
|
import { cutOf } from '../../src/core/properties/sounds';
|
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
|
import { makeSpriteScene } from '../helpers';
|
|
|
|
const FILE = `${'a1'.repeat(6)}/${'b2'.repeat(32)}.wav`;
|
|
const COPY = `${'a1'.repeat(6)}/${'c3'.repeat(32)}.ogg`;
|
|
const CLIP_COPY = `${'a1'.repeat(6)}/${'d4'.repeat(32)}.ogg`;
|
|
|
|
function streamed() {
|
|
const f = makeSpriteScene('Box');
|
|
const made = new CreateSoundCommand('Music', FILE);
|
|
f.stack.execute(made);
|
|
const sound = made.asset;
|
|
const clip = new AddClipCommand(sound.id, { start: 1, end: 3 });
|
|
f.stack.execute(clip);
|
|
const whole = new AddClipCommand(sound.id, { start: 0, end: 0 });
|
|
f.stack.execute(whole);
|
|
const turn = (on: boolean) => f.stack.execute(new SetAssetPropertyCommand(sound.id, 'stream', on));
|
|
const copy = (from: string, file: string): SoundStream => ({ file, from, frames: 96000, bytes: 4000 });
|
|
const cut = () => cutName(FILE, cutOf(sound.clips![0]!.props));
|
|
return { ...f, sound, clipId: clip.clipId, wholeId: whole.clipId, turn, copy, cut };
|
|
}
|
|
|
|
describe('which sounds stream', () => {
|
|
it('streams nothing while the switch is off, whatever copies there are', () => {
|
|
const f = streamed();
|
|
f.sound.stream = f.copy(FILE, COPY);
|
|
expect(streamTable(f.project).size).toBe(0);
|
|
});
|
|
|
|
it('streams a sound from a copy of its file, a clip from a copy of its cut, a whole-file clip as the sound', () => {
|
|
const f = streamed();
|
|
f.turn(true);
|
|
f.sound.stream = f.copy(FILE, COPY);
|
|
f.sound.clips![0]!.stream = f.copy(f.cut(), CLIP_COPY);
|
|
expect([...streamTable(f.project)]).toEqual([
|
|
[FILE, { file: COPY, frames: 96000 }],
|
|
[f.cut(), { file: CLIP_COPY, frames: 96000 }],
|
|
]);
|
|
});
|
|
|
|
it('plays a sound or a clip decoded while its copy is of something else', () => {
|
|
const f = streamed();
|
|
f.turn(true);
|
|
f.sound.stream = f.copy(FILE, COPY);
|
|
f.sound.clips![0]!.stream = f.copy(f.cut(), CLIP_COPY);
|
|
f.stack.execute(new SetClipPropsCommand(f.sound.id, f.clipId, { end: 2.5 }));
|
|
expect([...streamTable(f.project).keys()]).toEqual([FILE]);
|
|
});
|
|
|
|
it('asks for a copy of each clip with a cut of its own, of the sound once it streams, and lets that go once it is off', () => {
|
|
const f = streamed();
|
|
expect(streamWork(f.project)).toEqual({
|
|
jobs: [{ assetId: f.sound.id, clipId: f.clipId, from: f.cut() }],
|
|
drops: [],
|
|
});
|
|
f.turn(true);
|
|
expect(streamWork(f.project).jobs).toEqual([
|
|
{ assetId: f.sound.id, clipId: null, from: FILE },
|
|
{ assetId: f.sound.id, clipId: f.clipId, from: f.cut() },
|
|
]);
|
|
f.sound.stream = f.copy(FILE, COPY);
|
|
f.sound.clips![0]!.stream = f.copy(f.cut(), CLIP_COPY);
|
|
expect(streamWork(f.project)).toEqual({ jobs: [], drops: [] });
|
|
f.stack.execute(new SetClipPropsCommand(f.sound.id, f.clipId, { reverse: true }));
|
|
expect(streamWork(f.project).jobs).toEqual([{ assetId: f.sound.id, clipId: f.clipId, from: f.cut() }]);
|
|
f.sound.clips![0]!.stream = f.copy(f.cut(), CLIP_COPY);
|
|
f.turn(false);
|
|
expect(streamWork(f.project)).toEqual({ jobs: [], drops: [{ assetId: f.sound.id, clipId: null }] });
|
|
});
|
|
|
|
it('lets the copy of a clip go once it plays the whole file', () => {
|
|
const f = streamed();
|
|
f.sound.clips![0]!.stream = f.copy(f.cut(), CLIP_COPY);
|
|
f.stack.execute(new SetClipPropsCommand(f.sound.id, f.clipId, { start: 0, end: 0 }));
|
|
expect(streamWork(f.project)).toEqual({ jobs: [], drops: [{ assetId: f.sound.id, clipId: f.clipId }] });
|
|
});
|
|
|
|
it('leaves a sound that streams out of what is decoded ahead, its copy made or not', () => {
|
|
const f = streamed();
|
|
expect(projectFiles(f.project).sounds).toEqual([FILE]);
|
|
f.turn(true);
|
|
expect(projectFiles(f.project).sounds).toEqual([]);
|
|
});
|
|
|
|
it('streams every bake from itself', () => {
|
|
const f = streamed();
|
|
const made = new CreateAssetCommand('song', 'Theme');
|
|
f.stack.execute(made);
|
|
(made.asset as SongAsset).bake = {
|
|
file: COPY,
|
|
print: 'x',
|
|
peak: 1,
|
|
length: 100,
|
|
loopEnd: 80,
|
|
head: 100,
|
|
round: 40,
|
|
bytes: 9,
|
|
};
|
|
expect(streamTable(f.project).get(COPY)).toEqual({ file: COPY, frames: 140 });
|
|
});
|
|
});
|
|
|
|
describe('a clip decoded from its own copy', () => {
|
|
/** A sound only played through one clip of it, by a song. */
|
|
function clipped() {
|
|
const f = makeSpriteScene('Box');
|
|
const made = new CreateSoundCommand('Music', FILE);
|
|
f.stack.execute(made);
|
|
const sound = made.asset;
|
|
const clip = new AddClipCommand(sound.id, { start: 1, end: 3 });
|
|
f.stack.execute(clip);
|
|
const song = new CreateAssetCommand('song', 'Theme');
|
|
f.stack.execute(song);
|
|
const track = (song.asset as SongAsset).tracks[0]!.id;
|
|
const play = (asset: string) =>
|
|
f.stack.execute(
|
|
new SetSongCommand(song.asset.id, {
|
|
items: [newItem({ track, kind: 'sound', asset, tick: 0, length: 96 })],
|
|
}),
|
|
);
|
|
play(clip.clipId);
|
|
const cut = () => cutName(FILE, cutOf(sound.clips![0]!.props));
|
|
const copy = () =>
|
|
(sound.clips![0]!.stream = { file: CLIP_COPY, from: cut(), frames: 96000, bytes: 4000 });
|
|
return { ...f, sound, clipId: clip.clipId, play, cut, copy };
|
|
}
|
|
|
|
it('decodes the copy of each clip whose copy fits, whether its sound streams or not', () => {
|
|
const f = clipped();
|
|
expect(clipCopyTable(f.project).size).toBe(0);
|
|
f.copy();
|
|
expect([...clipCopyTable(f.project)]).toEqual([[f.cut(), CLIP_COPY]]);
|
|
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, 'stream', true));
|
|
expect([...clipCopyTable(f.project)]).toEqual([[f.cut(), CLIP_COPY]]);
|
|
f.stack.execute(new SetClipPropsCommand(f.sound.id, f.clipId, { fadeIn: 0.5 }));
|
|
expect(clipCopyTable(f.project).size).toBe(0);
|
|
});
|
|
|
|
it('loads the copies of its clips ahead rather than the whole file, until something plays it whole', () => {
|
|
const f = clipped();
|
|
expect(projectFiles(f.project).sounds).toEqual([FILE]);
|
|
f.copy();
|
|
expect(projectFiles(f.project).sounds).toEqual([f.cut()]);
|
|
f.play(f.sound.id);
|
|
expect(projectFiles(f.project).sounds).toEqual([FILE]);
|
|
});
|
|
|
|
it('loads the whole file while a clip has no copy of its cut, or is the whole file', () => {
|
|
const f = clipped();
|
|
f.copy();
|
|
const whole = new AddClipCommand(f.sound.id, { start: 0, end: 0 });
|
|
f.stack.execute(whole);
|
|
expect(projectFiles(f.project).sounds).toEqual([FILE]);
|
|
f.stack.execute(new SetClipPropsCommand(f.sound.id, whole.clipId, { start: 0.5 }));
|
|
expect(projectFiles(f.project).sounds).toEqual([FILE]);
|
|
});
|
|
});
|
|
|
|
describe('walking a copy', () => {
|
|
it('plays a sound from where it is asked to, round its whole length where it loops', () => {
|
|
expect(playedPlan(1000, 0, false)).toEqual({ from: 0, until: 1000, loop: null });
|
|
expect(playedPlan(STREAM_RATE * 2, 2.5, true)).toEqual({
|
|
from: STREAM_RATE / 2,
|
|
until: STREAM_RATE * 2,
|
|
loop: { start: 0, end: STREAM_RATE * 2 },
|
|
});
|
|
expect(playedPlan(1000, 1, false).from).toBe(1000);
|
|
});
|
|
|
|
it('reads straight on to the end of a run, then round the loop, starting a new stretch there', () => {
|
|
const cursor = new StreamCursor({ from: 50, until: 120, loop: { start: 100, end: 130 } });
|
|
const read: number[][] = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
const span = cursor.next(40)!;
|
|
read.push([span.from, span.to]);
|
|
}
|
|
expect(read).toEqual([
|
|
[50, 90],
|
|
[90, 120],
|
|
[100, 130],
|
|
[100, 130],
|
|
[100, 130],
|
|
]);
|
|
expect(cursor.peek()).toBe(100);
|
|
});
|
|
|
|
it('ends a run without a loop', () => {
|
|
const cursor = new StreamCursor({ from: 0, until: 10, loop: null });
|
|
expect(cursor.next(100)).toEqual({ from: 0, to: 10 });
|
|
expect(cursor.peek()).toBeNull();
|
|
expect(cursor.next(100)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('a copy kept with the project', () => {
|
|
it('comes back from a save, and one that is not right is dropped', () => {
|
|
const f = streamed();
|
|
f.sound.stream = f.copy(FILE, COPY);
|
|
f.sound.clips![0]!.stream = f.copy(f.cut(), CLIP_COPY);
|
|
const back = deserializeProject(serializeProject(f.project));
|
|
const sound = back.assets.find((a) => a.id === f.sound.id) as SoundAsset;
|
|
expect(sound.stream).toEqual(f.copy(FILE, COPY));
|
|
expect(sound.clips![0]!.stream).toEqual(f.copy(f.cut(), CLIP_COPY));
|
|
expect(sound.clips![1]!.stream).toBeUndefined();
|
|
expect(heldStream({ ...f.copy(FILE, COPY), frames: 1.5 })).toBeNull();
|
|
expect(heldStream({ ...f.copy('', COPY) })).toBeNull();
|
|
expect(heldStream({ ...f.copy(FILE, 'notes.txt') })).toBeNull();
|
|
});
|
|
|
|
it('is uploaded under a mark the sound picker leaves out by', () => {
|
|
expect(streamUploadName('Music / Intro')).toBe('[stream] Music - Intro.ogg');
|
|
expect(isStreamUpload(streamUploadName('Music'))).toBe(true);
|
|
expect(isStreamUpload('Music.ogg')).toBe(false);
|
|
});
|
|
});
|