Let a behavior hear a song, pattern or sound come to its end (#373)
feuerwerk / gate (push) Successful in 5m56s
feuerwerk / suite (push) Failing after 20m55s

A game should be able to start the next piece of music when one is
over, or wait for a sound before it goes on. Until now a behavior
could start and stop music and sounds, but never learn that they had
ended.

- Two new events: On music finished with the fields Song and Pattern
  and the input playback, and On sound finished with the field Sound
  and the input voice. Both give out what ended and stopped.
- Two new data nodes, Music playing and Sound playing, ask whether
  something is still heard.
- Over means nothing is heard any more: no tail of the effects, and
  a fade-out has run down.
- stopped is on for Stop song, pattern or sound, when the behavior
  that owns it ends and on a scene change. For a sound also when a
  newer voice needs its place or the file does not load.
- Only what the Play nodes of this run started is heard, across
  scene changes too. Previews in the tabs, compositions and notes of
  instruments do not count.
- Switch song ends nothing. The number goes on, and the end is told
  under the song heard last.
- Playbacks and the SoundLibrary tell each end once to a new
  PlayedEnds per run. The AudioSwitch passes them on from both sinks.

Found and fixed: a pattern whose last bar ends in silence counted as
done up to one planning window before its end. In e2e 65 the next
pattern started after 1.962 s instead of 2 s. Scheduler.done now also
wants the playhead to have reached the end. Cleaning up done
playbacks therefore comes a few tenths of a second later. One test
that expected the early end is adjusted.

Tests:
- Unit: 23 new (played-ends 12, sound-library 5, node catalog 5,
  audio-switch 1), all green.
- Counter-probes: 19 bends, all red. One was green at first for want
  of a case, which is added now.
- e2e 65 on atx: ALL PASSED, 78 checks. New 65k6: the event came
  once with stopped off, and the second pattern started 2.051 s after
  the stinger. Stop song gave exactly one end with stopped on, and
  the switch from 65k3 gave none.
- Gate on atx: CHECK EXIT 0, 337 files, 5222 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-30 14:48:16 +01:00
1 parent 2333f66ba2
commit e53121a0bb
22 files changed
+932 -29

No files matched your search

+67 -2
View File
@@ -1,4 +1,4 @@
import { edge, node } from '../lib/builders.mjs';
import { edge, node, port as readPort, variable, written } from '../lib/builders.mjs';
import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
import { sampleZoom } from './lib/zoomTransition.mjs';
@@ -1009,6 +1009,18 @@ export async function song(t) {
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 }),
// 65k6 (#373): once the stinger is over, the pattern once more, at its own tempo. The
// stinger's number is kept in a variable, as no event reads an output another one left; heard
// by it, the one started after is not heard. And the end of the song, logged.
node('sg65-keep', 'Variables', 1300, 1600, {}, [written('sting', 'number')]),
node('sg65-kept', 'Variables', 1040, 1800, {}, [readPort('sting', 'number')]),
node('sg65-fin', 'OnMusicFinished', 1300, 1400),
node('sg65-again', 'PlayPattern', 1560, 1400, { pattern: PATTERN, loop: false }),
node('sg65-jf', 'Join', 1560, 1600, { a: 'finished stinger ' }),
node('sg65-logf', 'Log', 1820, 1400),
node('sg65-fins', 'OnMusicFinished', 0, 2200, { song: id }),
node('sg65-js', 'Join', 260, 2200, { a: 'finished song ' }),
node('sg65-logs', 'Log', 520, 2200),
],
edges: [
edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'),
@@ -1048,9 +1060,24 @@ export async function song(t) {
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'),
edge('sg65-f0', 'sg65-sting', 'exec', 'sg65-keep', 'exec'),
edge('sg65-f00', 'sg65-sting', 'playback', 'sg65-keep', 'sting'),
edge('sg65-f1', 'sg65-kept', 'sting', 'sg65-fin', 'playback'),
edge('sg65-f2', 'sg65-fin', 'exec', 'sg65-again', 'exec'),
edge('sg65-f3', 'sg65-again', 'exec', 'sg65-logf', 'exec'),
edge('sg65-f4', 'sg65-fin', 'stopped', 'sg65-jf', 'b'),
edge('sg65-f5', 'sg65-jf', 'text', 'sg65-logf', 'message'),
edge('sg65-f6', 'sg65-fins', 'exec', 'sg65-logs', 'exec'),
edge('sg65-f7', 'sg65-fins', 'stopped', 'sg65-js', 'b'),
edge('sg65-f8', 'sg65-js', 'text', 'sg65-logs', 'message'),
],
},
});
await mutate(
`__gm.state.project.assets.find((a) => a.id === 'sg65-logic').variables = ${JSON.stringify([
variable('sting', 'number', 0),
])};`,
);
const playbacks = () => json('__gm.sound.playbacks()');
const heardFrom = t.consoleLog.length;
await play();
@@ -1233,7 +1260,18 @@ export async function song(t) {
const song = stung.find((p) => p.name === `song:${id}`);
const stinger = stung.find((p) => p.name === `pattern:${PATTERN}`);
const beatsIn = stinger && song ? (stinger.startedAt - song.startedAt) / 0.5 : NaN;
const alone = await waitFor(playbacks, (list) => list.length === 1, 60);
// Every pattern playing seen on the way, the one 65k6 starts after the stinger among them.
const patterns = new Map();
const alone = await waitFor(
async () => {
const list = await playbacks();
for (const p of list)
if (p.name === `pattern:${PATTERN}` && p.startedAt !== null) patterns.set(p.id, p);
return list;
},
(list) => list.length === 1 && patterns.size >= 2,
80,
);
check(
'a pattern synced to the song plays once on its next beat, to the sample, at the tempo of the song',
Math.abs(beatsIn - Math.round(beatsIn)) < 1e-6 &&
@@ -1244,6 +1282,28 @@ export async function song(t) {
alone[0].name === `song:${id}`,
JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }),
);
// 65k6. The end of a playing (#373): On music finished, hearing the stinger by its number, runs
// once it is over, its "stopped" off, and Play pattern starts the pattern once more – after
// the stinger's 2 s, within a frame and the lead of the scheduler. That one it does not hear.
const finishedLines = (what) =>
t.consoleLog.slice(heardFrom).filter((text) => text.includes(`finished ${what}`));
// P held over two beats plays two stingers; the listener hears the last, by the number it left.
const lastStinger = [...patterns.values()].filter((p) => p.startedAt < stinger.startedAt + 1.5).at(-1);
const chained = [...patterns.values()].filter((p) => p.startedAt > lastStinger.startedAt + 1);
const chainedAfter = chained[0] ? chained[0].startedAt - lastStinger.startedAt : NaN;
check(
'On music finished runs once the stinger is over, not stopped, and starts the pattern again right after it',
finishedLines('stinger').length === 1 &&
finishedLines('stinger')[0].endsWith('finished stinger false') &&
chained.length === 1 &&
chainedAfter >= 2 &&
chainedAfter < 2.4,
JSON.stringify({
lines: finishedLines('stinger'),
patterns: [...patterns.values()].map((p) => ({ id: p.id, startedAt: p.startedAt })),
chainedAfter,
}),
);
// 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
@@ -1318,6 +1378,11 @@ export async function song(t) {
leftBuses.length === 0,
JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }),
);
check(
'On music finished runs once for the song after its fade out, "stopped" on – not for the switch 65k3 made',
finishedLines('song').length === 1 && finishedLines('song')[0].endsWith('finished song true'),
JSON.stringify(finishedLines('song')),
);
await mutate(
`delete __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo;`,
);