Play sounds, patterns and songs on the tracks of a composition (#418)
feuerwerk / gate (push) Successful in 4m31s
feuerwerk / suite (push) Successful in 18m21s

A trailer needs its music and effects on the same timeline as its
pictures. Until now a composition could only place objects, so sound
had to be started by hand from a behavior and drifted against the
picture.

- Audio items: a sound, a clip of a sound, a pattern or a song dragged
  from the asset panel onto a track becomes an item as long as one
  pass of it. It may share a track with object and video items and
  overlap them.
- Editing works as for other items: move, resize, trim on the left
  (moves the offset), fit to media, delete, copy and paste, box
  selection. Sounds draw their waveform, the colour tells the kind.
- The inspector shows what it plays, start, length, offset, volume
  (-60 to +12 dB) and bus. The bus is "From asset", the master or one
  of the buses, as asked for in the issue.
- An item longer than its material plays in rounds. Sounds and
  patterns start over, a song runs on in its loop. Loop marks on the
  item follow the same rule, a song's first mark sits at its loop end.
- compositionAudio.ts turns the items into sequences, each with its
  own tempo. CompositionSound plays them, in the tab and in a game.
- In the tab the items are heard from the playhead on, straight into
  the master like every tab. A scrub is silent. Playing a song or
  pattern in its own tab stops the composition, and the other way
  round.
- In a game they are planned on the audio clock for the moment the
  composition clock reaches them, with the scheduler's LEAD made up.
  While the clock stands (waiting for a video, paused, placement off)
  they are silent and resume at the right spot.

Found along the way:

- The asset panel never let a song out of its list, so a song could
  not be dragged anywhere. Song and scene tab reject it as they reject
  a pattern.
- The asset panel scrolled to the selected asset on every rebuild of
  its list, so folding a group far below the selection jumped back up.
  Only a new selection scrolls now.
- At the end of the list, folding a group pulled every row down by the
  rows that went away. ScrollContainer has a new opt-in holdOffset,
  set only in the asset panel, that keeps the offset until the user
  scrolls up.

Limits: a time scale other than 1 or a frame longer than 0.1 s moves
the picture and not the sound. A baked song plays its notes here, not
the bake.

Tests:
- tsc, eslint, Prettier clean. Gate on melektaus-atx green,
  309 files, 4788 tests (4772 plus 16 new).
- Unit: composition-audio.test.ts 13 new, scroll-container 3 new.
  Counter-checks: without catching up on load time or without the
  silence on pause the unit tests go red.
- e2e 68h: the sound is planned 1.2 ms after the composition reaches
  0.5 s. Without the LEAD make-up it is 54 ms late and red.
- e2e 04 and 69 ALL PASSED, 86 checks. 69o-ii drags a pattern and a
  song onto a track, 20 frames each.
- e2e 04: folding Sound at the end of the list keeps its header at
  y 234. With the old panel it jumps to 984 and the check is red.
- Not run in a browser: the loop marks of a song item, copy and paste
  of audio items, the bus choice.
- README and wiki count 2072 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 21:28:36 +02:00
1 parent 30b618d9a9
commit 77ac9d1aaf
30 files changed
+1571 -101

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
/**
* Sounds, patterns and songs as items of a composition (#418): kept without an object, laid out as
* sequences in milliseconds on buses of their own, and heard in a game while the composition's
* clock runs.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetCompositionCommand } from '../../src/core/commands/compositionCommands';
import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type {
CompositionAsset,
CompositionItem,
InstrumentAsset,
SongAsset,
} from '../../src/core/model/Asset';
import { compositionLength, fittedLength, setSoundMeasure } from '../../src/core/model/composition';
import { carriedClips, itemMedia, mediaMarks } from '../../src/core/model/compositionEdit';
import { findAssetOf } from '../../src/core/model/Project';
import { audioItemBus, compositionPlayings, compositionSpan } from '../../src/core/music/compositionAudio';
import { MASTER_MIX, mixBusName } from '../../src/core/music/mixer';
import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks';
import { LEAD } from '../../src/core/music/Scheduler';
import { newItem } from '../../src/core/music/song';
import { MASTER_BUS } from '../../src/core/properties/buses';
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import { graphEditor, makeBehavior, makeProject } from '../helpers';
const FILE = `${'e5'.repeat(32)}.wav`;
const Q = 480;
/**
* A composition "Cut" at 10 fps placed in a scene, a sound "Blip", a one-bar pattern "Riff" at
* 120 BPM (two seconds) with notes on beats 1 and 3, and a song "Theme" playing it from its start.
*/
function makeCut() {
const f = makeProject();
const scene = new CreateAssetCommand('scene', 'Level');
const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 });
const sound = new CreateSoundCommand('Blip', FILE);
const ins = new CreateAssetCommand('instrument', 'Lead');
const pattern = new CreateAssetCommand('pattern', 'Riff');
const song = new CreateAssetCommand('song', 'Theme');
const bus = new CreateAssetCommand('bus', 'Music');
for (const c of [scene, comp, sound, ins, pattern, song, bus]) f.stack.execute(c);
(ins.asset as InstrumentAsset).graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound =
sound.asset.id;
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', ins.asset.id));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [
{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 },
{ id: 'n2', tick: 2 * Q, length: Q, note: 64, velocity: 1 },
]),
);
const theme = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset;
const track = theme().tracks[0]!;
f.stack.execute(
new SetSongCommand(song.asset.id, {
items: [newItem({ track: track.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: 4 * Q })],
}),
);
const place = new PlaceAssetCommand(scene.asset.id, comp.asset.id, { x: 0, y: 0 });
f.stack.execute(place);
const cut = () => findAssetOf(f.project, comp.asset.id, 'composition') as CompositionAsset;
const lay = (...items: CompositionItem[]) =>
f.stack.execute(new SetCompositionCommand(cut().id, { items }));
const audioItem = (
id: string,
kind: 'sound' | 'pattern' | 'song',
start: number,
length: number,
more: Partial<CompositionItem> & { volume?: number; bus?: string } = {},
): CompositionItem => ({
id,
track: cut().tracks[0]!.id,
start,
length,
offset: more.offset ?? 0,
audio: {
kind,
asset: { sound: sound.asset.id, pattern: pattern.asset.id, song: song.asset.id }[kind],
volume: more.volume ?? 0,
bus: more.bus ?? '',
},
});
return {
...f,
sceneId: scene.asset.id,
placement: place.objectId,
soundId: sound.asset.id,
patternId: pattern.asset.id,
songId: song.asset.id,
busId: bus.asset.id,
cut,
lay,
audioItem,
};
}
describe('audio items of a composition', () => {
it('are kept without an object, and count to its length', () => {
const f = makeCut();
f.lay(f.audioItem('a', 'sound', 5, 20, { volume: 40 }), f.audioItem('b', 'pattern', 0, 10));
expect(f.cut().items.map((i) => i.id)).toEqual(['a', 'b']);
// Held to the most a sound item may be turned up by.
expect(f.cut().items[0]!.audio).toEqual({ kind: 'sound', asset: f.soundId, volume: 12, bus: '' });
expect(f.cut().objects).toEqual([]);
expect(compositionLength(f.project, f.cut())).toBe(25);
});
it('drops one that plays nothing or an unknown kind', () => {
const f = makeCut();
const bad = {
...f.audioItem('a', 'sound', 0, 10),
audio: { kind: 'video', asset: 'x', volume: 0, bus: '' },
};
const empty = {
...f.audioItem('b', 'sound', 0, 10),
audio: { kind: 'sound', asset: '', volume: 0, bus: '' },
};
f.lay(bad as CompositionItem, empty as CompositionItem);
expect(f.cut().items).toEqual([]);
});
it('take the length of a pattern or song, of a sound once it is measured', () => {
const f = makeCut();
f.lay(f.audioItem('p', 'pattern', 0, 1, { offset: 0.5 }), f.audioItem('s', 'sound', 0, 1));
const [pattern, sound] = f.cut().items;
expect(fittedLength(f.project, f.cut(), pattern!)).toBe(15);
expect(fittedLength(f.project, f.cut(), sound!)).toBe(30);
setSoundMeasure((file) => (file === FILE ? 1.25 : null));
try {
expect(fittedLength(f.project, f.cut(), sound!)).toBe(13);
expect(itemMedia(f.project, f.cut(), sound!)).toEqual({ begins: 0, length: 12.5, loops: true });
} finally {
setSoundMeasure(() => null);
}
});
it('mark a song to the end of its loop, then every time round it', () => {
const f = makeCut();
f.stack.execute(new SetLoopCommand('song', f.songId, { start: Q, end: 3 * Q }));
f.lay(f.audioItem('s', 'song', 0, 40));
const song = f.cut().items[0]!;
expect(fittedLength(f.project, f.cut(), song)).toBe(15);
const media = itemMedia(f.project, f.cut(), song)!;
expect(media).toEqual({ begins: 0, length: 15, loops: true, round: 10 });
expect(mediaMarks(song, media).seams).toEqual([15, 25, 35]);
});
it('own no clips: a clip on one belongs to the object item it starts on', () => {
const f = makeCut();
const sound = f.audioItem('a', 'sound', 0, 20);
const object: CompositionItem = { id: 'o', track: sound.track, start: 0, length: 20, offset: 0 };
const clip = { id: 'c', track: sound.track, animation: 'x', start: 5 };
expect([...carriedClips([object, sound], [clip], new Set(['a']))]).toEqual([]);
expect([...carriedClips([object, sound], [clip], new Set(['o']))]).toEqual(['c']);
});
});
describe('the sequences of a composition', () => {
it('lay a sound in milliseconds on a bus of its own at its volume, late by an offset below 0', () => {
const f = makeCut();
f.lay(
f.audioItem('a', 'sound', 5, 20, { volume: -6, offset: 0.25 }),
f.audioItem('b', 'sound', 30, 10, { offset: -0.3 }),
);
const [playing] = compositionPlayings(f.project, f.cut());
expect(playing!.route).toBe(MASTER_MIX);
expect(playing!.sequence.clips).toEqual([
{
tick: 500,
length: 2000,
sound: f.soundId,
fx: '',
offset: 0.25,
volume: 1,
bus: audioItemBus(f.cut().id, 'a'),
},
{
tick: 3300,
length: 700,
sound: f.soundId,
fx: '',
offset: 0,
volume: 1,
bus: audioItemBus(f.cut().id, 'b'),
},
]);
expect(playing!.buses.map((b) => [b.name, Number(b.gain.toFixed(3))])).toEqual([
[audioItemBus(f.cut().id, 'a'), 0.501],
[audioItemBus(f.cut().id, 'b'), 1],
]);
});
it('play a pattern round and round at its own tempo, cut where the item ends', () => {
const f = makeCut();
// 3.5 s from half a second in: beat 3 (1 s), then round two (1.5 s, 2.5 s), then the next (3.5 s) is cut.
f.lay(f.audioItem('p', 'pattern', 10, 35, { offset: 0.5 }));
const notes = compositionPlayings(f.project, f.cut())[0]!.sequence.notes;
expect(notes.map((n) => [n.tick, n.length, n.note, n.tempo])).toEqual([
[1500, 500, 64, 120],
[2500, 500, 60, 120],
[3500, 500, 64, 120],
]);
});
it('give a song item its own copy of the song’s buses, sending into the item’s', () => {
const f = makeCut();
f.lay(f.audioItem('s', 'song', 0, 20), f.audioItem('t', 'song', 0, 20));
const [playing] = compositionPlayings(f.project, f.cut());
const bus = audioItemBus(f.cut().id, 's');
const track =
f.cut().tracks.length && (findAssetOf(f.project, f.songId, 'song') as SongAsset).tracks[0]!.id;
const names = playing!.buses.map((b) => b.name);
expect(names).toContain(`${bus}:${track}`);
expect(names).toContain(`${audioItemBus(f.cut().id, 't')}:${track}`);
expect(playing!.buses.find((b) => b.name === `${bus}:${track}`)!.sends).toEqual([
expect.objectContaining({ to: bus }),
]);
const own = playing!.sequence.notes.filter((n) => n.bus?.startsWith(`${bus}:`));
expect(own.map((n) => [n.tick, n.note])).toEqual([
[0, 60],
[1000, 64],
]);
});
it('go one to each place of the mix their items are sent to, or all to the master unrouted', () => {
const f = makeCut();
f.lay(
f.audioItem('a', 'sound', 0, 10),
f.audioItem('b', 'sound', 0, 10, { bus: f.busId }),
f.audioItem('c', 'pattern', 0, 10, { bus: MASTER_BUS }),
);
const routes = compositionPlayings(f.project, f.cut()).map((p) => [p.route, p.buses.length]);
expect(routes).toEqual([
[MASTER_MIX, 2],
[mixBusName(f.busId), 1],
]);
expect(compositionPlayings(f.project, f.cut(), false).map((p) => p.route)).toEqual([MASTER_MIX]);
});
it('run as long as the composition, round it where it loops', () => {
const f = makeCut();
f.lay(f.audioItem('a', 'sound', 5, 20));
expect(compositionSpan(f.project, f.cut())).toEqual({ length: 2500, loop: null });
f.stack.execute(new SetAssetPropertyCommand(f.cut().id, 'loop', true));
expect(compositionSpan(f.project, f.cut())).toEqual({ length: 2500, loop: { start: 0, end: 2500 } });
});
});
describe('a playing started from a tick (#418)', () => {
it('starts as much further in as it waited for its files', async () => {
let time = 0;
let loaded = () => undefined as void;
const started: number[] = [];
const playbacks = new Playbacks({
now: () => time,
latency: () => 0,
playing: () => false,
stop: () => undefined,
wake: () => () => undefined,
prepare: () => new Promise<void>((resolve) => (loaded = resolve)),
level: () => undefined,
after: () => undefined,
});
const play: SequencePlay = {
sequence: { notes: [{ tick: 0, length: 1, note: 60, velocity: 1, instrument: 'x' }] },
tempo: 125,
length: 10000,
loop: null,
needs: { layers: [], files: [] },
from: 1000,
strike: (note) => (started.push(note.at), 1),
};
playbacks.start(1, 'composition:x', play, '');
time = 0.25;
loaded();
await Promise.resolve();
const runner = playbacks.current()[0]!.runner as unknown as { position(): { ticks: number } };
expect(runner.position().ticks).toBe(1250);
});
});
/** A sink that writes down what it is asked to play and stop. */
function recordingAudio() {
const sink = {
...SILENT_AUDIO,
started: [] as { id: number; name: string; play: SequencePlay }[],
stopped: [] as number[],
playSequence: (name: string, play: SequencePlay) => {
const id = 100 + sink.started.length;
sink.started.push({ id, name, play });
return id;
},
stopPlayback: (id: number) => {
sink.stopped.push(id);
},
};
return sink;
}
describe('a composition played in a game', () => {
function run(f: ReturnType<typeof makeCut>) {
const behavior = makeBehavior(f, 'Director');
f.stack.execute(new PlaceAssetCommand(f.sceneId, behavior.id, { x: 0, y: 0 }, f.placement));
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const play = edit.addNode('PlayComposition');
edit.connect(start, 'exec', play, 'exec');
edit.connect(owner, 'object', play, 'object');
const audio = recordingAudio();
const world = World.instantiate(f.project, f.sceneId);
addGameSystems(world, f.project, new Input(), { audio });
return { audio, world, frame: (times = 1) => Array.from({ length: times }, () => world.tick(0.1)) };
}
it('is heard from where its clock stands once it runs, and stops with its end', () => {
const f = makeCut();
f.lay(f.audioItem('a', 'sound', 0, 5));
const game = run(f);
game.frame();
// The first tick shows frame 0; the clock and the sound run from the next.
expect(game.audio.started).toEqual([]);
game.frame();
expect(game.audio.started).toHaveLength(1);
expect(game.audio.started[0]!.name).toBe(`composition:${f.cut().id}`);
expect(game.audio.started[0]!.play.from).toBe(Math.round((0.1 + LEAD) * 1000));
game.frame(2);
expect(game.audio.started).toHaveLength(1);
game.frame(3);
expect(game.audio.stopped).toEqual([100]);
});
it('stands silent while the game is paused, and goes on from where its clock stood', () => {
const f = makeCut();
f.lay(f.audioItem('a', 'sound', 0, 50));
const game = run(f);
game.frame(3);
expect(game.audio.started).toHaveLength(1);
game.world.paused = true;
game.frame(2);
expect(game.audio.stopped).toEqual([100]);
expect(game.audio.started).toHaveLength(1);
game.world.paused = false;
game.frame();
expect(game.audio.started).toHaveLength(2);
expect(game.audio.started[1]!.play.from).toBe(Math.round((0.3 + LEAD) * 1000));
});
});