diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index 84d797f9..986db557 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -1,4 +1,5 @@ import type { FadeCurve } from '../model/sound'; +import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, laneMarks } from './automation'; import { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence'; import { COMMON_TIME, @@ -59,6 +60,18 @@ export interface ScheduledRamp { done: number; } +/** + * Where a lane of automation (#337) takes a bus over one window: each mark a second of the audio + * clock with the value the bus is ramped to, or jumps to there. With `fresh` the playing has just + * started, and what was planned for the bus from the first mark on is to go. + */ +export interface ScheduledAutomation { + bus: string; + target: AutomationTarget; + marks: LaneMark[]; + fresh: boolean; +} + /** What the scheduler needs of the audio side; a test hands in a clock of its own. */ export interface SchedulerHost { /** Seconds on the audio clock. */ @@ -77,6 +90,8 @@ export interface SchedulerHost { playClip?(clip: ScheduledClip): number; /** Ramps the level of a bus; a host without levels leaves it out. */ ramp?(ramp: ScheduledRamp): void; + /** Takes a bus along its automation; a host without it leaves it out. */ + automate?(automation: ScheduledAutomation): void; playing(voice: number): boolean; /** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */ stop(voices: readonly number[], letRing?: boolean): void; @@ -246,6 +261,19 @@ export class Scheduler { done: whole > 0 ? due.skip / whole : 0, }); } + if (this.host.automate) { + const anchor = this.anchor; + const step = ticksOfSeconds(AUTOMATION_STEP, this.tempo); + for (const lane of sequence.lanes ?? []) { + const marks = laneMarks(lane, this.from, this.loop, this.until, to, this.fresh, step); + this.host.automate({ + bus: lane.bus, + target: lane.target, + marks: marks.map((mark) => ({ ...mark, at: anchor + secondsOfTicks(mark.at, this.tempo) })), + fresh: this.fresh, + }); + } + } this.fresh = false; this.until = to; if (!this.loop && playheadAt(to, this.from, null) >= this.end) { diff --git a/src/core/music/automation.ts b/src/core/music/automation.ts new file mode 100644 index 00000000..17f373b1 --- /dev/null +++ b/src/core/music/automation.ts @@ -0,0 +1,314 @@ +/** + * Automation of a song (#337): the volume and the pan of a track changing over the song, as points + * on a lane. From each point the value runs to the next along that point's easing, as from one + * animation key to the next. While a lane has points it rules its value in place of the fader: before + * its first point it is that point's value, after its last the last one's. Two points on one tick + * are a jump there. + * + * 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`. + */ +import type { KeyInterpolation, VectorPoint } from '../model/Asset'; +import { easedShare, easingOf, isKeyInterpolation } from '../model/animation'; +import type { CurveLookup } from '../model/curve'; +import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level'; +import { type LoopRange, playheadAt } from './sequence'; + +export type AutomationTarget = 'volume' | 'pan'; + +export const AUTOMATION_TARGETS: readonly AutomationTarget[] = ['volume', 'pan']; + +export interface AutomationPoint { + tick: number; + /** In the units of its target: decibels for the volume, -1 (left) to 1 (right) for the pan. */ + value: number; + /** How it runs to the next point; linear where left out. */ + ease?: KeyInterpolation; +} + +export interface AutomationLane { + target: AutomationTarget; + /** In the order of their ticks. */ + points: AutomationPoint[]; +} + +/** 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 }; +} + +/** 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); + const held = Math.max(min, Math.min(max, value)); + return 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. + */ +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 points = (Array.isArray(lane.points) ? (lane.points as unknown[]) : []).flatMap( + (point): AutomationPoint[] => { + if (typeof point !== 'object' || point === null) return []; + const { tick, value, ease } = point as Record; + if (typeof tick !== 'number' || !Number.isFinite(tick) || !(tick >= 0)) return []; + if (typeof value !== 'number' || !Number.isFinite(value)) return []; + return [ + { + tick: Math.round(tick), + value: Math.max(min, Math.min(max, value)), + ...(isKeyInterpolation(ease) && ease !== 'linear' && { ease }), + }, + ]; + }, + ); + points.sort((a, b) => a.tick - b.tick); + if (points.length > 0) lanes.push({ target, points }); + } + return lanes; +} + +/** The lane of a track for that target, while it has a point. */ +export function laneOf( + track: { automation?: readonly AutomationLane[] }, + target: AutomationTarget, +): AutomationLane | undefined { + return track.automation?.find((lane) => lane.target === target && lane.points.length > 0); +} + +/** A lane on the bus it acts on, the Curve assets its points ease along looked up; one gone is linear. */ +export function sequenceLaneOf(lane: AutomationLane, bus: string, curves: CurveLookup): SequenceLane { + return { + bus, + target: lane.target, + points: lane.points.map((point) => { + const { shape, curve } = easingOf(point.ease ?? 'linear'); + const found = shape === 'curve' ? curves(curve ?? '') : undefined; + return found ? { ...point, curve: found } : point; + }), + }; +} + +/** A point as a sequence plays it: a Curve asset it eases along already looked up. */ +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 { + bus: string; + target: AutomationTarget; + points: readonly SequenceLanePoint[]; +} + +function between(a: SequenceLanePoint, b: SequenceLanePoint, t: number): number { + return a.value + (b.value - a.value) * easedShare(a.ease ?? 'linear', t, () => a.curve); +} + +/** The index of the last point on or before `tick`, -1 for none. */ +function lastAt(points: readonly SequenceLanePoint[], tick: number): number { + let i = -1; + while (i + 1 < points.length && points[i + 1]!.tick <= tick) i++; + return i; +} + +/** The value at `tick`, a jump there already taken. */ +export function laneValueAt(points: readonly SequenceLanePoint[], tick: number): number { + const i = lastAt(points, tick); + if (i < 0) return points[0]!.value; + const a = points[i]!; + const b = points[i + 1]; + return b ? between(a, b, (tick - a.tick) / (b.tick - a.tick)) : a.value; +} + +/** The value just before `tick`: what a jump there jumps from. */ +export function laneValueBefore(points: readonly SequenceLanePoint[], tick: number): number { + let i = -1; + while (i + 1 < points.length && points[i + 1]!.tick < tick) i++; + if (i < 0) return points[0]!.value; + const a = points[i]!; + const b = points[i + 1]; + if (!b) return a.value; + if (b.tick > tick) return between(a, b, (tick - a.tick) / (b.tick - a.tick)); + return a.ease === 'step' ? a.value : between(a, b, 1); +} + +/** Whether the way from `a` to `b` holds or is a straight line in lane units. */ +function straight(a: SequenceLanePoint, b: SequenceLanePoint): boolean { + const ease = a.ease ?? 'linear'; + 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. */ +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'); +} + +/** One place on the way of a lane: `at` in ticks, the value as its bus takes it, ramped to or jumped to. */ +export interface LaneMark { + at: number; + value: number; + jump: boolean; +} + +/** + * Where a lane is over the playing time from `start` up to `end`, as `notesIn` lays out notes: a mark + * where the window starts, at every point and every reading in between, and where it ends. Where the + * playhead lands – at the start with `fresh`, after a jump back – the first mark of that stretch is a + * jump. `step` is `AUTOMATION_STEP` in ticks. + */ +export function laneMarks( + lane: SequenceLane, + from: number, + loop: LoopRange | null, + start: number, + end: number, + fresh: boolean, + step: number, +): LaneMark[] { + const { points, target } = lane; + const audio = (value: number) => audioValueOf(target, value); + const marks: LaneMark[] = []; + const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity; + let elapsed = start; + let head = playheadAt(start, from, loop); + let landed = fresh; + while (elapsed < end) { + const span = Math.min(end - elapsed, stretchEnd - head); + if (!(span > 0)) break; + const to = head + span; + const first = laneValueAt(points, head); + marks.push({ + at: elapsed, + value: audio(first), + jump: landed || laneValueBefore(points, head) !== first, + }); + for (const tick of readings(lane, head, to, step)) { + const before = laneValueBefore(points, tick); + const value = laneValueAt(points, tick); + const at = elapsed + (tick - head); + marks.push({ at, value: audio(before), jump: false }); + if (value !== before) marks.push({ at, value: audio(value), jump: true }); + } + marks.push({ at: elapsed + span, value: audio(laneValueBefore(points, to)), jump: false }); + elapsed += span; + landed = to >= stretchEnd; + head = landed ? loop!.start : to; + } + return thinned(marks); +} + +/** The ticks strictly between `head` and `to` to mark: every point, and readings where the bus cannot ramp. */ +function readings(lane: SequenceLane, head: number, to: number, step: number): number[] { + const { points, target } = lane; + const ticks: number[] = []; + points.forEach((a, i) => { + if (a.tick > head && a.tick < to) ticks.push(a.tick); + const b = points[i + 1]; + if (b && b.tick > head && a.tick < to && !rampable(target, a, b)) + ticks.push(...stepsOf(a.tick, head, Math.min(b.tick, to), step)); + }); + ticks.sort((x, y) => x - y); + return ticks.filter((tick, i) => i === 0 || tick !== ticks[i - 1]); +} + +/** The ticks `step` apart from `origin` on, strictly between `after` and `before`; none without a step. */ +function stepsOf(origin: number, after: number, before: number, step: number): number[] { + const ticks: number[] = []; + if (!(step > 0)) return ticks; + for (let k = Math.max(1, Math.floor((after - origin) / step) + 1); origin + k * step < before; k++) + if (origin + k * step > after) ticks.push(origin + k * step); + return ticks; +} + +/** Without the marks a ramp would pass anyway: a value held from the mark before on. */ +function thinned(marks: readonly LaneMark[]): LaneMark[] { + const kept: LaneMark[] = []; + marks.forEach((mark, i) => { + const last = kept.at(-1); + const next = marks[i + 1]; + const held = + last !== undefined && + next !== undefined && + !mark.jump && + last.value === mark.value && + (next.jump || next.value === mark.value); + if (!held) kept.push(mark); + }); + return kept; +} + +/** + * A lane as it is heard over the playing time from `start` up to `end`, played from 0 round `loop` – + * a song in an item of a composition (#418) –, as a lane of its own in ticks of that playing time: + * each round with its points, cut at the loop end and jumping back there. A way cut in the middle + * that is no straight line is read every `step` ticks. + */ +export function unrolledLane( + points: readonly SequenceLanePoint[], + loop: LoopRange | null, + start: number, + end: number, + step: number, +): SequenceLanePoint[] { + const out: SequenceLanePoint[] = []; + const stretchEnd = loop !== null && loop.end > 0 ? loop.end : Infinity; + let elapsed = start; + let head = playheadAt(start, 0, loop); + while (elapsed < end) { + const span = Math.min(end - elapsed, stretchEnd - head); + if (!(span > 0)) break; + const shift = elapsed - head; + for (const point of stretchOf(points, head, head + span, step)) + out.push({ ...point, tick: point.tick + shift }); + elapsed += span; + head = head + span >= stretchEnd ? loop!.start : head + span; + } + return out; +} + +/** The points of a lane from `head` to `to` as a lane by itself, ending on the value it reaches there. */ +function stretchOf( + points: readonly SequenceLanePoint[], + head: number, + to: number, + step: number, +): SequenceLanePoint[] { + const out: SequenceLanePoint[] = []; + const at = lastAt(points, head); + const starts: { tick: number; own: number; whole: boolean }[] = [ + { tick: head, own: Math.max(0, at), whole: at >= 0 && points[at]!.tick === head }, + ]; + points.forEach((point, i) => { + if (point.tick > head && point.tick < to) starts.push({ tick: point.tick, own: i, whole: true }); + }); + starts.forEach((s, k) => { + const next = starts[k + 1]?.tick ?? to; + const a = points[s.own]!; + const b = points[s.own + 1]; + const value = s.whole ? a.value : laneValueAt(points, s.tick); + if (!b || (k === 0 && at < 0)) out.push({ tick: s.tick, value }); + else if (s.whole && b.tick <= to) out.push({ ...a, tick: s.tick }); + else if (straight(a, b)) out.push({ tick: s.tick, value, ...(a.ease === 'step' && { ease: 'step' }) }); + else { + out.push({ tick: s.tick, value }); + for (const tick of stepsOf(a.tick, s.tick, next, step)) + out.push({ tick, value: laneValueAt(points, tick) }); + } + }); + out.push({ tick: to, value: laneValueBefore(points, to) }); + return out; +} diff --git a/src/core/music/bake.ts b/src/core/music/bake.ts index 24f7f798..74be1bbd 100644 --- a/src/core/music/bake.ts +++ b/src/core/music/bake.ts @@ -233,6 +233,8 @@ export function bakePrint(project: Project, asset: BakedAsset): string { clips, buses, instruments, + // Only where there is any, so a bake made before automation (#337) still holds. + ...(sequence.lanes?.length ? [sequence.lanes] : []), ]); return hashText(text); } diff --git a/src/core/music/compositionAudio.ts b/src/core/music/compositionAudio.ts index 6211857d..fb1756a4 100644 --- a/src/core/music/compositionAudio.ts +++ b/src/core/music/compositionAudio.ts @@ -13,6 +13,7 @@ import { compositionFps, compositionLength, compositionLoops, itemFades } from ' import { findAssetOf, type Project } from '../model/Project'; import { findSound } from '../model/sound'; import { pickedFx } from '../properties/assetFx'; +import { AUTOMATION_STEP, type SequenceLane, unrolledLane } from './automation'; import { itemRoute, MASTER_MIX, type MixRoute } from './mixer'; import { fxVoiceOf } from './needs'; import { patternInstrument, patternLength, patternSequence, patternTempo } from './pattern'; @@ -75,6 +76,7 @@ export function compositionPlayings( (playing.sequence.notes as SequenceNote[]).push(...laid.notes); (playing.sequence.clips as SequenceClip[]).push(...laid.clips); (playing.sequence.levels as SequenceLevel[]).push(...laid.levels); + if (laid.lanes?.length) playing.sequence.lanes = [...(playing.sequence.lanes ?? []), ...laid.lanes]; playing.buses.push(...laid.buses); } const playings = [...byRoute.values()]; @@ -100,6 +102,8 @@ interface Laid { clips: SequenceClip[]; /** Its fades, on its bus. */ levels: SequenceLevel[]; + /** A song's automation (#337), round its loop as the song plays inside the item. */ + lanes?: SequenceLane[]; buses: BusLevel[]; /** The asset its routing is read from: the sound of a clip, else what it plays. */ routedAs: string; @@ -168,6 +172,11 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n return { notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, ...(n.bus && { bus: renamed(n.bus) }) })), clips: heard.clips.map((c) => ({ ...c, tick: begin + c.tick, bus: renamed(c.bus) })), + lanes: heard.lanes.map((lane) => ({ + ...lane, + bus: renamed(lane.bus), + points: lane.points.map((p) => ({ ...p, tick: begin + p.tick })), + })), levels, buses: [withFx(pickedFx('', song.props), tempo), ...tracks], routedAs: song.id, @@ -207,7 +216,7 @@ function played( tempo: number, into: number, seconds: number, -): { notes: SequenceNote[]; clips: SequenceClip[] } { +): { notes: SequenceNote[]; clips: SequenceClip[]; lanes: SequenceLane[] } { const from = Math.round(ticksOfSeconds(into, tempo)); const to = from + Math.ceil(ticksOfSeconds(seconds, tempo)); const span = compositionTicks(seconds); @@ -226,5 +235,10 @@ function played( const { at: _, skip, ...clip } = c; return [{ ...clip, tick, length, offset: c.offset + secondsOfTicks(skip, tempo), tempo }]; }); - return { notes, clips }; + const step = ticksOfSeconds(AUTOMATION_STEP, tempo); + const lanes = (sequence.lanes ?? []).map((lane) => ({ + ...lane, + points: unrolledLane(lane.points, loop, from, to, step).map((p) => ({ ...p, tick: at(p.tick) })), + })); + return { notes, clips, lanes }; } diff --git a/src/core/music/level.ts b/src/core/music/level.ts new file mode 100644 index 00000000..4e294d33 --- /dev/null +++ b/src/core/music/level.ts @@ -0,0 +1,7 @@ +/** The volume of a track of a song (#334), in decibels; at the lowest it is silent. */ +export const TRACK_MIN_DB = -60; +export const TRACK_MAX_DB = 12; + +export function dbToGain(db: number): number { + return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20); +} diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index b5209bbf..316288c2 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -1,9 +1,11 @@ import type { SendTap } from '../graph/Graph'; import type { FxPlan, FxVoice } from '../soundfx/nodes'; +import type { AutomationTarget } from './automation'; import type { BakePlayback } from './bake'; import type { MixSend } from './mixer'; import { LEAD, + type ScheduledAutomation, type ScheduledClip, type ScheduledNote, type ScheduledRamp, @@ -30,6 +32,8 @@ 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. */ + automated?: readonly AutomationTarget[]; } /** One send of a track (#350): into another track's bus, or the output of the song where `to` is left out. */ @@ -64,6 +68,8 @@ export interface SequencePlay { strikeClip?(clip: ScheduledClip): number; /** Ramps the level of one of its buses (#418). */ ramp?(ramp: ScheduledRamp): void; + /** Takes one of its buses along its automation (#337). */ + automate?(automation: ScheduledAutomation): void; /** Its bake (#340), played in place of its notes and clips where the host can. */ bake?: BakePlay; /** @@ -91,7 +97,10 @@ export interface Runner { stop(letRing?: boolean): void; } -export interface PlaybackHost extends Omit { +export interface PlaybackHost extends Omit< + SchedulerHost, + 'play' | 'playClip' | 'ahead' | 'ramp' | 'automate' +> { /** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */ prepare(needs: SequenceNeeds, id: number): Promise; /** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */ @@ -159,6 +168,7 @@ export class Playbacks { play: (note) => (keep(planned, note), play.strike(note)), ...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }), ...(play.ramp && { ramp: play.ramp }), + ...(play.automate && { automate: play.automate }), }, () => play.sequence, { tempo: play.tempo, loop: play.loop, end: play.length }, diff --git a/src/core/music/sequence.ts b/src/core/music/sequence.ts index 49d9e64d..18d1da7b 100644 --- a/src/core/music/sequence.ts +++ b/src/core/music/sequence.ts @@ -5,6 +5,7 @@ * where the playhead stands, which jumps back at the end of a loop. */ import type { FadeCurve } from '../model/sound'; +import type { SequenceLane } from './automation'; export interface SequenceNote { /** Where it starts in the sequence, and how long it is held, in ticks. */ @@ -59,6 +60,8 @@ export interface Sequence { notes: readonly SequenceNote[]; clips?: readonly SequenceClip[]; levels?: readonly SequenceLevel[]; + /** The automation of a song's tracks (#337), each lane on the bus it acts on. */ + lanes?: readonly SequenceLane[]; } /** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */ diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 588c1e64..808e45b1 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -8,10 +8,13 @@ import { newId } from '../ids'; import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes'; import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset'; +import { curveLookup } from '../model/curve'; 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 { 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'; import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time'; @@ -27,6 +30,8 @@ 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. */ + automation?: AutomationLane[]; } export type SongItemKind = 'pattern' | 'sound'; @@ -66,8 +71,7 @@ const DEFAULT_TRACKS = 1; /** A song is never shorter than this many bars, on the ruler or when it plays. */ const MIN_BARS = 1; -export const TRACK_MIN_DB = -60; -export const TRACK_MAX_DB = 12; +export { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB }; export const ITEM_MIN_DB = -60; /** A sound item can be turned up; a pattern item's notes are held to unity, as a note always is. */ export const SOUND_ITEM_MAX_DB = 12; @@ -138,10 +142,6 @@ export function itemBus(songId: string, item: Pick): s return `${trackBus(songId, item.track)}:${item.id}`; } -export function dbToGain(db: number): number { - return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20); -} - /** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */ export function itemFx(project: Project, item: SongItem): string { return pickedFx(item.fx, itemAsset(project, item)?.props ?? {}); @@ -228,7 +228,14 @@ export function songSequence(project: Project, song: SongAsset): Sequence { } notes.sort((a, b) => a.tick - b.tick || a.note - b.note); clips.sort((a, b) => a.tick - b.tick); - return { notes, clips }; + 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)] : []; + }), + ); + return { notes, clips, ...(lanes.length > 0 && { lanes }) }; } const finite = (value: unknown, fallback: number) => @@ -255,6 +262,8 @@ export function heldSongTracks(raw: unknown): SongTrack[] { solo: t.solo === true, fx: text(t.fx), }); + const automation = heldLanes(t.automation); + if (automation.length > 0) tracks.at(-1)!.automation = automation; } return tracks.length > 0 ? tracks : [newTrack(1)]; } diff --git a/src/core/music/songBuses.ts b/src/core/music/songBuses.ts index 38de4bac..b4ab8573 100644 --- a/src/core/music/songBuses.ts +++ b/src/core/music/songBuses.ts @@ -1,6 +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 { fxVoiceOf } from './needs'; import type { BusLevel } from './playbacks'; import { songRoutes, trackGates } from './routing'; @@ -18,8 +19,9 @@ 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 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 + * its routing sends it (#350), a level or pan its lane rules (#337) from where the lane starts; 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. */ export function songBuses( @@ -41,10 +43,16 @@ export function songBuses( 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 lane = laneOf(track, target); + return lane ? audioValueOf(target, laneValueAt(lane.points, 0)) : fader; + }; return { name: trackBus(song.id, track.id), - gain: dbToGain(track.volume), - pan: track.pan, + gain: atStart('volume', dbToGain(track.volume)), + pan: atStart('pan', track.pan), + ...(automated.length > 0 && { automated }), sends: routes .filter((route) => route.from === track.id) .map((route) => ({ diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts index 9348cb55..7c7a92f2 100644 --- a/src/editor/inspector/songSection.ts +++ b/src/editor/inspector/songSection.ts @@ -33,6 +33,9 @@ const FX_ROW = { /** What an item's pick says while it is empty (#352): what its asset names. */ const FROM_ASSET = 'From the asset'; +/** What a row of a track sets: one of its own values, never its lanes (#337). */ +type TrackField = Exclude; + const TRACK_ROWS: readonly PropertyDef[] = [ { key: 'volume', @@ -137,7 +140,7 @@ export function refreshSongSection( const track = asset?.tracks.find((t) => t.id === trackId); if (!track) return false; host.nameField?.setValue(track.name); - for (const def of TRACK_ROWS) host.fields.get(def.key)?.setValue(track[def.key as keyof SongTrack]); + for (const def of TRACK_ROWS) host.fields.get(def.key)?.setValue(track[def.key as TrackField]); return true; } @@ -161,21 +164,21 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo 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'); for (const def of TRACK_ROWS) { - const commit = (value: SongTrack[keyof SongTrack]) => + const commit = (value: SongTrack[TrackField]) => new SetSongCommand( asset.id, { tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) }, `Set track ${def.key}`, ); - const handlers = previewingHandlers(host, { + const handlers = previewingHandlers(host, { target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id), - read: (target) => target[def.key as keyof SongTrack], + read: (target) => target[def.key as TrackField], write: (target, value) => Object.assign(target, { [def.key]: value }), commit, }); if (def.key === FX_ROW.key) handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit); - const row = createFieldRow(host, def, track[def.key as keyof SongTrack], handlers); + const row = createFieldRow(host, def, track[def.key as TrackField], handlers); host.fields.set(def.key, row); host.body.add(row.row); } diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 2bcd2df8..bd19b067 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -30,7 +30,13 @@ 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 { ScheduledClip, ScheduledNote, ScheduledRamp } from '../core/music/Scheduler'; +import type { AutomationTarget } from '../core/music/automation'; +import type { + ScheduledAutomation, + ScheduledClip, + ScheduledNote, + ScheduledRamp, +} from '../core/music/Scheduler'; import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song'; import { DEFAULT_TEMPO } from '../core/music/time'; import { playedPlan, type StreamedFile, type StreamPlan } from '../core/music/streams'; @@ -53,6 +59,7 @@ import { GLIDE, glideKnobs, heldBack, + holdParam, type Knob, levelsOf, move, @@ -573,14 +580,41 @@ export class SoundLibrary implements AudioSink { /** * Sets the level and the pan of a track of a song (#334) in a quick glide, so a change reaches - * what is sounding on it already. A bus is made on its first use and kept. + * what is sounding on it already. A bus is made on its first use and kept. What is `automated` + * (#337) glides there once, and is its lane's from then on. */ - setBus(name: string, gain: number, pan: number): void { + setBus(name: string, gain: number, pan: number, automated: readonly AutomationTarget[] = []): void { const bus = this.bus(name); const now = this.audio().currentTime; bus.set = { gain: Math.max(0, gain), pan: Math.max(-1, Math.min(1, pan)) }; - bus.gain.gain.setTargetAtTime(Math.max(0, gain), now, GLIDE / 3); - bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3); + const glide = (target: AutomationTarget, param: AudioParam, value: number) => { + const ruled = automated.includes(target); + // Its lane takes it from here (#337); one let go hands it back from where the lane left it. + if (ruled && bus.automated.has(target)) return; + if (!ruled && bus.automated.has(target)) holdParam(param, now); + param.setTargetAtTime(value, now, GLIDE / 3); + }; + glide('volume', bus.gain.gain, bus.set.gain); + glide('pan', bus.pan.pan, bus.set.pan); + bus.automated = new Set(automated); + } + + /** + * A bus taken along its automation over one window (#337): ramped to each mark, or jumped there. + * A fresh start drops what was planned for it from its first mark on. + */ + automateBus(name: string, automation: ScheduledAutomation): void { + const bus = this.buses.get(name); + const first = automation.marks[0]; + if (!bus || !first) return; + const param = automation.target === 'volume' ? bus.gain.gain : bus.pan.pan; + const now = this.audio().currentTime; + if (automation.fresh) holdParam(param, Math.max(now, first.at)); + for (const mark of automation.marks) { + const at = Math.max(now, mark.at); + if (mark.jump) param.setValueAtTime(mark.value, at); + else param.linearRampToValueAtTime(mark.value, at); + } } /** @@ -590,7 +624,7 @@ export class SoundLibrary implements AudioSink { */ async setBuses(levels: readonly BusLevel[]): Promise { const waits: Promise[] = []; - for (const { name, gain, pan, into, sends, fx } of levels) { + for (const { name, gain, pan, into, sends, fx, automated } of levels) { if (into !== undefined) this.bus(into); const bus = this.bus(name, into ?? null); if (sends) @@ -599,7 +633,7 @@ export class SoundLibrary implements AudioSink { sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })), ); else this.route(name, bus, into ?? null); - this.setBus(name, gain, pan); + this.setBus(name, gain, pan, automated); waits.push(this.setFx(bus, fx ?? null)); } await Promise.all(waits); @@ -856,6 +890,7 @@ export class SoundLibrary implements AudioSink { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 }, + automated: new Set(), into: target, sends: null, fx: null, @@ -1261,6 +1296,9 @@ export class SoundLibrary implements AudioSink { ramp: (ramp) => { if (tracks.has(ramp.bus)) this.rampBus(groupBus(id, ramp.bus), ramp); }, + automate: (automation) => { + if (tracks.has(automation.bus)) this.automateBus(groupBus(id, automation.bus), automation); + }, }; this.sequences.start(id, name, routed, owner); return id; @@ -3031,6 +3069,8 @@ interface Bus { gain: GainNode; pan: StereoPannerNode; set: { gain: number; pan: number }; + /** What a lane of automation sets instead (#337), where `setBus` leaves it alone. */ + automated: Set; /** Where it goes as a whole while it has no sends; null straight out. */ into: string | null; sends: BusSendNodes[] | null; diff --git a/src/viewport/fxNodes.ts b/src/viewport/fxNodes.ts index e51e4231..6ab09ab3 100644 --- a/src/viewport/fxNodes.ts +++ b/src/viewport/fxNodes.ts @@ -620,6 +620,18 @@ export function fadeOut(param: AudioParam, now: number, seconds = FADE): void { param.linearRampToValueAtTime(0, now + seconds); } +/** Keeps a level where it is at `at` and drops what was planned for it after that. */ +export function holdParam(param: AudioParam, at: number): void { + // Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand. + const held = param as Omit & + Partial>; + if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at); + else { + param.cancelScheduledValues(at); + param.setValueAtTime(param.value, at); + } +} + /** * Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped * first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio` @@ -635,14 +647,7 @@ export function move( ease: EaseShare = 'linear', ): void { const from = param.value; - // Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand. - const held = param as Omit & - Partial>; - if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now); - else { - param.cancelScheduledValues(now); - param.setValueAtTime(from, now); - } + holdParam(param, now); const logarithmic = ratio && from > 0 && to > 0; if (time <= 0) param.setTargetAtTime(to, now, GLIDE); else if (ease === 'step') param.setValueAtTime(to, now + time); diff --git a/src/viewport/music.ts b/src/viewport/music.ts index 816c4e48..18ca6b6b 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -83,6 +83,7 @@ export class Music { ), play: (note) => this.playOne(note), playClip: (clip) => this.playClip(clip), + automate: (automation) => soundLibrary.automateBus(automation.bus, automation), playing: (voice) => soundLibrary.playing(voice), stop: (voices) => { for (const voice of voices) soundLibrary.stopVoice(voice); diff --git a/tests/core/automation.test.ts b/tests/core/automation.test.ts new file mode 100644 index 00000000..c91b8d8d --- /dev/null +++ b/tests/core/automation.test.ts @@ -0,0 +1,403 @@ +/** + * Automation of a song (#337): lanes of a track's volume and pan, their value along the song, and + * how the scheduler takes a bus along them on the audio clock – in the Song tab, in a game, in a + * composition and in a bake alike. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { SetCompositionCommand } from '../../src/core/commands/compositionCommands'; +import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetSongCommand } from '../../src/core/commands/songCommands'; +import type { CompositionAsset, SongAsset } from '../../src/core/model/Asset'; +import { curveLookup } from '../../src/core/model/curve'; +import { findAssetOf } from '../../src/core/model/Project'; +import { + type AutomationLane, + heldLanes, + laneMarks, + laneValueAt, + laneValueBefore, + type SequenceLane, + type SequenceLanePoint, + sequenceLaneOf, + unrolledLane, +} from '../../src/core/music/automation'; +import { bakePrint } from '../../src/core/music/bake'; +import { audioItemBus, compositionPlayings } from '../../src/core/music/compositionAudio'; +import { dbToGain } from '../../src/core/music/level'; +import { + LEAD, + type ScheduledAutomation, + Scheduler, + type SchedulerHost, +} from '../../src/core/music/Scheduler'; +import { heldSongTracks, newItem, songSequence, trackBus } from '../../src/core/music/song'; +import { songBuses } from '../../src/core/music/songBuses'; +import { makeProject, roundTrip } from '../helpers'; + +const Q = 480; +const BAR = 4 * Q; + +const pan = (points: SequenceLanePoint[]): SequenceLane => ({ bus: 'b', target: 'pan', points }); + +/** The marks with their ticks and values to a millionth, so a sum of floats reads as what it is. */ +const rounded = (marks: { at: number; value: number; jump: boolean }[]) => + marks.map(({ at, value, jump }) => [Math.round(at * 1e6) / 1e6, Math.round(value * 1e6) / 1e6, jump]); + +describe('the lanes of a track (#337)', () => { + it('are held to a target each, points in whole ticks from 0 with values in range, in tick order', () => { + expect( + heldLanes([ + { + target: 'pan', + points: [ + { tick: 960.4, value: 3 }, + { tick: 0, value: -0.5, ease: 'sine-in' }, + { tick: -1, value: 0 }, + { tick: 10, value: Number.NaN }, + ], + }, + { target: 'pan', points: [{ tick: 0, value: 1 }] }, + { target: 'volume', points: [] }, + { target: 'speed', points: [{ tick: 0, value: 1 }] }, + 'volume', + { + target: 'volume', + points: [ + { tick: 5, value: 0, ease: 'wobbly' }, + { tick: 0, value: -100, ease: 'linear' }, + ], + }, + ]), + ).toEqual([ + { + target: 'pan', + points: [ + { tick: 0, value: -0.5, ease: 'sine-in' }, + { tick: 960, value: 1 }, + ], + }, + { + target: 'volume', + points: [ + { tick: 0, value: -60 }, + { tick: 5, value: 0 }, + ], + }, + ]); + expect(heldLanes('x')).toEqual([]); + }); + + it('are kept with their track when the project is saved, and a track without one has none', () => { + const f = makeProject(); + const create = new CreateAssetCommand('song', 'Tune'); + f.stack.execute(create); + const song = () => findAssetOf(f.project, create.asset.id, 'song') as SongAsset; + const lane: AutomationLane = { target: 'volume', points: [{ tick: 0, value: -6, ease: 'expo-out' }] }; + const [first] = song().tracks; + f.stack.execute( + new SetSongCommand(song().id, { + tracks: [ + { ...first!, automation: [lane] }, + { ...first!, id: 'plain', automation: [] }, + ], + }), + ); + const saved = roundTrip(f.project).assets.find((a) => a.id === song().id) as SongAsset; + expect(saved.tracks.map((t) => t.automation)).toEqual([[lane], undefined]); + expect(Object.keys(heldSongTracks([{ id: 'x' }])[0]!)).not.toContain('automation'); + }); + + it('runs from each point to the next along its easing, holds outside them, and jumps where two meet', () => { + const points: SequenceLanePoint[] = [ + { tick: 0, value: 0 }, + { tick: 100, value: 1, ease: 'step' }, + { tick: 200, value: 0, ease: 'sine-in' }, + { tick: 300, value: 1 }, + { tick: 300, value: -1 }, + ]; + const at = (tick: number) => laneValueAt(points, tick); + const before = (tick: number) => laneValueBefore(points, tick); + expect([at(-5), at(50), at(100), before(100)]).toEqual([0, 0.5, 1, 1]); + // A step holds up to the next point, which it jumps to. + expect([at(150), before(200), at(200)]).toEqual([1, 1, 0]); + expect(at(250)).toBeCloseTo(1 - Math.cos(Math.PI / 4), 12); + expect(before(300)).toBeCloseTo(1, 12); + expect([at(300), at(400)]).toEqual([-1, -1]); + }); + + it('eases along a Curve asset it names, and linear once that is gone', () => { + const f = makeProject(); + f.project.assets.push({ + id: 'cv', + type: 'curve', + name: 'Knee', + props: {}, + points: [ + { x: 0, y: 0 }, + { x: 0.5, y: 0.9 }, + { x: 1, y: 1 }, + ], + }); + const lane: AutomationLane = { + target: 'pan', + points: [ + { tick: 0, value: 0, ease: 'curve:cv' }, + { tick: 100, value: 1 }, + ], + }; + const curves = curveLookup(f.project); + expect(laneValueAt(sequenceLaneOf(lane, 'b', curves).points, 25)).toBeCloseTo(0.45, 9); + f.project.assets.pop(); + expect(laneValueAt(sequenceLaneOf(lane, 'b', curveLookup(f.project)).points, 25)).toBe(0.25); + }); +}); + +describe('a lane over a window of the scheduler (#337)', () => { + const sweep = pan([ + { tick: 0, value: -1 }, + { tick: 960, value: 1 }, + ]); + + it('is one ramp where the bus can ramp along it: a straight pan', () => { + expect(rounded(laneMarks(sweep, 0, null, 0, 480, true, 9.6))).toEqual([ + [0, -1, true], + [480, 0, false], + ]); + // Going on, it starts where the last window ended, as a ramp. + expect(rounded(laneMarks(sweep, 0, null, 480, 600, false, 9.6))).toEqual([ + [480, 0, false], + [600, 0.25, false], + ]); + }); + + it('is read every step where the bus cannot: decibels in a gain, or an easing', () => { + const fade: SequenceLane = { + bus: 'b', + target: 'volume', + points: [ + { tick: 0, value: -60 }, + { tick: 960, value: 0 }, + ], + }; + const marks = laneMarks(fade, 0, null, 0, 480, true, 9.6); + // 0 and 480, and the 49 readings between. + expect(marks).toHaveLength(51); + expect(marks[0]).toEqual({ at: 0, value: 0, jump: true }); + for (const mark of marks) expect(mark.value).toBeCloseTo(dbToGain(-60 + (60 * mark.at) / 960), 12); + expect(marks.slice(1).every((mark, i) => !mark.jump && mark.at > marks[i]!.at)).toBe(true); + const eased = pan([ + { tick: 0, value: 0, ease: 'sine-in' }, + { tick: 100, value: 1 }, + ]); + expect(laneMarks(eased, 0, null, 0, 100, true, 25).map((mark) => mark.at)).toEqual([0, 25, 50, 75, 100]); + }); + + it('jumps back with the playhead at the end of a loop, and where a step lands', () => { + expect(rounded(laneMarks(sweep, 0, { start: 0, end: 960 }, 480, 1440, false, 9.6))).toEqual([ + [480, 0, false], + [960, 1, false], + [960, -1, true], + [1440, 0, false], + ]); + const step = pan([ + { tick: 0, value: -1, ease: 'step' }, + { tick: 100, value: 1 }, + ]); + expect(rounded(laneMarks(step, 0, null, 0, 200, true, 9.6))).toEqual([ + [0, -1, true], + [100, 1, true], + [200, 1, false], + ]); + }); +}); + +describe('a lane round the loop of a song inside a composition (#337)', () => { + it('is cut at the loop end and starts again there, each round on from where it was', () => { + const points: SequenceLanePoint[] = [ + { tick: 0, value: -1 }, + { tick: 960, value: 1 }, + ]; + const unrolled = unrolledLane(points, { start: 0, end: 960 }, 480, 1440, 9.6); + expect(unrolled).toEqual([ + { tick: 480, value: 0 }, + { tick: 960, value: 1 }, + { tick: 960, value: -1 }, + { tick: 1440, value: 0 }, + ]); + expect(laneValueAt(unrolled, 700)).toBeCloseTo(laneValueAt(points, 700), 12); + expect(laneValueAt(unrolled, 1200)).toBeCloseTo(laneValueAt(points, 240), 12); + }); + + it('keeps a whole easing as it is, and reads one cut in the middle every step', () => { + const eased: SequenceLanePoint[] = [ + { tick: 0, value: 0, ease: 'sine-in' }, + { tick: 960, value: 1 }, + ]; + expect(unrolledLane(eased, null, 0, 1920, 96)).toEqual([ + { tick: 0, value: 0, ease: 'sine-in' }, + { tick: 960, value: 1 }, + { tick: 1920, value: 1 }, + ]); + const cut = unrolledLane(eased, null, 480, 960, 96); + expect(cut.map((p) => p.tick)).toEqual([480, 576, 672, 768, 864, 960]); + for (const point of cut) { + expect(point.ease).toBeUndefined(); + expect(point.value).toBeCloseTo(laneValueAt(eased, point.tick), 12); + } + }); +}); + +/** A song "Tune" with a one-bar pattern of one note on its track, and a composition "Cut" at 10 fps. */ +function makeTune() { + const f = makeProject(); + const song = new CreateAssetCommand('song', 'Tune'); + const pattern = new CreateAssetCommand('pattern', 'Riff'); + const piano = new CreateAssetCommand('instrument', 'Piano'); + const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 }); + for (const command of [song, pattern, piano, comp]) f.stack.execute(command); + f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1)); + f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', piano.asset.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 automate = (...automation: AutomationLane[]) => + f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, automation }] })); + const cut = () => findAssetOf(f.project, comp.asset.id, 'composition') as CompositionAsset; + return { ...f, tune, trackId, automate, cut }; +} + +describe('a song with automation (#337)', () => { + it('plays its lanes on the buses of its tracks, which start where the lanes do', () => { + const f = makeTune(); + expect(songSequence(f.project, f.tune()).lanes).toBeUndefined(); + expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated'); + f.automate({ + target: 'volume', + points: [ + { tick: 0, value: -12 }, + { tick: BAR, value: 0 }, + ], + }); + expect(songSequence(f.project, f.tune()).lanes).toEqual([ + { + bus: trackBus(f.tune().id, f.trackId), + target: 'volume', + points: [ + { tick: 0, value: -12 }, + { tick: BAR, value: 0 }, + ], + }, + ]); + // The lane rules the volume, the fader the pan. + expect(songBuses(f.project, f.tune())[0]).toMatchObject({ + gain: dbToGain(-12), + pan: 0, + automated: ['volume'], + }); + }); + + it('is a bake of its own, and one without automation keeps the print it had', () => { + const f = makeTune(); + const plain = bakePrint(f.project, f.tune()); + const sweep = (to: number): AutomationLane => ({ + target: 'pan', + points: [ + { tick: 0, value: 0.5 }, + { tick: BAR / 2, value: to }, + ], + }); + f.automate(sweep(1)); + const panned = bakePrint(f.project, f.tune()); + expect(panned).not.toBe(plain); + // Only later in the song: where its tracks start is the same. + f.automate(sweep(-1)); + expect(bakePrint(f.project, f.tune())).not.toBe(panned); + f.automate(); + expect(bakePrint(f.project, f.tune())).toBe(plain); + }); + + it('plays its lanes in a composition on the buses of its item, round its loop, in milliseconds', () => { + const f = makeTune(); + f.automate({ + target: 'pan', + points: [ + { tick: 0, value: -1 }, + { tick: BAR, value: 1 }, + ], + }); + // From 1 s for 3 s, half a second into the song: it goes round its two-second bar once. + const item = { + id: 'it', + track: f.cut().tracks[0]!.id, + start: 10, + length: 30, + offset: 0.5, + audio: { kind: 'song' as const, asset: f.tune().id, volume: 0, bus: '' }, + }; + f.stack.execute(new SetCompositionCommand(f.cut().id, { items: [item] })); + const [playing] = compositionPlayings(f.project, f.cut()); + expect(playing!.sequence.lanes).toEqual([ + { + bus: `${audioItemBus(f.cut().id, 'it')}:${f.trackId}`, + target: 'pan', + points: [ + { tick: 1000, value: -0.5 }, + { tick: 2500, value: 1 }, + { tick: 2500, value: -1 }, + { tick: 4000, value: 0.5 }, + ], + }, + ]); + expect(playing!.buses.map((bus) => bus.name)).toContain(`${audioItemBus(f.cut().id, 'it')}:${f.trackId}`); + }); +}); + +describe('the scheduler and a lane (#337)', () => { + it('hands each window to the host on the audio clock, the first fresh, each going on from the last', () => { + const automated: ScheduledAutomation[] = []; + let time = 10; + let pump: (() => void) | null = null; + const host: SchedulerHost = { + now: () => time, + latency: () => 0, + play: () => 0, + automate: (automation) => void automated.push(automation), + playing: () => false, + stop: () => {}, + wake: (wake) => ((pump = wake), () => (pump = null)), + }; + const lane = { + ...pan([ + { tick: 0, value: -1 }, + { tick: BAR, value: 1 }, + ]), + bus: 'song:s:t', + }; + const scheduler = new Scheduler(host, () => ({ notes: [], lanes: [lane] }), { tempo: 120 }); + scheduler.start(); + for (let i = 0; i < 8; i++) { + time += 0.025; + pump!(); + } + expect(automated.map((a) => [a.bus, a.target, a.fresh])).toEqual([ + ['song:s:t', 'pan', true], + ...Array.from({ length: automated.length - 1 }, () => ['song:s:t', 'pan', false]), + ]); + expect(automated[0]!.marks[0]).toEqual({ at: 10 + LEAD, value: -1, jump: true }); + automated.slice(1).forEach((window, i) => { + expect(window.marks[0]!.at).toBeCloseTo(automated[i]!.marks.at(-1)!.at, 12); + expect(window.marks[0]!.jump).toBe(false); + }); + // Two seconds a bar: from -1 by 1 a second. + for (const { marks } of automated) + for (const mark of marks) expect(mark.value).toBeCloseTo(-1 + (mark.at - 10 - LEAD), 9); + }); +}); diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index ebb3a6bc..534c419a 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -17,7 +17,7 @@ import { } from '../../src/core/soundfx/nodes'; import type { MixBus } from '../../src/core/music/mixer'; import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks'; -import type { ScheduledNote } from '../../src/core/music/Scheduler'; +import type { ScheduledAutomation, ScheduledNote } from '../../src/core/music/Scheduler'; import type { LfoOptions, PitchOptions, StreamOptions } from '../../src/viewport/fxWorklet'; import { saturationCurve } from '../../src/core/soundfx/saturation'; import { stageNodes } from '../../src/viewport/fxNodes'; @@ -1982,6 +1982,99 @@ describe('the patterns and songs a game plays (#336)', () => { }); }); +describe('the automation of the tracks of a song (#337)', () => { + const automation = (fields: Partial): ScheduledAutomation => ({ + bus: 'song:s:t', + target: 'volume', + fresh: false, + marks: [], + ...fields, + }); + + it('leaves what a lane sets alone, and hands it back to the fader from where the lane left it', async () => { + const library = new SoundLibrary(false); + // The first gain is where the bus is fed, the second its level. + await library.setBuses([{ name: 'song:s:t', gain: 0.5, pan: 0.25, automated: ['pan'] }]); + await library.setBuses([{ name: 'song:s:t', gain: 0.4, pan: -1, automated: ['pan'] }]); + expect([gains[1]!.glides, panners[0]!.glides, panners[0]!.holds]).toEqual([[0.5, 0.4], [0.25], 0]); + await library.setBuses([{ name: 'song:s:t', gain: 0.4, pan: -1 }]); + expect([panners[0]!.glides, panners[0]!.holds, gains[1]!.holds]).toEqual([[0.25, -1], 1, 0]); + }); + + it('ramps a bus to each mark or jumps there, a fresh start first dropping what was planned', async () => { + const library = new SoundLibrary(false); + await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, automated: ['volume'] }]); + library.automateBus( + 'song:s:t', + automation({ + fresh: true, + marks: [ + { at: 1, value: 0, jump: true }, + { at: 2, value: 0.5, jump: false }, + ], + }), + ); + library.automateBus('song:s:t', automation({ marks: [{ at: 3, value: 0.25, jump: false }] })); + // The hold by hand of a context without cancelAndHoldAtTime, at the first mark. + expect(gains[1]!.ramps).toEqual([ + ['set', 1, 1], + ['set', 0, 1], + ['linear', 0.5, 2], + ['linear', 0.25, 3], + ]); + library.automateBus( + 'song:s:gone', + automation({ bus: 'song:s:gone', marks: [{ at: 1, value: 0, jump: true }] }), + ); + expect(library.busLevels().map((bus) => bus.name)).toEqual(['mix', 'song:s:t']); + }); + + it('takes the tracks of a playing in a game along their lanes, and no bus the song does not have', async () => { + const library = new SoundLibrary(false); + const lane = (bus: string) => ({ + bus, + target: 'pan' as const, + points: [ + { tick: 0, value: -1 }, + { tick: 1920, value: 1 }, + ], + }); + const id = library.playSequence( + 'song:s', + { + sequence: { + notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' }], + lanes: [lane('song:s:t'), lane('song:s:gone')], + }, + tempo: 120, + length: 1920, + loop: null, + needs: { layers: [], files: [] }, + buses: [{ name: 'song:s:t', gain: 1, pan: -1, automated: ['pan'] }], + strike: () => 0, + }, + '', + ); + await settle(); + expect(library.busLevels().map((bus) => bus.name)).toContain(`play:${id}:song:s:t`); + // One bus only is taken along: the track's, over its first window – on from the first note, a + // lookahead ahead, 48 ticks at 120 BPM. + const taken = panners.filter((panner) => panner.ramps.length > 0); + expect(taken).toHaveLength(1); + const ramps = taken[0]!.ramps.map(([kind, value, at]) => [ + kind, + value, + Math.round(Number(at) * 1e6) / 1e6, + ]); + expect(ramps).toEqual([ + ['set', -1, 0.05], + ['set', -1, 0.05], + ['linear', -0.95, 0.1], + ]); + expect(library.busLevels().some((bus) => bus.name.includes('gone'))).toBe(false); + }); +}); + describe('a bake (#340)', () => { const BAKED = 'baked.ogg'; /** A pattern of one note, baked: `round` null plays it once to `end`. */ diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 346c7c9d..9d1dea48 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -531,6 +531,42 @@ export async function song(t) { bus(tracks[1])?.gain === 1, JSON.stringify(buses), ); + + // 65h2. A lane of the second track's volume (#337), -40 dB to 0 dB over eight bars, takes its bus + // along while the song plays, from the window after it is set on: the level follows the + // playhead, and the fader turned all the way down meanwhile does not move it. The loop is + // made long, so the playhead does not jump back while it is read. + const loopBefore = (await model()).loop; + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.loop = { start: 0, end: ${64 * BAR} }; + song.tracks[1].automation = [{ target: 'volume', points: [{ tick: 0, value: -40 }, { tick: ${8 * BAR}, value: 0 }] }];`); + const laneDb = (ticks) => -40 + (40 * ticks) / (8 * BAR); + const readLane = () => + json(`({ + level: __gm.song.buses().find((b) => b.name === ${JSON.stringify(busOf(tracks[1]))})?.level, + at: __gm.song.position()?.ticks, + })`); + await sleep(300); + const laneFirst = await readLane(); + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.tracks[1].volume = -60;`); + await sleep(1000); + const laneLater = await readLane(); + const offLane = (r) => Math.abs(20 * Math.log10(r.level) - laneDb(r.at)); + check( + 'a lane of the volume takes the bus of its track along the playhead, and the fader no longer moves it', + [laneFirst, laneLater].every((r) => r.level > 0 && r.at < 8 * BAR && offLane(r) < 1) && + laneLater.at - laneFirst.at > 800 && + 20 * Math.log10(laneLater.level / laneFirst.level) > 2, + JSON.stringify({ laneFirst, laneLater, off: [laneFirst, laneLater].map(offLane) }), + ); + 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; + ${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. const closed = (list) => {