Play and stop a song from a behavior in the game #336
game-maker / gate (push) Successful in 3m35s
game-maker / suite (push) Failing after 16m36s

A song could only be heard in its tab. Now a game plays it through
Play song and ends it through Stop song, with its tracks as they are
set. A playing pattern or song also no longer undoes the pause of the
toolbar.

- Play song plays from the start of the song and then round the loop
  of its ruler, so an intro is heard once. Without loop it plays once
  to its end. Inputs are song, volume, fade in, loop (on by default)
  and detached (off by default). It gives out playback.
- Stop song stops one song, every song for none, or one playback by
  its number, over its fade out. Stop sound with no sound named stops
  songs too. Stop pattern with no pattern named stops only patterns.
- Songs play side by side, as patterns do. They are owned like a
  sound, so leaving the scene ends one unless it is detached. Pause
  game and the time scale leave the music alone.
- Each playing is heard through a master of its own, silent until its
  first note. The fades move it, and a song's tracks sit inside it on
  buses of their own. They go once the playing and its voices have.
- Notes and clips are struck the same way in the editor and the game
  (strikeNote, playClip). What a sequence needs comes from one place
  (core/music/needs).

Findings:
- Reading the audio clock resumed a suspended context. A playing
  pattern read it every 25 ms and so undid the toolbar's pause. The
  library now leaves a context alone while the toolbar holds it.
- A pattern played once to its end asked the list of playings about
  its voices, and that list asked the pattern again. Once its last
  voice was gone this ran until the stack overflowed. The scheduler of
  a playing now asks only after voices.
- e2e 64 reads the playings through __gm.sound.playbacks() now.

Tests:
- song-play.test.ts (new): 8 tests, node catalog +8 cases
- sound-library.test.ts: 66 tests, 4 new
- e2e 64 (melektaus-atx): 23 of 23 checks passed
- e2e 65 (melektaus-atx): ALL PASSED, 22 checks (+4)
- Gate (melektaus-atx): CHECK EXIT 0, 251 files, 3903 tests
- Counter-checks: the old way of asking after voices, a clock read
  that wakes a held context, and a start after a fade out began each
  fail 1 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 17:38:44 +02:00
1 parent 8f9c250eb6
commit f97961c8ff
23 files changed
+1134 -194

No files matched your search

