Files
feuerwerk/tests/core/automation.test.ts
adminandClaude Opus 5.5 389a7c0fd1 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>
2026-10-19 06:45:55 +02:00

404 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});