Let a game switch songs and turn their tracks on the beat (#338)
A game could start and stop a song, but not steer one while it plays. The battle theme could not take over on the next bar, a song could not jump to a marker, and a track could not fall away when the player hides. This is stage 3 of 5 in #338. - Quantize: now, next beat, next bar (the default) or next marker. The spot is the next one as the song is heard, and the change is planned on the audio clock to the sample, round the loop as the ruler counts. - Set song track turns one track in dB on top of the song's level, over a fade, on the bus input before its FX and sends. - Switch song plays another song, or the same one from a marker, in place of the playing one. It keeps the playback number, so Stop song, Set song track and On beat go on reaching it. - A playing now has a key for its buses apart from its public number. During a switch the old and the new song each have buses of their own. - Play song has a "live tracks" switch. With it off a baked song plays its bake, and Set song track on it writes a line to the log. - A Switch song to a marker plays the notes, as a bake only plays from its start. Found on the way: - A cut on a bar line faded over seconds in Chrome. cancelAndHoldAtTime holds nothing where nothing is planned later, so the ramp ran from the bus's last change. The turn now sets its start value on the spot. It had been measured at 0.52 of full level 0.2 s past the bar line. Tests: - Unit: 23 new cases in beats, song-play, sound-library and the node catalog. Counter-checks: 17 bends, all compile, all red. - e2e 65 (atx): ALL PASSED, 73 checks. 65k3 is new. The switch landed three bars after the start under the same number, and the track cut on the next bar line with nothing in between. With the old ramp 65k3 is red (0.53 and 0.51 between). - Gate (atx): CHECK EXIT 0, 334 files, 5144 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a6a41b6b69
commit
53ec1094f4
23 files changed
+1259
-101
No files matched your search
@@ -24,6 +24,8 @@ import { bakePlayback, validBake } from '../../core/music/bake';
|
||||
import { clipFx, fxVoiceOf, sequenceNeeds } from '../../core/music/needs';
|
||||
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
|
||||
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
|
||||
import { nextSpot, type Quantize, quantizeSpots } from '../../core/music/beats';
|
||||
import { heldSongMarkers } from '../../core/music/markers';
|
||||
import {
|
||||
dbToGain,
|
||||
songLength,
|
||||
@@ -31,8 +33,12 @@ import {
|
||||
songSequence,
|
||||
songTempo,
|
||||
SOUND_ITEM_MAX_DB,
|
||||
TRACK_MAX_DB,
|
||||
TRACK_MIN_DB,
|
||||
trackBus,
|
||||
} from '../../core/music/song';
|
||||
import type { SequencePlay } from '../../core/music/playbacks';
|
||||
import type { LandingAt, SequencePlay } from '../../core/music/playbacks';
|
||||
import { findSongMarker } from '../../core/properties/assetRef';
|
||||
import { songBuses } from '../../core/music/songBuses';
|
||||
import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer';
|
||||
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
|
||||
@@ -42,7 +48,7 @@ import type { World } from '../World';
|
||||
import type { AudioSink, NoteTiming } from './Audio';
|
||||
import type { Instance } from './behaviorPulses';
|
||||
import type { Input } from './Input';
|
||||
import { heardPlayings, positionOf } from './musicPulses';
|
||||
import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses';
|
||||
import type { RecordingSink } from '../../core/recording/recording';
|
||||
import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids';
|
||||
|
||||
@@ -207,8 +213,21 @@ export function behaviorContext(
|
||||
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) =>
|
||||
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams, bus),
|
||||
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) =>
|
||||
playSong(run, songId, {
|
||||
volume,
|
||||
fadeIn,
|
||||
loop,
|
||||
owner: detached ? '' : entity.id,
|
||||
fx,
|
||||
fxParams,
|
||||
bus,
|
||||
live,
|
||||
}),
|
||||
setSongTrack: (playback, track, volumeDb, fade, quantize) =>
|
||||
setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize),
|
||||
switchSong: (playback, songId, marker, quantize, fade) =>
|
||||
switchSong(run, world, playback, songId, marker, quantize, heldFade(fade)),
|
||||
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
|
||||
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
|
||||
soundLength: (assetId) => {
|
||||
@@ -434,48 +453,162 @@ function playPattern(
|
||||
);
|
||||
}
|
||||
|
||||
/** How a Play song asked for a song (#338): what a Switch song plays the next one by. */
|
||||
interface SongAsk {
|
||||
volume: number;
|
||||
fadeIn: number;
|
||||
loop: boolean;
|
||||
owner: string;
|
||||
fx: string;
|
||||
fxParams: FxParams;
|
||||
bus: string;
|
||||
/** Its notes, even where it has a bake: its tracks can be turned then. */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
/** What each playing of a song was asked for, by its number, in the sink it plays in. */
|
||||
const songAsks = new WeakMap<AudioSink, Map<number, SongAsk>>();
|
||||
/** So many asks are kept before those of playings gone are let go. */
|
||||
const ASKS_KEPT = 64;
|
||||
|
||||
function keepAsk(audio: AudioSink, id: number, ask: SongAsk): void {
|
||||
let asks = songAsks.get(audio);
|
||||
if (!asks) songAsks.set(audio, (asks = new Map<number, SongAsk>()));
|
||||
if (asks.size >= ASKS_KEPT) {
|
||||
const playing = new Set((audio.playingTimes?.() ?? []).map((time) => time.id));
|
||||
for (const known of asks.keys()) if (!playing.has(known)) asks.delete(known);
|
||||
}
|
||||
asks.set(id, ask);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Its bake (#340) is played instead while
|
||||
* it holds. Returns the number of the playing, 0 where there is no song or nothing in it to play.
|
||||
* it holds, unless `live` (#338). Returns the number of the playing, 0 where there is no song or
|
||||
* nothing in it to play.
|
||||
*/
|
||||
function playSong(
|
||||
function playSong(sources: Pick<ContextSources, 'project' | 'audio'>, songId: string, ask: SongAsk): number {
|
||||
const play = songPlay(sources, songId, ask, 0);
|
||||
const id = play ? sources.audio.playSequence(songVoiceName(songId), play, ask.owner) : 0;
|
||||
if (id !== 0) keepAsk(sources.audio, id, ask);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a song plays as, asked for so (see `playSong`), from `from` ticks on (#338): a bake plays only
|
||||
* from the start, so one asked to start further in plays its notes.
|
||||
*/
|
||||
function songPlay(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
songId: string,
|
||||
volume: number,
|
||||
fadeIn: number,
|
||||
loop: boolean,
|
||||
owner: string,
|
||||
fxId: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
): number {
|
||||
ask: SongAsk,
|
||||
from: number,
|
||||
): SequencePlay | null {
|
||||
const song = findAssetOf(project, songId, 'song');
|
||||
if (!song) return 0;
|
||||
if (!song) return null;
|
||||
const sequence = songSequence(project, song);
|
||||
const tempo = songTempo(song.props);
|
||||
const fx = fxVoiceOf(project, pickedFx(fxId, song.props), tempo, fxParams);
|
||||
const bake = validBake(project, song);
|
||||
return audio.playSequence(
|
||||
songVoiceName(songId),
|
||||
{
|
||||
sequence,
|
||||
tempo,
|
||||
length: songLength(song),
|
||||
loop: loop ? songLoop(song) : null,
|
||||
needs: sequenceNeeds(project, sequence),
|
||||
volume: heldLevel(volume),
|
||||
fadeIn: heldFade(fadeIn),
|
||||
buses: songBuses(project, song),
|
||||
...(fx && { fx }),
|
||||
...heardOn(playedRoute(project, bus, songId)),
|
||||
strike: (note) => strikeNote({ project, audio }, note, owner),
|
||||
strikeClip: (clip) => playClip({ project, audio }, clip),
|
||||
...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, loop) } }),
|
||||
},
|
||||
owner,
|
||||
const fx = fxVoiceOf(project, pickedFx(ask.fx, song.props), tempo, ask.fxParams);
|
||||
const bake = ask.live || from > 0 ? null : validBake(project, song);
|
||||
return {
|
||||
sequence,
|
||||
tempo,
|
||||
length: songLength(song),
|
||||
loop: ask.loop ? songLoop(song) : null,
|
||||
needs: sequenceNeeds(project, sequence),
|
||||
volume: heldLevel(ask.volume),
|
||||
fadeIn: heldFade(ask.fadeIn),
|
||||
buses: songBuses(project, song),
|
||||
...(fx && { fx }),
|
||||
...heardOn(playedRoute(project, ask.bus, songId)),
|
||||
strike: (note) => strikeNote({ project, audio }, note, ask.owner),
|
||||
strikeClip: (clip) => playClip({ project, audio }, clip),
|
||||
...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, ask.loop) } }),
|
||||
...(from > 0 && { from }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a change to a playing lands (#338): as soon as it can be heard, or on the next spot `quantize` names. */
|
||||
function landing(quantize: Quantize, playing: HeardPlaying): LandingAt {
|
||||
return ({ time, startedAt, soonest }) => {
|
||||
if (quantize === 'now' || startedAt === null) return soonest;
|
||||
const spots = quantizeSpots(quantize, playing.timeline.meter, playing.markers);
|
||||
const next = nextSpot({ ...time, meter: playing.timeline.meter }, soonest - startedAt, spots);
|
||||
return next === null ? soonest : startedAt + next;
|
||||
};
|
||||
}
|
||||
|
||||
/** The song playing numbered `playback` as it is heard now; null for none, or a pattern. */
|
||||
function heardSong(project: Project, audio: AudioSink, playback: number): HeardPlaying | null {
|
||||
if (!(playback > 0)) return null;
|
||||
return heardPlayings(project, audio).find((p) => p.id === playback && p.kind === 'song') ?? null;
|
||||
}
|
||||
|
||||
/** Turns a track of a playing song (#338); where it plays its bake there is nothing to turn, and the log says so. */
|
||||
function setSongTrack(
|
||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||
world: World,
|
||||
playback: number,
|
||||
track: string,
|
||||
volumeDb: number,
|
||||
fade: number,
|
||||
quantize: Quantize,
|
||||
): void {
|
||||
const playing = heardSong(project, audio, playback);
|
||||
if (!playing || track === '') return;
|
||||
const db = Number.isFinite(volumeDb) ? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, volumeDb)) : 0;
|
||||
const done = audio.setSongTrack?.(
|
||||
playback,
|
||||
trackBus(playing.asset, track),
|
||||
dbToGain(db),
|
||||
fade,
|
||||
landing(quantize, playing),
|
||||
);
|
||||
if (done === 'baked') {
|
||||
const name = findAssetOf(project, playing.asset, 'song')?.name ?? playing.asset;
|
||||
world.log.push(`Set song track: song "${name}" plays its bake; switch on "live tracks" in Play song`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays another song, or the same one from a marker (#338), in place of a playing song: asked for
|
||||
* as that one was, where `quantize` lands for the one heard now. A marker the song does not have is
|
||||
* logged, and nothing changes.
|
||||
*/
|
||||
function switchSong(
|
||||
sources: Pick<ContextSources, 'project' | 'audio'>,
|
||||
world: World,
|
||||
playback: number,
|
||||
songId: string,
|
||||
markerId: string,
|
||||
quantize: Quantize,
|
||||
fade: number,
|
||||
): void {
|
||||
const { project, audio } = sources;
|
||||
const playing = heardSong(project, audio, playback);
|
||||
if (!playing) return;
|
||||
const target = songId || playing.asset;
|
||||
const song = findAssetOf(project, target, 'song');
|
||||
if (!song) return;
|
||||
const marker = markerId ? heldSongMarkers(song.markers).find((m) => m.id === markerId) : undefined;
|
||||
if (markerId && !marker) {
|
||||
const named = findSongMarker(project, markerId)?.name ?? markerId;
|
||||
world.log.push(`Switch song: song "${song.name}" has no marker "${named}"`);
|
||||
return;
|
||||
}
|
||||
const ask = songAsks.get(audio)?.get(playback) ?? {
|
||||
volume: 1,
|
||||
fadeIn: 0,
|
||||
loop: true,
|
||||
owner: '',
|
||||
fx: '',
|
||||
fxParams: {},
|
||||
bus: '',
|
||||
live: false,
|
||||
};
|
||||
const play = songPlay(sources, target, ask, marker?.tick ?? 0);
|
||||
if (play) audio.switchSequence?.(playback, songVoiceName(target), play, landing(quantize, playing), fade);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user