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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user