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>
191 lines
6.9 KiB
TypeScript
191 lines
6.9 KiB
TypeScript
/**
|
||
* What a game hears of its music (#338): the songs and patterns playing, each with its time, and the
|
||
* beats, bars and markers each has passed since the frame before – what `On beat`, `On bar`,
|
||
* `On song marker` and `Song position` go by. A composition's playings are none of them.
|
||
*/
|
||
import { asNumber, type GraphNode, type Value } from '../../core/graph';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||
import {
|
||
heardPosition,
|
||
type HeardPosition,
|
||
newestPassed,
|
||
passedBar,
|
||
type PassedBeat,
|
||
type PassedMarker,
|
||
passedBeat,
|
||
passedMarkers,
|
||
type Timeline,
|
||
} from '../../core/music/beats';
|
||
import { heldSongMarkers, type SongMarker } from '../../core/music/markers';
|
||
import { patternMeter } from '../../core/music/pattern';
|
||
import { songMeter } from '../../core/music/song';
|
||
import { COMMON_TIME } from '../../core/music/time';
|
||
import type { AudioSink } from './Audio';
|
||
import type { Instance, Pulse } from './behaviorPulses';
|
||
|
||
/** A playing of a song or a pattern, as a game follows it. */
|
||
export interface HeardPlaying {
|
||
id: number;
|
||
/** Told apart from the playing of its number a Switch song took over from (#338). */
|
||
key: number;
|
||
kind: 'song' | 'pattern';
|
||
/** The id of the song or pattern. */
|
||
asset: string;
|
||
timeline: Timeline;
|
||
/** Seconds into its playing time it is heard now; null before it starts. */
|
||
heard: number | null;
|
||
markers: readonly SongMarker[];
|
||
}
|
||
|
||
/** The playings of songs and patterns, oldest first. */
|
||
export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[] {
|
||
return (audio.playingTimes?.() ?? []).flatMap((time): HeardPlaying[] => {
|
||
const split = time.name.indexOf(':');
|
||
const kind = time.name.slice(0, split);
|
||
const id = time.name.slice(split + 1);
|
||
if (kind !== 'song' && kind !== 'pattern') return [];
|
||
const song = kind === 'song' ? findAssetOf(project, id, 'song') : undefined;
|
||
const pattern = kind === 'pattern' ? findAssetOf(project, id, 'pattern') : undefined;
|
||
const meter = song ? songMeter(song.props) : pattern ? patternMeter(pattern.props) : COMMON_TIME;
|
||
const { tempo, from, loop, length } = time;
|
||
return [
|
||
{
|
||
id: time.id,
|
||
key: time.key,
|
||
kind,
|
||
asset: id,
|
||
timeline: { tempo, meter, from, loop, length },
|
||
heard: time.heard,
|
||
markers: song ? heldSongMarkers(song.markers) : [],
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
/** What a playing passed since the frame before. */
|
||
export interface MusicPassed {
|
||
playing: HeardPlaying;
|
||
beat: PassedBeat | null;
|
||
bar: PassedBeat | null;
|
||
markers: PassedMarker[];
|
||
}
|
||
|
||
/**
|
||
* Follows the playings from frame to frame: each frame what each passed since the last one. One
|
||
* first seen is told what it passed no more than the limit ago, its first beat among it – so is one
|
||
* a Switch song started in place of another, under the same number.
|
||
*/
|
||
export class MusicFollower {
|
||
private before = new Map<number, number>();
|
||
|
||
follow(playings: readonly HeardPlaying[]): MusicPassed[] {
|
||
const next = new Map<number, number>();
|
||
const passed = playings.map((playing) => {
|
||
const { key, heard, timeline } = playing;
|
||
const before = this.before.get(key) ?? null;
|
||
if (heard === null) return { playing, beat: null, bar: null, markers: [] };
|
||
next.set(key, heard);
|
||
return {
|
||
playing,
|
||
beat: passedBeat(timeline, before, heard),
|
||
bar: passedBar(timeline, before, heard),
|
||
markers: passedMarkers(timeline, before, heard, playing.markers),
|
||
};
|
||
});
|
||
this.before = next;
|
||
return passed;
|
||
}
|
||
}
|
||
|
||
/** The playing a node's "playback" input names; null where it is not connected. */
|
||
export type PlaybackOf = (instance: Instance, node: GraphNode) => Value;
|
||
|
||
/** Whether a node hears that playing: by its number, else by the song and the pattern picked. */
|
||
function hears(node: GraphNode, wanted: Value, playing: HeardPlaying, keys: readonly string[]): boolean {
|
||
if (wanted !== null) return asNumber(wanted) === playing.id;
|
||
const picked = keys.map((key) => ({ key, id: textOf(node.params[key] ?? '') })).filter((p) => p.id !== '');
|
||
return picked.length === 0 || picked.some((p) => p.key === playing.kind && p.id === playing.asset);
|
||
}
|
||
|
||
export function beatPulses(
|
||
instance: Instance,
|
||
passed: readonly MusicPassed[],
|
||
playbackOf: PlaybackOf,
|
||
): Pulse[] {
|
||
return instance.interpreter.eventNodes('OnBeat').flatMap((node) => {
|
||
const wanted = playbackOf(instance, node);
|
||
return passed.flatMap(({ playing, beat }) =>
|
||
beat && hears(node, wanted, playing, ['song', 'pattern'])
|
||
? [
|
||
{
|
||
instance,
|
||
node,
|
||
outputs: { playback: playing.id, bar: beat.bar, beat: beat.beat, late: beat.late },
|
||
},
|
||
]
|
||
: [],
|
||
);
|
||
});
|
||
}
|
||
|
||
export function barPulses(
|
||
instance: Instance,
|
||
passed: readonly MusicPassed[],
|
||
playbackOf: PlaybackOf,
|
||
): Pulse[] {
|
||
return instance.interpreter.eventNodes('OnBar').flatMap((node) => {
|
||
const wanted = playbackOf(instance, node);
|
||
return passed.flatMap(({ playing, bar }) =>
|
||
bar && hears(node, wanted, playing, ['song', 'pattern'])
|
||
? [{ instance, node, outputs: { playback: playing.id, bar: bar.bar, late: bar.late } }]
|
||
: [],
|
||
);
|
||
});
|
||
}
|
||
|
||
/** Of the markers a playing passed, the last spot of those the node hears: every marker on it. */
|
||
export function markerPulses(
|
||
instance: Instance,
|
||
passed: readonly MusicPassed[],
|
||
playbackOf: PlaybackOf,
|
||
): Pulse[] {
|
||
return instance.interpreter.eventNodes('OnSongMarker').flatMap((node) => {
|
||
const wanted = playbackOf(instance, node);
|
||
const marker = textOf(node.params.marker ?? '');
|
||
return passed.flatMap(({ playing, markers }) => {
|
||
if (markers.length === 0 || !hears(node, wanted, playing, ['song'])) return [];
|
||
const heard = newestPassed(markers.filter((m) => marker === '' || m.id === marker));
|
||
return heard.map((m) => ({
|
||
instance,
|
||
node,
|
||
outputs: { playback: playing.id, marker: m.id, name: m.name, late: m.late },
|
||
}));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Where the playing a `Song position` asks for stands: the one numbered `playback`, else the one
|
||
* started last of the song or pattern picked, of every one where none is.
|
||
*/
|
||
export function positionOf(
|
||
playings: readonly HeardPlaying[],
|
||
playback: number | null,
|
||
song: string,
|
||
pattern: string,
|
||
): HeardPosition | null {
|
||
const picked = [
|
||
...(song ? [{ kind: 'song', id: song }] : []),
|
||
...(pattern ? [{ kind: 'pattern', id: pattern }] : []),
|
||
];
|
||
const fits = (p: HeardPlaying) =>
|
||
playback !== null
|
||
? p.id === playback
|
||
: picked.length === 0 || picked.some((pick) => pick.kind === p.kind && pick.id === p.asset);
|
||
const playing = playings
|
||
.filter(fits)
|
||
.reduce<HeardPlaying | null>((last, p) => (!last || p.id > last.id ? p : last), null);
|
||
return playing ? heardPosition(playing.timeline, playing.heard) : null;
|
||
}
|