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

+15 -1
View File
@@ -16,6 +16,7 @@ import {
TRACK_MIN_DB,
} from '../../core/music/song';
import type { MenuEntry } from '../../ui';
import { laneLabel } from '../song/laneView';
import { ownFxEntries } from '../song/ownFx';
import type { EditorState } from '../state';
import { createFieldRow } from './fieldFactory';
@@ -107,7 +108,13 @@ export function songSignature(
const asset = findAssetOf(project, assetId, 'song');
const item = itemOf(asset, itemIds);
if (item) return `item:${item.id}:${item.kind}`;
return asset?.tracks.some((t) => t.id === trackId) ? `track:${trackId}` : 'missing';
const track = asset?.tracks.find((t) => t.id === trackId);
return track ? `track:${trackId}:${ruledLanes(track).join(',')}` : 'missing';
}
/** The lanes with a point on a track (#337): what its fields no longer set while it plays. */
function ruledLanes(track: SongTrack): string[] {
return (track.automation ?? []).flatMap((lane) => (lane.points.length > 0 ? [laneLabel(lane.target)] : []));
}
/** The item picked last (#334), else the track: its rows. */
@@ -163,6 +170,13 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
const count = asset.items.filter((i) => i.track === track.id).length;
addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`);
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
const ruled = ruledLanes(track);
if (ruled.length > 0)
addNote(
host,
`Automation lanes rule ${ruled.join(', ')} while it plays; a field here is only heard once its lane is cleared.`,
'small',
);
for (const def of TRACK_ROWS) {
const commit = (value: SongTrack[TrackField]) =>
new SetSongCommand(
+120
View File
@@ -0,0 +1,120 @@
import { heldRange } from '../../core/music/automation';
import { defaultParamRange, type TrackParam } from '../../core/music/trackParams';
import { type Button, Dropdown, Label, Menu, NumberField } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
const PICKER_WIDTH = 200;
const FIELD_WIDTH = 90;
const slot = dialogSlot<ParamLaneDialog>();
/** The open dialog, for the check scripts. */
export const currentParamLaneDialog = slot.current;
/** Opens the dialog centred, closing whichever one was open before it. */
export const openParamLaneDialog = slot.open;
/**
* A lane for a parameter of a track (#337): which parameter, and the range its lane spans – a
* parameter says nothing of its own. A new one starts at a range round its default; `editing` sets
* the range of a lane there is, its parameter fixed.
*/
export class ParamLaneDialog extends FormDialog {
private param: string;
private min: number;
private max: number;
private readonly picker: Dropdown;
private readonly minField: NumberField;
private readonly maxField: NumberField;
private readonly summary: Label;
/** Public for the check scripts. */
readonly confirm: Button;
constructor(
private readonly offered: readonly TrackParam[],
editing: { name: string; min: number; max: number } | null,
onConfirm: (name: string, range: { min: number; max: number }) => void,
) {
super(340, editing ? `Range of the ${editing.name} lane` : 'Add a parameter lane');
this.param = editing?.name ?? offered[0]?.name ?? '';
this.picker = new Dropdown('', { width: PICKER_WIDTH, onClick: () => this.openPicker() });
this.picker.setEnabled(!editing && offered.length > 1);
this.content.add(this.formRow(this.fieldLabel('Parameter'), this.picker));
({ min: this.min, max: this.max } = editing ?? this.rangeOf(this.param));
const field = (value: number, set: (value: number) => void) => {
const made = new NumberField(value, {
onInput: (now) => (set(now), this.refresh()),
onChange: (now) => (set(now), this.refresh()),
});
made.minWidth = FIELD_WIDTH;
return made;
};
this.minField = field(this.min, (value) => (this.min = value));
this.maxField = field(this.max, (value) => (this.max = value));
this.content.add(this.formRow(this.fieldLabel('From'), this.minField));
this.content.add(this.formRow(this.fieldLabel('To'), this.maxField));
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
this.confirm = this.addButtons(editing ? 'Set range' : 'Add lane', 'primary', () => {
const range = this.range();
if (range && this.param) onConfirm(this.param, range);
});
this.refresh();
}
/** What the dialog holds, for the check scripts. */
debugInfo(): { name: string; offered: string[]; min: number; max: number; ok: boolean } {
return {
name: this.param,
offered: this.offered.map((p) => p.name),
min: this.min,
max: this.max,
ok: this.confirm.enabled,
};
}
/** Picks a parameter and a range by value rather than by hand, for the check scripts. */
pick(name: string, range?: { min: number; max: number }): void {
this.param = name;
({ min: this.min, max: this.max } = range ?? this.rangeOf(name));
this.minField.setNumber(this.min);
this.maxField.setNumber(this.max);
this.refresh();
}
private rangeOf(name: string): { min: number; max: number } {
return defaultParamRange(this.offered.find((p) => p.name === name)?.default ?? 0);
}
private range(): { min: number; max: number } | null {
return heldRange(this.min, this.max);
}
private openPicker(): void {
new Menu(
this.offered.map((param) => ({
label: param.name,
current: param.name === this.param,
onSelect: () => this.pick(param.name),
})),
{ searchable: this.offered.length > 9 },
).openBelow(this.picker);
}
private refresh(): void {
this.picker.setText(this.param || '—');
const range = this.range();
const param = this.offered.find((p) => p.name === this.param);
this.summary.setText(
!this.param
? 'Nothing on this track has a parameter to move.'
: !range
? '"To" has to be above "From".'
: param
? `Its default is ${param.default}.`
: 'Nothing on the track has this parameter now.',
);
this.confirm.setEnabled(!!this.param && range !== null);
this.refreshLayout();
}
}
+136 -36
View File
@@ -2,14 +2,20 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi
import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import {
AUTOMATION_TARGETS,
type AutomationPoint,
type AutomationTarget,
isAutomationTarget,
laneOf,
laneParams,
laneRange,
laneValueAt,
paramOf,
paramTarget,
type SequenceLanePoint,
sequenceLaneOf,
type TrackTarget,
} from '../../core/music/automation';
import { trackParams } from '../../core/music/trackParams';
import { SetLoopCommand } from '../../core/commands/patternCommands';
import { SetSongCommand, type SongParts } from '../../core/commands/songCommands';
import {
@@ -84,9 +90,19 @@ import { snapDown, snapNear } from '../pattern/rollEdit';
import type { EditorState } from '../state';
import { axisTween } from '../viewTween';
import { easingEntries } from './easingMenu';
import { addedPoint, draggedPoint, easedPoint, lanePoints, withLanePoints, withoutPoint } from './laneEdit';
import {
LANE_LABELS,
addedPoint,
draggedPoint,
easedPoint,
lanePoints,
withLanePoints,
withoutLane,
withoutPoint,
withParamLane,
} from './laneEdit';
import {
laneKindOf,
laneLabel,
laneValue,
laneValueText,
laneWay,
@@ -95,8 +111,10 @@ import {
shownLanes,
type SongRow,
songRows,
trackLanes,
trackRows,
} from './laneView';
import { openParamLaneDialog, ParamLaneDialog } from './ParamLaneDialog';
import { drawMeter } from './meterDraw';
import {
gainToDb,
@@ -420,7 +438,7 @@ export class SongTimeline extends Widget {
this.open = new Map(
Object.entries(typeof lanes === 'object' && lanes !== null ? lanes : {}).map(([id, targets]) => [
id,
AUTOMATION_TARGETS.filter((t) => Array.isArray(targets) && targets.includes(t)),
Array.isArray(targets) ? (targets as unknown[]).filter(isAutomationTarget) : [],
]),
);
this.needsFit = false;
@@ -654,10 +672,11 @@ export class SongTimeline extends Widget {
const asset = this.asset();
const row = asset ? this.rows(asset).findIndex((r) => r.track === track && r.lane === target) : -1;
if (row < 0) return null;
const kind = laneKindOf(this.shownTracks(asset!)[track]!, target);
const at = this.getGlobalPosition();
return {
x: at.x + HEAD_WIDTH + tickToX(this.view, tick),
y: at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(target, value, this.view.trackHeight),
y: at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(kind, value, this.view.trackHeight),
};
}
@@ -909,7 +928,7 @@ export class SongTimeline extends Widget {
/** Opens all the lanes of a track, or closes them while any is open. */
toggleLanes(trackId: string): void {
const track = this.asset()?.tracks.find((t) => t.id === trackId);
if (track) this.setOpen(trackId, shownLanes(track, this.open).length > 0 ? [] : AUTOMATION_TARGETS);
if (track) this.setOpen(trackId, shownLanes(track, this.open).length > 0 ? [] : trackLanes(track));
}
/** Changes the points of a lane of a track as the song holds them now, as one step to undo. */
@@ -1059,9 +1078,7 @@ export class SongTimeline extends Widget {
);
} else if ((part === 'volume' || part === 'pan') && laneOf(track, part)) {
// Decision 4 of #337: a lane with points rules the value, the fader only shows it.
this.state.setNotice(
`${LANE_LABELS[part]} follows its automation lane – clear the lane to set it here`,
);
this.state.setNotice(`${laneLabel(part)} follows its automation lane – clear the lane to set it here`);
} else if (
(part === 'volume' || part === 'pan') &&
pressCount(e, `song-slider:${track.id}:${part}`) === 2
@@ -1129,8 +1146,9 @@ export class SongTimeline extends Widget {
const { track, target, top } = lane;
this.keepOpen(track);
const points = lanePoints(track, target);
const kind = laneKindOf(track, target);
const y = g.y - top;
const hit = pointAt(this.view, target, points, g.x, y);
const hit = pointAt(this.view, kind, points, g.x, y);
if (hit !== null && pressCount(e, `song-point:${track.id}:${target}:${hit}`) === 2) {
this.pickedPoint = null;
this.editLane(track.id, target, (now) => withoutPoint(now, hit), 'Delete automation point');
@@ -1141,12 +1159,7 @@ export class SongTimeline extends Widget {
let index = hit;
if (index === null) {
const tick = this.cursorTick(xToTick(this.view, g.x), songGrid(asset.props), e.altKey || e.shiftKey);
({ points: base, index } = addedPoint(
points,
target,
tick,
laneValue(target, y, this.view.trackHeight),
));
({ points: base, index } = addedPoint(points, kind, tick, laneValue(kind, y, this.view.trackHeight)));
}
const point = base[index]!;
this.pickedPoint = { track: track.id, target, index };
@@ -1159,7 +1172,7 @@ export class SongTimeline extends Widget {
points: base,
offset: {
x: tickToX(this.view, point.tick) - g.x,
y: laneY(target, point.value, this.view.trackHeight) - y,
y: laneY(kind, point.value, this.view.trackHeight) - y,
},
pointer: g,
moved: false,
@@ -1272,12 +1285,14 @@ export class SongTimeline extends Widget {
);
if (row < 0) break;
const y = g.y + gesture.offset.y - trackTop(this.view, row);
const owner = asset.tracks.find((t) => t.id === gesture.track);
const kind = owner ? laneKindOf(owner, gesture.target) : { target: gesture.target };
gesture.points = draggedPoint(
gesture.base,
gesture.target,
kind,
gesture.index,
this.cursorTick(xToTick(this.view, g.x + gesture.offset.x), grid, free),
laneValue(gesture.target, y, this.view.trackHeight),
laneValue(kind, y, this.view.trackHeight),
);
break;
}
@@ -1423,7 +1438,14 @@ export class SongTimeline extends Widget {
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
const lane = inside ? this.laneAt(asset, g.y) : null;
const point =
lane && pointAt(this.view, lane.target, lanePoints(lane.track, lane.target), g.x, g.y - lane.top);
lane &&
pointAt(
this.view,
laneKindOf(lane.track, lane.target),
lanePoints(lane.track, lane.target),
g.x,
g.y - lane.top,
);
if (lane && point !== null) {
const { track, target } = lane;
tooltips.point(
@@ -1535,8 +1557,9 @@ export class SongTimeline extends Widget {
global: Vec2,
): void {
const { track, target } = lane;
const param = paramOf(target);
const points = lanePoints(track, target);
const index = g ? pointAt(this.view, target, points, g.x, g.y - lane.top) : null;
const index = g ? pointAt(this.view, laneKindOf(track, target), points, g.x, g.y - lane.top) : null;
if (this.pickedTrack !== track.id || this.pickedIds.length > 0) this.pick([], track.id);
if (index !== null) {
this.pickedPoint = { track: track.id, target, index };
@@ -1572,9 +1595,57 @@ export class SongTimeline extends Widget {
shownLanes(track, this.open).filter((t) => t !== target),
),
},
// A parameter's lane (#337) has a range to set, and is there until it is taken away.
...(param === null
? []
: [
{ separator: true as const },
{ label: 'Range…', onSelect: () => this.openParamLaneDialog(track, param) },
{
label: 'Remove lane',
danger: true,
onSelect: () =>
this.editTrack(track.id, (t) => withoutLane(t, target), 'Remove automation lane'),
},
]),
]).open(global.x, global.y);
}
/**
* Adds a lane for a parameter of a track (#337), or sets the range of one: `name` given, only that
* one. A new lane opens under its track.
*/
openParamLaneDialog(track: SongTrack, name: string | null = null): ParamLaneDialog | null {
const asset = this.asset();
if (!asset) return null;
const taken = new Set(laneParams(track));
const offered = trackParams(this.state.project, asset, track).filter(
(param) => param.name === name || (name === null && !taken.has(param.name)),
);
const lane = name === null ? undefined : track.automation?.find((l) => l.target === paramTarget(name));
return openParamLaneDialog(
new ParamLaneDialog(
offered,
name === null ? null : { name, ...laneRange(lane ?? { target: paramTarget(name) }) },
(picked, range) => {
if (name === null) this.setOpen(track.id, [...shownLanes(track, this.open), paramTarget(picked)]);
this.editTrack(
track.id,
(t) => withParamLane(t, picked, range),
name === null ? 'Add automation lane' : 'Set automation range',
);
},
),
);
}
/** Changes one track as the song holds it now, as one step to undo. */
private editTrack(trackId: string, change: (track: SongTrack) => SongTrack, label: string): void {
const asset = this.asset();
if (!asset) return;
this.commit(asset, { tracks: asset.tracks.map((t) => (t.id === trackId ? change(t) : t)) }, label);
}
/** What the sign over a point of a lane says: its value, as the song holds it at the time. */
private pointTooltip(trackId: string, target: AutomationTarget, index: number): TooltipTarget | null {
const asset = this.asset();
@@ -1585,11 +1656,12 @@ export class SongTimeline extends Widget {
? this.rows(asset).findIndex((r) => r.lane === target && tracks[r.track]?.id === trackId)
: -1;
if (!point || row < 0) return null;
const kind = laneKindOf(track, target);
const at = this.getGlobalPosition();
const x = at.x + HEAD_WIDTH + tickToX(this.view, point.tick);
const y =
at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(target, point.value, this.view.trackHeight);
return { text: laneValueText(target, point.value), anchor: { x: x - 4, y: y - 4, w: 8, h: 8 } };
at.y + RULER_HEIGHT + trackTop(this.view, row) + laneY(kind, point.value, this.view.trackHeight);
return { text: laneValueText(kind, point.value), anchor: { x: x - 4, y: y - 4, w: 8, h: 8 } };
}
/**
@@ -1667,9 +1739,9 @@ export class SongTimeline extends Widget {
{ label: 'Move up', disabled: row === 0, onSelect: () => swap(row - 1) },
{ label: 'Move down', disabled: row === asset.tracks.length - 1, onSelect: () => swap(row + 1) },
{ separator: true },
// Which automation lanes (#337) show under the track.
...AUTOMATION_TARGETS.map((target) => ({
label: `${LANE_LABELS[target]} lane`,
// Which automation lanes (#337) show under the track, and one for a parameter to add.
...trackLanes(track).map((target) => ({
label: `${laneLabel(target)} lane`,
checked: () =>
shownLanes(this.asset()?.tracks.find((t) => t.id === track.id) ?? track, this.open).includes(
target,
@@ -1684,6 +1756,13 @@ export class SongTimeline extends Widget {
);
},
})),
{
label: 'Add parameter lane…',
disabled: trackParams(this.state.project, asset, track).every((p) =>
laneParams(track).includes(p.name),
),
onSelect: () => this.openParamLaneDialog(track),
},
{ separator: true },
{
label: 'Delete track',
@@ -1983,29 +2062,35 @@ export class SongTimeline extends Widget {
const top = trackTop(this.view, i);
if (!target || top > lh || top + th < 0) return;
const track = tracks[row.track]!;
// 0 dB, or straight ahead.
const middle = top + laneY(target, 0, th);
g.rect(0, Math.round(middle), lw, 1).fill({ color: c.text, alpha: 0.12 });
const kind = laneKindOf(track, target);
const param = paramOf(target);
// 0 dB, or straight ahead; a parameter has no middle of its own.
if (param === null) {
const middle = top + laneY(kind, 0, th);
g.rect(0, Math.round(middle), lw, 1).fill({ color: c.text, alpha: 0.12 });
}
const points = this.wayOf(track, target);
if (points.length === 0) {
const y = Math.round(top + laneY(target, track[target], th));
const value = this.unruledValue(asset, track, target);
if (value === null) return;
const y = Math.round(top + laneY(kind, value, th));
for (let x = 0; x < lw; x += 8) g.rect(x, y, 4, 1).fill({ color: AUTOMATION_COLOR, alpha: 0.45 });
return;
}
const way = laneWay(this.view, target, points, lw);
const way = laneWay(this.view, kind, points, lw);
way.forEach((p, k) => (k === 0 ? g.moveTo(p.x, top + p.y) : g.lineTo(p.x, top + p.y)));
g.stroke({ width: 1.5, color: AUTOMATION_COLOR, alpha: 0.95 });
points.forEach((point, k) => {
const x = tickToX(this.view, point.tick);
if (x < -6 || x > lw + 6) return;
const y = top + laneY(target, point.value, th);
const y = top + laneY(kind, point.value, th);
const lit = picked?.track === track.id && picked.target === target && picked.index === k;
g.rect(x - 3, y - 3, 6, 6)
.fill(lit ? c.accentText : AUTOMATION_COLOR)
.stroke({ width: 1, color: 0x000000, alpha: 0.6, alignment: 1 });
if (dragged?.track === track.id && dragged.target === target && dragged.index === k)
labels.push({
text: laneValueText(target, point.value),
text: laneValueText(kind, point.value),
x: x + 7,
y: Math.max(top, y - 16),
w: 90,
@@ -2022,6 +2107,16 @@ export class SongTimeline extends Widget {
return sequenceLaneOf({ target, points }, '', curveLookup(this.state.project)).points;
}
/**
* What a value is while no point of its lane rules it: the fader's, or a parameter's default where
* the track has it (#337); null for a parameter it does not have.
*/
private unruledValue(asset: SongAsset, track: SongTrack, target: AutomationTarget): number | null {
if (target === 'volume' || target === 'pan') return track[target];
const name = paramOf(target);
return trackParams(this.state.project, asset, track).find((p) => p.name === name)?.default ?? null;
}
private drawItems(asset: SongAsset, lw: number, lh: number, values: readonly Placed[]): void {
const c = Theme.colors;
const rows = this.rowsOfTracks(asset);
@@ -2187,10 +2282,15 @@ export class SongTimeline extends Widget {
const heard = (id: string) =>
routes.some((route) => route.from === id && gates.get(id)?.passes(route.to));
const now = this.playhead ?? this.shownStart();
const valueOf = (track: SongTrack, target: AutomationTarget) => {
const valueOf = (track: SongTrack, target: TrackTarget) => {
const points = this.wayOf(track, target);
return points.length > 0 ? laneValueAt(points, now) : track[target];
};
const laneText = (track: SongTrack, target: AutomationTarget) => {
const points = this.wayOf(track, target);
const value = points.length > 0 ? laneValueAt(points, now) : this.unruledValue(asset, track, target);
return value === null ? '—' : laneValueText(laneKindOf(track, target), value);
};
rows.forEach((row, i) => {
const y = trackTop(this.view, i);
const track = tracks[row.track]!;
@@ -2203,8 +2303,8 @@ export class SongTimeline extends Widget {
this.headsG.rect(HEAD_BOXES.name.x - 8, y, 2, th).fill({ color: AUTOMATION_COLOR, alpha: 0.7 });
const top = y + Math.max(2, Math.min(HEAD_BOXES.name.y, (th - 18) / 2));
labels.push(
{ text: LANE_LABELS[lane], x: HEAD_BOXES.name.x, y: top, w: 60 },
{ text: laneValueText(lane, valueOf(track, lane)), x: HEAD_BOXES.name.x + 62, y: top, w: 90 },
{ text: laneLabel(lane), x: HEAD_BOXES.name.x, y: top, w: 60 },
{ text: laneText(track, lane), x: HEAD_BOXES.name.x + 62, y: top, w: 90 },
);
return;
}
+53 -15
View File
@@ -1,8 +1,12 @@
import {
AUTOMATION_TARGETS,
type AutomationLane,
type AutomationPoint,
type AutomationTarget,
targetRange,
type LaneSpec,
laneRange,
paramOf,
paramTarget,
} from '../../core/music/automation';
import type { SongTrack } from '../../core/music/song';
import type { KeyInterpolation } from '../../core/model/Asset';
@@ -18,32 +22,66 @@ export function lanePoints(track: SongTrack, target: AutomationTarget): Automati
return track.automation?.find((lane) => lane.target === target)?.points ?? [];
}
/** The track with these points on that lane; with none the lane goes, and with the last one `automation`. */
/**
* The track with these points on that lane. With none the lane of the volume or the pan goes; a
* parameter's stays for its range. With the last lane `automation` goes.
*/
export function withLanePoints(
track: SongTrack,
target: AutomationTarget,
points: readonly AutomationPoint[],
): SongTrack {
const kept = (track.automation ?? []).filter((lane) => lane.target !== target);
const lanes =
points.length > 0
? AUTOMATION_TARGETS.flatMap((t) =>
t === target ? [{ target, points: [...points] }] : kept.filter((lane) => lane.target === t),
)
: kept;
const old = track.automation?.find((lane) => lane.target === target);
const range = paramOf(target) !== null ? laneRange(old ?? { target }) : null;
const lane: AutomationLane | null =
points.length > 0 || range ? { target, points: [...points], ...range } : null;
return withLane(track, target, lane);
}
/** The track with a lane for that parameter in this range, its points held to it; a new one is empty. */
export function withParamLane(
track: SongTrack,
name: string,
range: { min: number; max: number },
): SongTrack {
const target = paramTarget(name);
const points = lanePoints(track, target).map((point) => ({
...point,
value: Math.max(range.min, Math.min(range.max, point.value)),
}));
return withLane(track, target, { target, points, min: range.min, max: range.max });
}
/** The track without that lane at all, a parameter's range with it. */
export function withoutLane(track: SongTrack, target: AutomationTarget): SongTrack {
return withLane(track, target, null);
}
/** In a fixed order: the volume, the pan, then the parameters as they came; a new one last. */
function withLane(track: SongTrack, target: AutomationTarget, lane: AutomationLane | null): SongTrack {
const others = (track.automation ?? []).filter((l) => l.target !== target);
const fixed = AUTOMATION_TARGETS.flatMap((t) =>
t === target && lane ? [lane] : others.filter((l) => l.target === t),
);
const params = (track.automation ?? []).flatMap((l) =>
paramOf(l.target) === null ? [] : l.target === target ? (lane ? [lane] : []) : [l],
);
const known = (track.automation ?? []).some((l) => l.target === target);
if (lane && paramOf(target) !== null && !known) params.push(lane);
const lanes = [...fixed, ...params];
const { automation: _, ...rest } = track;
return lanes.length > 0 ? { ...rest, automation: lanes } : rest;
}
function held(target: AutomationTarget, value: number): number {
const { min, max } = targetRange(target);
function held(kind: LaneSpec, value: number): number {
const { min, max } = laneRange(kind);
return Math.max(min, Math.min(max, value));
}
/** A new point, after those already on its tick, and where it went. */
export function addedPoint(
points: readonly AutomationPoint[],
target: AutomationTarget,
kind: LaneSpec,
tick: number,
value: number,
): { points: AutomationPoint[]; index: number } {
@@ -51,14 +89,14 @@ export function addedPoint(
let index = 0;
while (index < points.length && points[index]!.tick <= at) index++;
const out = [...points];
out.splice(index, 0, { tick: at, value: held(target, value) });
out.splice(index, 0, { tick: at, value: held(kind, value) });
return { points: out, index };
}
/** The point at `index` dragged to a tick and a value, the tick held between its neighbours'. */
export function draggedPoint(
points: readonly AutomationPoint[],
target: AutomationTarget,
kind: LaneSpec,
index: number,
tick: number,
value: number,
@@ -71,7 +109,7 @@ export function draggedPoint(
out[index] = {
...point,
tick: Math.min(high, Math.max(low, Math.round(tick))),
value: held(target, value),
value: held(kind, value),
};
return out;
}
+65 -17
View File
@@ -2,11 +2,17 @@ import {
AUTOMATION_TARGETS,
type AutomationPoint,
type AutomationTarget,
type LaneKind,
laneKind,
laneOf,
laneParams,
laneRange,
laneValueAt,
laneValueBefore,
paramOf,
paramTarget,
type LaneSpec,
type SequenceLanePoint,
targetRange,
} from '../../core/music/automation';
import type { SongTrack } from '../../core/music/song';
import { clamp } from '../../ui';
@@ -28,12 +34,31 @@ export interface SongRow {
/** The lanes a track was opened to or closed on by hand, by its id. */
export type OpenLanes = ReadonlyMap<string, readonly AutomationTarget[]>;
export const LANE_LABELS: Readonly<Record<AutomationTarget, string>> = { volume: 'Volume', pan: 'Pan' };
/** What the head of a lane calls it: the volume, the pan, or the name of its parameter. */
export function laneLabel(target: AutomationTarget): string {
return target === 'volume' ? 'Volume' : target === 'pan' ? 'Pan' : (paramOf(target) ?? target);
}
/** The lanes a track shows, in a fixed order: as opened by hand, else the ones with a point. */
/** Every lane a track can show: the volume, the pan, and each parameter it has a lane of (#337). */
export function trackLanes(track: SongTrack): AutomationTarget[] {
return [...AUTOMATION_TARGETS, ...laneParams(track).map(paramTarget)];
}
/**
* The lanes a track shows, in a fixed order: as opened by hand, else the ones with a point and every
* parameter's – one is only there because it was added.
*/
export function shownLanes(track: SongTrack, open: OpenLanes): AutomationTarget[] {
const named = open.get(track.id);
return AUTOMATION_TARGETS.filter((target) => (named ? named.includes(target) : !!laneOf(track, target)));
return trackLanes(track).filter((target) =>
named ? named.includes(target) : paramOf(target) !== null || !!laneOf(track, target),
);
}
/** A track's lane of that target as far as its kind goes: a parameter's range is on its lane. */
export function laneKindOf(track: SongTrack, target: AutomationTarget): LaneKind {
const lane = track.automation?.find((l) => l.target === target);
return lane ? { target, min: lane.min, max: lane.max } : { target };
}
export function songRows(tracks: readonly SongTrack[], open: OpenLanes): SongRow[] {
@@ -58,24 +83,42 @@ export const LANE_INSET = 6;
export const POINT_REACH = 6;
/** Where a value is drawn in a lane `h` high, from its top; past the range it is held to the edge. */
export function laneY(target: AutomationTarget, value: number, h: number): number {
const { min, max } = targetRange(target);
export function laneY(kind: LaneSpec, value: number, h: number): number {
const { min, max } = laneRange(kind);
const share = clamp((value - min) / (max - min), 0, 1);
return LANE_INSET + (1 - share) * Math.max(1, h - 2 * LANE_INSET);
}
/** The value a height of a lane stands for: to a tenth of a dB, or a hundredth of the pan. */
export function laneValue(target: AutomationTarget, y: number, h: number): number {
const { min, max } = targetRange(target);
/**
* The step a lane's values are set in: a tenth of a dB, a hundredth of the pan, and for a parameter
* a power of ten that cuts its range into at least a hundred.
*/
export function laneStep(kind: LaneSpec): number {
const { target } = laneKind(kind);
if (target === 'volume') return 0.1;
if (target === 'pan') return 0.01;
const { min, max } = laneRange(kind);
return 10 ** Math.floor(Math.log10((max - min) / 100));
}
/** The value a height of a lane stands for, in its step. */
export function laneValue(kind: LaneSpec, y: number, h: number): number {
const { min, max } = laneRange(kind);
const share = clamp(1 - (y - LANE_INSET) / Math.max(1, h - 2 * LANE_INSET), 0, 1);
const step = target === 'volume' ? 0.1 : 0.01;
return clamp(Math.round((min + share * (max - min)) / step) * step, min, max);
const step = laneStep(kind);
return clamp(roundTo(min + share * (max - min), step), min, max);
}
/** Rounded to a step, without the tail a float adds to a tenth. */
function roundTo(value: number, step: number): number {
const digits = Math.max(0, -Math.floor(Math.log10(step)));
return Number((Math.round(value / step) * step).toFixed(Math.min(digits, 12)));
}
/** The point of a lane under a point of its row, the last drawn first; null for none. */
export function pointAt(
view: SongView,
target: AutomationTarget,
target: LaneSpec,
points: readonly AutomationPoint[],
x: number,
y: number,
@@ -95,7 +138,7 @@ export function pointAt(
*/
export function laneWay(
view: SongView,
target: AutomationTarget,
target: LaneSpec,
points: readonly SequenceLanePoint[],
width: number,
every = 2,
@@ -126,13 +169,18 @@ export function laneWay(
}
/** A value as the head of a lane and the sign over a point say it. */
export function laneValueText(target: AutomationTarget, value: number): string {
export function laneValueText(kind: LaneSpec, value: number): string {
const { target } = laneKind(kind);
if (target === 'volume') {
const { min } = targetRange('volume');
const { min } = laneRange('volume');
if (value <= min) return '-inf dB';
const shown = Math.round(value * 10) / 10;
return `${shown > 0 ? '+' : ''}${shown.toFixed(1)} dB`;
}
const share = Math.round(Math.abs(value) * 100);
return share === 0 ? 'Centre' : `${share}% ${value < 0 ? 'left' : 'right'}`;
if (target === 'pan') {
const share = Math.round(Math.abs(value) * 100);
return share === 0 ? 'Centre' : `${share}% ${value < 0 ? 'left' : 'right'}`;
}
const digits = Math.max(0, -Math.floor(Math.log10(laneStep(kind))));
return roundTo(value, laneStep(kind)).toFixed(Math.min(digits, 6));
}