Bake a pattern or song to Opus, played in its place by a game (#340)
A pattern or song played by a game runs every note through the scheduler, the instruments and their FX, live. Bake works that out once, keeps it as a file on the asset, and Play pattern and Play song play the file instead, as one voice, for as long as nothing it is made of has changed. The tabs always play live. - Bake is an icon in the header of the Pattern and the Song tab, lit while the bake holds. A click bakes, cancels a bake that runs, or offers Bake again and Remove bake. The toolbar shows the progress. The info of the tab says "baked" or "bake outdated". - The bake holds everything up to the output of the pattern or song. The sound FX of the node, fx params, volume, fade in, bus, Master routing and stopping with a fade stay live. - A print of the worked-out plan decides whether it holds: notes and clips, tempo, length, loop, tracks, the build of each instrument for each note, sound FX, files, cuts and levels. A rename keeps it, an undo back to the baked state makes it hold again. - The file holds the straight run and then one more round of the loop as heard the second time, tails included. Looped, it plays to the loop end and then round that round, to the sample. Once, it fades out over its last 10 ms. Let ring fades it out over 0.3 s. - It is worked out by a second SoundLibrary on an OfflineAudioContext, its scheduler woken by suspends of the offline clock, and what reaches the master is taken. A worklet probe tells whether that was mono, so a mono bake goes on exactly as the notes did. - Opus in Ogg, 160 kbit/s stereo or 96 kbit/s mono, encoded with WebCodecs, the Ogg pages written by hand. Normalized to full scale, the peak kept and played back. - The file goes up into the sounds of the account as "[bake] name", which the sound picker leaves out. An old bake file is deleted once the project is saved without it. Only a bake that holds is loaded. - Ogg files are decoded at 48 kHz: resampled to 44.1 kHz, the loop of a bake lost a sample a round. - Baking and removing a bake are no undo steps, like the EQ's zoom. Findings: - Chrome's AudioEncoder refuses opus.format 'ogg' (NotSupportedError) but hands out the OpusHead with a pre-skip of 312 for 'opus'. - The Song tab's header is full at 1280 px: a captioned Bake pushed Fit off the tab. Hence the icon. Tests: - Unit: tests/core/ogg.test.ts (6), tests/core/bake.test.ts (16), four new in sound-library.test.ts, one in song-play.test.ts. One test cuts, reverses and fades a clip on a song's track: each change outdates the bake, each undo makes it hold again. - e2e 64 (melektaus-atx): ALL PASSED, 36 checks (6 new). Live and baked rendered offline differ by at most 0.06 dB over 114 windows of 20 ms, two loop ends among them. e2e 65 had no red check with the icon in its header. - Gate (melektaus-atx): CHECK EXIT 0, 277 files, 4281 tests. - Counter-checks: print without instrument builds, print taking the sound's file instead of the clip's cut, round taken from the first pass, bake voice without its looping source, Play pattern or Play song ignoring the bake: one unit test red each. Pre-skip set to 0: the e2e comparison 4.8 dB off, only that check red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3377524958
commit
84cd36f902
28 files changed
+1979
-78
No files matched your search
@@ -570,6 +570,115 @@ export async function pattern(t) {
|
||||
once.length === 1 && gone.length === 0 && once[0].name === `pattern:${id}` && onceStats.playing === 0,
|
||||
JSON.stringify({ once: once.map((g) => g.planned.length), gone, onceStats }),
|
||||
);
|
||||
|
||||
// 64l'. Bake (#340) works the pattern out once, as Opus in Ogg on the pattern, and the game plays
|
||||
// that while nothing it is made of has changed. A note just before the loop end rings on
|
||||
// over it, which only the second round of the bake has in it.
|
||||
await mutate(`
|
||||
const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
|
||||
p.notes.push({ id: 'pt64-n4', tick: 1880, length: 40, note: 67, velocity: 1 });
|
||||
__gm.state.project.assets.find((a) => a.id === 'pt64-logic').graph.nodes.find((n) => n.id === 'pt64-play').params.loop = true;`);
|
||||
await showTab('pattern');
|
||||
const bakeInfo = async () => ({
|
||||
...(await json(`__gm.bake.info(${JSON.stringify(id)})`)),
|
||||
info: await json('__gm.pattern.info()'),
|
||||
});
|
||||
const unbaked = await bakeInfo();
|
||||
const patternName = await json(
|
||||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`,
|
||||
);
|
||||
await click(unbaked.button.x, unbaked.button.y);
|
||||
const bakedNow = await waitFor(bakeInfo, (info) => info.status === 'baked', 100);
|
||||
const bake = bakedNow.bake;
|
||||
const listed = (await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`)).find(
|
||||
(item) => item.file === bake?.file,
|
||||
);
|
||||
check(
|
||||
'Bake keeps an Ogg of the pattern on it: its bar of 1.6 s, then one round more, uploaded under a marked name',
|
||||
unbaked.status === 'none' &&
|
||||
!unbaked.button.toggled &&
|
||||
bakedNow.valid &&
|
||||
bakedNow.button.toggled &&
|
||||
bakedNow.info === '5 notes · Lead 64 · baked' &&
|
||||
/\.ogg$/.test(bake?.file ?? '') &&
|
||||
bake.length === 76800 &&
|
||||
bake.loopEnd === 76800 &&
|
||||
bake.head === 76800 &&
|
||||
bake.round === 76800 &&
|
||||
bake.peak > 0 &&
|
||||
listed?.name === `[bake] ${patternName}.ogg`,
|
||||
JSON.stringify({ unbaked, bakedNow, listed, patternName }),
|
||||
);
|
||||
await shot('64-baked');
|
||||
// 20 ms at a time, offline, from the notes and from the bake: within a decibel wherever it is heard.
|
||||
// Silence comes over as null (-Infinity has no JSON).
|
||||
const compared = await evaluate(`__gm.bake.compare(${JSON.stringify(id)}, true, 4, 0.02)`);
|
||||
const audible = (compared?.live ?? []).flatMap((live, i) =>
|
||||
live !== null && live > -50 ? [Math.abs(live - (compared.baked[i] ?? -Infinity))] : [],
|
||||
);
|
||||
const worst = Math.max(...audible);
|
||||
check(
|
||||
'the bake sounds as the notes do, 20 ms at a time over two and a half rounds, the loop ends among them',
|
||||
audible.length > 20 && worst < 1,
|
||||
JSON.stringify({ windows: compared?.live.length, audible: audible.length, worst: +worst.toFixed(2) }),
|
||||
);
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await play();
|
||||
const bakedGame = await waitFor(patterns, (list) => list[0]?.playing, 40);
|
||||
await sleep(600);
|
||||
const bakedLater = { patterns: await patterns(), stats: await stats() };
|
||||
await stop();
|
||||
check(
|
||||
'the game plays the bake round and round: one voice of its file, no note planned',
|
||||
bakedGame[0]?.baked &&
|
||||
bakedLater.patterns.length === 1 &&
|
||||
bakedLater.patterns[0].baked &&
|
||||
bakedLater.patterns[0].planned.length === 0 &&
|
||||
bakedLater.stats.last?.file === bake?.file &&
|
||||
bakedLater.stats.last?.loop === true,
|
||||
JSON.stringify(bakedLater),
|
||||
);
|
||||
await showTab('pattern');
|
||||
p = await json('__gm.pattern.gridAt(960, 69)');
|
||||
await click(p.x, p.y);
|
||||
const outdated = await bakeInfo();
|
||||
await play();
|
||||
const liveGame = await waitFor(patterns, (list) => list[0]?.planned.length >= 2, 40);
|
||||
await stop();
|
||||
check(
|
||||
'a note drawn outdates it, and the game plays the notes again',
|
||||
outdated.status === 'outdated' &&
|
||||
!outdated.button.toggled &&
|
||||
outdated.info === '6 notes · Lead 64 · bake outdated' &&
|
||||
!outdated.valid &&
|
||||
liveGame[0]?.baked === false &&
|
||||
liveGame[0].planned.length >= 2,
|
||||
JSON.stringify({ outdated, live: liveGame.map((g) => ({ baked: g.baked, planned: g.planned.length })) }),
|
||||
);
|
||||
await showTab('pattern');
|
||||
await press('z', { ctrl: true });
|
||||
const undone = await bakeInfo();
|
||||
check(
|
||||
'Ctrl+Z takes the note back, and the bake holds again',
|
||||
undone.status === 'baked' && undone.valid && undone.bake?.file === bake?.file,
|
||||
JSON.stringify(undone),
|
||||
);
|
||||
await click(undone.button.x, undone.button.y);
|
||||
await clickText('Remove bake');
|
||||
const removed = await bakeInfo();
|
||||
const fileGone = await waitFor(
|
||||
async () =>
|
||||
(await evaluate(`fetch('/game-maker/api/index.php?r=sounds').then((r) => r.json())`)).some(
|
||||
(item) => item.file === bake?.file,
|
||||
),
|
||||
(there) => !there,
|
||||
40,
|
||||
);
|
||||
check(
|
||||
'Remove bake takes it off the pattern, and its file goes once the project is saved without it',
|
||||
removed.bake === null && removed.status === 'none' && !removed.button.toggled && fileGone === false,
|
||||
JSON.stringify({ removed, fileGone }),
|
||||
);
|
||||
const left = await rig.remove();
|
||||
const idleLeft = await idle.remove();
|
||||
|
||||
|
||||
Reference in new issue
Block a user