diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index b82e9479..08a8b989 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -269,6 +269,14 @@ export interface NodeContext { instrumentParams: FxParams, tempo: number, ): number; + /** + * Plays a pattern asset through its instrument, at its own tempo, once or round and round, every + * note at `volume`. Owned like a `playSound` voice. Returns its number, for `stopVoice`; 0 where + * there is nothing to play. + */ + 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; /** * 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. diff --git a/src/core/graph/nodes/action/PlayPattern.ts b/src/core/graph/nodes/action/PlayPattern.ts new file mode 100644 index 00000000..7989c8cb --- /dev/null +++ b/src/core/graph/nodes/action/PlayPattern.ts @@ -0,0 +1,52 @@ +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asBoolean, asNumber, asString } from '../../values'; +import { exec, num, withDefault } from '../shared'; + +export const patternPort = (): PortDef => ({ + key: 'pattern', + label: 'pattern', + type: 'string', + field: { key: 'pattern', label: 'Pattern', kind: 'asset', default: '', assetTypes: ['pattern'] }, +}); + +export const PlayPattern: NodeDef = { + type: 'PlayPattern', + label: 'Play pattern', + category: 'action', + description: + 'Plays a pattern asset through its instrument, at the tempo the pattern has. The whole pattern ' + + 'is played, from its start to its end: once, or with "loop" round and round until a Stop ' + + 'pattern ends it – the loop set on its ruler is only for editing. "volume" makes every note of ' + + 'it quieter. Each play is a playing of its own: firing this twice plays it twice, overlapping. ' + + 'It belongs to this object like a sound and stops when the object is destroyed, unless ' + + '"detached" is on. "playback" is the number of this playing, for Stop pattern; 0 where the ' + + 'pattern has no instrument or no notes.', + inputs: [ + exec(), + patternPort(), + withDefault(num('volume'), 1), + { + 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 pattern = asString(inputs.pattern); + const playback = pattern + ? ctx.playPattern(pattern, asNumber(inputs.volume), asBoolean(inputs.loop), asBoolean(inputs.detached)) + : 0; + return { next: 'exec', outputs: { playback } }; + }, +}; diff --git a/src/core/graph/nodes/action/StopPattern.ts b/src/core/graph/nodes/action/StopPattern.ts new file mode 100644 index 00000000..630cef2a --- /dev/null +++ b/src/core/graph/nodes/action/StopPattern.ts @@ -0,0 +1,41 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean, asNumber, asString } from '../../values'; +import { exec, num } from '../shared'; +import { patternPort } from './PlayPattern'; + +export const StopPattern: NodeDef = { + type: 'StopPattern', + label: 'Stop pattern', + category: 'action', + description: + 'Stops every playing of a pattern, however many Play pattern started. With no pattern picked it ' + + 'stops every pattern, and leaves the sounds alone. With "playback" connected it stops only that ' + + 'one playing, the number Play pattern handed out, and nothing where it is 0. Its notes fade out ' + + 'in a few milliseconds; with "let ring" those of an instrument with an Envelope fall away in ' + + 'its release.', + inputs: [ + exec(), + patternPort(), + // Not typed in: unconnected it is null, and only then does the pattern decide. + { ...num('playback'), inline: false }, + { + key: 'letRing', + label: 'let ring', + type: 'boolean', + default: false, + field: { key: 'letRing', label: 'Let ring', kind: 'boolean', default: false }, + }, + ], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + const letRing = asBoolean(inputs.letRing); + if (inputs.playback === null || inputs.playback === undefined) + ctx.stopPattern(asString(inputs.pattern), letRing); + else { + const playback = asNumber(inputs.playback); + if (playback > 0) ctx.stopVoice(playback, letRing); + } + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index f13af91a..446548bd 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -25,6 +25,8 @@ import { RestartGame } from './action/RestartGame'; import { SetPosition } from './action/SetPosition'; import { SetSoundFxParameters } from './action/SetSoundFxParameters'; import { PlayNote } from './action/PlayNote'; +import { PlayPattern } from './action/PlayPattern'; +import { StopPattern } from './action/StopPattern'; import { SetZ } from './action/SetZ'; import { Spawn } from './action/Spawn'; import { StopGame } from './action/StopGame'; @@ -308,6 +310,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ SoundFxParameters, PlayNote, InstrumentParameterValues, + PlayPattern, + StopPattern, LoadScene, Log, StopGame, diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index 8a6a3464..17b104ec 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -37,7 +37,8 @@ export interface SchedulerHost { /** Starts the note, gives back the number of its voice; 0 where nothing plays. */ play(note: ScheduledNote): number; playing(voice: number): boolean; - stop(voices: readonly number[]): void; + /** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */ + stop(voices: readonly number[], letRing?: boolean): void; /** Calls `pump` every `INTERVAL` seconds, also in a hidden tab, until the returned function is called. */ wake(pump: () => void): () => void; } @@ -46,6 +47,8 @@ export interface SchedulerOptions { tempo?: number; meter?: Meter; loop?: LoopRange | null; + /** Where it ends, in ticks of the sequence, when it does not loop: it plans nothing after that. */ + end?: number; } /** @@ -58,6 +61,9 @@ export class Scheduler { private tempo: number; private meter: Meter; private loop: LoopRange | null; + private readonly end: number; + /** Planned up to its end: it wakes no more, and is over once its last notes are. */ + private ended = false; /** Where the playhead started, in ticks of the sequence. */ private from = 0; /** Where on the audio clock playing time 0 is; null while stopped. */ @@ -77,12 +83,18 @@ export class Scheduler { this.tempo = heldTempo(options.tempo ?? DEFAULT_TEMPO); this.meter = heldMeter(options.meter ?? COMMON_TIME); this.loop = heldLoop(options.loop ?? null); + this.end = options.end ?? Infinity; } get playing(): boolean { return this.anchor !== null; } + /** Played to its end, and none of its notes is heard any more. */ + get done(): boolean { + return this.ended && this.voices.every((voice) => !this.host.playing(voice)); + } + /** Plays from `from` ticks of the sequence, or from where it was last started or sent. */ start(from = this.from): void { this.stop(); @@ -90,18 +102,19 @@ export class Scheduler { this.anchor = this.host.now() + LEAD; this.until = 0; this.before = null; + this.ended = false; this.sleep = this.host.wake(() => this.pump()); this.pump(); } - /** Every note it started fades out, those still to come with them. */ - stop(): void { + /** Every note it started fades out, those still to come with them; with `letRing` in their release. */ + stop(letRing = false): void { this.sleep?.(); this.sleep = null; if (this.anchor !== null) this.from = Math.floor(this.headAt(this.host.now())); this.anchor = null; this.before = null; - if (this.voices.length > 0) this.host.stop(this.voices); + if (this.voices.length > 0) this.host.stop(this.voices, letRing); this.voices = []; } @@ -153,6 +166,11 @@ export class Scheduler { if (voice !== 0) this.voices.push(voice); } this.until = to; + if (!this.loop && playheadAt(to, this.from, null) >= this.end) { + this.ended = true; + this.sleep?.(); + this.sleep = null; + } } /** The playhead at a second of the audio clock; before the anchor, where it starts. */ diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts new file mode 100644 index 00000000..696e6949 --- /dev/null +++ b/src/core/music/playbacks.ts @@ -0,0 +1,90 @@ +import type { FxPlan } from '../soundfx/nodes'; +import { type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler'; +import type { Sequence } from './sequence'; + +/** What a game asks to hear of a pattern: its notes, how fast, how long, and whether round and round. */ +export interface SequencePlay { + sequence: Sequence; + tempo: number; + /** In ticks: where it ends, and with `loop` where it starts again from 0. */ + length: number; + loop: boolean; + /** What its notes are made of, loaded before the first one is planned. */ + layers: readonly FxPlan[]; + /** Starts one note as the player was asked to, and gives back the number of its voice. */ + strike(note: ScheduledNote): number; +} + +export interface PlaybackHost extends Omit { + prepare(layers: readonly FxPlan[]): Promise; +} + +export interface Playback { + id: number; + /** What stands for the pattern, as a file stands for a sound. */ + name: string; + owner: string; + scheduler: Scheduler; + /** The last notes planned, newest last, for a check to hold against the clock. */ + planned: ScheduledNote[]; +} + +/** + * 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. + */ +export class Playbacks { + private list: Playback[] = []; + + constructor(private readonly host: PlaybackHost) {} + + /** 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 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 }; + this.list.push(playback); + void this.host.prepare(play.layers).then(() => { + if (this.list.includes(playback)) scheduler.start(0); + }); + } + + /** Whether that one is still to be heard: waiting on its files, playing, 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); + } + + stopId(id: number, letRing = false): void { + this.stopWhere((playback) => playback.id === id, letRing); + } + + /** 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); + } + + /** What is playing, oldest first (test hook). */ + current(): readonly Readonly[] { + this.list = this.list.filter((playback) => !playback.scheduler.done); + 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)); + } +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index c931ba3d..2752429a 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -65,6 +65,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E stopAll: () => soundLibrary.stopAll(), /** What Stop sound asks for: every voice of that file, its echoes cut or left to ring (#324). */ stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing), + /** The patterns a game plays, with the notes last planned of each. */ + patterns: () => soundLibrary.patterns(), /** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */ stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage), /** What drives the two shares of a Mix "Both at center", in any context (unstarted). */ diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index 2f335d04..3d00f054 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -1,4 +1,5 @@ import type { EaseShare } from '../../core/model/animation'; +import type { SequencePlay } from '../../core/music/playbacks'; import type { FxParams, FxPlan, FxVoice } from '../../core/soundfx/nodes'; /** @@ -34,6 +35,15 @@ export interface AudioSink { duration: number, 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. + */ + 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; /** * 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. @@ -59,7 +69,7 @@ export interface AudioSink { */ tail(voice: number): number; /** - * Silences every voice of that file; `''` silences all of them. A voice is faded out in a few + * Silences every voice of that file; `''` silences all of them, patterns too. A voice is faded out in a few * milliseconds, its echoes with it – unless `letRing`, which stops the sound and lets them fade. */ stop(file: string, letRing?: boolean): void; @@ -81,6 +91,8 @@ export interface NoteTiming { export const SILENT_AUDIO: AudioSink = { play: () => 0, playNote: () => 0, + playSequence: () => 0, + stopSequence: () => {}, release: () => {}, tune: () => {}, length: () => 0, diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 4ff40bd8..abd8df7c 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -19,6 +19,7 @@ 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 { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern'; import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes'; import type { Entity } from '../Entity'; import { PhysicsSystem } from '../physics/PhysicsSystem'; @@ -173,6 +174,10 @@ export function behaviorContext( playNote(run, instrumentId, { note, velocity, volume, duration }, detached ? '' : entity.id, params, { tempo, }), + playPattern: (patternId, volume, loop, detached) => + playPattern(run, patternId, volume, loop, detached ? '' : entity.id), + stopPattern: (patternId, letRing) => + run.audio.stopSequence(patternId && patternVoiceName(patternId), letRing), soundLength: (assetId) => { const file = soundFileOf(project, assetId); return file ? run.audio.length(file) : 0; @@ -289,6 +294,52 @@ export function instrumentVoiceName(instrumentId: string): string { return `instrument:${instrumentId}`; } +/** + * 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. Returns the number of the playing, 0 where there is no pattern or nothing to hear. + */ +function playPattern( + { project, audio }: Pick, + patternId: string, + volume: number, + loop: boolean, + owner: string, +): number { + const asset = findAssetOf(project, patternId, 'pattern'); + if (!asset) return 0; + 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; + 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 }, + ), + }, + owner, + ); +} + +export function patternVoiceName(patternId: string): string { + return `pattern:${patternId}`; +} + /** 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); diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 6746b55c..883697a7 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -17,10 +17,13 @@ import { } from '../core/soundfx/nodes'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; import type { EaseShare } from '../core/model/animation'; +import { Playbacks, type SequencePlay } from '../core/music/playbacks'; +import type { ScheduledNote } from '../core/music/Scheduler'; import { DEFAULT_TEMPO } from '../core/music/time'; import type { AudioSink, NoteTiming } from '../runtime/systems/Audio'; import type { PitchOptions } from './pitchWorklet'; import pitchWorklet from './pitchWorklet.ts?worker&url'; +import { wake } from './wake'; const DEFAULT_BASE = '/game-maker/data/sounds'; /** Seconds a changed parameter takes to get most of the way there: quick, but no click (#315). */ @@ -226,6 +229,17 @@ export class SoundLibrary implements AudioSink { /** The same counter per owner, for the same reason – a behavior destroyed while bytes were in * flight must not be given its voice afterwards. */ private readonly ownerStops = new Map(); + /** The patterns a game plays, each on a scheduler of its own. */ + private readonly sequences = new Playbacks({ + now: () => this.time(), + latency: () => this.latency(), + playing: (id) => this.playing(id), + stop: (voices, letRing) => { + for (const id of voices) this.stopVoice(id, letRing); + }, + wake, + prepare: (layers) => this.prepare(layers), + }); /** Where the files are served from. Set once at start-up, before anything plays. */ configure(base: string): void { @@ -266,6 +280,29 @@ export class SoundLibrary implements AudioSink { return this.begin({ id: 0, file: name, files, volume, loop: false, owner, fx, duration, at }); } + /** A sequence without notes plays nothing, and is given no number. */ + playSequence(name: string, play: SequencePlay, owner = ''): number { + if (play.sequence.notes.length === 0) return 0; + const id = this.nextId++; + this.sequences.start(id, name, play, owner); + return id; + } + + stopSequence(name: string, letRing = false): void { + this.sequences.stopNamed(name, letRing); + } + + /** The patterns playing, oldest first, with the notes last planned of each (test hook). */ + patterns(): { id: number; name: string; owner: string; playing: boolean; planned: ScheduledNote[] }[] { + return this.sequences.current().map(({ id, name, owner, scheduler, planned }) => ({ + id, + name, + owner, + playing: scheduler.playing, + planned: [...planned], + })); + } + /** * The end of a note (#331): the ways that end with it fade out, a looping one that does not leaves * its loop, the others play on, and the voice goes once they have all ended – or rings on first @@ -388,20 +425,26 @@ export class SoundLibrary implements AudioSink { return voice ? this.tailOf(voice.fx) : 0; } - /** Whether a voice is still heard – or waiting on its bytes to be. */ + /** Whether a voice is still heard – or waiting on its bytes to be. A pattern counts as one. */ playing(id: number): boolean { - return this.voices.some((voice) => voice.id === id) || this.pending.some((other) => other.id === id); + return ( + this.voices.some((voice) => voice.id === id) || + this.pending.some((other) => other.id === id) || + this.sequences.has(id) + ); } /** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */ stop(file: string, letRing = false): void { + if (file === '') this.sequences.stopNamed('', letRing); this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file), letRing); if (file === '') this.allStops += 1; else this.stops.set(file, (this.stops.get(file) ?? 0) + 1); } - /** One voice by its number, also one still waiting on its bytes. */ + /** One voice by its number, also one still waiting on its bytes, or a pattern. */ stopVoice(id: number, letRing = false): void { + this.sequences.stopId(id, letRing); const waiting = this.pending.find((other) => other.id === id); if (waiting) waiting.stopped = true; this.silence( @@ -416,6 +459,7 @@ export class SoundLibrary implements AudioSink { */ stopOwner(owner: string): void { if (owner === '') return; + this.sequences.stopOwner(owner); this.silence(this.voices.filter((voice) => voice.owner === owner)); this.ownerStops.set(owner, (this.ownerStops.get(owner) ?? 0) + 1); } diff --git a/src/viewport/music.ts b/src/viewport/music.ts index abf7264d..f29c75ae 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -1,23 +1,10 @@ -import { INTERVAL, type ScheduledNote, Scheduler, type SchedulerOptions } from '../core/music/Scheduler'; +import { type ScheduledNote, Scheduler, type SchedulerOptions } from '../core/music/Scheduler'; import type { Sequence } from '../core/music/sequence'; import { instrumentBuildFor, instrumentSourcesOf } from '../core/instrument/compile'; import { findAssetOf, type Project } from '../core/model/Project'; import { playNote } from '../runtime/systems/behaviorContext'; -import TimerWorker from './musicTimer.ts?worker'; import { soundLibrary } from './SoundLibrary'; - -/** Calls `pump` every `INTERVAL` from a worker; where there are no workers, from the page. */ -function wake(pump: () => void): () => void { - const ms = INTERVAL * 1000; - if (typeof Worker === 'undefined') { - const timer = setInterval(pump, ms); - return () => clearInterval(timer); - } - const worker = new TimerWorker(); - worker.onmessage = pump; - worker.postMessage(ms); - return () => worker.terminate(); -} +import { wake } from './wake'; /** One planned note as the player was given it, and where the audio clock stood then (test hook). */ export interface PlannedNote extends ScheduledNote { diff --git a/src/viewport/wake.ts b/src/viewport/wake.ts new file mode 100644 index 00000000..3b85f565 --- /dev/null +++ b/src/viewport/wake.ts @@ -0,0 +1,15 @@ +import { INTERVAL } from '../core/music/Scheduler'; +import TimerWorker from './musicTimer.ts?worker'; + +/** Calls `pump` every `INTERVAL` from a worker; where there are no workers, from the page. */ +export function wake(pump: () => void): () => void { + const ms = INTERVAL * 1000; + if (typeof Worker === 'undefined') { + const timer = setInterval(pump, ms); + return () => clearInterval(timer); + } + const worker = new TimerWorker(); + worker.onmessage = pump; + worker.postMessage(ms); + return () => worker.terminate(); +} diff --git a/tests/core/keep-on-scene-load.test.ts b/tests/core/keep-on-scene-load.test.ts index cf85025f..6a992e4e 100644 --- a/tests/core/keep-on-scene-load.test.ts +++ b/tests/core/keep-on-scene-load.test.ts @@ -56,6 +56,8 @@ function start(f: Fixture, audio?: AudioSink) { const sink: AudioSink = audio ?? { play: () => 0, playNote: () => 0, + playSequence: () => 0, + stopSequence: () => {}, release: () => {}, tune: () => {}, length: () => 0, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 1d70ce24..cdde22c8 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2084,6 +2084,55 @@ const CASES: Record = { }, { name: 'with none picked the bundle is empty', outputs: { params: {} } }, ], + PlayPattern: [ + { + name: 'no pattern is a no-op that still continues the flow', + inputs: { pattern: '' }, + next: 'exec', + outputs: { playback: 0 }, + after: (ctx) => expect(ctx.patterns).toEqual([]), + }, + { + name: 'hands the pattern on with its volume, looping and owned unless asked otherwise', + inputs: { pattern: 'pat-1', volume: 0.5, loop: true }, + next: 'exec', + outputs: { playback: 1 }, + after: (ctx) => + expect(ctx.patterns).toEqual([{ patternId: 'pat-1', volume: 0.5, loop: true, detached: false }]), + }, + { + name: 'plays once and detached when asked to', + inputs: { pattern: 'pat-1', loop: false, detached: true }, + next: 'exec', + after: (ctx) => expect(ctx.patterns[0]).toMatchObject({ loop: false, detached: true }), + }, + ], + StopPattern: [ + { + name: 'stops the pattern it names, letting its notes ring when asked to', + inputs: { pattern: 'pat-1', letRing: true }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual(['pattern pat-1 (let ring)']), + }, + { + name: 'naming none stops every pattern', + inputs: { pattern: '' }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual(['pattern ']), + }, + { + name: 'with a playback connected stops only that one, whatever pattern is named', + inputs: { pattern: 'pat-1', playback: 4 }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual(['voice 4']), + }, + { + name: 'a connected playback of 0 – a Play pattern that played nothing – stops nothing', + inputs: { pattern: 'pat-1', playback: 0 }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual([]), + }, + ], SoundFxParameters: [ { name: 'bundles its picked parameters as numbers', diff --git a/tests/core/pattern-play.test.ts b/tests/core/pattern-play.test.ts new file mode 100644 index 00000000..c7b78d41 --- /dev/null +++ b/tests/core/pattern-play.test.ts @@ -0,0 +1,256 @@ +/** + * A game plays a pattern through Play pattern and ends it through Stop pattern: each playing on a + * scheduler of its own, numbered, owned and stopped like a voice, and gone by itself once played + * to its end. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateSoundCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import type { InstrumentAsset } from '../../src/core/model/Asset'; +import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; +import { INTERVAL, LEAD, type ScheduledNote } from '../../src/core/music/Scheduler'; +import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; +import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; + +const FILE = `${'a1'.repeat(32)}.wav`; +const Q = 480; + +/** An audio clock moved by hand, every voice sounding until it is stopped or let go. */ +function fakeHost() { + const host = { + time: 0, + sounding: new Set(), + stopped: [] as { voices: number[]; letRing: boolean }[], + pumps: new Set<() => void>(), + loading: [] as (() => void)[], + }; + const playbacks = new Playbacks({ + now: () => host.time, + latency: () => 0, + playing: (voice) => host.sounding.has(voice), + stop: (voices, letRing = false) => { + host.stopped.push({ voices: [...voices], letRing }); + for (const voice of voices) host.sounding.delete(voice); + }, + wake: (pump) => { + host.pumps.add(pump); + return () => host.pumps.delete(pump); + }, + prepare: () => new Promise((resolve) => host.loading.push(resolve)), + }); + const run = (seconds: number) => { + const end = host.time + seconds; + while (host.time + INTERVAL <= end + 1e-9) { + host.time += INTERVAL; + for (const pump of [...host.pumps]) pump(); + } + }; + const loaded = async () => { + for (const resolve of host.loading.splice(0)) resolve(); + await Promise.resolve(); + }; + return { host, playbacks, run, loaded }; +} + +/** A bar of four quarters at 120 BPM: two seconds long. Its voices sound in `sounding` until stopped. */ +function bar(struck: ScheduledNote[], loop: boolean, sounding = new Set()): SequencePlay { + let next = 100; + return { + sequence: { + notes: [0, 1, 2, 3].map((i) => ({ + tick: i * Q, + length: Q / 2, + note: 60 + i, + velocity: 1, + instrument: 'piano', + })), + }, + tempo: 120, + length: 4 * Q, + loop, + layers: [], + strike: (note) => { + struck.push(note); + sounding.add(next); + return next++; + }, + }; +} + +describe('the patterns a game plays', () => { + it('waits for its files, then plays the whole pattern once and is gone when its last note is', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + const struck: ScheduledNote[] = []; + playbacks.start(7, 'pattern:a', bar(struck, false, host.sounding), 'logic'); + run(0.5); + expect(struck).toEqual([]); + await loaded(); + run(3); + expect(struck.map((n) => n.note)).toEqual([60, 61, 62, 63]); + struck.forEach((n, i) => expect(n.at - struck[0]!.at).toBeCloseTo(i * 0.5, 9)); + expect(host.pumps.size).toBe(0); + expect(playbacks.has(7)).toBe(true); + host.sounding.clear(); + expect(playbacks.has(7)).toBe(false); + }); + + it('with loop runs round and round, 2 s to the round, until it is stopped', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + const struck: ScheduledNote[] = []; + playbacks.start(7, 'pattern:a', bar(struck, true, host.sounding), ''); + await loaded(); + run(5); + expect(struck.map((n) => n.note).slice(0, 10)).toEqual([60, 61, 62, 63, 60, 61, 62, 63, 60, 61]); + expect(struck[4]!.at - struck[0]!.at).toBeCloseTo(2, 9); + expect(struck[0]!.at).toBeCloseTo(LEAD, 9); + playbacks.stopId(7, true); + expect(host.stopped.at(-1)?.letRing).toBe(true); + expect([playbacks.has(7), host.pumps.size]).toEqual([false, 0]); + }); + + it('never starts when it was stopped while its files were loading', async () => { + const { host, playbacks, loaded, run } = fakeHost(); + const struck: ScheduledNote[] = []; + playbacks.start(7, 'pattern:a', bar(struck, true), 'logic'); + playbacks.stopOwner('logic'); + await loaded(); + run(1); + expect([struck.length, host.pumps.size, playbacks.has(7)]).toEqual([0, 0, false]); + }); + + it('stops by name, every one for no name, by owner – and an owner of nobody stops nothing', async () => { + const { playbacks, loaded } = fakeHost(); + playbacks.start(1, 'pattern:a', bar([], true), 'x'); + playbacks.start(2, 'pattern:a', bar([], true), ''); + playbacks.start(3, 'pattern:b', bar([], true), 'y'); + playbacks.start(4, 'pattern:c', bar([], true), ''); + await loaded(); + playbacks.stopOwner(''); + expect(playbacks.current().map((p) => p.id)).toEqual([1, 2, 3, 4]); + playbacks.stopNamed('pattern:a'); + expect(playbacks.current().map((p) => p.id)).toEqual([3, 4]); + playbacks.stopOwner('y'); + expect(playbacks.current().map((p) => p.id)).toEqual([4]); + playbacks.stopNamed(''); + expect(playbacks.current()).toEqual([]); + }); +}); + +/** A scene whose behavior plays a pattern of C4 and E4 on start, through an instrument of one sample. */ +function playingPattern(params: Record = {}, instrument = true) { + const f = makeSpriteScene('Box'); + const sound = new CreateSoundCommand('Blip', FILE); + f.stack.execute(sound); + const ins = new CreateAssetCommand('instrument', 'Lead'); + f.stack.execute(ins); + const lead = ins.asset as InstrumentAsset; + lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id; + const pattern = new CreateAssetCommand('pattern', 'Tune'); + f.stack.execute(pattern); + const id = pattern.asset.id; + if (instrument) f.stack.execute(new SetAssetPropertyCommand(id, 'instrument', lead.id)); + f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90)); + f.stack.execute(new SetAssetPropertyCommand(id, 'bars', 2)); + f.stack.execute( + new SetPatternNotesCommand(id, [ + { id: 'n1', tick: 0, length: Q, note: 60, velocity: 0.5 }, + { id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 }, + ]), + ); + const behavior = makeBehavior(f, 'Logic'); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const play = edit.addNode('PlayPattern', { pattern: id, volume: 0.5, ...params }); + edit.connect(start, 'exec', play, 'exec'); + return { ...f, patternId: id, lead, behavior, edit, play }; +} + +function recordingAudio() { + const sink = { + ...SILENT_AUDIO, + sequences: [] as { name: string; play: SequencePlay; owner: string }[], + notes: [] as { + name: string; + volume: number; + owner: string; + duration: number; + at?: number; + tempo?: number; + }[], + stoppedSequences: [] as string[], + stoppedVoices: [] as number[], + playSequence: (name: string, play: SequencePlay, owner: string) => { + sink.sequences.push({ name, play, owner }); + return 1000 + sink.sequences.length; + }, + playNote: ((name, _layers, _params, volume, owner, duration, timing = {}) => { + sink.notes.push({ name, volume, owner, duration, ...timing }); + return 1; + }) satisfies AudioSink['playNote'], + stopSequence: (name: string, letRing = false) => + sink.stoppedSequences.push(`${name}${letRing ? ' (let ring)' : ''}`), + stopVoice: (voice: number) => sink.stoppedVoices.push(voice), + }; + return sink; +} + +describe('Play pattern', () => { + it('hands the whole pattern to the player at its tempo, owned by the behavior, looping by default', () => { + const f = playingPattern(); + const audio = recordingAudio(); + const { world, frame } = startWorld(f, { behaviors: true, audio }); + frame(); + const logic = world.entities.find((e) => e.name === 'Logic')!; + const [asked] = audio.sequences; + expect(asked?.name).toBe(`pattern:${f.patternId}`); + expect(asked?.owner).toBe(logic.id); + expect(asked?.play).toMatchObject({ tempo: 90, length: 2 * 4 * Q, loop: true }); + expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note, n.instrument])).toEqual([ + [0, 60, f.lead.id], + [Q, 64, f.lead.id], + ]); + expect(asked?.play.layers.length).toBe(2); + }); + + it('strikes each note through the instrument at the node’s volume, owned like the playing', () => { + const f = playingPattern({ detached: true, loop: false }); + const audio = recordingAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + const [asked] = audio.sequences; + expect([asked?.owner, asked?.play.loop]).toEqual(['', false]); + asked!.play.strike({ at: 3, duration: 0.25, note: 60, velocity: 0.5, instrument: f.lead.id, tempo: 90 }); + expect(audio.notes).toEqual([ + { name: `instrument:${f.lead.id}`, volume: 0.25, owner: '', duration: 0.25, at: 3, tempo: 90 }, + ]); + }); + + it('plays nothing and hands out 0 for a pattern without an instrument', () => { + const f = playingPattern({}, false); + const audio = recordingAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + expect(audio.sequences).toEqual([]); + }); +}); + +describe('Stop pattern', () => { + it('stops one pattern by its name, every pattern for none, and one playing by its number', () => { + const f = playingPattern(); + const one = f.edit.addNode('StopPattern', { pattern: f.patternId, letRing: true }); + const all = f.edit.addNode('StopPattern', { pattern: '' }); + const byNumber = f.edit.addNode('StopPattern', { pattern: f.patternId }); + f.edit.connect(f.play, 'exec', one, 'exec'); + f.edit.connect(one, 'exec', all, 'exec'); + f.edit.connect(all, 'exec', byNumber, 'exec'); + f.edit.connect(f.play, 'playback', byNumber, 'playback'); + const audio = recordingAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + expect(audio.stoppedSequences).toEqual([`pattern:${f.patternId} (let ring)`, '']); + expect(audio.stoppedVoices).toEqual([1001]); + }); +}); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 03f93157..8806af5a 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -407,6 +407,8 @@ function fakeAudio(): AudioSink & { sink.times.push(time); }, playNote: () => 0, + playSequence: () => 0, + stopSequence: () => {}, release: () => {}, length: () => 0.25, stop: () => {}, diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index 9633c865..68621ecc 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -43,6 +43,8 @@ function fakeAudio(): AudioSink & { play: (file: string, volume: number, loop: boolean, owner: string) => sink.played.push({ file, volume, loop, owner }), playNote: () => 0, + playSequence: () => 0, + stopSequence: () => {}, release: () => {}, tune: () => {}, length: () => 0, diff --git a/tests/helpers.ts b/tests/helpers.ts index 54c1dc1d..955f5032 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -226,6 +226,8 @@ export type FakeContext = NodeContext & { instrumentParams: Readonly>; tempo: number; }[]; + /** The patterns the context was asked to play; a playing's number is how many have been played. */ + patterns: { patternId: string; volume: number; loop: boolean; detached: boolean }[]; silenced: string[]; tuned: { voice: number; fxParams: Readonly>; time: number; ease: EaseShare }[]; /** The scenes the context was asked to load, in order. */ @@ -285,6 +287,11 @@ export function fakeContext(overrides: Partial = {}): FakeContext { notes: [], playNote: (instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo) => ctx.notes.push({ instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo }), + patterns: [], + playPattern: (patternId, volume, loop, detached) => + ctx.patterns.push({ patternId, volume, loop, detached }), + stopPattern: (patternId, letRing) => + ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`), // Every sound lasts a quarter of a second, as if it had been loaded. soundLength: () => 0.25, // The tail of a voice is its number in tenths of a second, so a test can tell one from another. diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index b20c6cbc..984e7108 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -1,5 +1,5 @@ import { edge, node } from '../lib/builders.mjs'; -import { pickAssetType } from './lib/scaffold.mjs'; +import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; /** * Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its @@ -8,7 +8,7 @@ import { pickAssetType } from './lib/scaffold.mjs'; * them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the * start set on the ruler, the lane sets a velocity, a drag on the ruler sets the loop. Space plays the * pattern round its loop through the scheduler, and a note drawn meanwhile is planned without a - * restart. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and + * restart. A game plays it through Play pattern, looped or once. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and * the sound counters, as in e2e 34. * * Everything it adds carries a `pt64` id or is the pattern it made, and goes again at the end. @@ -18,6 +18,7 @@ const INSTRUMENT = 'pt64-ins'; export async function pattern(t) { const { check, sleep, json, evaluate, mutate, click, clickText, press, drag, wheel, shot, showTab } = t; + const { play, stop } = t; const stats = () => json('__gm.sound.stats()'); const waitFor = async (read, done, tries = 25) => { let value = await read(); @@ -335,7 +336,79 @@ export async function pattern(t) { JSON.stringify({ held, heldKey }), ); - // 64l. The step takes back everything it made. + // 64l. A game plays the pattern through Play pattern: a bar of quarters at 150 BPM, its notes at + // their ticks on the audio clock and round and round, owned by the behavior. Ending the run + // ends it; played once, it goes by itself after its bar. + await mutate(` + const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + p.props.tempo = 150; + p.props.bars = 1; + p.props.beats = 4; + p.props.beat = '4'; + p.notes = [60, 62, 64, 65].map((note, i) => ({ id: 'pt64-n' + i, tick: i * 480, length: 240, note, velocity: 1 }));`); + await showTab('scene'); + const rig = await scaffoldBehavior(t, { + assetId: 'pt64-logic', + name: 'Music 64', + boxId: 'pt64-box', + logicId: 'pt64-brain', + y: LANES.sound, + graph: { + nodes: [ + node('pt64-start', 'OnStart', 0, 0), + node('pt64-play', 'PlayPattern', 260, 0, { pattern: id, volume: 1, loop: true }), + ], + edges: [edge('pt64-e3', 'pt64-start', 'exec', 'pt64-play', 'exec')], + }, + }); + const patterns = () => json('__gm.sound.patterns()'); + await evaluate('__gm.sound.stopAll()'); + await play(); + const game = await waitFor(patterns, (list) => list[0]?.planned.length >= 6, 40); + const round = game[0]?.planned ?? []; + const gaps = round.slice(1, 6).map((n, i) => +(n.at - round[i].at).toFixed(6)); + check( + 'Play pattern plays it in the game round and round, its quarters 0.4 s apart at 150 BPM, owned by the behavior', + game.length === 1 && + game[0].name === `pattern:${id}` && + game[0].owner !== '' && + game[0].playing && + round + .slice(0, 6) + .map((n) => n.note) + .join() === '60,62,64,65,60,62' && + round.every( + (n) => n.instrument === INSTRUMENT && n.tempo === 150 && Math.abs(n.duration - 0.2) < 1e-9, + ) && + gaps.every((gap) => Math.abs(gap - 0.4) < 1e-6), + JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gaps }), + ); + await stop(); + const ended = await waitFor( + async () => ({ patterns: await patterns(), stats: await stats() }), + (s) => s.patterns.length === 0 && s.stats.playing === 0, + ); + check( + 'stopping the game ends the pattern and its notes', + ended.patterns.length === 0 && ended.stats.playing === 0, + JSON.stringify(ended), + ); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === 'pt64-logic').graph; + graph.nodes.find((n) => n.id === 'pt64-play').params.loop = false;`); + await play(); + const once = await waitFor(patterns, (list) => list[0]?.planned.length >= 1, 40); + const gone = await waitFor(patterns, (list) => list.length === 0, 40); + const onceStats = await stats(); + await stop(); + check( + 'played once, it plays its bar and goes by itself while the game runs on', + once.length === 1 && gone.length === 0 && once[0].name === `pattern:${id}` && onceStats.playing === 0, + JSON.stringify({ once: once.map((g) => g.planned.length), gone, onceStats }), + ); + const left = await rig.remove(); + + // 64m. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); await mutate(` const gone = new Set(['pt64-snd', ${JSON.stringify(INSTRUMENT)}, ${JSON.stringify(id)}]); @@ -347,7 +420,9 @@ export async function pattern(t) { 'the step leaves nothing of its own behind', (await json( `__gm.state.project.assets.filter((a) => a.id.startsWith('pt64') || a.type === 'pattern').length`, - )) === 0, - '', + )) === 0 && + left.objects === 0 && + left.assets === 0, + JSON.stringify(left), ); }