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

+12 -2
View File
@@ -1,5 +1,6 @@
import type { FadeCurve } from '../model/sound';
import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, laneMarks } from './automation';
import type { FxParams } from '../soundfx/nodes';
import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, laneMarks, paramsAt } from './automation';
import { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
import {
COMMON_TIME,
@@ -31,6 +32,8 @@ export interface ScheduledNote {
/** A factor on its loudness, and the track of a song it is heard on (#334). */
volume?: number;
bus?: string;
/** The values the parameter lanes of its track have where it starts (#337). */
params?: FxParams;
}
/** One clip handed to the player (#334): when it starts, how long it plays and from where in the sound. */
@@ -44,6 +47,8 @@ export interface ScheduledClip {
volume: number;
bus: string;
tempo: number;
/** As a note's. */
params?: FxParams;
}
/**
@@ -221,7 +226,9 @@ export class Scheduler {
if (to <= this.until) return;
this.voices = this.voices.filter((voice) => this.host.playing(voice));
const sequence = this.sequence();
const lanes = sequence.lanes ?? [];
for (const due of notesIn(sequence, this.from, this.loop, this.until, to)) {
const params = paramsAt(lanes, due.bus, due.tick);
const voice = this.host.play({
at: this.anchor + secondsOfTicks(due.at, this.tempo),
duration: secondsOfTicks(due.length, this.tempo),
@@ -231,11 +238,13 @@ export class Scheduler {
tempo: due.tempo ?? this.tempo,
...(due.volume !== undefined && { volume: due.volume }),
...(due.bus !== undefined && { bus: due.bus }),
...(params && { params }),
});
if (voice !== 0) this.voices.push(voice);
}
if (this.host.playClip)
for (const due of clipsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
const params = paramsAt(lanes, due.bus, due.tick + due.skip);
const voice = this.host.playClip({
at: this.anchor + secondsOfTicks(due.at, this.tempo),
duration: secondsOfTicks(due.length, this.tempo),
@@ -245,6 +254,7 @@ export class Scheduler {
volume: due.volume,
bus: due.bus,
tempo: due.tempo ?? this.tempo,
...(params && { params }),
});
if (voice !== 0) this.voices.push(voice);
}
@@ -264,7 +274,7 @@ export class Scheduler {
if (this.host.automate) {
const anchor = this.anchor;
const step = ticksOfSeconds(AUTOMATION_STEP, this.tempo);
for (const lane of sequence.lanes ?? []) {
for (const lane of lanes) {
const marks = laneMarks(lane, this.from, this.loop, this.until, to, this.fresh, step);
this.host.automate({
bus: lane.bus,
+120 -20
View File
@@ -7,16 +7,47 @@
*
* The scheduler reads a lane window by window as marks on the audio clock, each ramped or jumped to.
* Where the value moves along a shape the clock cannot ramp, it is read every `AUTOMATION_STEP`.
*
* A lane can also move a parameter (`param:<name>`): of the track's sound FX, of the sound FX of its
* items, of the instruments of its notes and the sound FX of its clips – wherever one of them has a
* parameter of that name, as `Set sound FX parameters` reaches them. A note or clip starts at the
* value its lane has there, so a parameter a voice only reads when it starts is heard from the next one.
*/
import type { KeyInterpolation, VectorPoint } from '../model/Asset';
import { easedShare, easingOf, isKeyInterpolation } from '../model/animation';
import type { CurveLookup } from '../model/curve';
import type { FxParams } from '../soundfx/nodes';
import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level';
import { type LoopRange, playheadAt } from './sequence';
export type AutomationTarget = 'volume' | 'pan';
/** What a lane always moves on a track: its fader and its pan. */
export type TrackTarget = 'volume' | 'pan';
export const AUTOMATION_TARGETS: readonly AutomationTarget[] = ['volume', 'pan'];
/** A parameter by its name (#337, stage 3); no name of a parameter has a colon. */
export type ParamTarget = `param:${string}`;
export type AutomationTarget = TrackTarget | ParamTarget;
export const AUTOMATION_TARGETS: readonly TrackTarget[] = ['volume', 'pan'];
const PARAM_PREFIX = 'param:';
export function paramTarget(name: string): ParamTarget {
return `${PARAM_PREFIX}${name}`;
}
/** The parameter a target moves; null for the volume and the pan. */
export function paramOf(target: AutomationTarget): string | null {
return target.startsWith(PARAM_PREFIX) ? target.slice(PARAM_PREFIX.length) : null;
}
/** Whether a text is a target a lane can have: the volume, the pan or a parameter with a name. */
export function isAutomationTarget(value: unknown): value is AutomationTarget {
if (value === 'volume' || value === 'pan') return true;
if (typeof value !== 'string' || !value.startsWith(PARAM_PREFIX)) return false;
const name = value.slice(PARAM_PREFIX.length);
return name !== '' && !name.includes(':');
}
export interface AutomationPoint {
tick: number;
@@ -30,35 +61,67 @@ export interface AutomationLane {
target: AutomationTarget;
/** In the order of their ticks. */
points: AutomationPoint[];
/**
* Only on a parameter's lane: the range its values are drawn and held in, `PARAM_RANGE` where left
* out. A parameter says nothing of its own range, so the lane does. Such a lane is kept without a
* point too, for its range; it rules its parameter once it has one.
*/
min?: number;
max?: number;
}
/** What says how a lane's values are held and drawn: its target, and a parameter's range. */
export type LaneKind = Pick<AutomationLane, 'target' | 'min' | 'max'>;
/** A lane's kind, or the volume or the pan by name: neither has a range of its own to name. */
export type LaneSpec = LaneKind | TrackTarget;
export function laneKind(spec: LaneSpec): LaneKind {
return typeof spec === 'string' ? { target: spec } : spec;
}
/** The range of a parameter's lane that sets none. */
export const PARAM_RANGE = { min: 0, max: 1 } as const;
/** Seconds between two readings of a value that moves along a shape the audio clock cannot ramp. */
export const AUTOMATION_STEP = 0.01;
/** The lowest and the highest value a lane of that target holds. */
export function targetRange(target: AutomationTarget): { min: number; max: number } {
return target === 'volume' ? { min: TRACK_MIN_DB, max: TRACK_MAX_DB } : { min: -1, max: 1 };
/** The lowest and the highest value a lane of that kind holds. */
export function laneRange(spec: LaneSpec): { min: number; max: number } {
const { target, min, max } = laneKind(spec);
if (target === 'volume') return { min: TRACK_MIN_DB, max: TRACK_MAX_DB };
if (target === 'pan') return { min: -1, max: 1 };
return heldRange(min, max) ?? { ...PARAM_RANGE };
}
/** A lane's value as its bus takes it: the volume as a gain, the pan as it is. */
export function audioValueOf(target: AutomationTarget, value: number): number {
const { min, max } = targetRange(target);
/** A range a parameter's lane may have: both ends finite, the top above the bottom; null otherwise. */
export function heldRange(min: unknown, max: unknown): { min: number; max: number } | null {
if (typeof min !== 'number' || typeof max !== 'number') return null;
if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return null;
return { min, max };
}
/** A lane's value as its bus takes it: the volume as a gain, the pan and a parameter as they are. */
export function audioValueOf(lane: LaneKind, value: number): number {
const { min, max } = laneRange(lane);
const held = Math.max(min, Math.min(max, value));
return target === 'volume' ? dbToGain(held) : held;
return lane.target === 'volume' ? dbToGain(held) : held;
}
/**
* Held to what the lanes of a track may be: a target it knows, one lane for each, points in whole
* ticks from 0 with their values in range, in the order of their ticks. A lane without one is dropped.
* ticks from 0 with their values in range, in the order of their ticks. A lane of the volume or the
* pan without a point is dropped; a parameter's is kept for its range.
*/
export function heldLanes(raw: unknown): AutomationLane[] {
const lanes: AutomationLane[] = [];
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
if (typeof entry !== 'object' || entry === null) continue;
const lane = entry as Record<string, unknown>;
const target = AUTOMATION_TARGETS.find((t) => t === lane.target);
if (!target || lanes.some((l) => l.target === target)) continue;
const { min, max } = targetRange(target);
const target = lane.target;
if (!isAutomationTarget(target) || lanes.some((l) => l.target === target)) continue;
const range = paramOf(target) !== null ? heldRange(lane.min, lane.max) : null;
const { min, max } = range ?? laneRange({ target });
const points = (Array.isArray(lane.points) ? (lane.points as unknown[]) : []).flatMap(
(point): AutomationPoint[] => {
if (typeof point !== 'object' || point === null) return [];
@@ -75,11 +138,17 @@ export function heldLanes(raw: unknown): AutomationLane[] {
},
);
points.sort((a, b) => a.tick - b.tick);
if (points.length > 0) lanes.push({ target, points });
if (range) lanes.push({ target, points, ...range });
else if (points.length > 0 || paramOf(target) !== null) lanes.push({ target, points });
}
return lanes;
}
/** The parameters a track's lanes move, in their order, those without a point among them. */
export function laneParams(track: { automation?: readonly AutomationLane[] }): string[] {
return (track.automation ?? []).flatMap((lane) => paramOf(lane.target) ?? []);
}
/** The lane of a track for that target, while it has a point. */
export function laneOf(
track: { automation?: readonly AutomationLane[] },
@@ -93,6 +162,7 @@ export function sequenceLaneOf(lane: AutomationLane, bus: string, curves: CurveL
return {
bus,
target: lane.target,
...(lane.min !== undefined && lane.max !== undefined && { min: lane.min, max: lane.max }),
points: lane.points.map((point) => {
const { shape, curve } = easingOf(point.ease ?? 'linear');
const found = shape === 'curve' ? curves(curve ?? '') : undefined;
@@ -106,13 +176,40 @@ export interface SequenceLanePoint extends AutomationPoint {
curve?: readonly VectorPoint[];
}
/** A lane as a sequence plays it: on the bus it acts on, never without a point. */
export interface SequenceLane {
/**
* A lane as a sequence plays it: on the bus it acts on, never without a point. A parameter's acts on
* that bus's sound FX and on everything heard on a bus inside it (`reaches`).
*/
export interface SequenceLane extends LaneKind {
bus: string;
target: AutomationTarget;
points: readonly SequenceLanePoint[];
}
/** Whether a voice or a bus by that name is on the track of `bus` – it, or a bus inside it (`<bus>:…`). */
export function reaches(bus: string, name: string): boolean {
return name === bus || name.startsWith(`${bus}:`);
}
/**
* The values the parameter lanes of a sequence have at `tick` for what is heard on `bus`, a note's or
* a clip's; undefined where none reaches it.
*/
export function paramsAt(
lanes: readonly SequenceLane[],
bus: string | undefined,
tick: number,
): FxParams | undefined {
if (bus === undefined) return undefined;
let params: Record<string, number> | undefined;
for (const lane of lanes) {
const name = paramOf(lane.target);
if (name === null || !reaches(lane.bus, bus)) continue;
const { min, max } = laneRange(lane);
(params ??= {})[name] = Math.max(min, Math.min(max, laneValueAt(lane.points, tick)));
}
return params;
}
function between(a: SequenceLanePoint, b: SequenceLanePoint, t: number): number {
return a.value + (b.value - a.value) * easedShare(a.ease ?? 'linear', t, () => a.curve);
}
@@ -151,7 +248,10 @@ function straight(a: SequenceLanePoint, b: SequenceLanePoint): boolean {
return a.value === b.value || ease === 'step' || ease === 'linear';
}
/** Whether the bus takes it as one ramp: held, or a straight line also in its own units. */
/**
* Whether the bus takes it as one ramp: held, or a straight line also in its own units. A parameter
* becomes its knobs along ways of their own, so only one held is a ramp of them.
*/
function rampable(target: AutomationTarget, a: SequenceLanePoint, b: SequenceLanePoint): boolean {
const ease = a.ease ?? 'linear';
return a.value === b.value || ease === 'step' || (ease === 'linear' && target === 'pan');
@@ -179,8 +279,8 @@ export function laneMarks(
fresh: boolean,
step: number,
): LaneMark[] {
const { points, target } = lane;
const audio = (value: number) => audioValueOf(target, value);
const { points } = lane;
const audio = (value: number) => audioValueOf(lane, value);
const marks: LaneMark[] = [];
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
let elapsed = start;
+3 -3
View File
@@ -22,9 +22,9 @@ export function fxVoiceOf(
return plan ? { plan, params, tempo } : null;
}
/** The sound FX a clip goes through. */
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
return fxVoiceOf(project, fxId, tempo) ?? DRY_VOICE;
/** The sound FX a clip goes through, with the values its parameters start at. */
export function clipFx(project: Project, fxId: string, tempo: number, params: FxParams = {}): FxVoice {
return fxVoiceOf(project, fxId, tempo, params) ?? DRY_VOICE;
}
/** Every layer and FX of every note and every file and FX of every clip a sequence plays. */
+4 -1
View File
@@ -32,7 +32,10 @@ export interface BusLevel {
sends?: readonly BusSend[];
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
fx?: FxVoice;
/** What its automation sets while it plays (#337): `gain` and `pan` are then only where it starts. */
/**
* What its automation sets while it plays (#337): `gain`, `pan` and a parameter of `fx` are then
* only where it starts.
*/
automated?: readonly AutomationTarget[];
}
+5 -6
View File
@@ -13,7 +13,7 @@ import { type PropertyBag, textOf } from '../model/Properties';
import { findAssetOf, type Project } from '../model/Project';
import { findSound } from '../model/sound';
import { pickedFx } from '../properties/assetFx';
import { AUTOMATION_TARGETS, type AutomationLane, heldLanes, laneOf, sequenceLaneOf } from './automation';
import { type AutomationLane, heldLanes, sequenceLaneOf } from './automation';
import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level';
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
@@ -30,7 +30,7 @@ export interface SongTrack {
solo: boolean;
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
fx: string;
/** Its volume and pan over the song (#337); left out without a lane. */
/** Its volume, pan and parameters over the song (#337); left out without a lane. */
automation?: AutomationLane[];
}
@@ -230,10 +230,9 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
clips.sort((a, b) => a.tick - b.tick);
const curves = curveLookup(project);
const lanes = song.tracks.flatMap((track) =>
AUTOMATION_TARGETS.flatMap((target) => {
const lane = laneOf(track, target);
return lane ? [sequenceLaneOf(lane, trackBus(song.id, track.id), curves)] : [];
}),
(track.automation ?? [])
.filter((lane) => lane.points.length > 0)
.map((lane) => sequenceLaneOf(lane, trackBus(song.id, track.id), curves)),
);
return { notes, clips, ...(lanes.length > 0 && { lanes }) };
}
+31 -9
View File
@@ -1,7 +1,7 @@
import type { Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import type { Graph } from '../graph/Graph';
import { AUTOMATION_TARGETS, type AutomationTarget, audioValueOf, laneOf, laneValueAt } from './automation';
import { audioValueOf, laneOf, laneValueAt, paramOf, type TrackTarget } from './automation';
import { fxVoiceOf } from './needs';
import type { BusLevel } from './playbacks';
import { songRoutes, trackGates } from './routing';
@@ -19,7 +19,8 @@ import {
/**
* The buses of a song: each track's at its volume and pan, through its sound FX (#346), on to where
* its routing sends it (#350), a level or pan its lane rules (#337) from where the lane starts; a bus
* its routing sends it (#350), a level, pan or parameter its lane rules (#337) from where the lane
* starts – a parameter on the sound FX of its items too; a bus
* for its own items into it; and one for each pattern item with a sound FX into that, at unity – its
* volume is its notes'. Mute and solo close sends and items
* (`trackGates`). A sound FX that does not compile is none, and the bus then only passes it on.
@@ -35,18 +36,30 @@ export function songBuses(
},
): BusLevel[] {
const tempo = songTempo(song.props);
const fxOf = (id: string) => {
const fx = fxVoiceOf(project, id, tempo);
const byId = new Map(song.tracks.map((track) => [track.id, track]));
// A parameter a lane rules starts where the lane does (#337).
const fxOf = (id: string, track: SongTrack | undefined) => {
const lanes = (track?.automation ?? []).filter((lane) => paramOf(lane.target) !== null);
const params = Object.fromEntries(
lanes.flatMap((lane) =>
lane.points.length > 0
? [[paramOf(lane.target)!, audioValueOf(lane, laneValueAt(lane.points, 0))]]
: [],
),
);
const fx = fxVoiceOf(project, id, tempo, params);
return fx ? { fx } : {};
};
const routes = songRoutes(song);
const gates = trackGates(song.tracks, routes);
const tracks = song.tracks.map((track) => {
const gate = gates.get(track.id);
const automated = AUTOMATION_TARGETS.filter((target) => laneOf(track, target));
const atStart = (target: AutomationTarget, fader: number) => {
const automated = (track.automation ?? []).flatMap((lane) =>
lane.points.length > 0 ? [lane.target] : [],
);
const atStart = (target: TrackTarget, fader: number) => {
const lane = laneOf(track, target);
return lane ? audioValueOf(target, laneValueAt(lane.points, 0)) : fader;
return lane ? audioValueOf(lane, laneValueAt(lane.points, 0)) : fader;
};
return {
name: trackBus(song.id, track.id),
@@ -60,7 +73,7 @@ export function songBuses(
gain: gate?.passes(route.to) ? dbToGain(route.volume) : 0,
tap: route.tap,
})),
...fxOf(track.fx),
...fxOf(track.fx, track),
};
});
const own = song.tracks.map((track) => ({
@@ -77,7 +90,16 @@ export function songBuses(
gain: 1,
pan: 0,
into: trackItemsBus(song.id, item.track),
...fxOf(itemFx(project, item)),
...fxOf(itemFx(project, item), byId.get(item.track)),
...automatedParams(byId.get(item.track)),
}));
return [...tracks, ...own, ...items];
}
/** The parameters a track's lanes rule, for the bus of one of its items: its sound FX leaves them alone. */
function automatedParams(track: SongTrack | undefined): Pick<BusLevel, 'automated'> {
const automated = (track?.automation ?? []).flatMap((lane) =>
paramOf(lane.target) !== null && lane.points.length > 0 ? [lane.target] : [],
);
return automated.length > 0 ? { automated } : {};
}
+63
View File
@@ -0,0 +1,63 @@
import { VELOCITY_PARAM } from '../instrument/nodes';
import { instrumentParams } from '../instrument/compile';
import { findAssetOf, type Project } from '../model/Project';
import { assetFxOf } from '../properties/assetFx';
import { soundFxParams } from '../soundfx/compile';
import { patternInstrument } from './pattern';
import { itemFx, type SongItem, type SongTrack } from './song';
/** A parameter a lane of a track can move, with the default it has where it is first declared. */
export interface TrackParam {
name: string;
default: number;
}
/**
* The parameters a lane of a track can move (#337): those of its sound FX, of the sound FX of its
* items, of the instruments its pattern items play and of their own sound FX, each name once, in
* that order. A name several of them have moves all of them.
*/
export function trackParams(
project: Project,
song: { items: readonly SongItem[] },
track: SongTrack,
): TrackParam[] {
const found = new Map<string, number>();
const add = (ports: readonly { key: string; default?: number }[]) => {
for (const port of ports)
if (!found.has(port.key) && port.key !== VELOCITY_PARAM) found.set(port.key, port.default ?? 0);
};
const addFx = (fxId: string) => {
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
if (fx) add(soundFxParams(fx.graph));
};
addFx(track.fx);
for (const item of song.items) {
if (item.track !== track.id) continue;
addFx(itemFx(project, item));
if (item.kind !== 'pattern') continue;
const pattern = findAssetOf(project, item.asset, 'pattern');
const id = item.instrument || (pattern ? patternInstrument(pattern.props) : '');
const instrument = id ? findAssetOf(project, id, 'instrument') : undefined;
if (!instrument) continue;
add(instrumentParams(instrument.graph));
addFx(assetFxOf(instrument.props));
}
return [...found].map(([name, value]) => ({ name, default: value }));
}
/**
* The range a new lane of a parameter gets: 0 to 1 for a default in there, else from 0 – or as far
* below – to twice the default, on a round number.
*/
export function defaultParamRange(value: number): { min: number; max: number } {
if (!Number.isFinite(value) || (value >= 0 && value <= 1)) return { min: 0, max: 1 };
const top = roundUp(Math.abs(value) * 2);
return value > 0 ? { min: 0, max: top } : { min: -top, max: top };
}
/** The next of 1, 2 and 5 times a power of ten from `value` up. */
function roundUp(value: number): number {
const power = 10 ** Math.floor(Math.log10(value));
return ([1, 2, 5, 10].find((step) => step * power >= value) ?? 10) * power;
}
+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));
}
+10 -3
View File
@@ -491,8 +491,15 @@ export function strikeNote(
duration: note.duration,
};
const timing = { at: note.at, tempo: note.tempo, ...(note.bus && { bus: note.bus }) };
const fx = fxVoiceOf(sources.project, instrumentFx(sources.project, note.instrument, ''), note.tempo);
return playNote(sources, note.instrument, struck, owner, {}, timing, fx);
// What the lanes of its track start it with (#337) reaches its instrument and its sound FX alike.
const params = note.params ?? {};
const fx = fxVoiceOf(
sources.project,
instrumentFx(sources.project, note.instrument, ''),
note.tempo,
params,
);
return playNote(sources, note.instrument, struck, owner, params, timing, fx);
}
/** A clip of a song (#334): its sound at its own volume times the item's, through its sound FX. */
@@ -509,7 +516,7 @@ export function playClip(
return audio.playClip(
playedSoundOf(ref),
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
clipFx(project, clip.fx, clip.tempo),
clipFx(project, clip.fx, clip.tempo, clip.params),
);
}
+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;
+33
View File
@@ -675,6 +675,39 @@ export function glideKnobs(
return moved;
}
/**
* Takes knobs along the marks of a parameter's lane (#337): `levels` are the knobs' levels at each
* mark, in the order of `marks`. A knob that stays where it is all the way is left alone. Each other
* one is ramped to its level at each mark, or jumped there; its first mark is a jump where nothing
* has been planned for it by a lane yet – `followed` says which have –, or where the playing has
* just started (`fresh`), which drops what was planned for it from there on.
*/
export function followMarks(
knobs: readonly Knob[],
levels: readonly (readonly number[])[],
marks: readonly { at: number; jump: boolean }[],
now: number,
fresh: boolean,
followed: WeakSet<Knob>,
): void {
knobs.forEach((knob, i) => {
const param = knob.param;
const way = levels.map((level) => level[i]);
if (!param || way.every((level) => level === undefined || level === knob.target)) return;
const restart = fresh || !followed.has(knob);
followed.add(knob);
marks.forEach((mark, m) => {
const level = way[m];
if (level === undefined) return;
const at = Math.max(now, mark.at);
if (m === 0 && fresh) holdParam(param, at);
if (mark.jump || (m === 0 && restart)) param.setValueAtTime(level, at);
else param.linearRampToValueAtTime(level, at);
});
knob.target = way.at(-1) ?? knob.target;
});
}
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
const CURVE_RATE = 200;
+310
View File
@@ -0,0 +1,310 @@
/**
* Automation of the parameters of a song's track (#337, stage 3): a lane `param:<name>` with a range
* of its own, the value each note and clip starts with, the sound FX of the track and its items set
* where the lane starts, and which parameters a track has to offer.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand } from '../../src/core/commands/songCommands';
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { findAssetOf } from '../../src/core/model/Project';
import { chipPresetGraph } from '../../src/core/instrument/chipPresets';
import {
type AutomationLane,
heldLanes,
laneMarks,
laneRange,
paramsAt,
reaches,
type SequenceLane,
} from '../../src/core/music/automation';
import {
type ScheduledClip,
type ScheduledNote,
Scheduler,
type SchedulerHost,
} from '../../src/core/music/Scheduler';
import { newItem, songSequence, trackBus } from '../../src/core/music/song';
import { songBuses } from '../../src/core/music/songBuses';
import { defaultParamRange, trackParams } from '../../src/core/music/trackParams';
import { strikeNote } from '../../src/runtime/systems/behaviorContext';
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { graphEditor, makeProject, type ProjectFixture } from '../helpers';
const Q = 480;
const BAR = 4 * Q;
describe('a lane of a parameter (#337)', () => {
it('is held to a name without a colon, its points to its own range, and kept without a point', () => {
expect(
heldLanes([
{
target: 'param:cut',
min: 100,
max: 5000,
points: [
{ tick: 0, value: 20000 },
{ tick: 10, value: 50 },
],
},
{ target: 'param:', points: [{ tick: 0, value: 1 }] },
{ target: 'param:a:b', points: [{ tick: 0, value: 1 }] },
{ target: 'param:mix', min: 1, max: 1, points: [{ tick: 0, value: 3 }] },
{ target: 'param:empty', min: -1, max: 1, points: [] },
{ target: 'param:bare', points: [] },
]),
).toEqual([
{
target: 'param:cut',
min: 100,
max: 5000,
points: [
{ tick: 0, value: 5000 },
{ tick: 10, value: 100 },
],
},
// A range that is none is the one a lane without a range has: 0 to 1.
{ target: 'param:mix', points: [{ tick: 0, value: 1 }] },
{ target: 'param:empty', min: -1, max: 1, points: [] },
{ target: 'param:bare', points: [] },
]);
expect(laneRange({ target: 'param:mix' })).toEqual({ min: 0, max: 1 });
expect(laneRange('volume')).toEqual({ min: -60, max: 12 });
});
it('reaches its track and every bus inside it, and starts what is heard there at its value', () => {
expect(reaches('song:s:t', 'song:s:t')).toBe(true);
expect(reaches('song:s:t', 'song:s:t:#items')).toBe(true);
expect(reaches('song:s:t', 'song:s:t2:#items')).toBe(false);
const lanes: SequenceLane[] = [
{
bus: 'song:s:t',
target: 'param:cut',
min: 0,
max: 10,
points: [
{ tick: 0, value: 0 },
{ tick: 100, value: 20 },
],
},
{ bus: 'song:s:t', target: 'volume', points: [{ tick: 0, value: -6 }] },
{ bus: 'song:s:u', target: 'param:other', points: [{ tick: 0, value: 1 }] },
];
expect(paramsAt(lanes, 'song:s:t:#items', 25)).toEqual({ cut: 5 });
// Past its range the way is held to it.
expect(paramsAt(lanes, 'song:s:t:item', 90)).toEqual({ cut: 10 });
expect(paramsAt(lanes, 'song:s:x:#items', 25)).toBeUndefined();
expect(paramsAt(lanes, undefined, 25)).toBeUndefined();
});
it('is read every step even where it runs straight, as a knob may not be the parameter itself', () => {
const lane: SequenceLane = {
bus: 'b',
target: 'param:cut',
min: 0,
max: 1000,
points: [
{ tick: 0, value: 0 },
{ tick: 960, value: 1000 },
],
};
const marks = laneMarks(lane, 0, null, 0, 480, true, 9.6);
expect(marks).toHaveLength(51);
// In the units of the parameter, not as a gain.
for (const mark of marks) expect(mark.value).toBeCloseTo((1000 * mark.at) / 960, 9);
const held = laneMarks({ ...lane, points: [{ tick: 0, value: 300 }] }, 0, null, 0, 480, true, 9.6);
expect(held).toEqual([
{ at: 0, value: 300, jump: true },
{ at: 480, value: 300, jump: false },
]);
});
});
describe('the scheduler and a parameter lane (#337)', () => {
it('starts each note and clip of the track at the value the lane has where it starts', () => {
const notes: ScheduledNote[] = [];
const clips: ScheduledClip[] = [];
let time = 0;
const host: SchedulerHost = {
now: () => time,
latency: () => 0,
play: (note) => (notes.push(note), 1),
playClip: (clip) => (clips.push(clip), 2),
playing: () => true,
stop: () => {},
wake: () => () => {},
};
const lane: SequenceLane = {
bus: 'song:s:t',
target: 'param:cut',
min: 0,
max: 1000,
points: [
{ tick: 0, value: 0 },
{ tick: 1000, value: 1000 },
],
};
const scheduler = new Scheduler(
host,
() => ({
notes: [
{ tick: 200, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t:#items' },
{ tick: 210, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:u:#items' },
],
clips: [{ tick: 100, length: 500, sound: 's', fx: '', offset: 0, volume: 1, bus: 'song:s:t:#items' }],
lanes: [lane],
}),
{ tempo: 120 },
);
scheduler.start(300);
time = 0.5;
scheduler.pump();
expect(notes).toEqual([]);
// Started inside the clip: it is taken up 200 ticks in, at the value there.
expect(clips.map((clip) => clip.params)).toEqual([{ cut: 300 }]);
scheduler.start(150);
time = 1;
scheduler.pump();
expect(notes.map((note) => [note.bus, note.params])).toEqual([
['song:s:t:#items', { cut: 200 }],
['song:s:u:#items', undefined],
]);
});
});
/** A sound FX "Filter" with a parameter `cut` (default 800) into its gain. */
function makeFilter(f: ProjectFixture): SoundFxAsset {
const command = new CreateAssetCommand('soundfx', 'Filter');
f.stack.execute(command);
const fx = command.asset as SoundFxAsset;
const edit = graphEditor(f, fx as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const gain = edit.addNode('SoundFxGain');
const output = edit.addNode('SoundFxOutput');
input.ports = [{ key: 'cut', kind: 'number', default: 800 }];
edit.connect(input, 'out', gain, 'in');
edit.connect(input, 'cut', gain, 'gain');
edit.connect(gain, 'out', output, 'in');
return fx;
}
/** A song "Tune" with a pattern of one note on its one track, played by a chip instrument with a parameter `tone`. */
function makeTune() {
const f = makeProject();
const song = new CreateAssetCommand('song', 'Tune');
const pattern = new CreateAssetCommand('pattern', 'Riff');
const chip = new CreateAssetCommand('instrument', 'Chip');
for (const command of [song, pattern, chip]) f.stack.execute(command);
const instrument = chip.asset as InstrumentAsset;
instrument.graph = chipPresetGraph('pickup', () => 0);
instrument.graph.nodes.push({
id: 'params',
type: 'InstrumentParameters',
x: 0,
y: 0,
params: {},
ports: [
{ key: 'tone', kind: 'number', default: 0.25 },
{ key: 'cut', kind: 'number', default: 5 },
],
});
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', instrument.id));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 }]),
);
const tune = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset;
const trackId = tune().tracks[0]!.id;
f.stack.execute(
new SetSongCommand(song.asset.id, {
items: [newItem({ track: trackId, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR })],
}),
);
const setTrack = (fields: Partial<SongAsset['tracks'][number]>) =>
f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, ...fields }] }));
return { ...f, tune, trackId, setTrack, instrument, patternId: pattern.asset.id };
}
describe('a song with a parameter lane (#337)', () => {
const cutLane: AutomationLane = {
target: 'param:cut',
min: 0,
max: 2000,
points: [
{ tick: 0, value: 400 },
{ tick: BAR, value: 1600 },
],
};
it('plays it on its track, and sets the sound FX of the track where it starts', () => {
const f = makeTune();
const fx = makeFilter(f);
f.setTrack({ fx: fx.id, automation: [cutLane] });
expect(songSequence(f.project, f.tune()).lanes).toEqual([
{ bus: trackBus(f.tune().id, f.trackId), ...cutLane },
]);
const [track] = songBuses(f.project, f.tune());
expect(track).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } });
// Without a point it rules nothing, and is not played.
f.setTrack({ automation: [{ ...cutLane, points: [] }] });
expect(songSequence(f.project, f.tune()).lanes).toBeUndefined();
expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated');
expect(songBuses(f.project, f.tune())[0]!.fx!.params).toEqual({});
});
it('sets the sound FX of a pattern item on the track the same way, which leaves it to the lane', () => {
const f = makeTune();
const fx = makeFilter(f);
const item = f.tune().items[0]!;
f.stack.execute(new SetSongCommand(f.tune().id, { items: [{ ...item, fx: fx.id }] }));
f.setTrack({ automation: [cutLane] });
const own = songBuses(f.project, f.tune()).find((bus) => bus.name.endsWith(item.id));
expect(own).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } });
});
it('offers the parameters of the track, its items and their instruments, each name once', () => {
const f = makeTune();
const fx = makeFilter(f);
expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([
{ name: 'tone', default: 0.25 },
{ name: 'cut', default: 5 },
]);
f.setTrack({ fx: fx.id });
// The track's own first: its `cut` is the one whose default is named.
expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([
{ name: 'cut', default: 800 },
{ name: 'tone', default: 0.25 },
]);
});
it('starts a new lane at a range round the default, on a round number', () => {
expect(defaultParamRange(0.25)).toEqual({ min: 0, max: 1 });
expect(defaultParamRange(800)).toEqual({ min: 0, max: 2000 });
expect(defaultParamRange(1.5)).toEqual({ min: 0, max: 5 });
expect(defaultParamRange(-3)).toEqual({ min: -10, max: 10 });
expect(defaultParamRange(Number.NaN)).toEqual({ min: 0, max: 1 });
});
it('hands a note its values for its instrument and its own sound FX alike', () => {
const f = makeTune();
const fx = makeFilter(f);
f.stack.execute(new SetAssetPropertyCommand(f.instrument.id, 'fx', fx.id));
const struck: { params: Record<string, number> }[] = [];
const audio: AudioSink = {
...SILENT_AUDIO,
playNote: (_name, _layers, params) => (struck.push({ params: { ...params } }), 1),
};
const note: ScheduledNote = {
at: 0,
duration: 0.1,
note: 60,
velocity: 1,
instrument: f.instrument.id,
tempo: 120,
params: { cut: 1200 },
};
expect(strikeNote({ project: f.project, audio }, note, '')).toBe(1);
expect(struck[0]!.params).toMatchObject({ cut: 1200, 'fx:cut': 1200 });
});
});
+53
View File
@@ -10,10 +10,15 @@ import {
easedPoint,
lanePoints,
withLanePoints,
withoutLane,
withoutPoint,
withParamLane,
} from '../../src/editor/song/laneEdit';
import {
LANE_INSET,
laneKindOf,
laneLabel,
laneStep,
laneValue,
laneValueText,
laneWay,
@@ -21,6 +26,7 @@ import {
pointAt,
shownLanes,
songRows,
trackLanes,
trackRows,
} from '../../src/editor/song/laneView';
import { trackAtY, type SongView } from '../../src/editor/song/songView';
@@ -165,6 +171,53 @@ describe('editing the points of a lane (#337)', () => {
});
});
describe('the lane of a parameter (#337)', () => {
const cut = { target: 'param:cut' as const, min: 0, max: 2000 };
const withCut = track('a', [
{ target: 'volume', points: [{ tick: 0, value: -6 }] },
{ ...cut, points: [] },
]);
it('is shown by its name after the volume and the pan, with no point too: it was added', () => {
expect(laneLabel('param:cut')).toBe('cut');
expect(trackLanes(withCut)).toEqual(['volume', 'pan', 'param:cut']);
expect(shownLanes(withCut, new Map())).toEqual(['volume', 'param:cut']);
expect(laneKindOf(withCut, 'param:cut')).toEqual(cut);
expect(laneKindOf(withCut, 'pan')).toEqual({ target: 'pan' });
});
it('draws and reads its values in its own range, to a step that cuts it into a hundred or more', () => {
expect(laneY(cut, 0, 52)).toBe(52 - LANE_INSET);
expect(laneY(cut, 2000, 52)).toBe(LANE_INSET);
expect(laneStep(cut)).toBe(10);
expect(laneStep({ target: 'param:mix' })).toBe(0.01);
expect(laneValue(cut, laneY(cut, 1234, 52), 52)).toBe(1230);
expect(laneValue({ target: 'param:mix' }, 26, 52)).toBe(0.5);
expect(laneValueText(cut, 1234)).toBe('1230');
expect(laneValueText({ target: 'param:mix' }, 0.123)).toBe('0.12');
expect(addedPoint([], cut, 0, 5000).points).toEqual([{ tick: 0, value: 2000 }]);
});
it('keeps its lane and range without a point, takes its points into a new range, and goes only when removed', () => {
const a = withLanePoints(withCut, 'param:cut', [{ tick: 0, value: 1500 }]);
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'param:cut']);
const emptied = withLanePoints(a, 'param:cut', []);
expect(emptied.automation?.[1]).toEqual({ ...cut, points: [] });
const narrow = withParamLane(a, 'cut', { min: 0, max: 1000 });
expect(narrow.automation?.[1]).toEqual({ ...cut, max: 1000, points: [{ tick: 0, value: 1000 }] });
// A new one comes last; a later volume lane still goes first.
const more = withLanePoints(withParamLane(narrow, 'tone', { min: 0, max: 1 }), 'pan', [
{ tick: 0, value: 0 },
]);
expect(more.automation?.map((l) => l.target)).toEqual(['volume', 'pan', 'param:cut', 'param:tone']);
expect(withoutLane(more, 'param:cut').automation?.map((l) => l.target)).toEqual([
'volume',
'pan',
'param:tone',
]);
});
});
describe('the easings as a menu (#337)', () => {
const project = {
assets: [
+80
View File
@@ -15,6 +15,8 @@ import {
type FxStagePlan,
type FxVoice,
} from '../../src/core/soundfx/nodes';
import type { AutomationTarget } from '../../src/core/music/automation';
import { dbToGain } from '../../src/core/music/level';
import type { MixBus } from '../../src/core/music/mixer';
import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledAutomation, ScheduledNote } from '../../src/core/music/Scheduler';
@@ -2073,6 +2075,84 @@ describe('the automation of the tracks of a song (#337)', () => {
]);
expect(library.busLevels().some((bus) => bus.name.includes('gone'))).toBe(false);
});
/** A gain whose decibels are the parameter `loud`; with `source` it plays FILE, as a clip's FX does. */
const loud = (fields: Partial<FxPlan> = {}): FxVoice => ({
plan: {
pitch: [],
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
...fields,
},
params: {},
tempo: 120,
});
const clip = (library: SoundLibrary, bus: string, file = FILE) =>
library.playClip(file, { at: 0, offset: 0, duration: 10, volume: 1, bus }, loud());
const tracks = (fx: FxVoice, automated: readonly AutomationTarget[] = ['param:loud']) => [
{ name: 'song:s:t', gain: 1, pan: 0, fx, automated },
{ name: 'song:s:t:#items', gain: 1, pan: 0, into: 'song:s:t' },
{ name: 'song:s:u:#items', gain: 1, pan: 0 },
];
const marks = (...values: [number, number][]) =>
automation({
target: 'param:loud',
marks: values.map(([at, value]) => ({ at, value, jump: false })),
});
/** What was planned for each gain before the clips fade out at their end, 10 s on. */
const moved = () =>
gains.map((gain) => gain.ramps.filter(([, , at]) => Number(at) < 5)).filter((ramps) => ramps.length > 0);
it('takes the sound FX of a track and every voice inside it along a parameter, in what their knobs hold', async () => {
const library = new SoundLibrary(false);
await library.setBuses(tracks(loud()));
await settle();
clip(library, 'song:s:t:#items');
clip(library, 'song:s:u:#items');
await settle();
await arrive();
library.automateBus('song:s:t', marks([1, -6], [2, -12]));
// The chain of the track and the voice on it, each first set where nothing was planned yet.
const way = [
['set', expect.closeTo(dbToGain(-6), 9), 1],
['linear', expect.closeTo(dbToGain(-12), 9), 2],
];
expect(moved()).toEqual([way, way]);
// The next window ramps on from there; a mark where a knob stays is left out.
library.automateBus('song:s:t', marks([2, -12], [3, 0]));
expect(moved()[0]).toEqual([...way, ['linear', expect.closeTo(dbToGain(-12), 9), 2], ['linear', 1, 3]]);
library.automateBus('song:s:t', marks([3, 0], [4, 0]));
expect(moved()[0]).toHaveLength(4);
});
it('takes up the marks planned for a voice still waiting on its bytes once it starts', async () => {
const library = new SoundLibrary(false);
await library.setBuses(tracks({ ...loud(), plan: { pitch: [], stages: [] } }, []));
clip(library, 'song:s:t:#items', OTHER);
library.automateBus('song:s:t', marks([1, -6], [2, -12]));
expect(moved()).toEqual([]);
await settle();
await arrive();
expect(moved()).toEqual([
[
['set', expect.closeTo(dbToGain(-6), 9), 1],
['linear', expect.closeTo(dbToGain(-12), 9), 2],
],
]);
});
it('leaves a parameter a lane rules alone when the tracks are set again, and hands one let go back', async () => {
const library = new SoundLibrary(false);
// One plan throughout, as a sound FX compiles to: another would be another chain.
const fx = loud();
await library.setBuses(tracks(fx));
library.automateBus('song:s:t', marks([1, -12]));
const knob = gains.find((gain) => gain.ramps.length > 0)!;
await library.setBuses(tracks({ ...fx, params: { loud: -3 } }));
expect(knob.glides).toEqual([]);
// The lane is gone: back to what the track's FX is set to, its default.
await library.setBuses(tracks(fx, []));
expect(knob.glides).toEqual([1]);
});
});
describe('a bake (#340)', () => {
+59 -1
View File
@@ -703,7 +703,65 @@ export async function song(t) {
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
delete song.tracks[1].automation;
song.tracks[1].volume = 0;
song.tracks[1].volume = 0;`);
// 65h3. A lane of a parameter (#337, stage 3): the sound item of the first track goes through a
// sound FX whose parameter `cut` is its gain in dB, and starts again a bar on, eight bars
// long. Its clip starts at the value the lane has there, and its knob follows the lane while
// it plays: one voice, read twice.
const cutItem = (await model()).items.find((i) => i.kind === 'sound');
const itemBefore = JSON.stringify(cutItem);
const startAt = Math.ceil(((await json('__gm.song.position()')).ticks + BAR) / BAR) * BAR;
await mutate(`
__gm.state.project.assets.push({
id: 'sg65-fx',
type: 'soundfx',
name: 'Cut 65',
props: {},
graph: {
nodes: ${JSON.stringify([
node('sg65-fx-in', 'SoundFxInput', 0, 0, {}, [{ key: 'cut', kind: 'number', default: 0 }]),
node('sg65-fx-gain', 'SoundFxGain', 200, 0),
node('sg65-fx-out', 'SoundFxOutput', 400, 0),
])},
edges: ${JSON.stringify([
edge('sg65-fx-e1', 'sg65-fx-in', 'out', 'sg65-fx-gain', 'in'),
edge('sg65-fx-e2', 'sg65-fx-in', 'cut', 'sg65-fx-gain', 'gain'),
edge('sg65-fx-e3', 'sg65-fx-gain', 'out', 'sg65-fx-out', 'in'),
])},
},
});
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const item = song.items.find((i) => i.id === ${JSON.stringify(cutItem.id)});
Object.assign(item, { fx: 'sg65-fx', tick: ${startAt}, length: ${8 * BAR} });
song.tracks[0].automation = [
{ target: 'param:cut', min: -40, max: 0, points: [{ tick: 0, value: -40 }, { tick: ${16 * BAR}, value: 0 }] },
];`);
const cutDb = (ticks) => -40 + (40 * ticks) / (16 * BAR);
const readCut = () =>
json(`({
voices: __gm.sound.levels(${JSON.stringify(blip?.file ?? '')}).filter((knobs) => knobs.length > 0),
at: __gm.song.position()?.ticks,
clip: __gm.song.clips().filter((c) => c.fx === 'sg65-fx').at(-1) ?? null,
})`);
const cutFirst = await waitFor(readCut, (r) => r.voices.length > 0 && r.at > startAt + 0.25 * BAR, 40);
await sleep(1500);
const cutLater = await readCut();
const offCut = (r) => Math.abs(20 * Math.log10(r.voices[0]?.at(-1) ?? 0) - cutDb(r.at));
check(
'a lane of a parameter starts a clip at its value there and takes its knob along while it plays',
[cutFirst, cutLater].every((r) => r.voices.length === 1 && offCut(r) < 1) &&
Math.abs(cutFirst.clip?.params?.cut - cutDb(startAt)) < 0.01 &&
cutLater.at - cutFirst.at > 600 &&
20 * Math.log10(cutLater.voices[0].at(-1) / cutFirst.voices[0].at(-1)) > 1,
JSON.stringify({ startAt, cutFirst, cutLater, off: [cutFirst, cutLater].map(offCut) }),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
const at = song.items.findIndex((i) => i.id === ${JSON.stringify(cutItem.id)});
song.items[at] = ${itemBefore};
delete song.tracks[0].automation;
__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'sg65-fx');
${loopBefore ? `song.loop = ${JSON.stringify(loopBefore)};` : 'delete song.loop;'}`);
await click(...Object.values(await json("__gm.song.headAt(0, 'mute')")));
// Mute closes every send of the track (#350); its own level stays as it is set.