Stage 3 of #337. Lanes moved volume and pan only. Now a lane can steer a parameter of anything on its track, and a voice already planned or still waiting for its file follows it too. - A lane may aim at param:<name>. A parameter has no range of its own, so the lane keeps min and max. A parameter lane stays without points, so its range is not lost. Remove lane takes it away. - It reaches the track FX, the FX of an item, the instrument of a note with its own FX, and the FX of a clip. When several have the name, all of them change, as with Set sound FX parameters. - The scheduler hands the lane value to a note or clip at its start. Values a voice only reads at its start act from the next note. - While a voice sounds, the knobs of buses and voices of the track follow the lane on the audio clock, read every 10 ms, because a knob is not always the parameter itself (trackParams.ts). - A voice waiting for its file takes the marks when it starts. - When a lane is removed, the parameter goes back to its default. An edit while playing does not reset a parameter a lane rules. - This holds in the song tab, for Play song, in compositions and in the bake. - Song tab: Add parameter lane in the track head menu opens a dialog for parameter and range (ParamLaneDialog.ts). The lane head menu has Range and Remove lane. - Inspector: while a lane rules a value, a hint at the track names the lanes and says the fields act once the lane is gone. Known limit: a knob that depends on two automated parameters reckons each lane with the last value of the other, so it may lag one window. Tests: - automation-params.test.ts (9, new), 3 new in sound-library and 3 in song-lanes. Affected files 407/407. - 17 counter checks, all compile, all red. - e2e 65 on melektaus-atx: ALL PASSED, 67 checks. 65h3 is new: a clip with FX parameter cut starts at the lane value and its knob stays within 0.04 dB of the lane. It goes red when automateBus does not pass parameters on. - Gate on melektaus-atx: CHECK EXIT 0, 330 files, 5080 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
/**
|
|
* Automation of the parameters of a song's track (#337, stage 3): a lane `param:<name>` 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<SongAsset['tracks'][number]>) =>
|
|
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<string, number> }[] = [];
|
|
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 });
|
|
});
|
|
});
|