Patterns could only be heard one at a time, in their own tab or from a behavior. A song lays patterns and sounds out on tracks along one time axis and plays them through the scheduler of #332, the third piece of the music tools of #330. - New asset type song under the heading Songs, with four empty tracks, 120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows in the inspector. Tracks, items and the loop are the asset's data, held to their rules on load and in every command. - A track has a name, volume, pan, mute and solo. Each is heard on a bus of its own, so a change reaches what is sounding at once. - A pattern item repeats its pattern for as long as it is, with a transpose and another instrument if wanted. A sound item plays its sound as a clip at its own speed, round and round, through a sound FX if wanted. Both have a volume on top of their track's. - Cut back at the left end or split, an item starts as far into what it plays as the cut is: ticks into a pattern, seconds into a sound. - The tab shows the heads with M, S, volume and pan, the ruler of the Pattern tab and the items, a pattern with its notes, a sound with its waveform, a missing asset red. Patterns and sounds are dragged in from the Assets panel. Body, right and left end move, lengthen and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits where playing starts, Delete, arrows. A double-click opens a pattern item in its tab. - What is picked is a new selection of its own, so the inspector shows the item or the track, with a button to delete it. - The scheduler plans clips beside notes. Where the playhead starts or jumps back inside a clip, it is taken up that far into its sound. A clip fades out at the end of its item or of the loop. - The editor plays one piece at a time: a pattern started stops the song and the other way round. - The rules are pure functions in core/music/song, editor/song/ songView and songEdit. Findings: - The suites of4ea752fand2a3b90bwere red at e2e 54: the Pattern pushed Struct below the fold of the + menu. The module now scrolls to it, as e2e 47 does for Vector art. It also left the menu open, which made e2e 55 fail behind it. - The Assets panel only dragged assets a scene can place. Patterns and sounds are dragged now as well, and a scene says why it does not take one instead of calling every asset a scene. Tests: - song.test.ts (new, 14) and song-edit.test.ts (new, 9): model, round trip, sequence of patterns and clips, mute and solo, cuts, clips on the scheduler, moving, sizing, trimming, splitting, duplicating, hits, head parts. sound-library.test.ts +5: clip offset, end fade, pitched clip, track bus, peaks. - e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and 55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests. - Counter-checks: no taking up of a clip, no pattern offset, no clip end each fail 3, 1 and 1 tests. - Probe on stein2: the tab with four tracks, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 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,
|
|
type SongItem,
|
|
songGrid,
|
|
songLength,
|
|
songLoop,
|
|
songSequence,
|
|
trackBus,
|
|
trackLevel,
|
|
} from '../../src/core/music/song';
|
|
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 its four tracks, a one-bar pattern of two notes played by "Piano", and a sound. */
|
|
function makeSong() {
|
|
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);
|
|
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 four tracks, nothing on them, at 120 BPM in 4/4 on a grid of a bar', () => {
|
|
const f = makeSong();
|
|
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']);
|
|
expect(f.song().items).toEqual([]);
|
|
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'beats', 'beat', 'grid']);
|
|
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 four bars long, else up to the bar line after its last item, and loops all of it', () => {
|
|
const f = makeSong();
|
|
expect(songLength(f.song())).toBe(4 * 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', 6, -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 },
|
|
],
|
|
tracks,
|
|
);
|
|
expect(items.map((i) => [i.tick, i.length, i.offset, i.volume, i.transpose])).toEqual([
|
|
[0, 10, 1.25, 0, 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 === trackBus(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: trackBus(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();
|
|
const [a, b] = f.song().tracks;
|
|
const level = trackLevel({ ...a!, volume: -6, pan: 0.5 }, f.song().tracks);
|
|
expect(level.gain).toBeCloseTo(0.501, 3);
|
|
expect(level.pan).toBe(0.5);
|
|
expect(trackLevel({ ...a!, mute: true }, f.song().tracks).gain).toBe(0);
|
|
const soloed = [{ ...a!, solo: true }, b!];
|
|
expect([trackLevel(soloed[0]!, soloed).gain, trackLevel(b!, soloed).gain]).toEqual([1, 0]);
|
|
expect(trackLevel({ ...a!, volume: -60 }, f.song().tracks).gain).toBe(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: trackBus(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);
|
|
});
|
|
});
|