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

+59 -1
View File
@@ -703,7 +703,65 @@ export async function song(t) {
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
delete song.tracks[1].automation;
song.tracks[1].volume = 0;
song.tracks[1].volume = 0;`);
// 65h3. A lane of a parameter (#337, stage 3): the sound item of the first track goes through a
// sound FX whose parameter `cut` is its gain in dB, and starts again a bar on, eight bars
// long. Its clip starts at the value the lane has there, and its knob follows the lane while
// it plays: one voice, read twice.
const cutItem = (await model()).items.find((i) => i.kind === 'sound');
const itemBefore = JSON.stringify(cutItem);
const startAt = Math.ceil(((await json('__gm.song.position()')).ticks + BAR) / BAR) * BAR;
await mutate(`
__gm.state.project.assets.push({
id: 'sg65-fx',
type: 'soundfx',
name: 'Cut 65',
props: {},
graph: {
nodes: ${JSON.stringify([
node('sg65-fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'cut', kind: 'number', default: 0 }]),
node('sg65-fx-gain', 'SoundFxGain', 200, 0),
node('sg65-fx-out', 'SoundFxOutput', 400, 0),
])},
edges: ${JSON.stringify([
edge('sg65-fx-e1', 'sg65-fx-in', 'out', 'sg65-fx-gain', 'in'),
edge('sg65-fx-e2', 'sg65-fx-in', 'cut', 'sg65-fx-gain', 'gain'),
edge('sg65-fx-e3', 'sg65-fx-gain', 'out', 'sg65-fx-out', 'in'),
])},
},
});
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const item = song.items.find((i) => i.id === ${JSON.stringify(cutItem.id)});
Object.assign(item, { fx: 'sg65-fx', tick: ${startAt}, length: ${8 * BAR} });
song.tracks[0].automation = [
{ target: 'param:cut', min: -40, max: 0, points: [{ tick: 0, value: -40 }, { tick: ${16 * BAR}, value: 0 }] },
];`);
const cutDb = (ticks) => -40 + (40 * ticks) / (16 * BAR);
const readCut = () =>
json(`({
voices: __gm.sound.levels(${JSON.stringify(blip?.file ?? '')}).filter((knobs) => knobs.length > 0),
at: __gm.song.position()?.ticks,
clip: __gm.song.clips().filter((c) => c.fx === 'sg65-fx').at(-1) ?? null,
})`);
const cutFirst = await waitFor(readCut, (r) => r.voices.length > 0 && r.at > startAt + 0.25 * BAR, 40);
await sleep(1500);
const cutLater = await readCut();
const offCut = (r) => Math.abs(20 * Math.log10(r.voices[0]?.at(-1) ?? 0) - cutDb(r.at));
check(
'a lane of a parameter starts a clip at its value there and takes its knob along while it plays',
[cutFirst, cutLater].every((r) => r.voices.length === 1 && offCut(r) < 1) &&
Math.abs(cutFirst.clip?.params?.cut - cutDb(startAt)) < 0.01 &&
cutLater.at - cutFirst.at > 600 &&
20 * Math.log10(cutLater.voices[0].at(-1) / cutFirst.voices[0].at(-1)) > 1,
JSON.stringify({ startAt, cutFirst, cutLater, off: [cutFirst, cutLater].map(offCut) }),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const at = song.items.findIndex((i) => i.id === ${JSON.stringify(cutItem.id)});
song.items[at] = ${itemBefore};
delete song.tracks[0].automation;
__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'sg65-fx');
${loopBefore ? `song.loop = ${JSON.stringify(loopBefore)};` : 'delete song.loop;'}`);
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
// Mute closes every send of the track (#350); its own level stays as it is set.