Bake a pattern or song to Opus, played in its place by a game (#340)
game-maker / gate (push) Successful in 3m49s
game-maker / suite (push) Successful in 15m6s

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:
adminandClaude Opus 5.5 committed 2026-10-09 19:13:11 +02:00
1 parent 3377524958
commit 84cd36f902
28 files changed
+1979 -78

No files matched your search

+109
View File
@@ -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();