Let a song track automate parameters of its sounds and FX (#337)

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-19 10:30:42 +02:00
1 parent bd99f9e79d
commit b50c7dbc35
19 files changed
+1248 -115

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
/**
* 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 });
});
});