Let volume and pan of a song track follow automation lanes (#337)
A song track can now change its volume and pan over time, played on the audio clock wherever the song is heard. This is stage 1 of #337: the model and the playback. Lanes cannot be drawn in the song tab yet. - A track holds automation lanes for volume (dB) and pan. Each point has a tick, a value and an easing from the shared list, curve assets included. Two points on one tick make a jump. Before the first point its value holds, after the last point the last value holds. - The rules are pure functions in core/music/automation.ts: the value at a tick and the pieces of a lane inside one scheduling window. - The scheduler hands those pieces to the host in every window. The host sets them as ramps or jumps on the gain and panner of the track's bus. Shapes the clock cannot ramp directly (dB as gain, curved easings) are sampled every 10 ms. At the loop end the value jumps back with the playhead. - This works in the song tab, for Play song, inside a composition (across the song loop, on the item's own bus) and in a bake. - While a lane has points the fader no longer sets that value. When the lane goes, the fader takes over from the current value. - Automation enters the bake fingerprint only when there is some, so existing bakes stay valid. - The level helpers moved to core/music/level.ts, so song.ts and automation.ts do not import each other. Known gap: a delaying sound FX on a bus after the track (a pitch shifter) makes the automation late by that delay. Tests: - automation.test.ts (13, new), 3 new in sound-library.test.ts. Song, bake and composition tests together 286/286. - 12 counter-checks, all compile, all red. - e2e 65 on atx: ALL PASSED, 60 checks. New 65h2: in the song tab the bus level follows a volume lane, 0.09 dB off, also with the fader at -60 dB. Red when the song tab does not pass the automation on. - Gate on atx: 328 files, 5053 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5e071ca68
commit
389a7c0fd1
16 files changed
+1011
-35
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import type { FadeCurve } from '../model/sound';
|
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 { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||||
import {
|
import {
|
||||||
COMMON_TIME,
|
COMMON_TIME,
|
||||||
@@ -59,6 +60,18 @@ export interface ScheduledRamp {
|
|||||||
done: number;
|
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. */
|
/** What the scheduler needs of the audio side; a test hands in a clock of its own. */
|
||||||
export interface SchedulerHost {
|
export interface SchedulerHost {
|
||||||
/** Seconds on the audio clock. */
|
/** Seconds on the audio clock. */
|
||||||
@@ -77,6 +90,8 @@ export interface SchedulerHost {
|
|||||||
playClip?(clip: ScheduledClip): number;
|
playClip?(clip: ScheduledClip): number;
|
||||||
/** Ramps the level of a bus; a host without levels leaves it out. */
|
/** Ramps the level of a bus; a host without levels leaves it out. */
|
||||||
ramp?(ramp: ScheduledRamp): void;
|
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;
|
playing(voice: number): boolean;
|
||||||
/** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */
|
/** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */
|
||||||
stop(voices: readonly number[], letRing?: boolean): void;
|
stop(voices: readonly number[], letRing?: boolean): void;
|
||||||
@@ -246,6 +261,19 @@ export class Scheduler {
|
|||||||
done: whole > 0 ? due.skip / whole : 0,
|
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.fresh = false;
|
||||||
this.until = to;
|
this.until = to;
|
||||||
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
|
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
const target = AUTOMATION_TARGETS.find((t) => t === lane.target);
|
||||||
|
if (!target || lanes.some((l) => l.target === target)) continue;
|
||||||
|
const { min, max } = targetRange(target);
|
||||||
|
const 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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -233,6 +233,8 @@ export function bakePrint(project: Project, asset: BakedAsset): string {
|
|||||||
clips,
|
clips,
|
||||||
buses,
|
buses,
|
||||||
instruments,
|
instruments,
|
||||||
|
// Only where there is any, so a bake made before automation (#337) still holds.
|
||||||
|
...(sequence.lanes?.length ? [sequence.lanes] : []),
|
||||||
]);
|
]);
|
||||||
return hashText(text);
|
return hashText(text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { compositionFps, compositionLength, compositionLoops, itemFades } from '
|
|||||||
import { findAssetOf, type Project } from '../model/Project';
|
import { findAssetOf, type Project } from '../model/Project';
|
||||||
import { findSound } from '../model/sound';
|
import { findSound } from '../model/sound';
|
||||||
import { pickedFx } from '../properties/assetFx';
|
import { pickedFx } from '../properties/assetFx';
|
||||||
|
import { AUTOMATION_STEP, type SequenceLane, unrolledLane } from './automation';
|
||||||
import { itemRoute, MASTER_MIX, type MixRoute } from './mixer';
|
import { itemRoute, MASTER_MIX, type MixRoute } from './mixer';
|
||||||
import { fxVoiceOf } from './needs';
|
import { fxVoiceOf } from './needs';
|
||||||
import { patternInstrument, patternLength, patternSequence, patternTempo } from './pattern';
|
import { patternInstrument, patternLength, patternSequence, patternTempo } from './pattern';
|
||||||
@@ -75,6 +76,7 @@ export function compositionPlayings(
|
|||||||
(playing.sequence.notes as SequenceNote[]).push(...laid.notes);
|
(playing.sequence.notes as SequenceNote[]).push(...laid.notes);
|
||||||
(playing.sequence.clips as SequenceClip[]).push(...laid.clips);
|
(playing.sequence.clips as SequenceClip[]).push(...laid.clips);
|
||||||
(playing.sequence.levels as SequenceLevel[]).push(...laid.levels);
|
(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);
|
playing.buses.push(...laid.buses);
|
||||||
}
|
}
|
||||||
const playings = [...byRoute.values()];
|
const playings = [...byRoute.values()];
|
||||||
@@ -100,6 +102,8 @@ interface Laid {
|
|||||||
clips: SequenceClip[];
|
clips: SequenceClip[];
|
||||||
/** Its fades, on its bus. */
|
/** Its fades, on its bus. */
|
||||||
levels: SequenceLevel[];
|
levels: SequenceLevel[];
|
||||||
|
/** A song's automation (#337), round its loop as the song plays inside the item. */
|
||||||
|
lanes?: SequenceLane[];
|
||||||
buses: BusLevel[];
|
buses: BusLevel[];
|
||||||
/** The asset its routing is read from: the sound of a clip, else what it plays. */
|
/** The asset its routing is read from: the sound of a clip, else what it plays. */
|
||||||
routedAs: string;
|
routedAs: string;
|
||||||
@@ -168,6 +172,11 @@ function laidOut(project: Project, compId: string, item: CompositionItem, fps: n
|
|||||||
return {
|
return {
|
||||||
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, ...(n.bus && { bus: renamed(n.bus) }) })),
|
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) })),
|
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,
|
levels,
|
||||||
buses: [withFx(pickedFx('', song.props), tempo), ...tracks],
|
buses: [withFx(pickedFx('', song.props), tempo), ...tracks],
|
||||||
routedAs: song.id,
|
routedAs: song.id,
|
||||||
@@ -207,7 +216,7 @@ function played(
|
|||||||
tempo: number,
|
tempo: number,
|
||||||
into: number,
|
into: number,
|
||||||
seconds: number,
|
seconds: number,
|
||||||
): { notes: SequenceNote[]; clips: SequenceClip[] } {
|
): { notes: SequenceNote[]; clips: SequenceClip[]; lanes: SequenceLane[] } {
|
||||||
const from = Math.round(ticksOfSeconds(into, tempo));
|
const from = Math.round(ticksOfSeconds(into, tempo));
|
||||||
const to = from + Math.ceil(ticksOfSeconds(seconds, tempo));
|
const to = from + Math.ceil(ticksOfSeconds(seconds, tempo));
|
||||||
const span = compositionTicks(seconds);
|
const span = compositionTicks(seconds);
|
||||||
@@ -226,5 +235,10 @@ function played(
|
|||||||
const { at: _, skip, ...clip } = c;
|
const { at: _, skip, ...clip } = c;
|
||||||
return [{ ...clip, tick, length, offset: c.offset + secondsOfTicks(skip, tempo), tempo }];
|
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 };
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { SendTap } from '../graph/Graph';
|
import type { SendTap } from '../graph/Graph';
|
||||||
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
||||||
|
import type { AutomationTarget } from './automation';
|
||||||
import type { BakePlayback } from './bake';
|
import type { BakePlayback } from './bake';
|
||||||
import type { MixSend } from './mixer';
|
import type { MixSend } from './mixer';
|
||||||
import {
|
import {
|
||||||
LEAD,
|
LEAD,
|
||||||
|
type ScheduledAutomation,
|
||||||
type ScheduledClip,
|
type ScheduledClip,
|
||||||
type ScheduledNote,
|
type ScheduledNote,
|
||||||
type ScheduledRamp,
|
type ScheduledRamp,
|
||||||
@@ -30,6 +32,8 @@ export interface BusLevel {
|
|||||||
sends?: readonly BusSend[];
|
sends?: readonly BusSend[];
|
||||||
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
|
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
|
||||||
fx?: FxVoice;
|
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. */
|
/** 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;
|
strikeClip?(clip: ScheduledClip): number;
|
||||||
/** Ramps the level of one of its buses (#418). */
|
/** Ramps the level of one of its buses (#418). */
|
||||||
ramp?(ramp: ScheduledRamp): void;
|
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. */
|
/** Its bake (#340), played in place of its notes and clips where the host can. */
|
||||||
bake?: BakePlay;
|
bake?: BakePlay;
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +97,10 @@ export interface Runner {
|
|||||||
stop(letRing?: boolean): void;
|
stop(letRing?: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead' | 'ramp'> {
|
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). */
|
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
|
||||||
prepare(needs: SequenceNeeds, id: number): Promise<void>;
|
prepare(needs: SequenceNeeds, id: number): Promise<void>;
|
||||||
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
|
/** 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)),
|
play: (note) => (keep(planned, note), play.strike(note)),
|
||||||
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
||||||
...(play.ramp && { ramp: play.ramp }),
|
...(play.ramp && { ramp: play.ramp }),
|
||||||
|
...(play.automate && { automate: play.automate }),
|
||||||
},
|
},
|
||||||
() => play.sequence,
|
() => play.sequence,
|
||||||
{ tempo: play.tempo, loop: play.loop, end: play.length },
|
{ tempo: play.tempo, loop: play.loop, end: play.length },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* where the playhead stands, which jumps back at the end of a loop.
|
* where the playhead stands, which jumps back at the end of a loop.
|
||||||
*/
|
*/
|
||||||
import type { FadeCurve } from '../model/sound';
|
import type { FadeCurve } from '../model/sound';
|
||||||
|
import type { SequenceLane } from './automation';
|
||||||
|
|
||||||
export interface SequenceNote {
|
export interface SequenceNote {
|
||||||
/** Where it starts in the sequence, and how long it is held, in ticks. */
|
/** Where it starts in the sequence, and how long it is held, in ticks. */
|
||||||
@@ -59,6 +60,8 @@ export interface Sequence {
|
|||||||
notes: readonly SequenceNote[];
|
notes: readonly SequenceNote[];
|
||||||
clips?: readonly SequenceClip[];
|
clips?: readonly SequenceClip[];
|
||||||
levels?: readonly SequenceLevel[];
|
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. */
|
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
|
||||||
|
|||||||
+16
-7
@@ -8,10 +8,13 @@
|
|||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||||
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
|
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
|
||||||
|
import { curveLookup } from '../model/curve';
|
||||||
import { type PropertyBag, textOf } from '../model/Properties';
|
import { type PropertyBag, textOf } from '../model/Properties';
|
||||||
import { findAssetOf, type Project } from '../model/Project';
|
import { findAssetOf, type Project } from '../model/Project';
|
||||||
import { findSound } from '../model/sound';
|
import { findSound } from '../model/sound';
|
||||||
import { pickedFx } from '../properties/assetFx';
|
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 { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||||
import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time';
|
import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time';
|
||||||
@@ -27,6 +30,8 @@ export interface SongTrack {
|
|||||||
solo: boolean;
|
solo: boolean;
|
||||||
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
|
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
|
||||||
fx: string;
|
fx: string;
|
||||||
|
/** Its volume and pan over the song (#337); left out without a lane. */
|
||||||
|
automation?: AutomationLane[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SongItemKind = 'pattern' | 'sound';
|
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. */
|
/** A song is never shorter than this many bars, on the ruler or when it plays. */
|
||||||
const MIN_BARS = 1;
|
const MIN_BARS = 1;
|
||||||
|
|
||||||
export const TRACK_MIN_DB = -60;
|
export { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB };
|
||||||
export const TRACK_MAX_DB = 12;
|
|
||||||
export const ITEM_MIN_DB = -60;
|
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. */
|
/** 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;
|
export const SOUND_ITEM_MAX_DB = 12;
|
||||||
@@ -138,10 +142,6 @@ export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): s
|
|||||||
return `${trackBus(songId, item.track)}:${item.id}`;
|
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. */
|
/** 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 {
|
export function itemFx(project: Project, item: SongItem): string {
|
||||||
return pickedFx(item.fx, itemAsset(project, item)?.props ?? {});
|
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);
|
notes.sort((a, b) => a.tick - b.tick || a.note - b.note);
|
||||||
clips.sort((a, b) => a.tick - b.tick);
|
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) =>
|
const finite = (value: unknown, fallback: number) =>
|
||||||
@@ -255,6 +262,8 @@ export function heldSongTracks(raw: unknown): SongTrack[] {
|
|||||||
solo: t.solo === true,
|
solo: t.solo === true,
|
||||||
fx: text(t.fx),
|
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)];
|
return tracks.length > 0 ? tracks : [newTrack(1)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import type { PropertyBag } from '../model/Properties';
|
import type { PropertyBag } from '../model/Properties';
|
||||||
import type { Graph } from '../graph/Graph';
|
import type { Graph } from '../graph/Graph';
|
||||||
|
import { AUTOMATION_TARGETS, type AutomationTarget, audioValueOf, laneOf, laneValueAt } from './automation';
|
||||||
import { fxVoiceOf } from './needs';
|
import { fxVoiceOf } from './needs';
|
||||||
import type { BusLevel } from './playbacks';
|
import type { BusLevel } from './playbacks';
|
||||||
import { songRoutes, trackGates } from './routing';
|
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
|
* 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
|
* its routing sends it (#350), a level or pan its lane rules (#337) from where the lane starts; a bus
|
||||||
* sound FX into that, at unity – its volume is its notes'. Mute and solo close sends and items
|
* 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.
|
* (`trackGates`). A sound FX that does not compile is none, and the bus then only passes it on.
|
||||||
*/
|
*/
|
||||||
export function songBuses(
|
export function songBuses(
|
||||||
@@ -41,10 +43,16 @@ export function songBuses(
|
|||||||
const gates = trackGates(song.tracks, routes);
|
const gates = trackGates(song.tracks, routes);
|
||||||
const tracks = song.tracks.map((track) => {
|
const tracks = song.tracks.map((track) => {
|
||||||
const gate = gates.get(track.id);
|
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 {
|
return {
|
||||||
name: trackBus(song.id, track.id),
|
name: trackBus(song.id, track.id),
|
||||||
gain: dbToGain(track.volume),
|
gain: atStart('volume', dbToGain(track.volume)),
|
||||||
pan: track.pan,
|
pan: atStart('pan', track.pan),
|
||||||
|
...(automated.length > 0 && { automated }),
|
||||||
sends: routes
|
sends: routes
|
||||||
.filter((route) => route.from === track.id)
|
.filter((route) => route.from === track.id)
|
||||||
.map((route) => ({
|
.map((route) => ({
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const FX_ROW = {
|
|||||||
/** What an item's pick says while it is empty (#352): what its asset names. */
|
/** What an item's pick says while it is empty (#352): what its asset names. */
|
||||||
const FROM_ASSET = 'From the asset';
|
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<keyof SongTrack, 'automation'>;
|
||||||
|
|
||||||
const TRACK_ROWS: readonly PropertyDef[] = [
|
const TRACK_ROWS: readonly PropertyDef[] = [
|
||||||
{
|
{
|
||||||
key: 'volume',
|
key: 'volume',
|
||||||
@@ -137,7 +140,7 @@ export function refreshSongSection(
|
|||||||
const track = asset?.tracks.find((t) => t.id === trackId);
|
const track = asset?.tracks.find((t) => t.id === trackId);
|
||||||
if (!track) return false;
|
if (!track) return false;
|
||||||
host.nameField?.setValue(track.name);
|
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;
|
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, `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');
|
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
|
||||||
for (const def of TRACK_ROWS) {
|
for (const def of TRACK_ROWS) {
|
||||||
const commit = (value: SongTrack[keyof SongTrack]) =>
|
const commit = (value: SongTrack[TrackField]) =>
|
||||||
new SetSongCommand(
|
new SetSongCommand(
|
||||||
asset.id,
|
asset.id,
|
||||||
{ tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) },
|
{ tracks: withTrack(state.project, asset.id, track.id, { [def.key]: value }) },
|
||||||
`Set track ${def.key}`,
|
`Set track ${def.key}`,
|
||||||
);
|
);
|
||||||
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
|
const handlers = previewingHandlers<SongTrack, SongTrack[TrackField]>(host, {
|
||||||
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
|
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 }),
|
write: (target, value) => Object.assign(target, { [def.key]: value }),
|
||||||
commit,
|
commit,
|
||||||
});
|
});
|
||||||
if (def.key === FX_ROW.key)
|
if (def.key === FX_ROW.key)
|
||||||
handlers.moreChoices = (current) => ownFxEntries(state, asset.id, current, commit);
|
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.fields.set(def.key, row);
|
||||||
host.body.add(row.row);
|
host.body.add(row.row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing';
|
|||||||
import type { SendTap } from '../core/graph/Graph';
|
import type { SendTap } from '../core/graph/Graph';
|
||||||
import { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer';
|
import { MASTER_MIX, type MixBus, type MixSend, sameMix } from '../core/music/mixer';
|
||||||
import { limiterLatency } from '../core/soundfx/dynamics';
|
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 { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||||
import { playedPlan, type StreamedFile, type StreamPlan } from '../core/music/streams';
|
import { playedPlan, type StreamedFile, type StreamPlan } from '../core/music/streams';
|
||||||
@@ -53,6 +59,7 @@ import {
|
|||||||
GLIDE,
|
GLIDE,
|
||||||
glideKnobs,
|
glideKnobs,
|
||||||
heldBack,
|
heldBack,
|
||||||
|
holdParam,
|
||||||
type Knob,
|
type Knob,
|
||||||
levelsOf,
|
levelsOf,
|
||||||
move,
|
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
|
* 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 bus = this.bus(name);
|
||||||
const now = this.audio().currentTime;
|
const now = this.audio().currentTime;
|
||||||
bus.set = { gain: Math.max(0, gain), pan: Math.max(-1, Math.min(1, pan)) };
|
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);
|
const glide = (target: AutomationTarget, param: AudioParam, value: number) => {
|
||||||
bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3);
|
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<void> {
|
async setBuses(levels: readonly BusLevel[]): Promise<void> {
|
||||||
const waits: Promise<void>[] = [];
|
const waits: Promise<void>[] = [];
|
||||||
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);
|
if (into !== undefined) this.bus(into);
|
||||||
const bus = this.bus(name, into ?? null);
|
const bus = this.bus(name, into ?? null);
|
||||||
if (sends)
|
if (sends)
|
||||||
@@ -599,7 +633,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })),
|
sends.map((send) => ({ ...send, to: send.to ?? MASTER_MIX })),
|
||||||
);
|
);
|
||||||
else this.route(name, bus, into ?? null);
|
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));
|
waits.push(this.setFx(bus, fx ?? null));
|
||||||
}
|
}
|
||||||
await Promise.all(waits);
|
await Promise.all(waits);
|
||||||
@@ -856,6 +890,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
gain: context.createGain(),
|
gain: context.createGain(),
|
||||||
pan: context.createStereoPanner(),
|
pan: context.createStereoPanner(),
|
||||||
set: { gain: 1, pan: 0 },
|
set: { gain: 1, pan: 0 },
|
||||||
|
automated: new Set(),
|
||||||
into: target,
|
into: target,
|
||||||
sends: null,
|
sends: null,
|
||||||
fx: null,
|
fx: null,
|
||||||
@@ -1261,6 +1296,9 @@ export class SoundLibrary implements AudioSink {
|
|||||||
ramp: (ramp) => {
|
ramp: (ramp) => {
|
||||||
if (tracks.has(ramp.bus)) this.rampBus(groupBus(id, ramp.bus), 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);
|
this.sequences.start(id, name, routed, owner);
|
||||||
return id;
|
return id;
|
||||||
@@ -3031,6 +3069,8 @@ interface Bus {
|
|||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
pan: StereoPannerNode;
|
pan: StereoPannerNode;
|
||||||
set: { gain: number; pan: number };
|
set: { gain: number; pan: number };
|
||||||
|
/** What a lane of automation sets instead (#337), where `setBus` leaves it alone. */
|
||||||
|
automated: Set<AutomationTarget>;
|
||||||
/** Where it goes as a whole while it has no sends; null straight out. */
|
/** Where it goes as a whole while it has no sends; null straight out. */
|
||||||
into: string | null;
|
into: string | null;
|
||||||
sends: BusSendNodes[] | null;
|
sends: BusSendNodes[] | null;
|
||||||
|
|||||||
+13
-8
@@ -620,6 +620,18 @@ export function fadeOut(param: AudioParam, now: number, seconds = FADE): void {
|
|||||||
param.linearRampToValueAtTime(0, now + seconds);
|
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<AudioParam, 'cancelAndHoldAtTime'> &
|
||||||
|
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||||||
|
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
|
* 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`
|
* 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',
|
ease: EaseShare = 'linear',
|
||||||
): void {
|
): void {
|
||||||
const from = param.value;
|
const from = param.value;
|
||||||
// Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand.
|
holdParam(param, now);
|
||||||
const held = param as Omit<AudioParam, 'cancelAndHoldAtTime'> &
|
|
||||||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
|
||||||
if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now);
|
|
||||||
else {
|
|
||||||
param.cancelScheduledValues(now);
|
|
||||||
param.setValueAtTime(from, now);
|
|
||||||
}
|
|
||||||
const logarithmic = ratio && from > 0 && to > 0;
|
const logarithmic = ratio && from > 0 && to > 0;
|
||||||
if (time <= 0) param.setTargetAtTime(to, now, GLIDE);
|
if (time <= 0) param.setTargetAtTime(to, now, GLIDE);
|
||||||
else if (ease === 'step') param.setValueAtTime(to, now + time);
|
else if (ease === 'step') param.setValueAtTime(to, now + time);
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export class Music {
|
|||||||
),
|
),
|
||||||
play: (note) => this.playOne(note),
|
play: (note) => this.playOne(note),
|
||||||
playClip: (clip) => this.playClip(clip),
|
playClip: (clip) => this.playClip(clip),
|
||||||
|
automate: (automation) => soundLibrary.automateBus(automation.bus, automation),
|
||||||
playing: (voice) => soundLibrary.playing(voice),
|
playing: (voice) => soundLibrary.playing(voice),
|
||||||
stop: (voices) => {
|
stop: (voices) => {
|
||||||
for (const voice of voices) soundLibrary.stopVoice(voice);
|
for (const voice of voices) soundLibrary.stopVoice(voice);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '../../src/core/soundfx/nodes';
|
} from '../../src/core/soundfx/nodes';
|
||||||
import type { MixBus } from '../../src/core/music/mixer';
|
import type { MixBus } from '../../src/core/music/mixer';
|
||||||
import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks';
|
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 type { LfoOptions, PitchOptions, StreamOptions } from '../../src/viewport/fxWorklet';
|
||||||
import { saturationCurve } from '../../src/core/soundfx/saturation';
|
import { saturationCurve } from '../../src/core/soundfx/saturation';
|
||||||
import { stageNodes } from '../../src/viewport/fxNodes';
|
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>): 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)', () => {
|
describe('a bake (#340)', () => {
|
||||||
const BAKED = 'baked.ogg';
|
const BAKED = 'baked.ogg';
|
||||||
/** A pattern of one note, baked: `round` null plays it once to `end`. */
|
/** A pattern of one note, baked: `round` null plays it once to `end`. */
|
||||||
|
|||||||
@@ -531,6 +531,42 @@ export async function song(t) {
|
|||||||
bus(tracks[1])?.gain === 1,
|
bus(tracks[1])?.gain === 1,
|
||||||
JSON.stringify(buses),
|
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')")));
|
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.
|
// Mute closes every send of the track (#350); its own level stays as it is set.
|
||||||
const closed = (list) => {
|
const closed = (list) => {
|
||||||
|
|||||||
Reference in new issue
Block a user