Let a song track automate parameters of its sounds and FX (#337)
Stage 3 of #337. Lanes moved volume and pan only. Now a lane can steer a parameter of anything on its track, and a voice already planned or still waiting for its file follows it too. - A lane may aim at param:<name>. A parameter has no range of its own, so the lane keeps min and max. A parameter lane stays without points, so its range is not lost. Remove lane takes it away. - It reaches the track FX, the FX of an item, the instrument of a note with its own FX, and the FX of a clip. When several have the name, all of them change, as with Set sound FX parameters. - The scheduler hands the lane value to a note or clip at its start. Values a voice only reads at its start act from the next note. - While a voice sounds, the knobs of buses and voices of the track follow the lane on the audio clock, read every 10 ms, because a knob is not always the parameter itself (trackParams.ts). - A voice waiting for its file takes the marks when it starts. - When a lane is removed, the parameter goes back to its default. An edit while playing does not reset a parameter a lane rules. - This holds in the song tab, for Play song, in compositions and in the bake. - Song tab: Add parameter lane in the track head menu opens a dialog for parameter and range (ParamLaneDialog.ts). The lane head menu has Range and Remove lane. - Inspector: while a lane rules a value, a hint at the track names the lanes and says the fields act once the lane is gone. Known limit: a knob that depends on two automated parameters reckons each lane with the last value of the other, so it may lag one window. Tests: - automation-params.test.ts (9, new), 3 new in sound-library and 3 in song-lanes. Affected files 407/407. - 17 counter checks, all compile, all red. - e2e 65 on melektaus-atx: ALL PASSED, 67 checks. 65h3 is new: a clip with FX parameter cut starts at the lane value and its knob stays within 0.04 dB of the lane. It goes red when automateBus does not pass parameters on. - Gate on melektaus-atx: CHECK EXIT 0, 330 files, 5080 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd99f9e79d
commit
b50c7dbc35
19 files changed
+1248
-115
No files matched your search
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Automation of the parameters of a song's track (#337, stage 3): a lane `param:<name>` with a range
|
||||
* of its own, the value each note and clip starts with, the sound FX of the track and its items set
|
||||
* where the lane starts, and which parameters a track has to offer.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
||||
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
||||
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||
import { findAssetOf } from '../../src/core/model/Project';
|
||||
import { chipPresetGraph } from '../../src/core/instrument/chipPresets';
|
||||
import {
|
||||
type AutomationLane,
|
||||
heldLanes,
|
||||
laneMarks,
|
||||
laneRange,
|
||||
paramsAt,
|
||||
reaches,
|
||||
type SequenceLane,
|
||||
} from '../../src/core/music/automation';
|
||||
import {
|
||||
type ScheduledClip,
|
||||
type ScheduledNote,
|
||||
Scheduler,
|
||||
type SchedulerHost,
|
||||
} from '../../src/core/music/Scheduler';
|
||||
import { newItem, songSequence, trackBus } from '../../src/core/music/song';
|
||||
import { songBuses } from '../../src/core/music/songBuses';
|
||||
import { defaultParamRange, trackParams } from '../../src/core/music/trackParams';
|
||||
import { strikeNote } from '../../src/runtime/systems/behaviorContext';
|
||||
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||||
import { graphEditor, makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
const Q = 480;
|
||||
const BAR = 4 * Q;
|
||||
|
||||
describe('a lane of a parameter (#337)', () => {
|
||||
it('is held to a name without a colon, its points to its own range, and kept without a point', () => {
|
||||
expect(
|
||||
heldLanes([
|
||||
{
|
||||
target: 'param:cut',
|
||||
min: 100,
|
||||
max: 5000,
|
||||
points: [
|
||||
{ tick: 0, value: 20000 },
|
||||
{ tick: 10, value: 50 },
|
||||
],
|
||||
},
|
||||
{ target: 'param:', points: [{ tick: 0, value: 1 }] },
|
||||
{ target: 'param:a:b', points: [{ tick: 0, value: 1 }] },
|
||||
{ target: 'param:mix', min: 1, max: 1, points: [{ tick: 0, value: 3 }] },
|
||||
{ target: 'param:empty', min: -1, max: 1, points: [] },
|
||||
{ target: 'param:bare', points: [] },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
target: 'param:cut',
|
||||
min: 100,
|
||||
max: 5000,
|
||||
points: [
|
||||
{ tick: 0, value: 5000 },
|
||||
{ tick: 10, value: 100 },
|
||||
],
|
||||
},
|
||||
// A range that is none is the one a lane without a range has: 0 to 1.
|
||||
{ target: 'param:mix', points: [{ tick: 0, value: 1 }] },
|
||||
{ target: 'param:empty', min: -1, max: 1, points: [] },
|
||||
{ target: 'param:bare', points: [] },
|
||||
]);
|
||||
expect(laneRange({ target: 'param:mix' })).toEqual({ min: 0, max: 1 });
|
||||
expect(laneRange('volume')).toEqual({ min: -60, max: 12 });
|
||||
});
|
||||
|
||||
it('reaches its track and every bus inside it, and starts what is heard there at its value', () => {
|
||||
expect(reaches('song:s:t', 'song:s:t')).toBe(true);
|
||||
expect(reaches('song:s:t', 'song:s:t:#items')).toBe(true);
|
||||
expect(reaches('song:s:t', 'song:s:t2:#items')).toBe(false);
|
||||
const lanes: SequenceLane[] = [
|
||||
{
|
||||
bus: 'song:s:t',
|
||||
target: 'param:cut',
|
||||
min: 0,
|
||||
max: 10,
|
||||
points: [
|
||||
{ tick: 0, value: 0 },
|
||||
{ tick: 100, value: 20 },
|
||||
],
|
||||
},
|
||||
{ bus: 'song:s:t', target: 'volume', points: [{ tick: 0, value: -6 }] },
|
||||
{ bus: 'song:s:u', target: 'param:other', points: [{ tick: 0, value: 1 }] },
|
||||
];
|
||||
expect(paramsAt(lanes, 'song:s:t:#items', 25)).toEqual({ cut: 5 });
|
||||
// Past its range the way is held to it.
|
||||
expect(paramsAt(lanes, 'song:s:t:item', 90)).toEqual({ cut: 10 });
|
||||
expect(paramsAt(lanes, 'song:s:x:#items', 25)).toBeUndefined();
|
||||
expect(paramsAt(lanes, undefined, 25)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is read every step even where it runs straight, as a knob may not be the parameter itself', () => {
|
||||
const lane: SequenceLane = {
|
||||
bus: 'b',
|
||||
target: 'param:cut',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
points: [
|
||||
{ tick: 0, value: 0 },
|
||||
{ tick: 960, value: 1000 },
|
||||
],
|
||||
};
|
||||
const marks = laneMarks(lane, 0, null, 0, 480, true, 9.6);
|
||||
expect(marks).toHaveLength(51);
|
||||
// In the units of the parameter, not as a gain.
|
||||
for (const mark of marks) expect(mark.value).toBeCloseTo((1000 * mark.at) / 960, 9);
|
||||
const held = laneMarks({ ...lane, points: [{ tick: 0, value: 300 }] }, 0, null, 0, 480, true, 9.6);
|
||||
expect(held).toEqual([
|
||||
{ at: 0, value: 300, jump: true },
|
||||
{ at: 480, value: 300, jump: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scheduler and a parameter lane (#337)', () => {
|
||||
it('starts each note and clip of the track at the value the lane has where it starts', () => {
|
||||
const notes: ScheduledNote[] = [];
|
||||
const clips: ScheduledClip[] = [];
|
||||
let time = 0;
|
||||
const host: SchedulerHost = {
|
||||
now: () => time,
|
||||
latency: () => 0,
|
||||
play: (note) => (notes.push(note), 1),
|
||||
playClip: (clip) => (clips.push(clip), 2),
|
||||
playing: () => true,
|
||||
stop: () => {},
|
||||
wake: () => () => {},
|
||||
};
|
||||
const lane: SequenceLane = {
|
||||
bus: 'song:s:t',
|
||||
target: 'param:cut',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
points: [
|
||||
{ tick: 0, value: 0 },
|
||||
{ tick: 1000, value: 1000 },
|
||||
],
|
||||
};
|
||||
const scheduler = new Scheduler(
|
||||
host,
|
||||
() => ({
|
||||
notes: [
|
||||
{ tick: 200, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t:#items' },
|
||||
{ tick: 210, length: 10, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:u:#items' },
|
||||
],
|
||||
clips: [{ tick: 100, length: 500, sound: 's', fx: '', offset: 0, volume: 1, bus: 'song:s:t:#items' }],
|
||||
lanes: [lane],
|
||||
}),
|
||||
{ tempo: 120 },
|
||||
);
|
||||
scheduler.start(300);
|
||||
time = 0.5;
|
||||
scheduler.pump();
|
||||
expect(notes).toEqual([]);
|
||||
// Started inside the clip: it is taken up 200 ticks in, at the value there.
|
||||
expect(clips.map((clip) => clip.params)).toEqual([{ cut: 300 }]);
|
||||
scheduler.start(150);
|
||||
time = 1;
|
||||
scheduler.pump();
|
||||
expect(notes.map((note) => [note.bus, note.params])).toEqual([
|
||||
['song:s:t:#items', { cut: 200 }],
|
||||
['song:s:u:#items', undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A sound FX "Filter" with a parameter `cut` (default 800) into its gain. */
|
||||
function makeFilter(f: ProjectFixture): SoundFxAsset {
|
||||
const command = new CreateAssetCommand('soundfx', 'Filter');
|
||||
f.stack.execute(command);
|
||||
const fx = command.asset as SoundFxAsset;
|
||||
const edit = graphEditor(f, fx as unknown as BehaviorAsset);
|
||||
const input = edit.addNode('SoundFxInput');
|
||||
const gain = edit.addNode('SoundFxGain');
|
||||
const output = edit.addNode('SoundFxOutput');
|
||||
input.ports = [{ key: 'cut', kind: 'number', default: 800 }];
|
||||
edit.connect(input, 'out', gain, 'in');
|
||||
edit.connect(input, 'cut', gain, 'gain');
|
||||
edit.connect(gain, 'out', output, 'in');
|
||||
return fx;
|
||||
}
|
||||
|
||||
/** A song "Tune" with a pattern of one note on its one track, played by a chip instrument with a parameter `tone`. */
|
||||
function makeTune() {
|
||||
const f = makeProject();
|
||||
const song = new CreateAssetCommand('song', 'Tune');
|
||||
const pattern = new CreateAssetCommand('pattern', 'Riff');
|
||||
const chip = new CreateAssetCommand('instrument', 'Chip');
|
||||
for (const command of [song, pattern, chip]) f.stack.execute(command);
|
||||
const instrument = chip.asset as InstrumentAsset;
|
||||
instrument.graph = chipPresetGraph('pickup', () => 0);
|
||||
instrument.graph.nodes.push({
|
||||
id: 'params',
|
||||
type: 'InstrumentParameters',
|
||||
x: 0,
|
||||
y: 0,
|
||||
params: {},
|
||||
ports: [
|
||||
{ key: 'tone', kind: 'number', default: 0.25 },
|
||||
{ key: 'cut', kind: 'number', default: 5 },
|
||||
],
|
||||
});
|
||||
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
|
||||
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', instrument.id));
|
||||
f.stack.execute(
|
||||
new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 }]),
|
||||
);
|
||||
const tune = () => findAssetOf(f.project, song.asset.id, 'song') as SongAsset;
|
||||
const trackId = tune().tracks[0]!.id;
|
||||
f.stack.execute(
|
||||
new SetSongCommand(song.asset.id, {
|
||||
items: [newItem({ track: trackId, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR })],
|
||||
}),
|
||||
);
|
||||
const setTrack = (fields: Partial<SongAsset['tracks'][number]>) =>
|
||||
f.stack.execute(new SetSongCommand(song.asset.id, { tracks: [{ ...tune().tracks[0]!, ...fields }] }));
|
||||
return { ...f, tune, trackId, setTrack, instrument, patternId: pattern.asset.id };
|
||||
}
|
||||
|
||||
describe('a song with a parameter lane (#337)', () => {
|
||||
const cutLane: AutomationLane = {
|
||||
target: 'param:cut',
|
||||
min: 0,
|
||||
max: 2000,
|
||||
points: [
|
||||
{ tick: 0, value: 400 },
|
||||
{ tick: BAR, value: 1600 },
|
||||
],
|
||||
};
|
||||
|
||||
it('plays it on its track, and sets the sound FX of the track where it starts', () => {
|
||||
const f = makeTune();
|
||||
const fx = makeFilter(f);
|
||||
f.setTrack({ fx: fx.id, automation: [cutLane] });
|
||||
expect(songSequence(f.project, f.tune()).lanes).toEqual([
|
||||
{ bus: trackBus(f.tune().id, f.trackId), ...cutLane },
|
||||
]);
|
||||
const [track] = songBuses(f.project, f.tune());
|
||||
expect(track).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } });
|
||||
// Without a point it rules nothing, and is not played.
|
||||
f.setTrack({ automation: [{ ...cutLane, points: [] }] });
|
||||
expect(songSequence(f.project, f.tune()).lanes).toBeUndefined();
|
||||
expect(songBuses(f.project, f.tune())[0]).not.toHaveProperty('automated');
|
||||
expect(songBuses(f.project, f.tune())[0]!.fx!.params).toEqual({});
|
||||
});
|
||||
|
||||
it('sets the sound FX of a pattern item on the track the same way, which leaves it to the lane', () => {
|
||||
const f = makeTune();
|
||||
const fx = makeFilter(f);
|
||||
const item = f.tune().items[0]!;
|
||||
f.stack.execute(new SetSongCommand(f.tune().id, { items: [{ ...item, fx: fx.id }] }));
|
||||
f.setTrack({ automation: [cutLane] });
|
||||
const own = songBuses(f.project, f.tune()).find((bus) => bus.name.endsWith(item.id));
|
||||
expect(own).toMatchObject({ automated: ['param:cut'], fx: { params: { cut: 400 } } });
|
||||
});
|
||||
|
||||
it('offers the parameters of the track, its items and their instruments, each name once', () => {
|
||||
const f = makeTune();
|
||||
const fx = makeFilter(f);
|
||||
expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([
|
||||
{ name: 'tone', default: 0.25 },
|
||||
{ name: 'cut', default: 5 },
|
||||
]);
|
||||
f.setTrack({ fx: fx.id });
|
||||
// The track's own first: its `cut` is the one whose default is named.
|
||||
expect(trackParams(f.project, f.tune(), f.tune().tracks[0]!)).toEqual([
|
||||
{ name: 'cut', default: 800 },
|
||||
{ name: 'tone', default: 0.25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts a new lane at a range round the default, on a round number', () => {
|
||||
expect(defaultParamRange(0.25)).toEqual({ min: 0, max: 1 });
|
||||
expect(defaultParamRange(800)).toEqual({ min: 0, max: 2000 });
|
||||
expect(defaultParamRange(1.5)).toEqual({ min: 0, max: 5 });
|
||||
expect(defaultParamRange(-3)).toEqual({ min: -10, max: 10 });
|
||||
expect(defaultParamRange(Number.NaN)).toEqual({ min: 0, max: 1 });
|
||||
});
|
||||
|
||||
it('hands a note its values for its instrument and its own sound FX alike', () => {
|
||||
const f = makeTune();
|
||||
const fx = makeFilter(f);
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.instrument.id, 'fx', fx.id));
|
||||
const struck: { params: Record<string, number> }[] = [];
|
||||
const audio: AudioSink = {
|
||||
...SILENT_AUDIO,
|
||||
playNote: (_name, _layers, params) => (struck.push({ params: { ...params } }), 1),
|
||||
};
|
||||
const note: ScheduledNote = {
|
||||
at: 0,
|
||||
duration: 0.1,
|
||||
note: 60,
|
||||
velocity: 1,
|
||||
instrument: f.instrument.id,
|
||||
tempo: 120,
|
||||
params: { cut: 1200 },
|
||||
};
|
||||
expect(strikeNote({ project: f.project, audio }, note, '')).toBe(1);
|
||||
expect(struck[0]!.params).toMatchObject({ cut: 1200, 'fx:cut': 1200 });
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
easedPoint,
|
||||
lanePoints,
|
||||
withLanePoints,
|
||||
withoutLane,
|
||||
withoutPoint,
|
||||
withParamLane,
|
||||
} from '../../src/editor/song/laneEdit';
|
||||
import {
|
||||
LANE_INSET,
|
||||
laneKindOf,
|
||||
laneLabel,
|
||||
laneStep,
|
||||
laneValue,
|
||||
laneValueText,
|
||||
laneWay,
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
pointAt,
|
||||
shownLanes,
|
||||
songRows,
|
||||
trackLanes,
|
||||
trackRows,
|
||||
} from '../../src/editor/song/laneView';
|
||||
import { trackAtY, type SongView } from '../../src/editor/song/songView';
|
||||
@@ -165,6 +171,53 @@ describe('editing the points of a lane (#337)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lane of a parameter (#337)', () => {
|
||||
const cut = { target: 'param:cut' as const, min: 0, max: 2000 };
|
||||
const withCut = track('a', [
|
||||
{ target: 'volume', points: [{ tick: 0, value: -6 }] },
|
||||
{ ...cut, points: [] },
|
||||
]);
|
||||
|
||||
it('is shown by its name after the volume and the pan, with no point too: it was added', () => {
|
||||
expect(laneLabel('param:cut')).toBe('cut');
|
||||
expect(trackLanes(withCut)).toEqual(['volume', 'pan', 'param:cut']);
|
||||
expect(shownLanes(withCut, new Map())).toEqual(['volume', 'param:cut']);
|
||||
expect(laneKindOf(withCut, 'param:cut')).toEqual(cut);
|
||||
expect(laneKindOf(withCut, 'pan')).toEqual({ target: 'pan' });
|
||||
});
|
||||
|
||||
it('draws and reads its values in its own range, to a step that cuts it into a hundred or more', () => {
|
||||
expect(laneY(cut, 0, 52)).toBe(52 - LANE_INSET);
|
||||
expect(laneY(cut, 2000, 52)).toBe(LANE_INSET);
|
||||
expect(laneStep(cut)).toBe(10);
|
||||
expect(laneStep({ target: 'param:mix' })).toBe(0.01);
|
||||
expect(laneValue(cut, laneY(cut, 1234, 52), 52)).toBe(1230);
|
||||
expect(laneValue({ target: 'param:mix' }, 26, 52)).toBe(0.5);
|
||||
expect(laneValueText(cut, 1234)).toBe('1230');
|
||||
expect(laneValueText({ target: 'param:mix' }, 0.123)).toBe('0.12');
|
||||
expect(addedPoint([], cut, 0, 5000).points).toEqual([{ tick: 0, value: 2000 }]);
|
||||
});
|
||||
|
||||
it('keeps its lane and range without a point, takes its points into a new range, and goes only when removed', () => {
|
||||
const a = withLanePoints(withCut, 'param:cut', [{ tick: 0, value: 1500 }]);
|
||||
expect(a.automation?.map((l) => l.target)).toEqual(['volume', 'param:cut']);
|
||||
const emptied = withLanePoints(a, 'param:cut', []);
|
||||
expect(emptied.automation?.[1]).toEqual({ ...cut, points: [] });
|
||||
const narrow = withParamLane(a, 'cut', { min: 0, max: 1000 });
|
||||
expect(narrow.automation?.[1]).toEqual({ ...cut, max: 1000, points: [{ tick: 0, value: 1000 }] });
|
||||
// A new one comes last; a later volume lane still goes first.
|
||||
const more = withLanePoints(withParamLane(narrow, 'tone', { min: 0, max: 1 }), 'pan', [
|
||||
{ tick: 0, value: 0 },
|
||||
]);
|
||||
expect(more.automation?.map((l) => l.target)).toEqual(['volume', 'pan', 'param:cut', 'param:tone']);
|
||||
expect(withoutLane(more, 'param:cut').automation?.map((l) => l.target)).toEqual([
|
||||
'volume',
|
||||
'pan',
|
||||
'param:tone',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the easings as a menu (#337)', () => {
|
||||
const project = {
|
||||
assets: [
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
type FxStagePlan,
|
||||
type FxVoice,
|
||||
} from '../../src/core/soundfx/nodes';
|
||||
import type { AutomationTarget } from '../../src/core/music/automation';
|
||||
import { dbToGain } from '../../src/core/music/level';
|
||||
import type { MixBus } from '../../src/core/music/mixer';
|
||||
import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks';
|
||||
import type { ScheduledAutomation, ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
@@ -2073,6 +2075,84 @@ describe('the automation of the tracks of a song (#337)', () => {
|
||||
]);
|
||||
expect(library.busLevels().some((bus) => bus.name.includes('gone'))).toBe(false);
|
||||
});
|
||||
|
||||
/** A gain whose decibels are the parameter `loud`; with `source` it plays FILE, as a clip's FX does. */
|
||||
const loud = (fields: Partial<FxPlan> = {}): FxVoice => ({
|
||||
plan: {
|
||||
pitch: [],
|
||||
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
|
||||
...fields,
|
||||
},
|
||||
params: {},
|
||||
tempo: 120,
|
||||
});
|
||||
const clip = (library: SoundLibrary, bus: string, file = FILE) =>
|
||||
library.playClip(file, { at: 0, offset: 0, duration: 10, volume: 1, bus }, loud());
|
||||
const tracks = (fx: FxVoice, automated: readonly AutomationTarget[] = ['param:loud']) => [
|
||||
{ name: 'song:s:t', gain: 1, pan: 0, fx, automated },
|
||||
{ name: 'song:s:t:#items', gain: 1, pan: 0, into: 'song:s:t' },
|
||||
{ name: 'song:s:u:#items', gain: 1, pan: 0 },
|
||||
];
|
||||
const marks = (...values: [number, number][]) =>
|
||||
automation({
|
||||
target: 'param:loud',
|
||||
marks: values.map(([at, value]) => ({ at, value, jump: false })),
|
||||
});
|
||||
/** What was planned for each gain before the clips fade out at their end, 10 s on. */
|
||||
const moved = () =>
|
||||
gains.map((gain) => gain.ramps.filter(([, , at]) => Number(at) < 5)).filter((ramps) => ramps.length > 0);
|
||||
|
||||
it('takes the sound FX of a track and every voice inside it along a parameter, in what their knobs hold', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
await library.setBuses(tracks(loud()));
|
||||
await settle();
|
||||
clip(library, 'song:s:t:#items');
|
||||
clip(library, 'song:s:u:#items');
|
||||
await settle();
|
||||
await arrive();
|
||||
library.automateBus('song:s:t', marks([1, -6], [2, -12]));
|
||||
// The chain of the track and the voice on it, each first set where nothing was planned yet.
|
||||
const way = [
|
||||
['set', expect.closeTo(dbToGain(-6), 9), 1],
|
||||
['linear', expect.closeTo(dbToGain(-12), 9), 2],
|
||||
];
|
||||
expect(moved()).toEqual([way, way]);
|
||||
// The next window ramps on from there; a mark where a knob stays is left out.
|
||||
library.automateBus('song:s:t', marks([2, -12], [3, 0]));
|
||||
expect(moved()[0]).toEqual([...way, ['linear', expect.closeTo(dbToGain(-12), 9), 2], ['linear', 1, 3]]);
|
||||
library.automateBus('song:s:t', marks([3, 0], [4, 0]));
|
||||
expect(moved()[0]).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('takes up the marks planned for a voice still waiting on its bytes once it starts', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
await library.setBuses(tracks({ ...loud(), plan: { pitch: [], stages: [] } }, []));
|
||||
clip(library, 'song:s:t:#items', OTHER);
|
||||
library.automateBus('song:s:t', marks([1, -6], [2, -12]));
|
||||
expect(moved()).toEqual([]);
|
||||
await settle();
|
||||
await arrive();
|
||||
expect(moved()).toEqual([
|
||||
[
|
||||
['set', expect.closeTo(dbToGain(-6), 9), 1],
|
||||
['linear', expect.closeTo(dbToGain(-12), 9), 2],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a parameter a lane rules alone when the tracks are set again, and hands one let go back', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
// One plan throughout, as a sound FX compiles to: another would be another chain.
|
||||
const fx = loud();
|
||||
await library.setBuses(tracks(fx));
|
||||
library.automateBus('song:s:t', marks([1, -12]));
|
||||
const knob = gains.find((gain) => gain.ramps.length > 0)!;
|
||||
await library.setBuses(tracks({ ...fx, params: { loud: -3 } }));
|
||||
expect(knob.glides).toEqual([]);
|
||||
// The lane is gone: back to what the track's FX is set to, its default.
|
||||
await library.setBuses(tracks(fx, []));
|
||||
expect(knob.glides).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a bake (#340)', () => {
|
||||
|
||||
Reference in new issue
Block a user