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

+76 -1
View File
@@ -30,7 +30,7 @@ import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
import type { SendTap } from '../core/graph/Graph';
import { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer';
import { limiterLatency } from '../core/soundfx/dynamics';
import type { AutomationTarget } from '../core/music/automation';
import { type AutomationTarget, paramOf, reaches } from '../core/music/automation';
import type {
ScheduledAutomation,
ScheduledClip,
@@ -54,6 +54,7 @@ import {
FADE,
type Follow,
followCurve,
followMarks,
fadeOut,
gainNode,
GLIDE,
@@ -283,6 +284,8 @@ interface Pending {
bus: string;
fx: Layered;
timed: { params: FxParams; time: number; ease: EaseShare }[];
/** The marks of parameter lanes (#337) planned for it meanwhile, taken up once it starts. */
lanes: { param: string; automation: ScheduledAutomation }[];
/** Stopped by its number before it started. */
stopped: boolean;
/** A note released before it started: it starts and is released at once, so a one-shot is heard. */
@@ -361,6 +364,8 @@ export class SoundLibrary implements AudioSink {
private readonly failed = new Set<string>();
private voices: Voice[] = [];
private pending: Pending[] = [];
/** The knobs a parameter's lane (#337) has planned for: the next window ramps on from there. */
private readonly followed = new WeakSet<Knob>();
/**
* The last note of each Acid (#421), by who plays it and the Acid: when it starts, until when it
* is held, its frequency and voice, and what cuts it short – each of the notes that started then.
@@ -604,6 +609,8 @@ export class SoundLibrary implements AudioSink {
* A fresh start drops what was planned for it from its first mark on.
*/
automateBus(name: string, automation: ScheduledAutomation): void {
const named = paramOf(automation.target);
if (named !== null) return this.automateParam(name, named, automation);
const bus = this.buses.get(name);
const first = automation.marks[0];
if (!bus || !first) return;
@@ -635,10 +642,74 @@ export class SoundLibrary implements AudioSink {
else this.route(name, bus, into ?? null);
this.setBus(name, gain, pan, automated);
waits.push(this.setFx(bus, fx ?? null));
this.keepParams(bus, fx ?? null, automated ?? []);
}
await Promise.all(waits);
}
/**
* A parameter taken along its lane over one window (#337): on the sound FX of the track's bus and of
* every bus inside it, and on every voice heard inside it – playing, or still waiting on its bytes.
* Each mark is read through the chain, so a knob that is not the parameter itself follows too; one
* without a parameter of that name stays where it is.
*/
private automateParam(name: string, param: string, automation: ScheduledAutomation): void {
if (automation.marks.length === 0) return;
const now = this.audio().currentTime;
for (const [busName, bus] of this.buses) {
const fx = bus.fx;
if (!fx || !reaches(name, busName)) continue;
const values = automation.marks.map((mark) => ({ ...fx.params, [param]: mark.value }));
if (bus.chain) {
const levels = values.map((params) => levelsOf(fxChainOf(fx.plan, params, fx.tempo)));
followMarks(bus.chain.knobs, levels, automation.marks, now, automation.fresh, this.followed);
}
bus.fx = { ...fx, params: values.at(-1)! };
}
for (const voice of this.voices)
if (voice.bus !== '' && reaches(name, voice.bus)) this.followVoice(voice, param, automation);
for (const waiting of this.pending)
if (waiting.bus !== '' && reaches(name, waiting.bus)) waiting.lanes.push({ param, automation });
}
/**
* A voice along a parameter's lane: a note's instrument and its own sound FX (`fx:`, see
* `strikeNote`) alike, as `Set sound FX parameters` reaches both.
*/
private followVoice(voice: Voice, param: string, automation: ScheduledAutomation): void {
const { layers, tempo } = voice.fx;
const values = automation.marks.map((mark) => ({
...voice.fx.params,
[param]: mark.value,
[`fx:${param}`]: mark.value,
}));
const levels = values.map((params) => {
const out: number[] = [];
for (const plan of layers) levelsOf(fxChainOf(plan, params, tempo), out);
return out;
});
const now = this.audio().currentTime;
followMarks(voice.knobs, levels, automation.marks, now, automation.fresh, this.followed);
voice.fx = { ...voice.fx, params: values.at(-1)! };
}
/**
* A sound FX set again as it was (#337): a parameter goes to what it is set to now – one a lane let
* go back to where it stands without one –, but one a lane rules is the lane's.
*/
private keepParams(bus: Bus, fx: FxVoice | null, automated: readonly AutomationTarget[]): void {
const held = bus.fx;
if (!fx || !held || held === fx || held.plan !== fx.plan) return;
const ruled = new Set(automated.flatMap((target) => paramOf(target) ?? []));
const params: Record<string, number> = { ...fx.params };
for (const key of ruled) if (held.params[key] !== undefined) params[key] = held.params[key];
const keys = new Set([...Object.keys(params), ...Object.keys(held.params)]);
if ([...keys].every((key) => params[key] === held.params[key])) return;
bus.fx = { ...held, params };
const levels = levelsOf(fxChainOf(held.plan, params, held.tempo));
if (bus.chain) glideKnobs(bus.chain.knobs, levels, this.audio().currentTime, 0, 'linear');
}
/**
* Lets these buses go once what is on them now has faded (#346): their chains would otherwise run
* on for nothing. One used again before then stays.
@@ -1698,6 +1769,7 @@ export class SoundLibrary implements AudioSink {
bus: start.bus ?? '',
fx: { ...start.fx, params: { ...start.fx.params } },
timed: [],
lanes: [],
stopped: false,
released: false,
};
@@ -1718,6 +1790,9 @@ export class SoundLibrary implements AudioSink {
if (stream?.failed) delete started.stream;
this.start(started);
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
const voice = this.voices.find((other) => other.id === id);
if (voice)
for (const { param, automation } of waiting.lanes) this.followVoice(voice, param, automation);
if (waiting.released) this.release(id);
});
return id;