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>
This commit is contained in:
1 parent
e390d39314
commit
f9428c5bc2
18 files changed
+1170
-18
No files matched your search
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* A game takes a lane of a song over (#338): Set song lane holds it on a value of its own, Release
|
||||
* song lane takes it back to its curve. The way of a hold in playing time, and how the scheduler
|
||||
* plans it in place of the lane's own points.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SequenceLane } from '../../src/core/music/automation';
|
||||
import {
|
||||
heldMarks,
|
||||
heldParamsAt,
|
||||
heldValueAt,
|
||||
holdSpans,
|
||||
type LaneHold,
|
||||
type LanePlaying,
|
||||
} from '../../src/core/music/laneHolds';
|
||||
import {
|
||||
LEAD,
|
||||
type ScheduledAutomation,
|
||||
type ScheduledNote,
|
||||
Scheduler,
|
||||
type SchedulerHost,
|
||||
} from '../../src/core/music/Scheduler';
|
||||
import type { Sequence } from '../../src/core/music/sequence';
|
||||
|
||||
const Q = 480;
|
||||
const BAR = 4 * Q;
|
||||
|
||||
/** 120 BPM: 960 ticks a second, played from the start, its clock at 0 where playing time is. */
|
||||
const PLAYING: LanePlaying = { from: 0, loop: null, elapsed: (at) => at * 960 };
|
||||
|
||||
/** From -1 at the start to 1 a bar (two seconds) later: a second of playing time moves it by 1. */
|
||||
const PAN: SequenceLane = {
|
||||
bus: 'song:s:t',
|
||||
target: 'pan',
|
||||
points: [
|
||||
{ tick: 0, value: -1 },
|
||||
{ tick: BAR, value: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const hold = (fields: Partial<LaneHold> = {}): LaneHold => ({
|
||||
at: 1,
|
||||
fade: 0.5,
|
||||
start: 0,
|
||||
value: 0.5,
|
||||
release: null,
|
||||
...fields,
|
||||
});
|
||||
|
||||
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 way of a held lane (#338)', () => {
|
||||
it('goes from where the lane was to the value over its fade and stays; before it the lane is its own', () => {
|
||||
const held = hold();
|
||||
expect(holdSpans([held], PLAYING)).toEqual([{ hold: held, start: 960, end: Infinity }]);
|
||||
expect([480, 960, 1200, 1440, 5000].map((t) => heldValueAt(PAN, [held], PLAYING, t))).toEqual([
|
||||
-0.5, 0, 0.25, 0.5, 0.5,
|
||||
]);
|
||||
});
|
||||
|
||||
it('let go, goes back over its fade to where the curve is by then, and follows the curve from there', () => {
|
||||
// Let go at 2 s over 1 s: back to the curve at 3 s, which is past the last point, at 1.
|
||||
const held = hold({ release: { at: 2, fade: 1 } });
|
||||
expect(holdSpans([held], PLAYING)).toEqual([{ hold: held, start: 960, end: 2880 }]);
|
||||
expect([1920, 2400, 2880, 3000].map((t) => heldValueAt(PAN, [held], PLAYING, t))).toEqual([
|
||||
0.5, 0.75, 1, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('let go in the middle of its fade, goes back from where the fade had got to', () => {
|
||||
const held = hold({ fade: 1, release: { at: 1.5, fade: 0 } });
|
||||
expect(heldValueAt(PAN, [held], PLAYING, 1439)).toBeCloseTo(479 / 1920, 9);
|
||||
// At 1.5 s the curve is at 0.5 itself.
|
||||
expect(heldValueAt(PAN, [held], PLAYING, 1440)).toBe(0.5);
|
||||
expect(heldValueAt(PAN, [held], PLAYING, 1500)).toBeCloseTo(0.5625, 9);
|
||||
});
|
||||
|
||||
it('is marked in place of the lane: its own points before, the way of the hold from a jump on', () => {
|
||||
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 0, 1920, true, 9.6))).toEqual([
|
||||
[0, -1, true],
|
||||
[960, 0, false],
|
||||
[960, 0, true],
|
||||
[1440, 0.5, false],
|
||||
[1920, 0.5, false],
|
||||
]);
|
||||
// A window going on from the last one jumps where the hold begins, all the same.
|
||||
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 900, 1000, false, 9.6))).toEqual([
|
||||
[900, -0.0625, false],
|
||||
[960, 0, false],
|
||||
[960, 0, true],
|
||||
[1000, 0.041667, false],
|
||||
]);
|
||||
// A window that starts inside the hold goes on from where the last one ended, without a jump.
|
||||
expect(rounded(heldMarks(PAN, [hold()], PLAYING, 1200, 1300, false, 9.6))).toEqual([
|
||||
[1200, 0.25, false],
|
||||
[1300, 0.354167, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('let go, the lane is its own again from a jump where the way back ends', () => {
|
||||
const held = hold({ release: { at: 1.5, fade: 0.25 } });
|
||||
expect(rounded(heldMarks(PAN, [held], PLAYING, 1400, 2000, false, 9.6))).toEqual([
|
||||
[1400, 0.458333, false],
|
||||
[1440, 0.5, false],
|
||||
[1680, 0.75, false],
|
||||
[1680, 0.75, true],
|
||||
[1920, 1, false],
|
||||
[2000, 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a later hold takes over where it lands, the one before ruling until then; one let go leaves the lane between', () => {
|
||||
const first = hold({ fade: 0, release: { at: 2, fade: 0 } });
|
||||
const second = hold({ at: 1.5, fade: 0, start: 0.5, value: -0.5 });
|
||||
expect(holdSpans([first, second], PLAYING).map((s) => [s.start, s.end])).toEqual([
|
||||
[960, 1440],
|
||||
[1440, Infinity],
|
||||
]);
|
||||
expect([1200, 1440, 2400].map((t) => heldValueAt(PAN, [first, second], PLAYING, t))).toEqual([
|
||||
0.5, -0.5, -0.5,
|
||||
]);
|
||||
const gap = hold({ at: 3, fade: 0, start: 1, value: 0 });
|
||||
expect([1200, 2400, 3000].map((t) => heldValueAt(PAN, [first, gap], PLAYING, t))).toEqual([0.5, 1, 0]);
|
||||
});
|
||||
|
||||
it('a held parameter gives what starts inside its track the held value, the others are the lanes’', () => {
|
||||
const cutoff: SequenceLane = {
|
||||
bus: 'song:s:t',
|
||||
target: 'param:cutoff',
|
||||
points: [
|
||||
{ tick: 0, value: 0 },
|
||||
{ tick: BAR, value: 1 },
|
||||
],
|
||||
};
|
||||
const drive: SequenceLane = { ...cutoff, target: 'param:drive' };
|
||||
const holds = new Map([[`song:s:t\nparam:cutoff`, [hold({ fade: 0, value: 0.9 })]]]);
|
||||
expect(heldParamsAt([cutoff, drive], holds, PLAYING, 'song:s:t:item', 1200, 1200)).toEqual({
|
||||
cutoff: 0.9,
|
||||
drive: 0.625,
|
||||
});
|
||||
// Before the hold lands, and on a bus outside the track, it is the lane's.
|
||||
expect(heldParamsAt([cutoff], holds, PLAYING, 'song:s:t', 480, 480)).toEqual({ cutoff: 0.25 });
|
||||
expect(heldParamsAt([cutoff], holds, PLAYING, 'song:s:other', 1200, 1200)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scheduler and a held lane (#338)', () => {
|
||||
/** A clock at 10 s moved by hand, every lane window the host is handed, every note struck. */
|
||||
function playing(sequence: Sequence) {
|
||||
const automated: ScheduledAutomation[] = [];
|
||||
const struck: ScheduledNote[] = [];
|
||||
let pump: (() => void) | null = null;
|
||||
const clock = { time: 10 };
|
||||
const host: SchedulerHost = {
|
||||
now: () => clock.time,
|
||||
latency: () => 0,
|
||||
play: (note) => (struck.push(note), 0),
|
||||
automate: (automation) => void automated.push(automation),
|
||||
playing: () => false,
|
||||
stop: () => {},
|
||||
wake: (wake) => ((pump = wake), () => (pump = null)),
|
||||
};
|
||||
const scheduler = new Scheduler(host, () => sequence, { tempo: 120 });
|
||||
const run = (seconds: number) => {
|
||||
for (let t = 0; t < seconds - 1e-9; t += 0.025) {
|
||||
clock.time += 0.025;
|
||||
pump?.();
|
||||
}
|
||||
};
|
||||
return { scheduler, automated, struck, run, clock, host };
|
||||
}
|
||||
/** Where the lane is at a second of the clock, played from 10 s + LEAD on. */
|
||||
const panAt = (at: number) => -1 + (at - 10 - LEAD);
|
||||
const onSpot = (at: number, spot: number) => Math.abs(at - spot) < 1e-9;
|
||||
const marksFrom = (automated: ScheduledAutomation[], from = 0) =>
|
||||
automated.slice(from).flatMap((a) => a.marks);
|
||||
|
||||
it('holds from where it lands, what was planned past it planned again; let go, it follows its curve again', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
run(0.1);
|
||||
const before = automated.length;
|
||||
expect(scheduler.holdLane('song:s:t', 'pan', 0.5, 10.2, 0)).toBe(true);
|
||||
// Planned up to 10.2 s and further already: planned again from there, a fresh window.
|
||||
const replanned = automated[before]!;
|
||||
expect([replanned.fresh, replanned.marks[0]]).toEqual([true, { at: 10.2, value: 0.5, jump: true }]);
|
||||
run(0.5);
|
||||
for (const mark of marksFrom(automated, before)) expect(mark.value).toBe(0.5);
|
||||
const held = automated.length;
|
||||
expect(scheduler.releaseLane('song:s:t', 'pan', 10.7, 0)).toBe(true);
|
||||
expect(automated[held]!.fresh).toBe(true);
|
||||
expect(automated[held]!.marks[0]).toMatchObject({ at: 10.7, jump: true });
|
||||
run(0.5);
|
||||
for (const mark of marksFrom(automated, held)) expect(mark.value).toBeCloseTo(panAt(mark.at), 9);
|
||||
});
|
||||
|
||||
it('held again for a later spot, stays held on the old value until then', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
|
||||
run(0.2);
|
||||
// As a node does on every beat while a key is held: each time for the next bar.
|
||||
scheduler.holdLane('song:s:t', 'pan', -0.5, 10.61, 0);
|
||||
const before = automated.length;
|
||||
run(0.8);
|
||||
// On a spot itself a stretch ends at the old value and the next jumps from there: both are right.
|
||||
const marks = marksFrom(automated, before - 1).filter(
|
||||
(mark) => mark.at > 10.1 && !onSpot(mark.at, 10.61),
|
||||
);
|
||||
for (const mark of marks) expect(mark.value).toBe(mark.at < 10.61 ? 0.5 : -0.5);
|
||||
expect(marks.some((mark) => mark.at < 10.61) && marks.some((mark) => mark.at > 10.61)).toBe(true);
|
||||
});
|
||||
|
||||
it('let go before a later hold lands, that one is never heard and the one before goes back', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
|
||||
scheduler.holdLane('song:s:t', 'pan', -0.5, 10.61, 0);
|
||||
scheduler.releaseLane('song:s:t', 'pan', 10.31, 0);
|
||||
run(1);
|
||||
for (const mark of marksFrom(automated).filter((m) => m.at > 10.1 && !onSpot(m.at, 10.31)))
|
||||
expect(mark.value).toBeCloseTo(mark.at < 10.31 ? 0.5 : panAt(mark.at), 9);
|
||||
});
|
||||
|
||||
it('says where it has no such lane, and a release where none is held', () => {
|
||||
const { scheduler } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
expect(scheduler.holdLane('song:s:t', 'volume', 0, 10.2, 0)).toBe(false);
|
||||
expect(scheduler.holdLane('song:s:other', 'pan', 0, 10.2, 0)).toBe(false);
|
||||
expect(scheduler.releaseLane('song:s:t', 'pan', 10.2, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('let go before it lands, is never heard', () => {
|
||||
const { scheduler, automated, run } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 11, 0);
|
||||
// Not on the edge of a window: one starts at 10.3 s.
|
||||
scheduler.releaseLane('song:s:t', 'pan', 10.31, 0);
|
||||
run(1.5);
|
||||
for (const mark of marksFrom(automated)) expect(mark.value).toBeCloseTo(panAt(mark.at), 9);
|
||||
// Each window once, in the order of the clock.
|
||||
for (const { marks } of automated)
|
||||
marks.forEach((mark, i) => i > 0 && expect(mark.at).toBeGreaterThanOrEqual(marks[i - 1]!.at));
|
||||
});
|
||||
|
||||
it('a note that starts while a parameter is held starts at the held value', () => {
|
||||
const cutoff: SequenceLane = { ...PAN, target: 'param:cutoff', points: [{ tick: 0, value: 0.25 }] };
|
||||
const note = { tick: Q, length: Q, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' };
|
||||
const { scheduler, struck, run } = playing({ notes: [note], lanes: [cutoff] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'param:cutoff', 0.9, 10.1, 0);
|
||||
run(1);
|
||||
expect(struck.map((n) => n.params)).toEqual([{ cutoff: 0.9 }]);
|
||||
});
|
||||
|
||||
it('one taking over keeps what the other holds, from its first window on', () => {
|
||||
const { scheduler } = playing({ notes: [], lanes: [PAN] });
|
||||
scheduler.start();
|
||||
scheduler.holdLane('song:s:t', 'pan', 0.5, 10.1, 0);
|
||||
const next = playing({ notes: [], lanes: [PAN] });
|
||||
next.clock.time = 12;
|
||||
next.scheduler.keepHolds(scheduler);
|
||||
next.scheduler.start();
|
||||
const [first] = next.automated;
|
||||
expect([first?.fresh, first?.marks[0]]).toEqual([true, { at: 12 + LEAD, value: 0.5, jump: true }]);
|
||||
for (const mark of first!.marks) expect(mark.value).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -2562,6 +2562,28 @@ const CASES: Record<string, Case[]> = {
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -60 dB fade 0 bar']),
|
||||
},
|
||||
],
|
||||
SetSongLane: [
|
||||
{
|
||||
name: 'holds the lane of the track of the playing it is given, on the next bar where nothing else is picked (#338)',
|
||||
inputs: { playback: 4, track: 't1', lane: 'cutoff', value: 0.5, fade: 2 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['lane 4 t1 cutoff 0.5 fade 2 bar']),
|
||||
},
|
||||
{
|
||||
name: 'where its quantize says',
|
||||
inputs: { playback: 4, track: 't1', lane: 'volume', value: -6, fade: 0, quantize: 'beat' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['lane 4 t1 volume -6 fade 0 beat']),
|
||||
},
|
||||
],
|
||||
ReleaseSongLane: [
|
||||
{
|
||||
name: 'lets the lane of the track of the playing it is given go (#338)',
|
||||
inputs: { playback: 4, track: 't1', lane: 'pan', fade: 1.5 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.songChanges).toEqual(['release 4 t1 pan fade 1.5']),
|
||||
},
|
||||
],
|
||||
SwitchSong: [
|
||||
{
|
||||
name: 'switches the playing it is given, in a cut on the next bar by default (#338)',
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
Playbacks,
|
||||
type SequencePlay,
|
||||
} from '../../src/core/music/playbacks';
|
||||
import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
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';
|
||||
@@ -486,6 +492,20 @@ function recordingAudio() {
|
||||
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;
|
||||
}
|
||||
@@ -685,6 +705,148 @@ describe('Set song track and Switch song (#338)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -422,6 +422,10 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
songChanges: [],
|
||||
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
|
||||
ctx.songChanges.push(`track ${playback} ${track} ${volumeDb} dB fade ${fade} ${quantize}`),
|
||||
setSongLane: (playback, track, lane, value, fade, quantize) =>
|
||||
ctx.songChanges.push(`lane ${playback} ${track} ${lane} ${value} fade ${fade} ${quantize}`),
|
||||
releaseSongLane: (playback, track, lane, fade) =>
|
||||
ctx.songChanges.push(`release ${playback} ${track} ${lane} fade ${fade}`),
|
||||
switchSong: (playback, songId, marker, quantize, fade) =>
|
||||
ctx.songChanges.push(
|
||||
`switch ${playback} to ${songId || '(same)'} at ${marker || '(start)'} ${quantize} fade ${fade}`,
|
||||
|
||||
@@ -2181,6 +2181,73 @@ describe('the automation of the tracks of a song (#337)', () => {
|
||||
expect(library.busLevels().some((bus) => bus.name.includes('gone'))).toBe(false);
|
||||
});
|
||||
|
||||
it('a game holds a lane of a track from where it lands, and lets it go back to the curve (#338)', async () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const play: SequencePlay = {
|
||||
sequence: {
|
||||
notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i', bus: 'song:s:t' }],
|
||||
lanes: [
|
||||
{
|
||||
bus: 'song:s:t',
|
||||
target: 'pan',
|
||||
points: [
|
||||
{ tick: 0, value: -1 },
|
||||
{ tick: 1920, value: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
tempo: 120,
|
||||
length: 1920,
|
||||
loop: null,
|
||||
needs: { layers: [], files: [] },
|
||||
buses: [{ name: 'song:s:t', gain: 1, pan: -1, automated: ['pan'] }],
|
||||
strike: () => 0,
|
||||
};
|
||||
const id = library.playSequence('song:s', play, '');
|
||||
await settle();
|
||||
const [panner] = panners.filter((p) => p.ramps.length > 0);
|
||||
const planned = panner!.ramps.length;
|
||||
const at: LandingAt = (clock) => clock.startedAt! + 0.03;
|
||||
expect(library.holdSongLane(id, 'song:s:t', 'pan', 0.5, 0, at)).toBe('set');
|
||||
const ramps = () =>
|
||||
panner!.ramps
|
||||
.slice(planned)
|
||||
.map(([kind, value, when]) => [kind, value, Math.round(Number(when) * 1e6) / 1e6]);
|
||||
// Held by hand where it lands (the fake holds its value), set there, and held to where planning got.
|
||||
expect(ramps()).toEqual([
|
||||
['set', -1, 0.08],
|
||||
['set', 0.5, 0.08],
|
||||
['linear', 0.5, 0.1],
|
||||
]);
|
||||
expect(library.holdSongLane(id, 'song:s:t', 'volume', 0, 0, at)).toBe('none');
|
||||
expect(library.holdSongLane(id + 99, 'song:s:t', 'pan', 0, 0, at)).toBe('none');
|
||||
expect(library.releaseSongLane(id, 'song:s:t', 'pan', 0)).toBe('set');
|
||||
expect(library.releaseSongLane(id, 'song:s:t', 'volume', 0)).toBe('none');
|
||||
});
|
||||
|
||||
it('a playing of its bake has no lanes to hold (#338)', () => {
|
||||
const library = new SoundLibrary(false);
|
||||
const bake = { file: 'baked.ogg', gain: 0.5, playback: { end: 2, until: 2, round: null } };
|
||||
const id = library.playSequence(
|
||||
'song:s',
|
||||
{
|
||||
sequence: { notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i' }] },
|
||||
tempo: 120,
|
||||
length: 1920,
|
||||
loop: null,
|
||||
needs: { layers: [], files: [] },
|
||||
strike: () => 0,
|
||||
bake,
|
||||
},
|
||||
'',
|
||||
);
|
||||
expect([
|
||||
library.holdSongLane(id, 'song:s:t', 'pan', 0.5, 0, () => 0),
|
||||
library.releaseSongLane(id, 'song:s:t', 'pan', 0),
|
||||
]).toEqual(['baked', 'baked']);
|
||||
});
|
||||
|
||||
/** 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: {
|
||||
|
||||
Reference in new issue
Block a user