Every track of a song went straight into its output. Now the Song tab has a second view, Routing: a graph with a node per track and one Output, the links between them sends. A track can go into other tracks, into several at once, and a link has its own volume and the point of the track it taps. - The song asset has a graph like an instrument (SongTrack, SongOutput). heldSongGraph keeps it in step with the tracks on load and in SetSongCommand: a node per track named after it, one output, a new track and every track of an old song linked to the output. Links of gone tracks, doubled links and links closing a loop are dropped. - A link carries an optional send (volume in dB, tap). The inspector shows Volume and Mode for a picked link of a song: Post volume (Post pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand. - NodeDef has two new flags. `fixed` nodes are never added or deleted by hand. Links between `acyclic` nodes may not close a loop, checked in canConnect, dropTargetPort and ConnectCommand. - songBuses gives each track its sends instead of one `into`, and a bus `song:<song>:<track>:#items` for its own items. That bus is what solo closes on a track that only passes a soloed one on. - Mute and solo now act on the sends (trackGates). A muted track passes nothing on, on any tap. A soloed track is heard with what goes into it, through the tracks it goes through, their own items silent. Only sends on a way through a soloed track stay open, so a track going into it and into the output is heard through it only. - SoundLibrary: a bus has either `into` or sends. Each send is tap, delay, level, target. busLatency takes the latest way (arrivalOf), and each send is held back so all ways of a track arrive together (sendDelays). Taps are wired again whenever a chain comes or goes. - The Song tab has Timeline and Routing in its header. The routing uses GraphCanvas with an empty palette. A track linked nowhere shows "Goes nowhere". Delete takes links, never a track node. - Game playings route the same way, the output being the master of the playing. Decided without asking: a send ranges -60 to +12 dB like a track, default 0 dB Post. Mute closes the sends, the track's own level stays. Found on the way: the inspector built the Mode row with the send as it was when the rows were made. Setting Volume and then Mode wrote the old volume back. Both rows now read the current send when they change. Tests: - Unit song-routing (new): 13 green. Sound library: 4 new, 94 green. 11 counter-probes (loop on load, canConnect, solo, solo past the soloed track, mute, preFx latency, graph sync, delete guard, delays, taps, glide), each red. - Existing tests adjusted to the new bus names and send shape: song, bus-fx, song-play, graph registry, edge description. - e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first run caught the inspector bug. Counter-probe with the old closure: 2 FAILED. - Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red (registry rule, edge shape), fixed and rerun locally: 56 green. - Not rerun after the per-send solo fix: e2e 65 and the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
|
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
|
import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands';
|
|
import type { PatternAsset, SongAsset } from '../../src/core/model/Asset';
|
|
import {
|
|
heldSongItems,
|
|
heldSongTracks,
|
|
itemFrom,
|
|
newItem,
|
|
newTrack,
|
|
type SongItem,
|
|
type SongTrack,
|
|
songGrid,
|
|
songLength,
|
|
songLoop,
|
|
songSequence,
|
|
trackItemsBus,
|
|
} from '../../src/core/music/song';
|
|
import { songBuses } from '../../src/core/music/songBuses';
|
|
import { type ScheduledClip, Scheduler } from '../../src/core/music/Scheduler';
|
|
import { clipsIn, type Sequence } from '../../src/core/music/sequence';
|
|
import { secondsOfTicks } from '../../src/core/music/time';
|
|
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
|
|
import { makeProject, roundTrip } from '../helpers';
|
|
|
|
const BAR = 1920;
|
|
|
|
/** A song with four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */
|
|
function makeSong(tracks = 4) {
|
|
const f = makeProject();
|
|
const create = new CreateAssetCommand('song', 'Tune');
|
|
const pattern = new CreateAssetCommand('pattern', 'Riff');
|
|
const piano = new CreateAssetCommand('instrument', 'Piano');
|
|
const organ = new CreateAssetCommand('instrument', 'Organ');
|
|
for (const command of [create, pattern, piano, organ]) f.stack.execute(command);
|
|
// A new song has one track; the tests lay things out on more, without an undo step of their own.
|
|
const created = f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
|
|
for (let i = created.tracks.length; i < tracks; i++) created.tracks.push(newTrack(i + 1));
|
|
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
|
|
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.id));
|
|
f.stack.execute(
|
|
new SetPatternNotesCommand(pattern.asset.id, [
|
|
{ id: 'n1', tick: 0, length: 480, note: 60, velocity: 1 },
|
|
{ id: 'n2', tick: 960, length: 1200, note: 64, velocity: 0.5 },
|
|
]),
|
|
);
|
|
f.project.assets.push({ id: 'snd', type: 'sound', name: 'Drum loop', props: {}, file: 'loop.wav' });
|
|
const song = () => f.project.assets.find((a) => a.id === create.asset.id) as SongAsset;
|
|
const track = (i: number) => song().tracks[i]!.id;
|
|
const item = (fields: Partial<SongItem> & Pick<SongItem, 'tick' | 'length'>): SongItem => ({
|
|
...newItem({ track: track(0), kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
|
|
...fields,
|
|
});
|
|
const lay = (...items: SongItem[]) => f.stack.execute(new SetSongCommand(song().id, { items }));
|
|
return {
|
|
...f,
|
|
song,
|
|
track,
|
|
item,
|
|
lay,
|
|
patternId: pattern.asset.id,
|
|
piano: piano.asset.id,
|
|
organ: organ.asset.id,
|
|
};
|
|
}
|
|
|
|
describe('the song asset (#334)', () => {
|
|
it('starts with one track, nothing on it, at 120 BPM in 4/4 on a grid of a bar', () => {
|
|
const f = makeSong(1);
|
|
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
|
|
expect(f.song().items).toEqual([]);
|
|
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual([
|
|
'tempo',
|
|
'beats',
|
|
'beat',
|
|
'grid',
|
|
'bus',
|
|
]);
|
|
expect(songGrid(f.song().props)).toBe(BAR);
|
|
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
|
|
expect(songGrid(f.song().props)).toBe(160);
|
|
});
|
|
|
|
it('is at least a bar long, else up to the bar line after its last item, and loops all of it', () => {
|
|
const f = makeSong();
|
|
expect(songLength(f.song())).toBe(BAR);
|
|
f.lay(f.item({ tick: 0, length: 100 }));
|
|
expect(songLength(f.song())).toBe(BAR);
|
|
f.lay(f.item({ tick: 4 * BAR, length: 100 }));
|
|
expect(songLength(f.song())).toBe(5 * BAR);
|
|
expect(songLoop(f.song())).toEqual({ start: 0, end: 5 * BAR });
|
|
f.stack.execute(new SetSongLoopCommand(f.song().id, { start: BAR, end: 2 * BAR }));
|
|
expect(songLoop(f.song())).toEqual({ start: BAR, end: 2 * BAR });
|
|
f.stack.undo();
|
|
expect(f.song().loop).toBeUndefined();
|
|
});
|
|
|
|
it('keeps tracks and items to their rules on load, and a song survives the round trip', () => {
|
|
const tracks = heldSongTracks([{ id: 'a', name: '', volume: 99, pan: -4, mute: 1 }, 'x', { id: 'a' }]);
|
|
expect(tracks.map((t) => [t.name, t.volume, t.pan, t.mute])).toEqual([
|
|
['Track 1', 12, -1, false],
|
|
['Track 2', 0, 0, false],
|
|
]);
|
|
expect(tracks[1]!.id).not.toBe('a');
|
|
expect(heldSongTracks([])).toHaveLength(1);
|
|
const items = heldSongItems(
|
|
[
|
|
{
|
|
id: 'i',
|
|
track: 'a',
|
|
kind: 'pattern',
|
|
asset: 'p',
|
|
tick: 10.4,
|
|
length: 0.2,
|
|
offset: 3.7,
|
|
volume: 5,
|
|
transpose: 99,
|
|
},
|
|
{ track: 'gone', kind: 'pattern', tick: 0, length: 10 },
|
|
{ track: 'a', kind: 'video', tick: 0, length: 10 },
|
|
{ track: 'a', kind: 'sound', tick: -1, length: 10 },
|
|
{ track: 'a', kind: 'sound', tick: 0, length: 10, offset: 1.25, volume: 30 },
|
|
],
|
|
tracks,
|
|
);
|
|
// A sound item goes up to +12 dB, a pattern item's notes no louder than they are.
|
|
expect(items.map((i) => [i.tick, i.length, i.offset, i.volume, i.transpose])).toEqual([
|
|
[0, 10, 1.25, 12, 0],
|
|
[10, 1, 4, 0, 48],
|
|
]);
|
|
|
|
const f = makeSong();
|
|
f.lay(f.item({ tick: BAR, length: BAR, transpose: -2, volume: -6 }));
|
|
f.stack.execute(new SetSongLoopCommand(f.song().id, { start: 0, end: BAR }));
|
|
const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset;
|
|
expect([back.tracks, back.items, back.loop]).toEqual([f.song().tracks, f.song().items, f.song().loop]);
|
|
});
|
|
|
|
it('takes a track with its items, and an undo brings both back', () => {
|
|
const f = makeSong();
|
|
f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: 0, length: BAR, track: f.track(1) }));
|
|
const before = JSON.stringify(f.song());
|
|
const tracks = f.song().tracks.filter((t) => t.id !== f.track(0));
|
|
f.stack.execute(new SetSongCommand(f.song().id, { tracks }));
|
|
expect(f.song().items.map((i) => i.track)).toEqual([tracks[0]!.id]);
|
|
f.stack.undo();
|
|
expect(JSON.stringify(f.song())).toBe(before);
|
|
expect(SetSongCommand.changes(f.song(), { items: f.song().items })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('what a song plays (#334)', () => {
|
|
it('repeats a pattern for as long as its item is, each note held to the item and the pattern', () => {
|
|
const f = makeSong();
|
|
f.lay(f.item({ tick: BAR, length: 2 * BAR + 480 }));
|
|
const { notes } = songSequence(f.project, f.song());
|
|
expect(notes.map((n) => [n.tick, n.length, n.note])).toEqual([
|
|
[BAR, 480, 60],
|
|
[BAR + 960, 960, 64],
|
|
[2 * BAR, 480, 60],
|
|
[2 * BAR + 960, 960, 64],
|
|
[3 * BAR, 480, 60],
|
|
]);
|
|
expect(
|
|
notes.every((n) => n.instrument === f.piano && n.bus === trackItemsBus(f.song().id, f.track(0))),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('starts a pattern as far in as its offset says, moved by its transpose, with another instrument', () => {
|
|
const f = makeSong();
|
|
f.lay(f.item({ tick: 0, length: BAR, offset: 960, transpose: 12, instrument: f.organ, volume: -6 }));
|
|
const { notes } = songSequence(f.project, f.song());
|
|
expect(notes.map((n) => [n.tick, n.length, n.note, n.instrument])).toEqual([
|
|
[0, 960, 76, f.organ],
|
|
[960, 480, 72, f.organ],
|
|
]);
|
|
expect(notes[0]!.volume).toBeCloseTo(0.501, 3);
|
|
});
|
|
|
|
it('plays a sound as a clip with its offset and FX, and leaves out what has nothing to play', () => {
|
|
const f = makeSong();
|
|
f.lay(
|
|
f.item({
|
|
kind: 'sound',
|
|
asset: 'snd',
|
|
tick: 480,
|
|
length: BAR,
|
|
offset: 0.25,
|
|
fx: 'fx1',
|
|
track: f.track(2),
|
|
}),
|
|
f.item({ kind: 'sound', asset: 'gone', tick: 0, length: BAR }),
|
|
f.item({ asset: 'gone', tick: 0, length: BAR }),
|
|
);
|
|
expect(songSequence(f.project, f.song())).toEqual({
|
|
notes: [],
|
|
clips: [
|
|
{
|
|
tick: 480,
|
|
length: BAR,
|
|
sound: 'snd',
|
|
fx: 'fx1',
|
|
offset: 0.25,
|
|
volume: 1,
|
|
bus: trackItemsBus(f.song().id, f.track(2)),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('silences a muted track, and while any track is soloed every one that is not', () => {
|
|
const f = makeSong(2);
|
|
const [a, b] = f.song().tracks;
|
|
const sends = (tracks: SongTrack[]) =>
|
|
songBuses(f.project, { ...f.song(), tracks }).flatMap((bus) =>
|
|
bus.sends ? [bus.sends.map((s) => s.gain)] : [],
|
|
);
|
|
const level = songBuses(f.project, { ...f.song(), tracks: [{ ...a!, volume: -6, pan: 0.5 }, b!] })[0]!;
|
|
expect(level.gain).toBeCloseTo(0.501, 3);
|
|
expect(level.pan).toBe(0.5);
|
|
expect(sends([a!, b!])).toEqual([[1], [1]]);
|
|
expect(sends([{ ...a!, mute: true }, b!])).toEqual([[0], [1]]);
|
|
expect(sends([{ ...a!, solo: true }, b!])).toEqual([[1], [0]]);
|
|
});
|
|
|
|
it('cuts an item in two where its rest plays on: ticks into a pattern, seconds into a sound', () => {
|
|
const f = makeSong();
|
|
const pattern = f.item({ tick: BAR, length: BAR, offset: 120 });
|
|
expect(itemFrom(pattern, BAR + 480, 120)).toMatchObject({
|
|
tick: BAR + 480,
|
|
length: BAR - 480,
|
|
offset: 600,
|
|
});
|
|
const sound = f.item({ kind: 'sound', asset: 'snd', tick: BAR, length: BAR, offset: 0.5 });
|
|
expect(itemFrom(sound, BAR + 480, 120)).toMatchObject({ tick: BAR + 480, offset: 1 });
|
|
expect(itemFrom(sound, BAR, 120)).toBeNull();
|
|
expect(itemFrom(sound, 2 * BAR, 120)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('clips on the scheduler (#334)', () => {
|
|
const clip = (tick: number, length: number) => ({
|
|
tick,
|
|
length,
|
|
sound: 's',
|
|
fx: '',
|
|
offset: 0,
|
|
volume: 1,
|
|
bus: '',
|
|
});
|
|
const sequence: Sequence = { notes: [], clips: [clip(0, 4 * BAR), clip(2 * BAR, BAR)] };
|
|
|
|
it('takes a clip up mid-way where the playhead starts, and only there', () => {
|
|
expect(clipsIn(sequence, BAR, null, 0, 10, true).map((c) => [c.at, c.skip, c.length])).toEqual([
|
|
[0, BAR, 3 * BAR],
|
|
]);
|
|
expect(clipsIn(sequence, BAR, null, 0, 10, false)).toEqual([]);
|
|
expect(clipsIn(sequence, BAR, null, BAR, BAR + 10, false).map((c) => [c.at, c.skip])).toEqual([[BAR, 0]]);
|
|
});
|
|
|
|
it('ends a clip at the end of the loop and takes it up again after the jump back', () => {
|
|
const loop = { start: BAR, end: 2 * BAR + 960 };
|
|
const due = clipsIn(sequence, BAR, loop, 0, 2 * BAR, true);
|
|
expect(due.map((c) => [c.at, c.skip, c.length])).toEqual([
|
|
[0, BAR, BAR + 960],
|
|
[BAR, 0, 960],
|
|
[BAR + 960, BAR, BAR + 960],
|
|
]);
|
|
});
|
|
|
|
it('hands a clip to the player at its second, as far into the sound as the playhead is', () => {
|
|
const played: ScheduledClip[] = [];
|
|
let now = 10;
|
|
const scheduler = new Scheduler(
|
|
{
|
|
now: () => now,
|
|
latency: () => 0,
|
|
play: () => 0,
|
|
playClip: (c) => played.push(c),
|
|
playing: () => true,
|
|
stop: () => {},
|
|
wake: () => () => {},
|
|
},
|
|
() => ({ notes: [], clips: [{ ...clip(0, 4 * BAR), offset: 0.25, volume: 0.5, bus: 'b' }] }),
|
|
{ tempo: 120 },
|
|
);
|
|
scheduler.start(BAR + 480);
|
|
expect(played).toHaveLength(1);
|
|
expect(played[0]!.at).toBeCloseTo(10.05, 9);
|
|
expect(played[0]!.offset).toBeCloseTo(0.25 + secondsOfTicks(BAR + 480, 120), 9);
|
|
expect(played[0]!.duration).toBeCloseTo(secondsOfTicks(4 * BAR - BAR - 480, 120), 9);
|
|
expect([played[0]!.volume, played[0]!.bus, played[0]!.tempo]).toEqual([0.5, 'b', 120]);
|
|
now += 0.5;
|
|
scheduler.pump();
|
|
expect(played).toHaveLength(1);
|
|
});
|
|
|
|
it('gives the notes of a song their volume and track on the way to the player', () => {
|
|
const f = makeSong();
|
|
f.lay(f.item({ tick: 0, length: 480, volume: -6 }));
|
|
const notes: unknown[] = [];
|
|
const scheduler = new Scheduler(
|
|
{
|
|
now: () => 0,
|
|
latency: () => 0,
|
|
play: (n) => notes.push(n),
|
|
playing: () => true,
|
|
stop: () => {},
|
|
wake: () => () => {},
|
|
},
|
|
() => songSequence(f.project, f.song()),
|
|
);
|
|
scheduler.start();
|
|
expect(notes).toHaveLength(1);
|
|
expect(notes[0]).toMatchObject({ note: 60, bus: trackItemsBus(f.song().id, f.track(0)) });
|
|
expect((notes[0] as { volume: number }).volume).toBeCloseTo(0.501, 3);
|
|
});
|
|
});
|
|
|
|
describe('a pattern laid out in a song', () => {
|
|
it('is found by its id: an edit to the pattern is heard everywhere it lies', () => {
|
|
const f = makeSong();
|
|
f.lay(f.item({ tick: 0, length: BAR }), f.item({ tick: BAR, length: BAR, track: f.track(1) }));
|
|
f.stack.execute(
|
|
new SetPatternNotesCommand(f.patternId, [{ id: 'n', tick: 240, length: 10, note: 70, velocity: 1 }]),
|
|
);
|
|
expect(songSequence(f.project, f.song()).notes.map((n) => [n.tick, n.note])).toEqual([
|
|
[240, 70],
|
|
[BAR + 240, 70],
|
|
]);
|
|
expect((f.project.assets.find((a) => a.id === f.patternId) as PatternAsset).notes).toHaveLength(1);
|
|
});
|
|
});
|