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:
adminandClaude Opus 5.5 committed 2026-10-30 07:22:12 +01:00
1 parent e390d39314
commit f9428c5bc2
18 files changed
+1170 -18

No files matched your search

+83
View File
@@ -941,6 +941,9 @@ export async function song(t) {
{ id: 'sg65-mark', name: 'Round', tick: ${BAR} },
{ id: 'sg65-half', name: 'Half', tick: ${BAR + BAR / 2} },
];
// 65k5: the volume of the sound's track rises from -24 to 0 dB over each round of the loop. It
// starts at 0 dB, as the track is set.
song.tracks[0].automation = [{ target: 'volume', points: [{ tick: 0, value: 0 }, { tick: ${BAR}, value: -24 }, { tick: ${2 * BAR}, value: 0 }] }];
// 65k4: a tempo of its own, for the song's to show when it plays in time with it.
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo = 100;`);
await showTab('scene');
@@ -992,6 +995,20 @@ export async function song(t) {
node('sg65-p', 'KeyIsDown', 0, 1600, { key: 'p' }),
node('sg65-pif', 'Branch', 780, 1400),
node('sg65-sting', 'PlayPattern', 1040, 1400, { pattern: PATTERN, loop: false, quantize: 'beat' }),
// 65k5: with L held, the volume lane of the sound's track held at -40 dB on the next bar; with
// R held, let go back to its curve over half a second.
node('sg65-l', 'KeyIsDown', 0, 1800, { key: 'l' }),
node('sg65-lif', 'Branch', 1040, 1600),
node('sg65-hold', 'SetSongLane', 1300, 1600, {
track: tracks[0],
lane: 'volume',
value: -40,
fade: 0,
quantize: 'bar',
}),
node('sg65-r', 'KeyIsDown', 0, 2000, { key: 'r' }),
node('sg65-rif', 'Branch', 1300, 1800),
node('sg65-let', 'ReleaseSongLane', 1560, 1800, { track: tracks[0], lane: 'volume', fade: 0.5 }),
],
edges: [
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
@@ -1023,6 +1040,14 @@ export async function song(t) {
edge('sg65-c10', 'sg65-p', 'down', 'sg65-pif', 'condition'),
edge('sg65-c11', 'sg65-pif', 'then', 'sg65-sting', 'exec'),
edge('sg65-c12', 'sg65-cue', 'playback', 'sg65-sting', 'sync'),
edge('sg65-c13', 'sg65-pif', 'else', 'sg65-lif', 'exec'),
edge('sg65-c14', 'sg65-l', 'down', 'sg65-lif', 'condition'),
edge('sg65-c15', 'sg65-lif', 'then', 'sg65-hold', 'exec'),
edge('sg65-c16', 'sg65-cue', 'playback', 'sg65-hold', 'playback'),
edge('sg65-c17', 'sg65-lif', 'else', 'sg65-rif', 'exec'),
edge('sg65-c18', 'sg65-r', 'down', 'sg65-rif', 'condition'),
edge('sg65-c19', 'sg65-rif', 'then', 'sg65-let', 'exec'),
edge('sg65-c20', 'sg65-cue', 'playback', 'sg65-let', 'playback'),
],
},
});
@@ -1210,6 +1235,64 @@ export async function song(t) {
alone[0].name === `song:${id}`,
JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }),
);
// 65k5. A game takes a lane over (#338): L held over a beat holds the volume lane of the sound's
// track at -40 dB from the next bar line on, its curve (-24 to 0 dB each round) no longer
// heard; R held lets it go, back to the curve over half a second. Read from the level of the
// track's bus, against the bar lines 65k3 worked out.
const swell = `${newMaster}:${busOf(tracks[0])}`;
const levelOf = async () => {
const at = await clockNow();
const bus = (await json('__gm.song.buses()')).find((b) => b.name === swell);
return { at, db: bus ? 20 * Math.log10(Math.max(bus.level, 1e-6)) : NaN };
};
const onCurve = (r) => r.db >= -24.5 && r.db <= 0.5;
const held = (r) => Math.abs(r.db + 40) < 0.1;
const holding = [await levelOf()];
await t.keyDown('l');
const heldFrom = await waitFor(async () => (holding.push(await levelOf()), holding.at(-1)), held, 40);
await t.keyUp('l');
const lastFree = holding.findLast((r) => !held(r));
const holdBar = lastFree && firstBar + Math.max(0, Math.ceil((lastFree.at - 0.05 - firstBar) / 2)) * 2;
const heldRound = [];
for (let i = 0; i < 10; i++) {
heldRound.push(await levelOf());
await sleep(200);
}
check(
'Set song lane holds the volume lane at -40 dB from the next bar line on, in a cut, and stays there as the curve would rise',
held(heldFrom) &&
holding.every((r) => held(r) || onCurve(r)) &&
holdBar !== undefined &&
heldFrom.at >= holdBar - 0.05 &&
heldFrom.at < holdBar + 0.5 &&
heldRound.every(held),
JSON.stringify({ swell, holdBar, holding, heldRound }),
);
// Read as fast as the page answers, so the half second of the way back is seen.
const letting = [];
await t.keyDown('r');
for (const begun = Date.now(); Date.now() - begun < 6000;) {
letting.push(await levelOf());
const laneFreed = letting.find((r) => !held(r));
if (laneFreed && letting.at(-1).at - laneFreed.at > 0.8) break;
}
await t.keyUp('r');
const laneFreed = letting.find((r) => !held(r));
const freeRound = [];
for (let i = 0; i < 10; i++) {
freeRound.push(await levelOf());
await sleep(200);
}
const spread = Math.max(...freeRound.map((r) => r.db)) - Math.min(...freeRound.map((r) => r.db));
check(
'Release song lane glides back to the curve over half a second, then it follows the curve round the loop again',
laneFreed !== undefined &&
laneFreed.db < -30 &&
letting.filter((r) => r.at >= laneFreed.at + 0.55).every(onCurve) &&
freeRound.every(onCurve) &&
spread > 6,
JSON.stringify({ laneFreed, letting: letting.filter((r, i) => i % 3 === 0), freeRound, spread }),
);
await press('s');
const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null);
const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40);