+28 -6
View File
@@ -36,14 +36,25 @@ export interface AudioSink {
timing?: NoteTiming,
): number;
/**
* Plays the notes of a pattern on the audio clock, each struck through `play.strike`. `name` stands
* for the pattern as a file does for a sound, and the number it gives back is one no voice has:
* `stopVoice` and `playing` take it like a voice's, `stopOwner` and stopping everything reach it.
* Returns 0 where nothing is going to play.
* A sound as a clip of a song (#334): from `clip.at` on the audio clock, `clip.offset` seconds into
* it, round and round for `clip.duration` seconds, on the bus `clip.bus`.
*/
playClip(file: string, clip: ClipTiming, fx?: FxVoice): number;
/**
* Plays the notes and clips of a pattern or a song on the audio clock, each struck through
* `play.strike`. `name` stands for it as a file does for a sound, and the number it gives back is
* one no voice has: `stopVoice` and `playing` take it like a voice's, `stopOwner` and stopping
* everything reach it. Returns 0 where nothing is going to play.
*/
playSequence(name: string, play: SequencePlay, owner: string): number;
/** Stops every pattern of that name, `''` every pattern; with `letRing` its notes fall away in their release. */
stopSequence(name: string, letRing?: boolean): void;
/**
* Stops every playing of that name, every one of a kind for `pattern:` or `song:`, every one for
* `''`. With `letRing` its notes fall away in their release, with `fade` it falls silent over that
* many seconds first.
*/
stopSequence(name: string, letRing?: boolean, fade?: number): void;
/** One playing by its number, falling silent over `fade` seconds; a voice by its number is cut. */
stopPlayback(id: number, fade: number): void;
/**
* The end of a note (#331): its ways that end with the note fade out, those that play through
* go on. Nothing happens for a voice that is no note or not there.
@@ -90,11 +101,22 @@ export interface NoteTiming {
bus?: string;
}
/** When and how a clip of a song plays (#334); see `playClip`. */
export interface ClipTiming {
at: number;
offset: number;
duration: number;
volume: number;
bus: string;
}
export const SILENT_AUDIO: AudioSink = {
play: () => 0,
playNote: () => 0,
playClip: () => 0,
playSequence: () => 0,
stopSequence: () => {},
stopPlayback: () => {},
release: () => {},
tune: () => {},
length: () => 0,
+94 -17
View File
@@ -19,7 +19,10 @@ import { soundFxBuildFor } from '../../core/soundfx/compile';
import { instrumentBuildFor, instrumentSourcesOf } from '../../core/instrument/compile';
import { VELOCITY_PARAM } from '../../core/instrument/nodes';
import { heldNote } from '../../core/instrument/notes';
import { clipFx, sequenceNeeds } from '../../core/music/needs';
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
import { songBuses, songLength, songLoop, songSequence, songTempo } from '../../core/music/song';
import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes';
import type { Entity } from '../Entity';
import { PhysicsSystem } from '../physics/PhysicsSystem';
@@ -176,8 +179,11 @@ export function behaviorContext(
}),
playPattern: (patternId, volume, loop, detached) =>
playPattern(run, patternId, volume, loop, detached ? '' : entity.id),
stopPattern: (patternId, letRing) =>
run.audio.stopSequence(patternId && patternVoiceName(patternId), letRing),
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
playSong: (songId, volume, fadeIn, loop, detached) =>
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id),
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
soundLength: (assetId) => {
const file = soundFileOf(project, assetId);
return file ? run.audio.length(file) : 0;
@@ -311,35 +317,106 @@ function playPattern(
const sequence = patternSequence(asset);
const instrument = findAssetOf(project, patternInstrument(asset.props), 'instrument');
if (!instrument || sequence.notes.length === 0) return 0;
const sources = instrumentSourcesOf(project);
const pitches = [...new Set(sequence.notes.map((n) => n.note))];
const level = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
const level = heldLevel(volume);
const length = patternLength(asset);
return audio.playSequence(
patternVoiceName(patternId),
{
sequence,
tempo: patternTempo(asset.props),
length: patternLength(asset),
loop,
layers: pitches.flatMap((note) => instrumentBuildFor(instrument, note, sources).layers),
strike: ({ instrument: id, note, velocity, duration, at, tempo }) =>
playNote(
{ project, audio },
id,
{ note, velocity, volume: level, duration },
owner,
{},
{ at, tempo },
),
length,
loop: loop ? { start: 0, end: length } : null,
needs: sequenceNeeds(project, sequence),
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
},
owner,
);
}
/**
* A song played by a game (#336): its tracks at their level, pan, mute and solo, at its tempo. With
* `loop` it plays from its start and then runs round the loop of its ruler, else once to its end.
* `volume` and `fadeIn` are the level of the whole playing. Returns the number of the playing, 0
* where there is no song or nothing in it to play.
*/
function playSong(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
songId: string,
volume: number,
fadeIn: number,
loop: boolean,
owner: string,
): number {
const song = findAssetOf(project, songId, 'song');
if (!song) return 0;
const sequence = songSequence(project, song);
return audio.playSequence(
songVoiceName(songId),
{
sequence,
tempo: songTempo(song.props),
length: songLength(song),
loop: loop ? songLoop(song) : null,
needs: sequenceNeeds(project, sequence),
volume: heldLevel(volume),
fadeIn: heldFade(fadeIn),
buses: songBuses(song),
strike: (note) => strikeNote({ project, audio }, note, owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
},
owner,
);
}
/** A note the scheduler planned (#332), struck through its instrument at its volume, on its bus. */
export function strikeNote(
sources: Pick<ContextSources, 'project' | 'audio'>,
note: ScheduledNote,
owner: string,
): number {
const struck = {
note: note.note,
velocity: note.velocity,
volume: note.volume ?? 1,
duration: note.duration,
};
const timing = { at: note.at, tempo: note.tempo, ...(note.bus && { bus: note.bus }) };
return playNote(sources, note.instrument, struck, owner, {}, timing);
}
/** A clip of a song (#334): its sound at its own volume times the item's, through its sound FX. */
export function playClip(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
clip: ScheduledClip,
): number {
const asset = findAssetOf(project, clip.sound, 'sound');
if (!asset?.file) return 0;
const volume = Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume));
return audio.playClip(
asset.file,
{ at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus },
clipFx(project, clip.fx, clip.tempo),
);
}
function heldLevel(volume: number): number {
return Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
}
function heldFade(seconds: number): number {
return Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
}
/** `pattern:` alone stands for every pattern (see `goesBy`). */
export function patternVoiceName(patternId: string): string {
return `pattern:${patternId}`;
}
/** `song:` alone stands for every song. */
export function songVoiceName(songId: string): string {
return `song:${songId}`;
}
/** Where a ray of that length ends; where it has no direction, where it starts. */
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
const d = Math.hypot(direction.x, direction.y);