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>
187 lines
6.9 KiB
TypeScript
187 lines
6.9 KiB
TypeScript
import {
|
||
AUTOMATION_TARGETS,
|
||
type AutomationPoint,
|
||
type AutomationTarget,
|
||
type LaneKind,
|
||
laneKind,
|
||
laneOf,
|
||
laneParams,
|
||
laneRange,
|
||
laneValueAt,
|
||
laneValueBefore,
|
||
paramOf,
|
||
paramTarget,
|
||
type LaneSpec,
|
||
type SequenceLanePoint,
|
||
} from '../../core/music/automation';
|
||
import type { SongTrack } from '../../core/music/song';
|
||
import { clamp } from '../../ui';
|
||
import { tickToX, xToTick } from '../music/timeView';
|
||
import type { SongView } from './songView';
|
||
|
||
/**
|
||
* The automation lanes of the Song tab (#337), apart from Pixi so a test can drive them: each lane a
|
||
* row of its own right under its track, as high as a track, so the rows stay evenly spaced and the
|
||
* geometry of `songView` holds for them as for the tracks.
|
||
*/
|
||
|
||
/** A row of the tracks: a track itself, or one of its lanes. `track` is the track's index. */
|
||
export interface SongRow {
|
||
track: number;
|
||
lane: AutomationTarget | null;
|
||
}
|
||
|
||
/** The lanes a track was opened to or closed on by hand, by its id. */
|
||
export type OpenLanes = ReadonlyMap<string, readonly AutomationTarget[]>;
|
||
|
||
/** 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);
|
||
}
|
||
|
||
/** 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 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[] {
|
||
return tracks.flatMap((track, i) => [
|
||
{ track: i, lane: null },
|
||
...shownLanes(track, open).map((lane) => ({ track: i, lane })),
|
||
]);
|
||
}
|
||
|
||
/** The row of each track, by its id. */
|
||
export function trackRows(tracks: readonly SongTrack[], rows: readonly SongRow[]): Map<string, number> {
|
||
const out = new Map<string, number>();
|
||
rows.forEach((row, i) => {
|
||
if (row.lane === null) out.set(tracks[row.track]!.id, i);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** What is left free above and below the way of a lane, so a point at either end can still be taken. */
|
||
export const LANE_INSET = 6;
|
||
/** How near a point a press takes it. */
|
||
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(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 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 = 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: LaneSpec,
|
||
points: readonly AutomationPoint[],
|
||
x: number,
|
||
y: number,
|
||
): number | null {
|
||
for (let i = points.length - 1; i >= 0; i--) {
|
||
const point = points[i]!;
|
||
const dx = tickToX(view, point.tick) - x;
|
||
const dy = laneY(target, point.value, view.trackHeight) - y;
|
||
if (Math.abs(dx) <= POINT_REACH && Math.abs(dy) <= POINT_REACH) return i;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The way of a lane across `width` pixels, a corner every `every` pixels and at each point – a jump
|
||
* shows as an upright –, in the row's own pixels.
|
||
*/
|
||
export function laneWay(
|
||
view: SongView,
|
||
target: LaneSpec,
|
||
points: readonly SequenceLanePoint[],
|
||
width: number,
|
||
every = 2,
|
||
): { x: number; y: number }[] {
|
||
if (points.length === 0) return [];
|
||
const h = view.trackHeight;
|
||
const way: { x: number; y: number }[] = [];
|
||
const corners = new Set<number>();
|
||
for (let x = 0; x <= width; x += every) corners.add(x);
|
||
for (const point of points) {
|
||
const x = tickToX(view, point.tick);
|
||
if (x > 0 && x < width) corners.add(x);
|
||
}
|
||
for (const x of [...corners].sort((a, b) => a - b)) {
|
||
const at = points.findIndex((p) => tickToX(view, p.tick) === x);
|
||
if (at < 0) {
|
||
way.push({ x, y: laneY(target, laneValueAt(points, xToTick(view, x)), h) });
|
||
continue;
|
||
}
|
||
// On a point the way comes in with the value before it and leaves from the last point on its tick.
|
||
const tick = points[at]!.tick;
|
||
const before = laneValueBefore(points, tick);
|
||
if (before !== points[at]!.value) way.push({ x, y: laneY(target, before, h) });
|
||
for (let i = at; i < points.length && points[i]!.tick === tick; i++)
|
||
way.push({ x, y: laneY(target, points[i]!.value, h) });
|
||
}
|
||
return way;
|
||
}
|
||
|
||
/** A value as the head of a lane and the sign over a point say it. */
|
||
export function laneValueText(kind: LaneSpec, value: number): string {
|
||
const { target } = laneKind(kind);
|
||
if (target === '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`;
|
||
}
|
||
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));
|
||
}
|