A song had two rows for its meter, Beats per bar and Beat, and the patterns it kept to itself took both from it on every change. Their length is counted in bars, so a new sign cut or stretched them and their items. - The song has one row Sign with 1/2, 2/2, 3/2, 1/4 to 6/4 and 5/8 to 9/8, 4/4 by default. It is stored as `sign`. A value not in the list reads as 4/4. - An embedded pattern keeps its own tempo and meter, as a project pattern does. Its inspector shows Tempo, Beats per bar and Beat again and hides only Bus. - A pattern made in a song still starts with the song's tempo and sign, so its first bar fits the item it gets. - A copy (Embed a copy, a paste from another song) keeps its clock. `clockFollowers` and the follow step in `AssetPropertyCommand` are gone. - No load migration. The two songs of the user's project were moved from `beats`/`beat` to `sign` by hand. No other project had a song with a meter of its own. Tests: - Unit song + embedded + pattern: 44 green, 1 new, 4 rewritten. Counter-probes: a copy taking the song's clock and a sign outside the list both turn a test red. - e2e 65 (melektaus-atx): ALL PASSED, 57 checks (1 new: Sign picked from its list in the inspector). - Gate (melektaus-atx): CHECK EXIT 0, 272 files, 4214 tests. After it, `SIGNS` lost its `as const` and the cast went with it. Typecheck, lint and the 44 unit tests ran again, all green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
367 lines
14 KiB
TypeScript
367 lines
14 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,
|
|
songMeter,
|
|
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',
|
|
'sign',
|
|
'grid',
|
|
'fx',
|
|
'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 written in one of the signs from 1/2 to 9/8, else in 4/4 (#363)', () => {
|
|
const f = makeSong();
|
|
const sign = schemaForAsset(f.project, f.song()).find((d) => d.key === 'sign');
|
|
expect([sign?.label, sign?.default]).toEqual(['Sign', '4/4']);
|
|
expect(sign?.kind === 'enum' && sign.options.map((o) => o.value)).toEqual([
|
|
'1/2',
|
|
'2/2',
|
|
'3/2',
|
|
'1/4',
|
|
'2/4',
|
|
'3/4',
|
|
'4/4',
|
|
'5/4',
|
|
'6/4',
|
|
'5/8',
|
|
'6/8',
|
|
'7/8',
|
|
'8/8',
|
|
'9/8',
|
|
]);
|
|
expect(songMeter(f.song().props)).toEqual({ beats: 4, unit: 4 });
|
|
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'sign', '9/8'));
|
|
expect([songMeter(f.song().props), songLength(f.song())]).toEqual([{ beats: 9, unit: 8 }, 9 * 240]);
|
|
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'sign', '3/2'));
|
|
expect(songMeter(f.song().props)).toEqual({ beats: 3, unit: 2 });
|
|
expect([songMeter({ sign: '7/4' }), songMeter({ sign: 'x' })]).toEqual([
|
|
{ beats: 4, unit: 4 },
|
|
{ beats: 4, unit: 4 },
|
|
]);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|