Play and stop a song from a behavior in the game #336
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:
1 parent
8f9c250eb6
commit
f97961c8ff
23 files changed
+1134
-194
No files matched your search
@@ -277,6 +277,17 @@ export interface NodeContext {
|
||||
playPattern(patternId: string, volume: number, loop: boolean, detached: boolean): number;
|
||||
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
|
||||
stopPattern(patternId: string, letRing: boolean): void;
|
||||
/**
|
||||
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
|
||||
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
|
||||
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
|
||||
* Returns its number; 0 where there is nothing to play.
|
||||
*/
|
||||
playSong(songId: string, volume: number, fadeIn: number, loop: boolean, detached: boolean): number;
|
||||
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
|
||||
stopSong(songId: string, fadeOut: number): void;
|
||||
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
|
||||
stopPlayback(playback: number, fadeOut: number): void;
|
||||
/**
|
||||
* How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not
|
||||
* exist or has not been loaded yet.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
|
||||
export const songPort = (): PortDef => ({
|
||||
key: 'song',
|
||||
label: 'song',
|
||||
type: 'string',
|
||||
field: { key: 'song', label: 'Song', kind: 'asset', default: '', assetTypes: ['song'] },
|
||||
});
|
||||
|
||||
export const PlaySong: NodeDef = {
|
||||
type: 'PlaySong',
|
||||
label: 'Play song',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays a song asset at its tempo, its tracks with the volume, pan, mute and solo they have. With ' +
|
||||
'"loop" it plays from its start and then round and round the loop set on its ruler – an intro ' +
|
||||
'once, then the loop – until a Stop song ends it; without, once to its end. "volume" is the ' +
|
||||
'level of the whole song, "fade in" the seconds it rises there from silence. Each play is a ' +
|
||||
'playing of its own: firing this twice plays it twice, side by side. It belongs to this object ' +
|
||||
'like a sound and stops when the object is destroyed or the scene is left, unless "detached" ' +
|
||||
'is on. "playback" is the number of this playing, for Stop song; 0 where the song has nothing ' +
|
||||
'to play.',
|
||||
inputs: [
|
||||
exec(),
|
||||
songPort(),
|
||||
withDefault(num('volume'), 1),
|
||||
withDefault(num('fadeIn', 'fade in'), 0),
|
||||
{
|
||||
key: 'loop',
|
||||
label: 'loop',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
field: { key: 'loop', label: 'Loop', kind: 'boolean', default: true },
|
||||
},
|
||||
{
|
||||
key: 'detached',
|
||||
label: 'detached',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
||||
},
|
||||
],
|
||||
outputs: [exec(), num('playback')],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const song = asString(inputs.song);
|
||||
const playback = song
|
||||
? ctx.playSong(
|
||||
song,
|
||||
asNumber(inputs.volume),
|
||||
asNumber(inputs.fadeIn),
|
||||
asBoolean(inputs.loop),
|
||||
asBoolean(inputs.detached),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
import { songPort } from './PlaySong';
|
||||
|
||||
export const StopSong: NodeDef = {
|
||||
type: 'StopSong',
|
||||
label: 'Stop song',
|
||||
category: 'action',
|
||||
description:
|
||||
'Stops every playing of a song, however many Play song started. With no song picked it stops ' +
|
||||
'every song, and leaves patterns and sounds alone. With "playback" connected it stops only that ' +
|
||||
'one playing, the number Play song handed out, and nothing where it is 0. "fade out" is the ' +
|
||||
'seconds it falls silent over before it ends; 0 ends it at once.',
|
||||
inputs: [
|
||||
exec(),
|
||||
songPort(),
|
||||
// Not typed in: unconnected it is null, and only then does the song decide.
|
||||
{ ...num('playback'), inline: false },
|
||||
withDefault(num('fadeOut', 'fade out'), 0),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const fade = asNumber(inputs.fadeOut);
|
||||
if (inputs.playback === null || inputs.playback === undefined) ctx.stopSong(asString(inputs.song), fade);
|
||||
else {
|
||||
const playback = asNumber(inputs.playback);
|
||||
if (playback > 0) ctx.stopPlayback(playback, fade);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,8 @@ import { SetSoundFxParameters } from './action/SetSoundFxParameters';
|
||||
import { PlayNote } from './action/PlayNote';
|
||||
import { PlayPattern } from './action/PlayPattern';
|
||||
import { StopPattern } from './action/StopPattern';
|
||||
import { PlaySong } from './action/PlaySong';
|
||||
import { StopSong } from './action/StopSong';
|
||||
import { SetZ } from './action/SetZ';
|
||||
import { Spawn } from './action/Spawn';
|
||||
import { StopGame } from './action/StopGame';
|
||||
@@ -312,6 +314,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
InstrumentParameterValues,
|
||||
PlayPattern,
|
||||
StopPattern,
|
||||
PlaySong,
|
||||
StopSong,
|
||||
LoadScene,
|
||||
Log,
|
||||
StopGame,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { soundFxBuildFor } from '../soundfx/compile';
|
||||
import { DRY_VOICE, type FxPlan, type FxVoice } from '../soundfx/nodes';
|
||||
import type { SequenceNeeds } from './playbacks';
|
||||
import type { Sequence } from './sequence';
|
||||
|
||||
/** The sound FX a clip goes through; one that does not compile plays it dry, as `Play sound` does. */
|
||||
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
|
||||
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
|
||||
const plan = fx && soundFxBuildFor(fx).compiled;
|
||||
return plan ? { plan, params: {}, tempo } : DRY_VOICE;
|
||||
}
|
||||
|
||||
/** Every layer of every note and every file and FX of every clip a sequence plays. */
|
||||
export function sequenceNeeds(project: Project, sequence: Sequence): SequenceNeeds {
|
||||
const sources = instrumentSourcesOf(project);
|
||||
const seen = new Set<string>();
|
||||
const layers: FxPlan[] = [];
|
||||
for (const { instrument, note } of sequence.notes) {
|
||||
const key = `${instrument}\u0000${note}`;
|
||||
const asset = findAssetOf(project, instrument, 'instrument');
|
||||
if (seen.has(key) || !asset) continue;
|
||||
seen.add(key);
|
||||
layers.push(...instrumentBuildFor(asset, note, sources).layers);
|
||||
}
|
||||
const files = new Set<string>();
|
||||
for (const clip of sequence.clips ?? []) {
|
||||
const file = findAssetOf(project, clip.sound, 'sound')?.file;
|
||||
if (file) files.add(file);
|
||||
const fx = clipFx(project, clip.fx, 120);
|
||||
if (fx !== DRY_VOICE) layers.push(fx.plan);
|
||||
}
|
||||
return { layers, files: [...files] };
|
||||
}
|
||||
+99
-36
@@ -1,37 +1,72 @@
|
||||
import type { FxPlan } from '../soundfx/nodes';
|
||||
import { type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import type { Sequence } from './sequence';
|
||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import type { LoopRange, Sequence } from './sequence';
|
||||
|
||||
/** What a game asks to hear of a pattern: its notes, how fast, how long, and whether round and round. */
|
||||
/** What a sequence is made of: loaded before the first of its notes and clips is planned. */
|
||||
export interface SequenceNeeds {
|
||||
layers: readonly FxPlan[];
|
||||
files: readonly string[];
|
||||
}
|
||||
|
||||
/** What a track of a song is set to: its bus, its level and its pan (#334). */
|
||||
export interface BusLevel {
|
||||
name: string;
|
||||
gain: number;
|
||||
pan: number;
|
||||
}
|
||||
|
||||
/** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */
|
||||
export interface SequencePlay {
|
||||
sequence: Sequence;
|
||||
tempo: number;
|
||||
/** In ticks: where it ends, and with `loop` where it starts again from 0. */
|
||||
/** In ticks: where it ends when it does not loop. */
|
||||
length: number;
|
||||
loop: boolean;
|
||||
/** What its notes are made of, loaded before the first one is planned. */
|
||||
layers: readonly FxPlan[];
|
||||
/** What it runs round once the playhead gets there, from the start on; null plays it once to `length`. */
|
||||
loop: LoopRange | null;
|
||||
needs: SequenceNeeds;
|
||||
/** The level of the whole playing, 1 where left out, and the seconds it rises to it from silence. */
|
||||
volume?: number;
|
||||
fadeIn?: number;
|
||||
/** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */
|
||||
buses?: readonly BusLevel[];
|
||||
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
||||
strike(note: ScheduledNote): number;
|
||||
strikeClip?(clip: ScheduledClip): number;
|
||||
}
|
||||
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play'> {
|
||||
prepare(layers: readonly FxPlan[]): Promise<void>;
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip'> {
|
||||
prepare(needs: SequenceNeeds): Promise<void>;
|
||||
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
|
||||
level(id: number, gain: number, at: number, seconds: number): void;
|
||||
/** Calls `then` once the audio clock reaches `at`. */
|
||||
after(at: number, then: () => void): void;
|
||||
/** A playing is gone: stopped, or played to its end. */
|
||||
closed?(id: number): void;
|
||||
}
|
||||
|
||||
export interface Playback {
|
||||
id: number;
|
||||
/** What stands for the pattern, as a file stands for a sound. */
|
||||
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
|
||||
name: string;
|
||||
owner: string;
|
||||
scheduler: Scheduler;
|
||||
/** The last notes planned, newest last, for a check to hold against the clock. */
|
||||
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
||||
planned: ScheduledNote[];
|
||||
clips: ScheduledClip[];
|
||||
/** The second of the audio clock a fade out ends it at; null while it is not fading. */
|
||||
fading: number | null;
|
||||
}
|
||||
|
||||
/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */
|
||||
export function goesBy(playing: string, name: string): boolean {
|
||||
return name === '' || playing === name || (name.endsWith(':') && playing.startsWith(name));
|
||||
}
|
||||
|
||||
const KEPT = 64;
|
||||
|
||||
/**
|
||||
* The patterns a game plays (#336 ahead of songs): each on a scheduler of its own, numbered like a
|
||||
* voice, owned like one and stopped like one. One played to its end goes by itself.
|
||||
* The patterns and songs a game plays (#336): each on a scheduler of its own, numbered like a voice,
|
||||
* owned like one and stopped like one. One played to its end goes by itself.
|
||||
*/
|
||||
export class Playbacks {
|
||||
private list: Playback[] = [];
|
||||
@@ -41,50 +76,78 @@ export class Playbacks {
|
||||
/** Starts once its files are there; a stop that came first wins. */
|
||||
start(id: number, name: string, play: SequencePlay, owner: string): void {
|
||||
const planned: ScheduledNote[] = [];
|
||||
const strike = (note: ScheduledNote) => {
|
||||
planned.push(note);
|
||||
if (planned.length > 64) planned.shift();
|
||||
return play.strike(note);
|
||||
const clips: ScheduledClip[] = [];
|
||||
const keep = <T>(list: T[], item: T) => {
|
||||
list.push(item);
|
||||
if (list.length > KEPT) list.shift();
|
||||
};
|
||||
const scheduler = new Scheduler({ ...this.host, play: strike }, () => play.sequence, {
|
||||
tempo: play.tempo,
|
||||
loop: play.loop ? { start: 0, end: play.length } : null,
|
||||
end: play.length,
|
||||
});
|
||||
const playback = { id, name, owner, scheduler, planned };
|
||||
const strikeClip = play.strikeClip;
|
||||
const scheduler = new Scheduler(
|
||||
{
|
||||
...this.host,
|
||||
play: (note) => (keep(planned, note), play.strike(note)),
|
||||
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
||||
},
|
||||
() => play.sequence,
|
||||
{ tempo: play.tempo, loop: play.loop, end: play.length },
|
||||
);
|
||||
const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null };
|
||||
this.list.push(playback);
|
||||
void this.host.prepare(play.layers).then(() => {
|
||||
if (this.list.includes(playback)) scheduler.start(0);
|
||||
void this.host.prepare(play.needs).then(() => {
|
||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||
scheduler.start(0);
|
||||
this.host.level(id, play.volume ?? 1, this.host.now() + LEAD, Math.max(0, play.fadeIn ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether that one is still to be heard: waiting on its files, playing, or its last notes ringing. */
|
||||
/** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */
|
||||
has(id: number): boolean {
|
||||
return this.current().some((playback) => playback.id === id);
|
||||
}
|
||||
|
||||
/** Those whose name is `name`, every one where it is `''`. */
|
||||
stopNamed(name: string, letRing = false): void {
|
||||
this.stopWhere((playback) => name === '' || playback.name === name, letRing);
|
||||
/** Those that go by `name` (see `goesBy`); with `fade` they fall silent over that many seconds first. */
|
||||
stopNamed(name: string, letRing = false, fade = 0): void {
|
||||
this.stopWhere((playback) => goesBy(playback.name, name), letRing, fade);
|
||||
}
|
||||
|
||||
stopId(id: number, letRing = false): void {
|
||||
this.stopWhere((playback) => playback.id === id, letRing);
|
||||
stopId(id: number, letRing = false, fade = 0): void {
|
||||
this.stopWhere((playback) => playback.id === id, letRing, fade);
|
||||
}
|
||||
|
||||
/** An empty owner is no one, as for a voice: those were started to outlive whatever started them. */
|
||||
stopOwner(owner: string): void {
|
||||
if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false);
|
||||
if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false, 0);
|
||||
}
|
||||
|
||||
/** What is playing, oldest first (test hook). */
|
||||
current(): readonly Readonly<Playback>[] {
|
||||
this.list = this.list.filter((playback) => !playback.scheduler.done);
|
||||
const gone = this.list.filter((playback) => playback.scheduler.done);
|
||||
if (gone.length > 0) this.close(gone, false);
|
||||
return this.list;
|
||||
}
|
||||
|
||||
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean): void {
|
||||
for (const playback of this.list.filter(going)) playback.scheduler.stop(letRing);
|
||||
this.list = this.list.filter((playback) => !going(playback));
|
||||
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void {
|
||||
const stopping = this.list.filter(going);
|
||||
if (!(fade > 0)) return this.close(stopping, letRing);
|
||||
const now = this.host.now();
|
||||
const end = now + fade;
|
||||
for (const playback of stopping) {
|
||||
if (playback.fading !== null && playback.fading <= end) continue;
|
||||
playback.fading = end;
|
||||
this.host.level(playback.id, 0, now, fade);
|
||||
this.host.after(end, () => {
|
||||
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Out of the list before anyone hears of it, so whoever is told finds it gone. */
|
||||
private close(going: readonly Playback[], letRing: boolean): void {
|
||||
if (going.length === 0) return;
|
||||
this.list = this.list.filter((playback) => !going.includes(playback));
|
||||
for (const playback of going) {
|
||||
playback.scheduler.stop(letRing);
|
||||
this.host.closed?.(playback.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { SongAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import type { BusLevel } from './playbacks';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
|
||||
|
||||
@@ -126,6 +127,14 @@ export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { ga
|
||||
return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan };
|
||||
}
|
||||
|
||||
/** The level and pan each track's bus is set to. */
|
||||
export function songBuses(song: { id: string; tracks: readonly SongTrack[] }): BusLevel[] {
|
||||
return song.tracks.map((track) => ({
|
||||
name: trackBus(song.id, track.id),
|
||||
...trackLevel(track, song.tracks),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of an item from `tick` on, which is where it is cut by a split or by its left edge: what
|
||||
* it plays starts as far in as the cut is from its start. Null where `tick` is not inside it.
|
||||
|
||||
Reference in new issue
Block a user