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

+4
View File
@@ -1,4 +1,5 @@
import type { Graph } from '../graph/Graph';
import type { Bake } from '../music/bake';
import type { PatternNote } from '../music/pattern';
import type { SongItem, SongTrack } from '../music/song';
import type { LoopRange } from '../music/sequence';
@@ -357,6 +358,8 @@ export interface PatternAsset extends AssetBase {
type: 'pattern';
notes: PatternNote[];
loop?: LoopRange;
/** What it was last baked to (#340); it plays only while nothing it is made of has changed since. */
bake?: Bake;
}
/**
@@ -370,6 +373,7 @@ export interface SongAsset extends AssetBase {
items: SongItem[];
loop?: LoopRange;
graph: Graph;
bake?: Bake;
}
/**
+5 -1
View File
@@ -1,3 +1,4 @@
import { validBake } from '../music/bake';
import { isFontFile } from './font';
import { celFilesOf } from './pixelart';
import type { Project } from './Project';
@@ -15,7 +16,8 @@ export interface ProjectFiles {
/**
* Every file an asset names, used or not: which frame an animation or a tile reaches next is only
* known while it plays, and a file fetched then shows as a gap. A name that is no stored file –
* an upload that failed – is left out, as the libraries would never get it anyway.
* an upload that failed – is left out, as the libraries would never get it anyway. So is a bake that
* no longer holds (#340): it would never be played, and decoded it weighs as much as the music.
*/
export function projectFiles(project: Project): ProjectFiles {
const files: ProjectFiles = { textures: [], fonts: [], cels: [], sounds: [] };
@@ -24,6 +26,8 @@ export function projectFiles(project: Project): ProjectFiles {
else if (asset.type === 'font' && isFontFile(asset.file)) files.fonts.push(asset.file);
else if (asset.type === 'pixelart') files.cels.push(...celFilesOf(asset));
else if (asset.type === 'sound' && isSoundFile(asset.file)) files.sounds.push(asset.file);
else if ((asset.type === 'pattern' || asset.type === 'song') && validBake(project, asset))
files.sounds.push(asset.bake!.file);
}
return files;
}
+252
View File
@@ -0,0 +1,252 @@
/**
* A pattern or a song baked (#340): what it plays worked out once, kept as Opus in Ogg, and played
* from there by `Play pattern` and `Play song` for as long as nothing it is made of has changed.
* Everything after its output stays live: the sound FX of the node, its volume, its bus.
*
* The file holds two parts, counted in frames at `BAKE_RATE`: the playing straight from the start
* (`head` long, as far as the end or the loop end, whichever is later) and then one more round of the
* loop as it is heard the second time round, with what rang on from the first in it. Played once, a
* bake runs from 0 to `length`; looped, it runs to `loopEnd` and then round its second round for good.
*/
import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile';
import { heldNote } from '../instrument/notes';
import type { PatternAsset, SongAsset } from '../model/Asset';
import { findAssetOf, type Project } from '../model/Project';
import { findSound, isSoundFile } from '../model/sound';
import { pickedFx } from '../properties/assetFx';
import { playedSoundOf } from '../properties/playedSound';
import { resolveAssetProps } from '../properties/resolve';
import { volumeOf } from '../properties/sounds';
import { clipFx, fxVoiceOf } from './needs';
import { OPUS_RATE } from './ogg';
import { patternLength, patternSequence, patternTempo } from './pattern';
import type { BusLevel } from './playbacks';
import type { LoopRange, Sequence } from './sequence';
import { songLength, songLoop, songSequence, songTempo } from './song';
import { songBuses } from './songBuses';
import { secondsOfTicks } from './time';
export const BAKE_RATE = OPUS_RATE;
/** Goes up whenever the way a playing sounds changes, so every bake made before is outdated. */
const BAKE_VERSION = 1;
/** What a pattern or song keeps of its bake. Frames are at `BAKE_RATE`. */
export interface Bake {
/** The Ogg file in the sounds of its owner (#344). */
file: string;
/** What it was baked from (`bakePrint`): it plays only while that is still the same. */
print: string;
/** The loudest sample before it was raised to full scale; what it is played at. */
peak: number;
length: number;
loopEnd: number;
head: number;
round: number;
/** The size of the file, to show. */
bytes: number;
}
export type BakedAsset = PatternAsset | SongAsset;
/** What a bake is worked out from: the same a game hears with `Play pattern` or `Play song`. */
export interface BakeSource {
sequence: Sequence;
tempo: number;
/** In ticks: where it ends played once, and what it runs round looped. */
length: number;
loop: LoopRange;
/** The tracks of a song, each on its bus. */
buses: readonly BusLevel[];
}
/** Null where there is nothing to hear: a pattern without its instrument or notes, an empty song. */
export function bakeSourceOf(project: Project, asset: BakedAsset): BakeSource | null {
if (asset.type === 'pattern') {
const sequence = patternSequence(asset);
if (sequence.notes.length === 0) return null;
const length = patternLength(asset);
return { sequence, tempo: patternTempo(asset.props), length, loop: { start: 0, end: length }, buses: [] };
}
const sequence = songSequence(project, asset);
if (sequence.notes.length === 0 && !sequence.clips?.length) return null;
return {
sequence,
tempo: songTempo(asset.props),
length: songLength(asset),
loop: songLoop(asset),
buses: songBuses(project, asset),
};
}
/** Where the parts of a bake lie, in frames, for a source played at its tempo. */
export function bakeSpans(
source: Pick<BakeSource, 'tempo' | 'length' | 'loop'>,
): Omit<Bake, 'file' | 'print' | 'peak' | 'bytes'> {
const frames = (ticks: number) => Math.round(secondsOfTicks(ticks, source.tempo) * BAKE_RATE);
const length = frames(source.length);
const loopEnd = frames(source.loop.end);
return { length, loopEnd, head: Math.max(length, loopEnd), round: loopEnd - frames(source.loop.start) };
}
/**
* The playings a bake is made of: always one looped, as far as one more round past the loop end, and
* one played once only where it goes on past the loop end – up to there the two are the same.
*/
export function bakeRuns(spans: Pick<Bake, 'length' | 'loopEnd' | 'round'>): {
looped: number;
once: number | null;
} {
return { looped: spans.loopEnd + spans.round, once: spans.length > spans.loopEnd ? spans.length : null };
}
/** The file's samples, side by side: the head out of the playing once (or the looped one), then the round. */
export function assembleBake(
spans: Pick<Bake, 'loopEnd' | 'head' | 'round'>,
looped: readonly Float32Array[],
once: readonly Float32Array[] | null,
): Float32Array[] {
return looped.map((side, c) => {
const out = new Float32Array(spans.head + spans.round);
const straight = once?.[c] ?? side;
out.set(straight.subarray(0, spans.head));
out.set(side.subarray(spans.loopEnd, spans.loopEnd + spans.round), spans.head);
return out;
});
}
/** Raises the samples to full scale in place; gives back the loudest one before, 0 for silence. */
export function normalizeBake(channels: readonly Float32Array[]): number {
let peak = 0;
for (const side of channels) for (const sample of side) peak = Math.max(peak, Math.abs(sample));
if (peak > 0) for (const side of channels) for (let i = 0; i < side.length; i++) side[i]! /= peak;
return peak;
}
/**
* How a bake is played (#340), in seconds into its file. Once: from 0 to `end`. Looped: from 0 up to
* `until`, then from `round.start` round and round to `round.end` – one source where the round follows
* at once, two where the head is longer than the loop end.
*/
export interface BakePlayback {
end: number | null;
until: number;
round: { start: number; end: number } | null;
}
export function bakePlayback(
bake: Pick<Bake, 'length' | 'loopEnd' | 'head' | 'round'>,
loop: boolean,
): BakePlayback {
const seconds = (frames: number) => frames / BAKE_RATE;
if (!loop || bake.round <= 0)
return { end: seconds(bake.length), until: seconds(bake.length), round: null };
return {
end: null,
until: seconds(bake.loopEnd),
round: { start: seconds(bake.head), end: seconds(bake.head + bake.round) },
};
}
/**
* What a bake is uploaded as: its owner's name behind a mark the sound picker leaves out by – in
* front, as the server cuts a long name at its end, and with no slash, which it takes for a folder.
*/
const BAKE_MARK = '[bake] ';
export function bakeUploadName(assetName: string): string {
return `${BAKE_MARK}${assetName.replace(/[/\\]/g, '-')}.ogg`;
}
export function isBakeUpload(name: string): boolean {
return name.startsWith(BAKE_MARK);
}
/** Held to what a bake may be, else none: a file of the sounds, whole frames that fit together. */
export function heldBake(raw: unknown): Bake | null {
if (typeof raw !== 'object' || raw === null) return null;
const b = raw as Record<string, unknown>;
const whole = (value: unknown) =>
typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
const [length, loopEnd, head, round, bytes] = [b.length, b.loopEnd, b.head, b.round, b.bytes].map(whole);
const peak = typeof b.peak === 'number' && Number.isFinite(b.peak) && b.peak >= 0 ? b.peak : null;
if (typeof b.file !== 'string' || !isSoundFile(b.file) || typeof b.print !== 'string' || b.print === '')
return null;
if (length == null || loopEnd == null || head == null || round == null || bytes == null || peak === null)
return null;
if (head !== Math.max(length, loopEnd) || round > loopEnd) return null;
return { file: b.file, print: b.print, peak, length, loopEnd, head, round, bytes };
}
/** The bake of a pattern or song while it still holds, else null: something it is made of changed. */
export function validBake(project: Project, asset: BakedAsset): Bake | null {
const bake = asset.bake;
return bake && bake.print === bakePrint(project, asset) ? bake : null;
}
/** JSON of the plans the compilers keep: made once per plan, not once per note. */
const planJson = new WeakMap<object, string>();
function jsonOf(plan: object): string {
let json = planJson.get(plan);
if (json === undefined) planJson.set(plan, (json = JSON.stringify(plan)));
return json;
}
/**
* What a bake is made from, as a short text: every note and clip with what plays it – the build of
* each instrument for each note it plays, its sound FX, the file, cut and level of each sound – the
* tempo, the length, the loop and the tracks. A change to any of them, also to an instrument or a
* sound the pattern only names, makes another. The name is not in it: renaming changes no sound.
* '' where there is nothing to bake.
*/
export function bakePrint(project: Project, asset: BakedAsset): string {
const source = bakeSourceOf(project, asset);
if (!source) return '';
const { sequence, tempo } = source;
const sources = instrumentSourcesOf(project);
const instruments: Record<string, { fx: string; notes: Record<number, string[]> }> = {};
for (const { instrument, note } of sequence.notes) {
const found = findAssetOf(project, instrument, 'instrument');
if (!found) continue;
const known = (instruments[instrument] ??= {
fx: jsonOf(fxVoiceOf(project, pickedFx('', found.props), tempo)?.plan ?? {}),
notes: {},
});
known.notes[note] ??= instrumentBuildFor(found, heldNote(note), sources).layers.map(jsonOf);
}
const clips = (sequence.clips ?? []).map((clip) => {
const ref = findSound(project, clip.sound);
return {
...clip,
file: ref ? playedSoundOf(ref) : '',
level: ref ? volumeOf(resolveAssetProps(project, ref.asset)) : 0,
fx: jsonOf(clipFx(project, clip.fx, tempo).plan),
};
});
const buses = source.buses.map(({ fx, ...bus }) => ({ ...bus, fx: fx ? jsonOf(fx.plan) : '' }));
const text = JSON.stringify([
BAKE_VERSION,
tempo,
source.length,
source.loop,
sequence.notes,
clips,
buses,
instruments,
]);
return hashText(text);
}
/** 53 bits of cyrb53, as base 36: no file depends on two prints never meeting, only a stale bake. */
function hashText(text: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 2654435761);
h2 = Math.imul(h2 ^ c, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Opus in Ogg (RFC 7845), the file a bake is kept in (#340): the pages around the packets a WebCodecs
* `AudioEncoder` hands out, and the way back. The head says how many samples the encoder ran ahead
* (`preSkip`) and the last page how many there are, so a decoder gives back exactly what went in,
* to the sample.
*/
/** Opus always counts at 48 kHz, whatever the rate of what went in. */
export const OPUS_RATE = 48000;
/** One packet as the encoder gave it, and how many samples per channel it stands for. */
export interface OpusPacket {
data: Uint8Array;
samples: number;
}
export interface OpusStream {
channels: number;
preSkip: number;
/** Samples per channel of what went in, without the pre-skip. */
frames: number;
packets: readonly OpusPacket[];
}
/** What a page holds at most: 255 lacing values, and so at most a packet of about 64 kB. */
const MAX_SEGMENTS = 255;
const SERIAL = 0x6d616b65;
const VENDOR = 'game-maker';
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let r = i << 24;
for (let b = 0; b < 8; b++) r = r & 0x80000000 ? (r << 1) ^ 0x04c11db7 : r << 1;
table[i] = r >>> 0;
}
return table;
})();
/** The checksum of an Ogg page: CRC-32 with the polynomial 0x04c11db7, not reflected, from 0. */
export function oggCrc(bytes: Uint8Array): number {
let crc = 0;
for (const byte of bytes) crc = ((crc << 8) ^ CRC_TABLE[((crc >>> 24) ^ byte) & 0xff]!) >>> 0;
return crc;
}
/** The identification header: version 1, mapping family 0 (mono or stereo), no output gain. */
export function opusHead(channels: number, preSkip: number): Uint8Array {
const head = new Uint8Array(19);
const view = new DataView(head.buffer);
head.set(ascii('OpusHead'));
head[8] = 1;
head[9] = channels;
view.setUint16(10, preSkip, true);
view.setUint32(12, OPUS_RATE, true);
return head;
}
/** The pre-skip an identification header names, as an encoder hands one out; null for anything else. */
export function preSkipOf(head: Uint8Array): number | null {
if (head.length < 19 || text(head.subarray(0, 8)) !== 'OpusHead') return null;
return new DataView(head.buffer, head.byteOffset, head.byteLength).getUint16(10, true);
}
function opusTags(): Uint8Array {
const vendor = ascii(VENDOR);
const tags = new Uint8Array(8 + 4 + vendor.length + 4);
const view = new DataView(tags.buffer);
tags.set(ascii('OpusTags'));
view.setUint32(8, vendor.length, true);
tags.set(vendor, 12);
return tags;
}
/**
* The whole file: the head and the tags on a page each, then the packets, as many on a page as fit.
* A page's granule position counts every sample finished on it, the pre-skip among them; the last
* one stops at the end of what went in, which cuts off what the encoder padded the last packet with.
*/
export function oggOpus(stream: OpusStream): Uint8Array {
const pages: Uint8Array[] = [];
let sequence = 0;
const page = (packets: readonly Uint8Array[], granule: number, flags: number) =>
pages.push(oggPage(packets, granule, flags, sequence++));
page([opusHead(stream.channels, stream.preSkip)], 0, 0x02);
page([opusTags()], 0, 0);
const end = stream.preSkip + stream.frames;
let granule = 0;
let held: Uint8Array[] = [];
let segments = 0;
stream.packets.forEach((packet, i) => {
const needs = Math.floor(packet.data.length / 255) + 1;
if (segments + needs > MAX_SEGMENTS) {
page(held, Math.min(granule, end), 0);
held = [];
segments = 0;
}
held.push(packet.data);
segments += needs;
granule += packet.samples;
if (i === stream.packets.length - 1) page(held, end, 0x04);
});
if (stream.packets.length === 0) page([], end, 0x04);
return concat(pages);
}
function oggPage(
packets: readonly Uint8Array[],
granule: number,
flags: number,
sequence: number,
): Uint8Array {
const lacing: number[] = [];
for (const packet of packets) {
for (let left = packet.length; left >= 255; left -= 255) lacing.push(255);
lacing.push(packet.length % 255);
}
const body = concat(packets);
const page = new Uint8Array(27 + lacing.length + body.length);
const view = new DataView(page.buffer);
page.set(ascii('OggS'));
page[5] = flags;
view.setUint32(6, granule % 2 ** 32, true);
view.setUint32(10, Math.floor(granule / 2 ** 32), true);
view.setUint32(14, SERIAL, true);
view.setUint32(18, sequence, true);
page[26] = lacing.length;
page.set(lacing, 27);
page.set(body, 27 + lacing.length);
view.setUint32(22, oggCrc(page), true);
return page;
}
/** What `readOggOpus` finds: the head, the last granule position and every packet after the tags. */
export interface ReadOpus {
channels: number;
preSkip: number;
granule: number;
packets: Uint8Array[];
pages: number;
}
/** Reads a file `oggOpus` wrote, checking every page; throws at the first that is not right. */
export function readOggOpus(bytes: Uint8Array): ReadOpus {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const all: Uint8Array[] = [];
let open: Uint8Array[] = [];
let granule = 0;
let pages = 0;
for (let at = 0; at < bytes.length; pages++) {
if (text(bytes.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
const count = bytes[at + 26]!;
const lacing = bytes.subarray(at + 27, at + 27 + count);
const size = 27 + count + lacing.reduce((sum, value) => sum + value, 0);
const page = bytes.slice(at, at + size);
const crc = view.getUint32(at + 22, true);
page.fill(0, 22, 26);
if (oggCrc(page) !== crc) throw new Error(`page ${pages} has a wrong checksum`);
granule = view.getUint32(at + 6, true) + view.getUint32(at + 10, true) * 2 ** 32;
let from = at + 27 + count;
for (const value of lacing) {
open.push(bytes.subarray(from, from + value));
from += value;
if (value < 255) {
all.push(concat(open));
open = [];
}
}
at += size;
}
const head = all[0];
const preSkip = head ? preSkipOf(head) : null;
if (!head || preSkip === null) throw new Error('no OpusHead');
return { channels: head[9]!, preSkip, granule, packets: all.slice(2), pages };
}
function ascii(value: string): Uint8Array {
return Uint8Array.from(value, (char) => char.charCodeAt(0));
}
function text(bytes: Uint8Array): string {
return String.fromCharCode(...bytes);
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
+97 -16
View File
@@ -1,5 +1,6 @@
import type { SendTap } from '../graph/Graph';
import type { FxPlan, FxVoice } from '../soundfx/nodes';
import type { BakePlayback } from './bake';
import type { MixSend } from './mixer';
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
import type { LoopRange, Sequence } from './sequence';
@@ -53,6 +54,26 @@ export interface SequencePlay {
/** Starts one note as the player was asked to, and gives back the number of its voice. */
strike(note: ScheduledNote): number;
strikeClip?(clip: ScheduledClip): number;
/** Its bake (#340), played in place of its notes and clips where the host can. */
bake?: BakePlay;
}
/** A bake as it is played: its file, the level it is heard at, and which stretches of it. */
export interface BakePlay {
file: string;
gain: number;
playback: BakePlayback;
}
/** What a playing is driven by: a scheduler of its notes, or a bake. */
export interface Runner {
readonly playing: boolean;
/** The second of the audio clock it is heard from; null before it starts. */
readonly startedAt: number | null;
/** Played to its end, and nothing of it is heard any more. */
readonly done: boolean;
start(from?: number): void;
stop(letRing?: boolean): void;
}
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead'> {
@@ -66,6 +87,8 @@ export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' |
after(at: number, then: () => void): void;
/** A playing is gone: stopped, or played to its end. */
closed?(id: number): void;
/** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */
playBaked?(id: number, bake: BakePlay, at: number): number;
}
export interface Playback {
@@ -73,12 +96,14 @@ export interface Playback {
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
name: string;
owner: string;
scheduler: Scheduler;
runner: Runner;
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
planned: ScheduledNote[];
clips: ScheduledClip[];
/** The second of the audio clock a fade out ends it at; null while it is not fading. */
fading: number | null;
/** Played from its bake (#340) rather than its notes. */
baked: boolean;
}
/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */
@@ -106,23 +131,38 @@ export class Playbacks {
if (list.length > KEPT) list.shift();
};
const strikeClip = play.strikeClip;
const scheduler = new Scheduler(
{
...this.host,
ahead: () => this.host.ahead?.(id) ?? 0,
play: (note) => (keep(planned, note), play.strike(note)),
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
},
() => play.sequence,
{ tempo: play.tempo, loop: play.loop, end: play.length },
);
const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null };
const ahead = () => this.host.ahead?.(id) ?? 0;
const { bake } = play;
const playBaked = this.host.playBaked?.bind(this.host);
const runner: Runner =
bake && playBaked
? new BakedRun(this.host, ahead, (at) => playBaked(id, bake, at), bake.playback.end !== null)
: new Scheduler(
{
...this.host,
ahead,
play: (note) => (keep(planned, note), play.strike(note)),
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
},
() => play.sequence,
{ tempo: play.tempo, loop: play.loop, end: play.length },
);
const playback: Playback = {
id,
name,
owner,
runner,
planned,
clips,
fading: null,
baked: runner instanceof BakedRun,
};
this.list.push(playback);
void this.host.prepare(play.needs, id).then(() => {
if (!this.list.includes(playback) || playback.fading !== null) return;
scheduler.start(0);
runner.start(0);
// From the first note heard, which is later where the buses are (#346).
const at = scheduler.startedAt ?? this.host.now() + LEAD;
const at = runner.startedAt ?? this.host.now() + LEAD;
this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0));
});
}
@@ -148,7 +188,7 @@ export class Playbacks {
/** What is playing, oldest first (test hook). */
current(): readonly Readonly<Playback>[] {
const gone = this.list.filter((playback) => playback.scheduler.done);
const gone = this.list.filter((playback) => playback.runner.done);
if (gone.length > 0) this.close(gone, false);
return this.list;
}
@@ -173,8 +213,49 @@ export class Playbacks {
if (going.length === 0) return;
this.list = this.list.filter((playback) => !going.includes(playback));
for (const playback of going) {
playback.scheduler.stop(letRing);
playback.runner.stop(letRing);
this.host.closed?.(playback.id);
}
}
}
/**
* A bake played in place of the notes (#340): one voice from the moment the first note would be
* heard, ended as the notes would be. One that ends is done once its voice is.
*/
class BakedRun implements Runner {
private voice = 0;
private anchor: number | null = null;
constructor(
private readonly host: Pick<PlaybackHost, 'now' | 'playing' | 'stop'>,
private readonly ahead: () => number,
private readonly play: (at: number) => number,
private readonly ends: boolean,
) {}
get playing(): boolean {
return this.anchor !== null;
}
get startedAt(): number | null {
return this.anchor;
}
get done(): boolean {
return this.anchor !== null && this.ends && !this.host.playing(this.voice);
}
start(): void {
this.stop();
const ahead = this.ahead();
this.anchor = this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
this.voice = this.play(this.anchor);
}
stop(letRing = false): void {
if (this.voice !== 0) this.host.stop([this.voice], letRing);
this.voice = 0;
this.anchor = null;
}
}
+13 -1
View File
@@ -21,6 +21,7 @@ import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import { isSoundFxNodeDef } from '../soundfx/nodes';
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
import { heldBake } from '../music/bake';
import { heldPatternNotes } from '../music/pattern';
import { isEmbeddable } from '../music/embedded';
import { heldSongItems, heldSongTracks } from '../music/song';
@@ -284,9 +285,19 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, colors: normalizePalette(a.colors) });
} else if (type === 'pattern') {
const loop = readLoop(a.loop);
assets.push({ id, type, name, props, notes: heldPatternNotes(a.notes), ...(loop && { loop }) });
const bake = heldBake(a.bake);
assets.push({
id,
type,
name,
props,
notes: heldPatternNotes(a.notes),
...(loop && { loop }),
...(bake && { bake }),
});
} else if (type === 'song') {
const loop = readLoop(a.loop);
const bake = heldBake(a.bake);
const tracks = heldSongTracks(a.tracks);
assets.push({
id,
@@ -297,6 +308,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
items: heldSongItems(a.items, tracks),
...(loop && { loop }),
graph: heldSongGraph(normalizeGraph(a.graph, isSongRoutingDef), tracks),
...(bake && { bake }),
});
} else if (type === 'curve') {
assets.push({ id, type, name, props, points: sanitizedCurve(normalizePoints(a.points)) });