Files
feuerwerk/tests/core/song-play.test.ts
T
adminandClaude Opus 5.5 f9428c5bc2 Let a game take a song's automation lane over and give it back (#338)
A game should be able to steer a lane of a song, the filter of the
pads in a fight, the pan of a track when the player turns, and later
hand it back to the curve the composer drew. Set song lane holds a
lane of a track at a value, Release song lane glides it back.

- Set song lane takes playback, track ("Song / Track"), lane, value,
  fade and quantize (Next bar by default). lane is volume, pan or a
  parameter name, with or without "param:".
- From where the lane stands it goes over fade to value and stays.
  The value is clamped to the lane's range. It replaces the lane and
  is not mixed with it (decision 10).
- Release song lane glides over fade back to the curve, to where the
  curve stands at the end of the fade. Then the lane follows the
  curve again.
- The override sits in the Scheduler, where the lane marks are made.
  Volume, pan and parameters go one way. The maths is pure functions
  in the new module laneHolds.ts.
- Notes that start during a parameter hold get the held value. A
  Switch song to the same song keeps the holds.
- The holds are a list per lane. The running one holds until the next
  one lands. A key held down fires the node on every beat, and with a
  single hold per lane the new call replaced the running one before it
  landed, so the lane fell back to the curve. e2e 65 found this.
- A track without that lane and a baked song write a line to the log
  (with a hint at "live tracks").
- The test harness knows the keys l and r.

Tests:
- Unit: 24 new (lane-holds 14, song-play 5, sound-library 2, node
  catalog 3), affected groups 748/748.
- Counter-probes: 12 bends, all red.
- e2e 65 on atx: ALL PASSED, 76 checks. New 65k5: the hold landed on
  the bar line, curve until 0.02 s before, -40 dB 0.14 s after, no
  values between. It stood 1.8 s. The release began at -39.8 dB and
  followed the curve again from 0.55 s on. With one hold per lane
  65k5 is red.
- Gate on atx: CHECK EXIT 0, 335 files, 5176 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:22:12 +01:00

