/** * Automation of the parameters of a song's track (#337, stage 3): a lane `param:` with a range * of its own, the value each note and clip starts with, the sound FX of the track and its items set * where the lane starts, and which parameters a track has to offer. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset'; import { findAssetOf } from '../../src/core/model/Project'; import { chipPresetGraph } from '../../src/core/instrument/chipPresets'; import { type AutomationLane, heldLanes, laneMarks, laneRange, paramsAt, reaches, type SequenceLane, } from '../../src/core/music/automation'; import { type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost, } from '../../src/core/music/Scheduler'; import { newItem, songSequence, trackBus } from '../../src/core/music/song'; import { songBuses } from '../../src/core/music/songBuses'; import { defaultParamRange, trackParams } from '../../src/core/music/trackParams'; import { strikeNote } from '../../src/runtime/systems/behaviorContext'; import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; import { graphEditor, makeProject, type ProjectFixture } from '../helpers'; const Q = 480; const BAR = 4 * Q; describe('a lane of a parameter (#337)', () => { it('is held to a name without a colon, its points to its own range, and kept without a point', () => { expect( heldLanes([ { target: 'param:cut', min: 100, max: 5000, points: [ { tick: 0, value: 20000 }, { tick: 10, value: 50 }, ], }, { target: 'param:', points: [{ tick: 0, value: 1 }] }, { target: 'param:a:b', points: [{ tick: 0, value: 1 }] }, { target: 'param:mix', min: 1, max: 1, points: [{ tick: 0, value: 3 }] }, { target: 'param:empty', min: -1, max: 1, points: [] }, { target: 'param:bare', points: [] }, ]), ).toEqual([ { target: 'param:cut', min: 100, max: 5000, points: [ { tick: 0, value: 5000 }, { tick: 10, value: 100 }, ], }, // A range that is none is the one a lane without a range has: 0 to 1. { target: 'param:mix', points: [{ tick: 0, value: 1 }] }, { target: 'param:empty', min: -1, max: 1, points: [] }, { target: 'param:bare', points: [] }, ]); expect(laneRange({ target: 'param:mix' })).toEqual({ min: 0, max: 1 }); expect(laneRange('volume')).toEqual({ min: -60, max: 12 }); }); it('reaches its track and every bus inside it, and starts what is heard there at its value', () => { expect(reaches('song:s:t', 'song:s:t')).toBe(true); expect(reaches('song:s:t', 'song:s:t:#items')).toBe(true); expect(reaches('song:s:t', 'song:s:t2:#items')).toBe(false); const lanes: SequenceLane[] = [ { bus: 'song:s:t', target: 'param:cut', min: 0, max: 10, points: [ { tick: 0, value: 0 }, { tick: 100, value: 20 }, ], }, { bus: 'song:s:t', target: 'volume', points: [{ tick: 0, value: -6 }] }, { bus: 'song:s:u', target: 'param:other', points: [{ tick: 0, value: 1 }] }, ]; expect(paramsAt(lanes, 'song:s:t:#items', 25)).toEqual({ cut: 5 }); // Past its range the way is held to it. expect(paramsAt(lanes, 'song:s:t:item', 90)).toEqual({ cut: 10 }); expect(paramsAt(lanes, 'song:s:x:#items', 25)).toBeUndefined(); expect(paramsAt(lanes, undefined, 25)).toBeUndefined(); }); it('is read every step even where it runs straight, as a knob may not be the parameter itself', () => { const lane: SequenceLane = { bus: 'b', target: 'param:cut', min: 0, max: 1000, points: [ { tick: 0, value: 0 }, { tick: 960, value: 1000 }, ], }; const marks = laneMarks(lane, 0, null, 0, 480, true, 9.6); expect(marks).toHaveLength(51); // In the units of the parameter, not as a gain. for (const mark of marks) expect(mark.value).toBeCloseTo((1000 * mark.at) / 960, 9); const held = laneMarks({ ...lane, points: [{ tick: 0, value: 300 }] }, 0, null, 0, 480, true, 9.6); expect(held).toEqual([ { at: 0, value: 300, jump: true }, { at: 480, value: 300, jump: false }, ]); }); }); describe('the scheduler and a parameter lane (#337)', () => { it('starts each note and clip of the track at the value the lane has where it starts', () => { const notes: ScheduledNote[] = []; const clips: ScheduledClip[] = []; let time = 0; const host: SchedulerHost = { now: () => time, latency: () => 0, play: (note) => (notes.push(note), 1), playClip: (clip) => (clips.push(clip), 2), playing: () => true, stop: () => {}, wake: () => () => {}, }; const lane: SequenceLane = { bus: 'song:s:t', target: 'param:cut', min: 0, max: 1000, points: [ { tick: 0, value: 0 }, { tick: 1000, value: 1000 }, ], }; const scheduler = new Scheduler( host, () => ({ notes: [ { tick: 200, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t:#items' }, { tick: 210, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:u:#items' }, ], clips: [{ tick: 100, length: 500, sound: 's', fx: '', offset: 0, volume: 1, bus: 'song:s:t:#items' }], lanes: [lane], }), { tempo: 120 }, ); scheduler.start(300); time = 0.5; scheduler.pump(); expect(notes).toEqual([]); // Started inside the clip: it is taken up 200 ticks in, at the value there. expect(clips.map((clip) => clip.params)).toEqual([{ cut: 300 }]); scheduler.start(150); time = 1; scheduler.pump(); expect(notes.map((note) => [note.bus, note.params])).toEqual([ ['song:s:t:#items', { cut: 200 }], ['song:s:u:#items', undefined], ]); }); }); /** A sound FX "Filter" with a parameter `cut` (default 800) into its gain. */ function makeFilter(f: ProjectFixture): SoundFxAsset { const command = new CreateAssetCommand('soundfx', 'Filter'); f.stack.execute(command); const fx = command.asset as SoundFxAsset; const edit = graphEditor(f, fx as unknown as BehaviorAsset); const input = edit.addNode('SoundFxInput'); const gain = edit.addNode('SoundFxGain'); const output = edit.addNode('SoundFxOutput'); input.ports = [{ key: 'cut', kind: 'number', default: 800 }]; edit.connect(input, 'out', gain, 'in'); edit.connect(input, 'cut', gain, 'gain'); edit.connect(gain, 'out', output, 'in'); return fx; } /** A song "Tune" with a pattern of one note on its one track, played by a chip instrument with a parameter `tone`. */ function makeTune() { const f = makeProject(); const song = new CreateAssetCommand('song', 'Tune'); const pattern = new CreateAssetCommand('pattern', 'Riff'); const chip = new CreateAssetCommand('instrument', 'Chip'); for (const command of [song, pattern, chip]) f.stack.execute(command); const instrument = chip.asset as InstrumentAsset; instrument.graph = chipPresetGraph('pickup', () => 0); instrument.graph.nodes.push({ id: 'params', type: 'InstrumentParameters', x: 0, y: 0, params: {}, ports: [ { key: 'tone', kind: 'number', default: 0.25 }, { key: 'cut', kind: 'number', default: 5 }, ], }); f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1)); f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', instrument.id)); f.stack.execute( new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 }]), ); const tune = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset; const trackId = tune().tracks[0]!.id; f.stack.execute( new SetSongCommand(song.asset.id, { items: [newItem({ track: trackId, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR })], }), ); const setTrack = (fields: Partial) => f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, ...fields }] })); return { ...f, tune, trackId, setTrack, instrument, patternId: pattern.asset.id }; } describe('a song with a parameter lane (#337)', () => { const cutLane: AutomationLane = { target: 'param:cut', min: 0, max: 2000, points: [ { tick: 0, value: 400 }, { tick: BAR, value: 1600 }, ], }; it('plays it on its track, and sets the sound FX of the track where it starts', () => { const f = makeTune(); const fx = makeFilter(f); f.setTrack({ fx: fx.id, automation: [cutLane] }); expect(songSequence(f.project, f.tune()).lanes).toEqual([ { bus: trackBus(f.tune().id, f.trackId), ...cutLane }, ]); const [track] = songBuses(f.project, f.tune()); expect(track).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } }); // Without a point it rules nothing, and is not played. f.setTrack({ automation: [{ ...cutLane, points: [] }] }); expect(songSequence(f.project, f.tune()).lanes).toBeUndefined(); expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated'); expect(songBuses(f.project, f.tune())[0]!.fx!.params).toEqual({}); }); it('sets the sound FX of a pattern item on the track the same way, which leaves it to the lane', () => { const f = makeTune(); const fx = makeFilter(f); const item = f.tune().items[0]!; f.stack.execute(new SetSongCommand(f.tune().id, { items: [{ ...item, fx: fx.id }] })); f.setTrack({ automation: [cutLane] }); const own = songBuses(f.project, f.tune()).find((bus) => bus.name.endsWith(item.id)); expect(own).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } }); }); it('offers the parameters of the track, its items and their instruments, each name once', () => { const f = makeTune(); const fx = makeFilter(f); expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([ { name: 'tone', default: 0.25 }, { name: 'cut', default: 5 }, ]); f.setTrack({ fx: fx.id }); // The track's own first: its `cut` is the one whose default is named. expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([ { name: 'cut', default: 800 }, { name: 'tone', default: 0.25 }, ]); }); it('starts a new lane at a range round the default, on a round number', () => { expect(defaultParamRange(0.25)).toEqual({ min: 0, max: 1 }); expect(defaultParamRange(800)).toEqual({ min: 0, max: 2000 }); expect(defaultParamRange(1.5)).toEqual({ min: 0, max: 5 }); expect(defaultParamRange(-3)).toEqual({ min: -10, max: 10 }); expect(defaultParamRange(Number.NaN)).toEqual({ min: 0, max: 1 }); }); it('hands a note its values for its instrument and its own sound FX alike', () => { const f = makeTune(); const fx = makeFilter(f); f.stack.execute(new SetAssetPropertyCommand(f.instrument.id, 'fx', fx.id)); const struck: { params: Record }[] = []; const audio: AudioSink = { ...SILENT_AUDIO, playNote: (_name, _layers, params) => (struck.push({ params: { ...params } }), 1), }; const note: ScheduledNote = { at: 0, duration: 0.1, note: 60, velocity: 1, instrument: f.instrument.id, tempo: 120, params: { cut: 1200 }, }; expect(strikeNote({ project: f.project, audio }, note, '')).toBe(1); expect(struck[0]!.params).toMatchObject({ cut: 1200, 'fx:cut': 1200 }); }); });