diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index 986db557..3f1c93fb 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -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, diff --git a/src/core/music/automation.ts b/src/core/music/automation.ts index 17f373b1..b92ab1e5 100644 --- a/src/core/music/automation.ts +++ b/src/core/music/automation.ts @@ -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:`): 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; + +/** 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; - 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 (`:…`). */ +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 | 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; diff --git a/src/core/music/needs.ts b/src/core/music/needs.ts index 86800d74..833a559f 100644 --- a/src/core/music/needs.ts +++ b/src/core/music/needs.ts @@ -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. */ diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index 316288c2..59df2fb5 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -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[]; } diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 808e45b1..734fc465 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -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 }) }; } diff --git a/src/core/music/songBuses.ts b/src/core/music/songBuses.ts index b4ab8573..dac62122 100644 --- a/src/core/music/songBuses.ts +++ b/src/core/music/songBuses.ts @@ -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 { + const automated = (track?.automation ?? []).flatMap((lane) => + paramOf(lane.target) !== null && lane.points.length > 0 ? [lane.target] : [], + ); + return automated.length > 0 ? { automated } : {}; +} diff --git a/src/core/music/trackParams.ts b/src/core/music/trackParams.ts new file mode 100644 index 00000000..19a55910 --- /dev/null +++ b/src/core/music/trackParams.ts @@ -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(); + 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; +} diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts index 7c7a92f2..7ea20620 100644 --- a/src/editor/inspector/songSection.ts +++ b/src/editor/inspector/songSection.ts @@ -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( diff --git a/src/editor/song/ParamLaneDialog.ts b/src/editor/song/ParamLaneDialog.ts new file mode 100644 index 00000000..db799325 --- /dev/null +++ b/src/editor/song/ParamLaneDialog.ts @@ -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(); + +/** 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(); + } +} diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 5c4a498c..2b08dd28 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -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; } diff --git a/src/editor/song/laneEdit.ts b/src/editor/song/laneEdit.ts index 2802b568..e6c142ad 100644 --- a/src/editor/song/laneEdit.ts +++ b/src/editor/song/laneEdit.ts @@ -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; } diff --git a/src/editor/song/laneView.ts b/src/editor/song/laneView.ts index c17e73c3..c1f3cd12 100644 --- a/src/editor/song/laneView.ts +++ b/src/editor/song/laneView.ts @@ -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; -export const LANE_LABELS: Readonly> = { 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)); } diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index cecd50da..304f4d56 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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), ); } diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index bd19b067..3190e84e 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -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(); 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(); /** * 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 = { ...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; diff --git a/src/viewport/fxNodes.ts b/src/viewport/fxNodes.ts index 6ab09ab3..3a439891 100644 --- a/src/viewport/fxNodes.ts +++ b/src/viewport/fxNodes.ts @@ -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, +): 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; diff --git a/tests/core/automation-params.test.ts b/tests/core/automation-params.test.ts new file mode 100644 index 00000000..82015a1e --- /dev/null +++ b/tests/core/automation-params.test.ts @@ -0,0 +1,310 @@ +/** + * Automation of the parameters of a song's track (#337, stage 3): a lane `param:` 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) => + 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 }[] = []; + 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 }); + }); +}); diff --git a/tests/editor/song-lanes.test.ts b/tests/editor/song-lanes.test.ts index 7f94c4fa..54769353 100644 --- a/tests/editor/song-lanes.test.ts +++ b/tests/editor/song-lanes.test.ts @@ -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: [ diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 534c419a..ebea64d6 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -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 = {}): 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)', () => { diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index a9003a0d..8ff79493 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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.