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:
1 parent
bd99f9e79d
commit
b50c7dbc35
19 files changed
+1248
-115
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user