Let a sound, note or pattern land on the beat of the music (#338)

A stinger has to start on the next beat or bar of the song that is
playing, not whenever the frame happens to run. Play sound, Play note
and Play pattern now take the number of a song or pattern playback and
plan their start on the audio clock, as the music is heard.

- New inputs sync (a playback number) and quantize (Next bar by
  default, decision 6) on Play sound, Play note and Play pattern.
- The landing is worked out in Playbacks (landOn), on the next beat,
  bar or marker of the playback as it is heard. SoundLibrary and the
  audio switch only pass it on, and only on the clock the stinger
  plays on.
- A pattern with sync plays in the tempo of the playback. It takes its
  bake only when both tempos match.
- Without a link, or when the playback is not heard, the node starts
  at once as before.
- The sync ports live in their own module musicSync.ts. Putting them
  in songSync.ts would have made an import cycle through Play song.

Tests:
- Unit: 8 new (6 in song-play, 1 each in sound-library and
  audio-switch), all green.
- Counter-probes: 12 bends, all red.
- e2e 65 on atx: ALL PASSED, 74 checks. New 65k4: the pattern (own
  tempo 100) started exactly 8 beats after the song and played at the
  song's 120 BPM. Without sync it is red (6.2 beats, 100 BPM).
- Gate on atx: CHECK EXIT 0, 334 files, 5152 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-30 06:40:25 +01:00
1 parent 53ec1094f4
commit e390d39314
16 files changed
+413 -42

No files matched your search

+75 -11
View File
@@ -3,7 +3,7 @@
* the world, the keyboard, collisions and rays, physics, sound, the clock and the shared values.
* Handles arriving from a graph are resolved to the world's entities here, so no node holds one.
*/
import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
import type { EntityHandle, MusicSync, NodeContext, PhysicsControl, SharedState } from '../../core/graph';
import { keyName } from '../../core/keys';
import { collisionFilterOf } from '../../core/model/collisionLayers';
import type { Vec2 } from '../../core/model/Properties';
@@ -198,20 +198,56 @@ export function behaviorContext(
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo, bus),
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams, bus) =>
playNote(
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) =>
playSound(
run,
assetId,
volume,
loop,
detached ? '' : entity.id,
fx,
fxParams,
tempo,
bus,
syncedStart(run, sync)?.at,
),
playNote: (
instrumentId,
note,
velocity,
volume,
duration,
detached,
params,
tempo,
fx,
fxParams,
bus,
sync,
) => {
const synced = syncedStart(run, sync);
return playNote(
run,
instrumentId,
{ note, velocity, volume, duration },
detached ? '' : entity.id,
params,
{ tempo, bus: instrumentBus(project, instrumentId, bus) },
{ tempo, bus: instrumentBus(project, instrumentId, bus), ...(synced && { at: synced.at }) },
fxVoiceOf(project, instrumentFx(project, instrumentId, fx), tempo, fxParams),
);
},
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) =>
playPattern(
run,
patternId,
volume,
loop,
detached ? '' : entity.id,
fx,
fxParams,
bus,
syncedStart(run, sync),
),
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams, bus),
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) =>
playSong(run, songId, {
@@ -327,6 +363,7 @@ function playSound(
fxParams: FxParams,
tempo: number,
bus: string,
at?: number,
): number {
const ref = findSound(project, assetId);
if (!ref?.asset.file) return 0;
@@ -340,9 +377,31 @@ function playSound(
owner,
fx ?? DRY_VOICE,
playedBus(project, bus, ref.asset.id),
at,
);
}
/** Where a sound, note or pattern synced to a playing starts (#338), and the tempo that one plays at. */
interface SyncedStart {
at: number;
tempo: number;
}
/**
* The start of a stinger (#338): on the spot `sync.quantize` names of the playing it is synced to,
* as that is heard. Null where it is not synced, or nothing of that number is heard: it starts at once.
*/
function syncedStart(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
sync: MusicSync,
): SyncedStart | null {
if (!(sync.playback > 0)) return null;
const playing = heardPlayings(project, audio).find((p) => p.id === sync.playback);
if (!playing) return null;
const at = audio.landOn?.(sync.playback, landing(sync.quantize, playing)) ?? null;
return at === null ? null : { at, tempo: playing.timeline.tempo };
}
/** The sound FX a note of that instrument goes through, by the pick of a node (#352); '' for none. */
export function instrumentFx(project: Project, instrumentId: string, picked: string): string {
return pickedFx(picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {});
@@ -414,7 +473,9 @@ function heardOn(route: MixRoute): Pick<SequencePlay, 'bus' | 'sends'> {
* A pattern played by a game: its notes through its instrument at its tempo, the whole of it once or
* round and round – the loop set on its ruler is for editing. Its notes take the volume and the owner
* of the playing; its bake (#340) takes the volume where it still holds, and is played instead.
* Returns the number of the playing, 0 where there is no pattern or nothing to hear.
* `synced` starts it in time with another playing (#338), at that one's tempo: its bake is of its
* own tempo, so only that plays it. Returns the number of the playing, 0 where there is no pattern
* or nothing to hear.
*/
function playPattern(
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
@@ -425,6 +486,7 @@ function playPattern(
fxId: string,
fxParams: FxParams,
bus: string,
synced: SyncedStart | null = null,
): number {
const asset = findAssetOf(project, patternId, 'pattern');
if (!asset) return 0;
@@ -433,9 +495,10 @@ function playPattern(
if (!instrument || sequence.notes.length === 0) return 0;
const level = heldLevel(volume);
const length = patternLength(asset);
const tempo = patternTempo(asset.props);
const own = patternTempo(asset.props);
const tempo = synced?.tempo ?? own;
const fx = fxVoiceOf(project, pickedFx(fxId, asset.props), tempo, fxParams);
const bake = validBake(project, asset);
const bake = tempo === own ? validBake(project, asset) : null;
return audio.playSequence(
patternVoiceName(patternId),
{
@@ -448,6 +511,7 @@ function playPattern(
...heardOn(playedRoute(project, bus, patternId)),
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
...(bake && { bake: { file: bake.file, gain: bake.peak * level, playback: bakePlayback(bake, loop) } }),
...(synced && { at: synced.at }),
},
owner,
);