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