Files
feuerwerk/tests/core/lane-holds.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

270 lines
11 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 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);
});
});