923 lines
38 KiB
TypeScript
Raw 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.
/**
* A game plays a song through Play song and ends it through Stop song (#336): from its start, then
* round the loop of its ruler, its tracks on buses of their own, the whole of it faded in and out.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateSoundCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
import { SetSongCommand, SetSongMarkersCommand } from '../../src/core/commands/songCommands';
import type { InstrumentAsset, PatternAsset, SongAsset } from '../../src/core/model/Asset';
import { bakePlayback, bakePrint, bakeSpans } from '../../src/core/music/bake';
import {
CUT,
goesBy,
type LandingAt,
type PlayingClock,
Playbacks,
type SequencePlay,
} from '../../src/core/music/playbacks';
import {
INTERVAL,
LEAD,
type ScheduledAutomation,
type ScheduledClip,
type ScheduledNote,
} from '../../src/core/music/Scheduler';
import { dbToGain, newItem, newTrack, trackBus } from '../../src/core/music/song';
import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
const FILE = `${'b2'.repeat(32)}.wav`;
const Q = 480;
const BAR = 4 * Q;
/** An audio clock moved by hand, its timers run as it passes them. */
function fakeHost(baked = false) {
const host = {
time: 0,
latency: 0,
sounding: new Set<number>(),
pumps: new Set<() => void>(),
loading: [] as (() => void)[],
levels: [] as { id: number; gain: number; at: number; seconds: number }[],
timers: [] as { at: number; then: () => void }[],
closed: [] as number[],
};
const playbacks = new Playbacks({
now: () => host.time,
latency: () => host.latency,
playing: (voice) => host.sounding.has(voice),
stop: (voices) => {
for (const voice of voices) host.sounding.delete(voice);
},
wake: (pump) => {
host.pumps.add(pump);
return () => host.pumps.delete(pump);
},
prepare: () => new Promise<void>((resolve) => host.loading.push(resolve)),
level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }),
after: (at, then) => host.timers.push({ at, then }),
closed: (id) => host.closed.push(id),
...(baked && { playBaked: () => 77 }),
});
const run = (seconds: number) => {
const end = host.time + seconds;
while (host.time + INTERVAL <= end + 1e-9) {
host.time += INTERVAL;
for (const pump of [...host.pumps]) pump();
for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) {
host.timers.splice(host.timers.indexOf(timer), 1);
timer.then();
}
}
};
const loaded = async () => {
for (const resolve of host.loading.splice(0)) resolve();
await Promise.resolve();
};
return { host, playbacks, run, loaded };
}
/**
* Two bars at 120 BPM, two seconds each: a note on the first beat of each and a clip on the second
* bar. Looped, the second bar is its loop.
*/
function song(struck: (ScheduledNote | ScheduledClip)[], options: Partial<SequencePlay> = {}): SequencePlay {
let next = 100;
const voice = () => next++;
return {
sequence: {
notes: [0, BAR].map((tick, i) => ({ tick, length: Q, note: 60 + i, velocity: 1, instrument: 'piano' })),
clips: [{ tick: BAR, length: Q, sound: 'drum', fx: '', offset: 0, volume: 1, bus: 'track' }],
},
tempo: 120,
length: 2 * BAR,
loop: { start: BAR, end: 2 * BAR },
needs: { layers: [], files: [] },
strike: (note) => (struck.push(note), voice()),
strikeClip: (clip) => (struck.push(clip), voice()),
...options,
};
}
const what = (struck: (ScheduledNote | ScheduledClip)[]) =>
struck.map((s) => ('note' in s ? `note ${s.note}` : `clip ${s.sound}`));
describe('the songs a game plays', () => {
it('plays the intro once, then runs round the loop, clips with the notes', async () => {
const { playbacks, run, loaded } = fakeHost();
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck), '');
await loaded();
run(9);
expect(what(struck).slice(0, 6)).toEqual([
'note 60',
'note 61',
'clip drum',
'note 61',
'clip drum',
'note 61',
]);
const notes = struck.filter((s): s is ScheduledNote => 'note' in s);
expect(notes[1]!.at - notes[0]!.at).toBeCloseTo(2, 9);
expect(notes[2]!.at - notes[1]!.at).toBeCloseTo(2, 9);
expect(playbacks.current()[0]?.clips[0]).toMatchObject({ sound: 'drum', bus: 'track' });
});
it('tells how far into it is heard: from its first note, the output latency taken off (#338)', async () => {
const { host, playbacks, loaded } = fakeHost();
host.latency = 0.02;
playbacks.start(5, 'song:a', song([]), '');
expect(playbacks.times()).toEqual([
{
id: 5,
key: 5,
name: 'song:a',
heard: null,
baked: false,
tempo: 120,
from: 0,
loop: { start: BAR, end: 2 * BAR },
length: 2 * BAR,
},
]);
host.time = 1;
await loaded();
// Its first note is heard LEAD after it starts, and the speakers are 0.02 s behind the clock.
host.time = 3;
expect(playbacks.times()[0]!.heard).toBeCloseTo(3 - 0.02 - (1 + LEAD), 9);
});
it('asks no clock while nothing plays: that would make the audio context before a gesture (#338)', () => {
let asked = 0;
const playbacks = new Playbacks({
now: () => (asked++, 0),
latency: () => 0,
playing: () => false,
stop: () => {},
wake: () => () => {},
prepare: () => Promise.resolve(),
level: () => {},
after: () => {},
});
expect(playbacks.times()).toEqual([]);
expect(asked).toBe(0);
});
it('tells how far a baked one is heard the same way, from where its bake is heard (#338)', async () => {
const { host, playbacks, loaded } = fakeHost(true);
const bake = { file: 'b.ogg', gain: 1, playback: { end: null, until: 0, round: null } };
playbacks.start(5, 'song:a', song([], { bake }), '');
host.time = 1;
await loaded();
host.time = 2.5;
expect(playbacks.current()[0]?.baked).toBe(true);
expect(playbacks.times()[0]!.heard).toBeCloseTo(2.5 - (1 + LEAD), 9);
});
it('starts where it was asked to, as far further in as it waited on its files (#418)', async () => {
const { host, playbacks, loaded } = fakeHost();
playbacks.start(5, 'composition:c', song([], { from: 960 }), '');
host.time = 0.5;
await loaded();
expect(playbacks.times()[0]!.from).toBe(960 + 480);
});
it('rises from silence to its volume over its fade in, from its first note on', async () => {
const { host, playbacks, loaded } = fakeHost();
playbacks.start(5, 'song:a', song([], { volume: 0.5, fadeIn: 3 }), '');
host.time = 1;
await loaded();
expect(host.levels).toEqual([{ id: 5, gain: 0.5, at: 1 + LEAD, seconds: 3 }]);
});
it('starts its fade in with its first note heard, later by as much as its buses are late (#346)', async () => {
const levels: { at: number }[] = [];
let release = () => {};
const playbacks = new Playbacks({
now: () => 1,
latency: () => 0,
ahead: (id) => (id === 5 ? 0.125 : 0),
playing: () => false,
stop: () => {},
wake: () => () => {},
prepare: () => new Promise<void>((resolve) => (release = resolve)),
level: (_id, _gain, at) => void levels.push({ at }),
after: () => {},
});
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck, { volume: 0.5, fadeIn: 3 }), '');
release();
await Promise.resolve();
expect(levels).toEqual([{ at: 1 + LEAD + 0.125 }]);
expect(struck[0]).toMatchObject({ at: 1 + LEAD + 0.125 });
});
it('fades out over its fade, playing on meanwhile, then is gone; a hard stop then ends it at once', async () => {
const { host, playbacks, run, loaded } = fakeHost();
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck), '');
playbacks.start(6, 'song:a', song([]), '');
await loaded();
run(1);
playbacks.stopNamed('song:a', false, 4);
expect(
host.levels.slice(-2).map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(9), seconds]),
).toEqual([
[5, 0, 1, 4],
[6, 0, 1, 4],
]);
run(2);
expect([playbacks.has(5), playbacks.current()[0]?.fading]).toEqual([true, expect.closeTo(5, 9)]);
expect(struck.length).toBeGreaterThan(1);
playbacks.stopId(6);
expect(playbacks.current().map((p) => p.id)).toEqual([5]);
run(2.1);
expect([playbacks.has(5), host.pumps.size, host.closed]).toEqual([false, 0, [6, 5]]);
});
it('never starts when a fade out began while its files were loading', async () => {
const { host, playbacks, run, loaded } = fakeHost();
const struck: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(5, 'song:a', song(struck), '');
playbacks.stopId(5, false, 1);
await loaded();
run(2);
expect([struck.length, host.pumps.size, playbacks.has(5)]).toEqual([0, 0, false]);
});
it('goes by its own name, by its kind and by none – a kind never reaches the other', async () => {
expect(['song:a', 'song:', '', 'pattern:', 'song:b'].map((name) => goesBy('song:a', name))).toEqual([
true,
true,
true,
false,
false,
]);
const { playbacks, loaded } = fakeHost();
playbacks.start(1, 'song:a', song([]), '');
playbacks.start(2, 'pattern:p', song([]), '');
await loaded();
playbacks.stopNamed('song:');
expect(playbacks.current().map((p) => p.id)).toEqual([2]);
});
});
describe('a song switched to in place of another (#338)', () => {
/** Song a playing from 0.05 s on, as heard, and a clock it was asked for at 1 s. */
async function playingA() {
const fake = fakeHost();
const old: (ScheduledNote | ScheduledClip)[] = [];
fake.playbacks.start(5, 'song:a', song(old), '');
await fake.loaded();
fake.run(1);
return { ...fake, old };
}
const levelsOf = (host: ReturnType<typeof fakeHost>['host']) =>
host.levels.map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(6), seconds]);
it('is heard from where it lands, under the same number; the old one falls silent there in a cut and goes', async () => {
const { host, playbacks, run, loaded } = await playingA();
const fresh: (ScheduledNote | ScheduledClip)[] = [];
const asked: PlayingClock[] = [];
const twoBarsIn: LandingAt = (clock) => (asked.push(clock), clock.startedAt! + 2);
playbacks.switchTo(5, 9, 'song:b', song(fresh, { from: BAR }), '', twoBarsIn, 0);
await loaded();
expect(asked.map((c) => [c.startedAt, +c.now.toFixed(9), +c.soonest.toFixed(9)])).toEqual([
[LEAD, 1, 1 + LEAD],
]);
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 2 + LEAD, CUT],
[9, 1, 2 + LEAD, 0],
]);
// Until the clock gets there the old one is the one heard, from there on the new one.
expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 5]]);
run(1.1);
expect(playbacks.times().map((t) => [t.id, t.key, t.from, +t.heard!.toFixed(6)])).toEqual([
[5, 9, BAR, +(host.time - 2 - LEAD).toFixed(6)],
]);
expect([playbacks.has(5), host.closed]).toEqual([true, [5]]);
const [first] = fresh as ScheduledNote[];
expect([first?.note, first?.at]).toEqual([61, 2 + LEAD]);
});
it('over its fade the old one falls as the new one rises, and goes once it is silent', async () => {
const { host, playbacks, run, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:a', song([]), '', (clock) => clock.startedAt! + 2, 1.5);
await loaded();
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 2 + LEAD, 1.5],
[9, 1, 2 + LEAD, 1.5],
]);
// While the old one fades, only the new one is told: beats and the position go by it.
run(1.2);
expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 9]]);
run(1.2);
expect(host.closed).toEqual([]);
run(0.3);
expect(host.closed).toEqual([5]);
});
it('asked again before it lands, drops the one waiting, and the old one is heard on at its level', async () => {
const { host, playbacks, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
playbacks.switchTo(5, 10, 'song:c', song([]), '', (clock) => clock.startedAt! + 4, 0);
expect(host.closed).toEqual([9]);
expect(levelsOf(host).at(-1)).toEqual([5, 1, 1, 0]);
await loaded();
expect(levelsOf(host).slice(-2)).toEqual([
[5, 0, 4 + LEAD, CUT],
[10, 1, 4 + LEAD, 0],
]);
expect(playbacks.named(5).map((p) => p.key)).toEqual([5, 10]);
});
it('a stop of its number stops the old one and the one waiting alike', async () => {
const { host, playbacks, loaded } = await playingA();
playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
playbacks.stopId(5);
expect([playbacks.has(5), host.closed.sort()]).toEqual([false, [5, 9]]);
});
it('where nothing of that number plays any more, starts as soon as it can', async () => {
const { host, playbacks, loaded } = fakeHost();
let asked = 0;
playbacks.switchTo(5, 9, 'song:b', song([]), '', () => (asked++, 99), 0);
await loaded();
expect([asked, levelsOf(host)]).toEqual([0, [[9, 1, LEAD, 0]]]);
});
});
describe('a stinger in time with a playing (#338)', () => {
it('lands where the playing heard now says, no sooner than something started now is heard', async () => {
const { playbacks, loaded, run } = fakeHost();
playbacks.start(5, 'song:a', song([]), '');
await loaded();
run(1);
const asked: PlayingClock[] = [];
expect(playbacks.landOn(5, (clock) => (asked.push(clock), clock.startedAt! + 2))).toBeCloseTo(
2 + LEAD,
9,
);
expect(asked.map((c) => [c.startedAt, +c.soonest.toFixed(9)])).toEqual([[LEAD, +(1 + LEAD).toFixed(9)]]);
expect(playbacks.landOn(5, () => 0.5)).toBeCloseTo(1 + LEAD, 9);
expect(playbacks.landOn(6, () => 2)).toBeNull();
});
it('a pattern asked to start there plays its first note there, its level rising from there', async () => {
const { host, playbacks, loaded, run } = fakeHost();
const notes: (ScheduledNote | ScheduledClip)[] = [];
playbacks.start(7, 'pattern:p', song(notes, { at: 2 }), '');
await loaded();
expect(host.levels.map(({ id, at }) => [id, at])).toEqual([[7, 2]]);
run(1);
expect(notes).toEqual([]);
run(1);
expect((notes as ScheduledNote[])[0]?.at).toBe(2);
});
});
/**
* A scene whose behavior plays a song on start: on its first track a two-note pattern at bar 1, on
* its second a sound at bar 2, the second track at -6 dB and panned left, the third soloed.
*/
function playingSong(params: Record<string, string | number | boolean> = {}) {
const f = makeSpriteScene('Box');
const sound = new CreateSoundCommand('Blip', FILE);
f.stack.execute(sound);
const ins = new CreateAssetCommand('instrument', 'Lead');
f.stack.execute(ins);
const lead = ins.asset as InstrumentAsset;
lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id;
const pattern = new CreateAssetCommand('pattern', 'Riff');
f.stack.execute(pattern);
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', lead.id));
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
f.stack.execute(
new SetPatternNotesCommand(pattern.asset.id, [
{ id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 },
{ id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 },
]),
);
const create = new CreateAssetCommand('song', 'Theme');
f.stack.execute(create);
const id = create.asset.id;
const asset = () => f.project.assets.find((a) => a.id === id) as SongAsset;
f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90));
// A new song has one track: two more to lay things out on.
const [one, two, three] = [...asset().tracks, newTrack(2), newTrack(3)];
f.stack.execute(
new SetSongCommand(id, {
tracks: [one, { ...two, volume: -6, pan: -1 }, three!],
items: [
newItem({ track: one.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }),
newItem({ track: two.id, kind: 'sound', asset: sound.asset.id, tick: BAR, length: BAR }),
],
}),
);
f.stack.execute(new SetLoopCommand('song', id, { start: BAR, end: 2 * BAR }));
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const play = edit.addNode('PlaySong', { song: id, volume: 0.8, fadeIn: 2, ...params });
edit.connect(start, 'exec', play, 'exec');
return { ...f, songId: id, tracks: [one.id, two.id, three!.id], lead, edit, play };
}
function recordingAudio() {
const sink = {
...SILENT_AUDIO,
sequences: [] as { name: string; play: SequencePlay; owner: string }[],
notes: [] as { name: string; volume: number; owner: string; bus?: string; at?: number }[],
clips: [] as { file: string; clip: ClipTiming }[],
sounds: [] as { file: string; at?: number }[],
stopped: [] as string[],
play: ((file, _volume, _loop, _owner, _fx, _bus, at) => {
sink.sounds.push({ file, ...(at !== undefined && { at }) });
return 3;
}) satisfies AudioSink['play'],
/** A stinger lands on the clock of the playing it is synced to (#338), as a switch does. */
landOn: (id: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001];
return playing ? at(sink.clockOf(playing.play)) : null;
},
playSequence: (name: string, play: SequencePlay, owner: string) => {
sink.sequences.push({ name, play, owner });
return 1000 + sink.sequences.length;
},
playNote: ((name, _layers, _params, volume, owner, _duration, timing = {}) => {
sink.notes.push({ name, volume, owner, ...timing });
return 1;
}) satisfies AudioSink['playNote'],
playClip: (file: string, clip: ClipTiming) => (sink.clips.push({ file, clip }), 2),
stopSequence: (name: string, _letRing = false, fade = 0) => sink.stopped.push(`${name} (fade ${fade})`),
stopPlayback: (id: number, fade: number) => sink.stopped.push(`playback ${id} (fade ${fade})`),
/** Every playing heard 0.3 s into a clock that started it at 10 s (#338). */
clockOf: (play: SequencePlay): PlayingClock => ({
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
startedAt: 10,
now: 10.3,
soonest: 10.31,
}),
playingTimes: () =>
sink.sequences.map(({ name, play }, i) => ({
id: 1001 + i,
key: 1001 + i,
name,
heard: 0.3,
baked: play.bake !== undefined,
tempo: play.tempo,
from: 0,
loop: play.loop,
length: play.length,
})),
switched: [] as { id: number; name: string; play: SequencePlay; at: number; fade: number }[],
switchSequence: (id: number, name: string, play: SequencePlay, at: LandingAt, fade: number) => {
const from = sink.sequences[id - 1001]!.play;
sink.switched.push({ id, name, play, at: at(sink.clockOf(from)), fade });
return true;
},
turned: [] as { id: number; track: string; gain: number; fade: number; at: number }[],
setSongTrack: (id: number, track: string, gain: number, fade: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001]!.play;
if (playing.bake) return 'baked' as const;
sink.turned.push({ id, track, gain, fade, at: at(sink.clockOf(playing)) });
return 'set' as const;
},
lanes: [] as string[],
holdSongLane: (id: number, track: string, target: string, value: number, fade: number, at: LandingAt) => {
const playing = sink.sequences[id - 1001]!.play;
if (playing.bake) return 'baked' as const;
sink.lanes.push(
`hold ${id} ${track} ${target} ${value} fade ${fade} at ${+at(sink.clockOf(playing)).toFixed(6)}`,
);
return 'set' as const;
},
releaseSongLane: (id: number, track: string, target: string, fade: number) => {
if (sink.sequences[id - 1001]!.play.bake) return 'baked' as const;
sink.lanes.push(`release ${id} ${track} ${target} fade ${fade}`);
return 'set' as const;
},
};
return sink;
}
describe('Play song', () => {
it('hands over its bake while it holds, at the peak it had, its volume left live (#340)', () => {
const f = playingSong();
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
const file = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`;
song.bake = { file, print: bakePrint(f.project, song), peak: 0.5, bytes: 1, ...spans };
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.sequences[0]?.play).toMatchObject({
bake: { file, gain: 0.5, playback: bakePlayback(spans, true) },
volume: 0.8,
fadeIn: 2,
});
// The second track panned the other way: the song sounds otherwise, its notes are played.
const tracks = song.tracks.map((track, i) => (i === 1 ? { ...track, pan: 1 } : track));
f.stack.execute(new SetSongCommand(f.songId, { tracks }));
const changed = recordingAudio();
startWorld(f, { behaviors: true, audio: changed }).frame();
expect(changed.sequences[0]?.play.bake).toBeUndefined();
});
it('hands the song to the player: its tempo, intro then loop, its tracks and the level of the whole', () => {
const f = playingSong();
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
const logic = world.entities.find((e) => e.name === 'Logic')!;
const [asked] = audio.sequences;
expect([asked?.name, asked?.owner]).toEqual([`song:${f.songId}`, logic.id]);
expect(asked?.play).toMatchObject({
tempo: 90,
length: 2 * BAR,
loop: { start: BAR, end: 2 * BAR },
volume: 0.8,
fadeIn: 2,
});
expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note])).toEqual([
[0, 60],
[Q, 64],
]);
expect(asked?.play.needs.files).toEqual([FILE]);
const tracks = asked?.play.buses?.filter((b) => b.sends);
expect(tracks?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([
[trackBus(f.songId, f.tracks[0]!), 1, 0],
[trackBus(f.songId, f.tracks[1]!), 0.501, -1],
[trackBus(f.songId, f.tracks[2]!), 1, 0],
]);
});
it('strikes notes on their track, owned like the playing, and clips at their volume on theirs', () => {
const f = playingSong({ detached: true, loop: false, volume: 3 });
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [asked] = audio.sequences;
expect([asked?.owner, asked?.play.loop, asked?.play.volume]).toEqual(['', null, 1]);
const bus = trackBus(f.songId, f.tracks[1]!);
asked!.play.strike({
at: 3,
duration: 0.25,
note: 60,
velocity: 1,
instrument: f.lead.id,
tempo: 90,
bus,
});
asked!.play.strikeClip!({
at: 4,
duration: 2,
offset: 0.5,
sound: f.project.assets.find((a) => a.type === 'sound')!.id,
fx: '',
volume: 0.5,
bus,
tempo: 90,
});
expect(audio.notes).toEqual([
{ name: `instrument:${f.lead.id}`, volume: 1, owner: '', at: 3, tempo: 90, bus, note: 60 },
]);
expect(audio.clips).toEqual([
{ file: FILE, clip: { at: 4, offset: 0.5, duration: 2, volume: 0.5, bus } },
]);
});
});
describe('Stop song', () => {
it('stops one song over its fade, every song for none, and one playing by its number', () => {
const f = playingSong();
const one = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 1.5 });
const all = f.edit.addNode('StopSong', { song: '' });
const byNumber = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 2 });
f.edit.connect(f.play, 'exec', one, 'exec');
f.edit.connect(one, 'exec', all, 'exec');
f.edit.connect(all, 'exec', byNumber, 'exec');
f.edit.connect(f.play, 'playback', byNumber, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.stopped).toEqual([
`song:${f.songId} (fade 1.5)`,
'song: (fade 0)',
'playback 1001 (fade 2)',
]);
});
});
describe('Set song track and Switch song (#338)', () => {
/** 90 BPM: a beat is 2/3 s, a bar 8/3 s, played from 10 s on and heard 0.3 s in. */
const BEAT = 2 / 3;
it('turns a track of the playing on the next beat, in decibels on top of its level', () => {
const f = playingSong();
const turn = f.edit.addNode('SetSongTrack', {
track: f.tracks[1]!,
volume: -6,
fade: 0.5,
quantize: 'beat',
});
f.edit.connect(f.play, 'exec', turn, 'exec');
f.edit.connect(f.play, 'playback', turn, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.turned.map((t) => ({ ...t, at: +t.at.toFixed(9) }))).toEqual([
{
id: 1001,
track: trackBus(f.songId, f.tracks[1]!),
gain: dbToGain(-6),
fade: 0.5,
at: +(10 + BEAT).toFixed(9),
},
]);
});
it('a playing of its bake has no tracks: the log says to switch on "live tracks", which plays its notes', () => {
const baked = (live: boolean) => {
const f = playingSong({ live });
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
const file = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`;
song.bake = { file, print: bakePrint(f.project, song), peak: 0.5, bytes: 1, ...spans };
const turn = f.edit.addNode('SetSongTrack', { track: f.tracks[0]!, volume: -60, quantize: 'now' });
f.edit.connect(f.play, 'exec', turn, 'exec');
f.edit.connect(f.play, 'playback', turn, 'playback');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
return { audio, log: world.log };
};
const off = baked(false);
expect([off.audio.turned, off.log]).toEqual([
[],
['Set song track: song "Theme" plays its bake; switch on "live tracks" in Play song'],
]);
const on = baked(true);
expect([on.audio.sequences[0]?.play.bake, on.audio.turned[0], on.log]).toEqual([
undefined,
expect.objectContaining({ gain: 0, at: 10.31 }),
[],
]);
});
it('switches to a marker of the same song on the next marker, asked for as its Play song was', () => {
const f = playingSong();
f.stack.execute(new SetSongMarkersCommand(f.songId, [{ id: 'm1', name: 'Chorus', tick: BAR + Q }]));
const go = f.edit.addNode('SwitchSong', { marker: 'm1', quantize: 'marker', fade: 1.5 });
f.edit.connect(f.play, 'exec', go, 'exec');
f.edit.connect(f.play, 'playback', go, 'playback');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [switched] = audio.switched;
// Bar 2 beat 2 is 2400 ticks in, 3 1/3 s at 90 BPM.
expect([switched?.id, switched?.name, switched?.fade, switched?.at]).toEqual([
1001,
`song:${f.songId}`,
1.5,
expect.closeTo(10 + 10 / 3, 9),
]);
expect(switched?.play).toMatchObject({ from: BAR + Q, volume: 0.8, loop: { start: BAR, end: 2 * BAR } });
expect(switched?.play.bake).toBeUndefined();
});
it('logs a marker the song does not have, and switches nothing', () => {
const f = playingSong();
const other = new CreateAssetCommand('song', 'Boss');
f.stack.execute(other);
f.stack.execute(new SetSongMarkersCommand(other.asset.id, [{ id: 'm9', name: 'Rage', tick: 0 }]));
const go = f.edit.addNode('SwitchSong', { marker: 'm9' });
f.edit.connect(f.play, 'exec', go, 'exec');
f.edit.connect(f.play, 'playback', go, 'playback');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect([audio.switched, world.log]).toEqual([[], ['Switch song: song "Theme" has no marker "Rage"']]);
});
});
describe('Set song lane and Release song lane (#338)', () => {
/** The song of `playingSong`, its first track with a lane of its volume and one of a parameter. */
function withLanes(params: Record<string, string | number | boolean> = {}) {
const f = playingSong(params);
const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset;
const tracks = song.tracks.map((track, i) =>
i === 0
? {
...track,
automation: [
{
target: 'volume' as const,
points: [
{ tick: 0, value: 0 },
{ tick: BAR, value: -12 },
],
},
{ target: 'param:cutoff' as const, min: 100, max: 8000, points: [{ tick: 0, value: 400 }] },
],
}
: track,
);
f.stack.execute(new SetSongCommand(f.songId, { tracks }));
const steer = (type: string, fields: Record<string, string | number>) => {
const node = f.edit.addNode(type, { track: f.tracks[0]!, ...fields });
f.edit.connect(f.play, 'playback', node, 'playback');
return node;
};
return { ...f, song, steer };
}
it('holds a lane of a track on the next bar, held to its range, and lets it go', () => {
const f = withLanes();
const hold = f.steer('SetSongLane', { lane: 'volume', value: -80, fade: 0.5 });
const release = f.steer('ReleaseSongLane', { lane: 'volume', fade: 2 });
f.edit.connect(f.play, 'exec', hold, 'exec');
f.edit.connect(hold, 'exec', release, 'exec');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
// 90 BPM: the next bar is 8/3 s after the start at 10 s.
const bus = trackBus(f.songId, f.tracks[0]!);
expect(audio.lanes).toEqual([
`hold 1001 ${bus} volume -60 fade 0.5 at ${+(10 + 8 / 3).toFixed(6)}`,
`release 1001 ${bus} volume fade 2`,
]);
});
it('names a parameter with or without "param:"; a lane the track does not have is logged', () => {
const f = withLanes();
const bare = f.steer('SetSongLane', { lane: 'cutoff', value: 20000, quantize: 'now' });
const named = f.steer('SetSongLane', { lane: 'param:cutoff', value: 1000, quantize: 'now' });
const none = f.steer('SetSongLane', { lane: 'pan', value: 1 });
const gone = f.steer('ReleaseSongLane', { lane: 'drive' });
f.edit.connect(f.play, 'exec', bare, 'exec');
f.edit.connect(bare, 'exec', named, 'exec');
f.edit.connect(named, 'exec', none, 'exec');
f.edit.connect(none, 'exec', gone, 'exec');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
const bus = trackBus(f.songId, f.tracks[0]!);
const track = f.song.tracks[0]!.name;
expect([audio.lanes, world.log]).toEqual([
[
`hold 1001 ${bus} param:cutoff 8000 fade 1 at 10.31`,
`hold 1001 ${bus} param:cutoff 1000 fade 1 at 10.31`,
],
[
`Set song lane: track "${track}" of song "Theme" has no lane "pan"`,
`Release song lane: track "${track}" of song "Theme" has no lane "drive"`,
],
]);
});
it('a playing of its bake has no lanes: the log says to switch on "live tracks"', () => {
const f = withLanes();
const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } });
f.song.bake = { file: 'theme.ogg', print: bakePrint(f.project, f.song), peak: 0.5, bytes: 1, ...spans };
const hold = f.steer('SetSongLane', { lane: 'volume', value: -6 });
const release = f.steer('ReleaseSongLane', { lane: 'volume' });
f.edit.connect(f.play, 'exec', hold, 'exec');
f.edit.connect(hold, 'exec', release, 'exec');
const audio = recordingAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect([audio.lanes, world.log]).toEqual([
[],
[
'Set song lane: song "Theme" plays its bake; switch on "live tracks" in Play song',
'Release song lane: song "Theme" plays its bake; switch on "live tracks" in Play song',
],
]);
});
});
describe('a held lane of a playing (#338)', () => {
/** A pan lane from -1 to 1 over the first bar, each window it is planned in kept. */
function panned(windows: ScheduledAutomation[]): Partial<SequencePlay> {
const lane = {
bus: 'song:a:t',
target: 'pan' as const,
points: [
{ tick: 0, value: -1 },
{ tick: BAR, value: 1 },
],
};
return {
sequence: {
notes: [{ tick: 0, length: Q, note: 60, velocity: 1, instrument: 'piano' }],
lanes: [lane],
},
automate: (automation) => void windows.push(automation),
};
}
it('is held under the number of the playing, and kept by one that takes over from it', async () => {
const { playbacks, run, loaded } = fakeHost();
const old: ScheduledAutomation[] = [];
playbacks.start(5, 'song:a', song([], panned(old)), '');
await loaded();
run(0.5);
expect(playbacks.holdLane(5, 'song:a:t', 'pan', 0.5, 0.6, 0)).toBe(true);
expect(playbacks.holdLane(5, 'song:a:other', 'pan', 0.5, 0.6, 0)).toBe(false);
expect(playbacks.holdLane(6, 'song:a:t', 'pan', 0.5, 0.6, 0)).toBe(false);
const fresh: ScheduledAutomation[] = [];
playbacks.switchTo(5, 9, 'song:a', song([], panned(fresh)), '', (clock) => clock.startedAt! + 2, 0);
await loaded();
run(2);
expect(fresh[0]?.marks[0]).toEqual({ at: 2 + LEAD, value: 0.5, jump: true });
for (const mark of fresh.flatMap((w) => w.marks)) expect(mark.value).toBe(0.5);
expect(playbacks.releaseLane(5, 'song:a:t', 'pan', 3, 0)).toBe(true);
});
it('a bake has no lanes to hold', async () => {
const { playbacks, loaded } = fakeHost(true);
const bake = { file: FILE, gain: 1, playback: { end: null, until: 2 * BAR, round: null } };
playbacks.start(5, 'song:a', song([], { ...panned([]), bake }), '');
await loaded();
expect(playbacks.holdLane(5, 'song:a:t', 'pan', 0.5, 1, 0)).toBe(false);
});
});
describe('Stingers: a sound, note or pattern synced to a playing (#338)', () => {
/** 90 BPM: a beat is 2/3 s, a bar 8/3 s, played from 10 s on and heard 0.3 s in. */
const BEAT = 2 / 3;
const assetOf = (f: ReturnType<typeof playingSong>, type: string) =>
f.project.assets.find((a) => a.type === type)!.id;
it('a pattern starts on the next beat of the song, at its tempo', () => {
const f = playingSong();
const sting = f.edit.addNode('PlayPattern', {
pattern: assetOf(f, 'pattern'),
loop: false,
quantize: 'beat',
});
f.edit.connect(f.play, 'exec', sting, 'exec');
f.edit.connect(f.play, 'playback', sting, 'sync');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
const [, stinger] = audio.sequences;
expect([stinger?.name, stinger?.play.tempo, stinger?.play.at]).toEqual([
`pattern:${assetOf(f, 'pattern')}`,
90,
expect.closeTo(10 + BEAT, 9),
]);
});
it('its bake is of its own tempo: at the song’s it plays its notes, at its own its bake', () => {
const bakedStinger = (tempo: number) => {
const f = playingSong();
const pattern = f.project.assets.find((a) => a.type === 'pattern') as PatternAsset;
f.stack.execute(new SetAssetPropertyCommand(pattern.id, 'tempo', tempo));
const spans = bakeSpans({ tempo, length: BAR, loop: { start: 0, end: BAR } });
pattern.bake = { file: 'riff.ogg', print: bakePrint(f.project, pattern), peak: 1, bytes: 1, ...spans };
const sting = f.edit.addNode('PlayPattern', { pattern: pattern.id, loop: false, quantize: 'beat' });
f.edit.connect(f.play, 'exec', sting, 'exec');
f.edit.connect(f.play, 'playback', sting, 'sync');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
return audio.sequences[1]?.play.bake?.file;
};
expect([bakedStinger(120), bakedStinger(90)]).toEqual([undefined, 'riff.ogg']);
});
it('a sound on the next bar, a note at once: as soon as it can be heard', () => {
const f = playingSong();
const sound = f.edit.addNode('PlaySound', { sound: assetOf(f, 'sound') });
const note = f.edit.addNode('PlayNote', { instrument: f.lead.id, duration: 0.5, quantize: 'now' });
f.edit.connect(f.play, 'exec', sound, 'exec');
f.edit.connect(sound, 'exec', note, 'exec');
f.edit.connect(f.play, 'playback', sound, 'sync');
f.edit.connect(f.play, 'playback', note, 'sync');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.sounds.map((s) => [s.file, s.at])).toEqual([[FILE, expect.closeTo(10 + 4 * BEAT, 9)]]);
expect(audio.notes.map((n) => n.at)).toEqual([10.31]);
});
it('unsynced, or synced to nothing heard, it starts at once, a pattern at its own tempo', () => {
const f = playingSong();
const own = f.edit.addNode('PlayPattern', { pattern: assetOf(f, 'pattern'), loop: false });
const lost = f.edit.addNode('PlayPattern', { pattern: assetOf(f, 'pattern'), loop: false, sync: 77 });
const sound = f.edit.addNode('PlaySound', { sound: assetOf(f, 'sound'), sync: 77 });
f.edit.connect(f.play, 'exec', own, 'exec');
f.edit.connect(own, 'exec', lost, 'exec');
f.edit.connect(lost, 'exec', sound, 'exec');
const audio = recordingAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.sequences.slice(1).map((s) => [s.play.tempo, s.play.at])).toEqual([
[120, undefined],
[120, undefined],
]);
expect(audio.sounds).toEqual([{ file: FILE }]);
});
});