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

+53
View File
@@ -10,10 +10,15 @@ import {
easedPoint,
lanePoints,
withLanePoints,
withoutLane,
withoutPoint,
withParamLane,
} from '../../src/editor/song/laneEdit';
import {
LANE_INSET,
laneKindOf,
laneLabel,
laneStep,
laneValue,
laneValueText,
laneWay,
@@ -21,6 +26,7 @@ import {
pointAt,
shownLanes,
songRows,
trackLanes,
trackRows,
} from '../../src/editor/song/laneView';
import { trackAtY, type SongView } from '../../src/editor/song/songView';
@@ -165,6 +171,53 @@ describe('editing the points of a lane (#337)', () => {
});
});
describe('the lane of a parameter (#337)', () => {
const cut = { target: 'param:cut' as const, min: 0, max: 2000 };
const withCut = track('a', [
{ target: 'volume', points: [{ tick: 0, value: -6 }] },
{ ...cut, points: [] },
]);
it('is shown by its name after the volume and the pan, with no point too: it was added', () => {
expect(laneLabel('param:cut')).toBe('cut');
expect(trackLanes(withCut)).toEqual(['volume', 'pan', 'param:cut']);
expect(shownLanes(withCut, new Map())).toEqual(['volume', 'param:cut']);
expect(laneKindOf(withCut, 'param:cut')).toEqual(cut);
expect(laneKindOf(withCut, 'pan')).toEqual({ target: 'pan' });
});
it('draws and reads its values in its own range, to a step that cuts it into a hundred or more', () => {
expect(laneY(cut, 0, 52)).toBe(52 - LANE_INSET);
expect(laneY(cut, 2000, 52)).toBe(LANE_INSET);
expect(laneStep(cut)).toBe(10);
expect(laneStep({ target: 'param:mix' })).toBe(0.01);
expect(laneValue(cut, laneY(cut, 1234, 52), 52)).toBe(1230);
expect(laneValue({ target: 'param:mix' }, 26, 52)).toBe(0.5);
expect(laneValueText(cut, 1234)).toBe('1230');
expect(laneValueText({ target: 'param:mix' }, 0.123)).toBe('0.12');
expect(addedPoint([], cut, 0, 5000).points).toEqual([{ tick: 0, value: 2000 }]);
});
it('keeps its lane and range without a point, takes its points into a new range, and goes only when removed', () => {
const a = withLanePoints(withCut, 'param:cut', [{ tick: 0, value: 1500 }]);
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'param:cut']);
const emptied = withLanePoints(a, 'param:cut', []);
expect(emptied.automation?.[1]).toEqual({ ...cut, points: [] });
const narrow = withParamLane(a, 'cut', { min: 0, max: 1000 });
expect(narrow.automation?.[1]).toEqual({ ...cut, max: 1000, points: [{ tick: 0, value: 1000 }] });
// A new one comes last; a later volume lane still goes first.
const more = withLanePoints(withParamLane(narrow, 'tone', { min: 0, max: 1 }), 'pan', [
{ tick: 0, value: 0 },
]);
expect(more.automation?.map((l) => l.target)).toEqual(['volume', 'pan', 'param:cut', 'param:tone']);
expect(withoutLane(more, 'param:cut').automation?.map((l) => l.target)).toEqual([
'volume',
'pan',
'param:tone',
]);
});
});
describe('the easings as a menu (#337)', () => {
const project = {
assets: [