diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index b480ddeb..656d6d57 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -408,6 +408,11 @@ export interface NodeContext { * the one started last of `song` or `pattern`, of every one where both are `''`; null for none. */ songPosition(playback: number | null, song: string, pattern: string): HeardPosition | null; + /** + * Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its files, + * playing, fading or ringing on; false for 0. + */ + playing(id: number): boolean; /** * Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be * able to finish the flow it is in, and half a frame in the old world and half in the new one is diff --git a/src/core/graph/nodes/data/MusicPlaying.ts b/src/core/graph/nodes/data/MusicPlaying.ts new file mode 100644 index 00000000..4d62a27e --- /dev/null +++ b/src/core/graph/nodes/data/MusicPlaying.ts @@ -0,0 +1,33 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber } from '../../values'; +import { PLAYBACK_INPUT, playbackPort } from '../events/musicEvents'; +import { bool, num } from '../shared'; + +export const MusicPlaying: NodeDef = { + type: 'MusicPlaying', + label: 'Music playing', + category: 'data', + description: + 'Whether the song or pattern numbered "playback" is still to be heard: from Play song or Play ' + + 'pattern on, while its files load, while it plays and fades, until its last echoes are gone – ' + + 'the moment On music finished runs. Unlike "playing" of Song position it is on before the first ' + + 'note is heard. Off for 0 and for a number nothing plays under.', + inputs: [playbackPort()], + outputs: [bool('playing')], + params: [], + read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input(PLAYBACK_INPUT))) }), +}; + +export const SoundPlaying: NodeDef = { + type: 'SoundPlaying', + label: 'Sound playing', + category: 'data', + description: + 'Whether the voice numbered "voice", the number Play sound handed out, is still to be heard: ' + + 'while its file loads, while it plays, until the echoes of its FX are gone – the moment On sound ' + + 'finished runs. Off for 0 and for a voice that has ended.', + inputs: [{ ...num('voice'), inline: false }], + outputs: [bool('playing')], + params: [], + read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input('voice'))) }), +}; diff --git a/src/core/graph/nodes/events/OnSoundFinished.ts b/src/core/graph/nodes/events/OnSoundFinished.ts new file mode 100644 index 00000000..b66b0d0c --- /dev/null +++ b/src/core/graph/nodes/events/OnSoundFinished.ts @@ -0,0 +1,25 @@ +import { textOf } from '../../../model/Properties'; +import type { NodeDef } from '../../NodeRegistry'; +import { hiddenOrder, orderParam } from '../../eventOrder'; +import { bool, exec, num } from '../shared'; + +/** The input that picks one play (#373): not typed in, so unconnected it is null and the Sound decides. */ +export const VOICE_INPUT = 'voice'; + +export const OnSoundFinished: NodeDef = { + type: 'OnSoundFinished', + label: 'On sound finished', + category: 'event', + description: + 'Runs once when a sound a Play sound of this game started is over: once nothing of it is heard ' + + 'any more, the echoes of its FX included. "stopped" is on where it did not play to its end but ' + + 'was stopped – by Stop sound, by the end of the behavior that owns it, or to make room for ' + + 'newer voices – and where it never started because its file would not load. A looping sound ' + + 'ends only so. Pick a Sound to hear only that one, or connect "voice" to hear only that play, ' + + 'the number Play sound handed out. A note of an instrument is none of them. It comes in the ' + + 'first frame after the end, so up to a frame late.', + inputs: [{ ...num(VOICE_INPUT), inline: false }], + outputs: [exec(), num('voice'), bool('stopped')], + params: [{ key: 'sound', label: 'Sound', kind: 'sound', default: '' }, orderParam], + hiddenParams: (params) => [...(textOf(params.sound ?? '') ? [] : ['sound']), ...hiddenOrder(params)], +}; diff --git a/src/core/graph/nodes/events/musicEvents.ts b/src/core/graph/nodes/events/musicEvents.ts index 1ff00a71..6a09f603 100644 --- a/src/core/graph/nodes/events/musicEvents.ts +++ b/src/core/graph/nodes/events/musicEvents.ts @@ -2,7 +2,7 @@ import { textOf } from '../../../model/Properties'; import type { PropertyDef } from '../../../properties/PropertySchema'; import type { NodeDef, PortDef } from '../../NodeRegistry'; import { hiddenOrder, orderParam } from '../../eventOrder'; -import { exec, num, str } from '../shared'; +import { bool, exec, num, str } from '../shared'; /** The input that picks one playing (#338): not typed in, so unconnected it is null and hears every one. */ export const PLAYBACK_INPUT = 'playback'; @@ -76,6 +76,24 @@ export const OnBar: NodeDef = { hiddenParams: hiddenEmpty('song', 'pattern'), }; +export const OnMusicFinished: NodeDef = { + type: 'OnMusicFinished', + label: 'On music finished', + category: 'event', + description: + 'Runs once when a song or pattern a Play song or Play pattern of this game started is over: once ' + + 'nothing of it is heard any more, its last echoes and a fade out included. "stopped" is on where ' + + 'it did not play to its end but was stopped – by Stop song, Stop pattern or Stop sound, or by ' + + 'the end of the behavior that owns it. One with a loop ends only so. After a Switch song it ' + + 'counts as the song heard last. Pick a Song or a Pattern to hear only that one (both picked, ' + + 'either), or connect "playback" to hear only that playing. It comes in the first frame after ' + + 'the end, so up to a frame late.', + inputs: [playbackPort()], + outputs: [exec(), num('playback'), bool('stopped')], + params: [songParam, patternParam, orderParam], + hiddenParams: hiddenEmpty('song', 'pattern'), +}; + export const OnSongMarker: NodeDef = { type: 'OnSongMarker', label: 'On song marker', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 91def37a..4357923f 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -128,7 +128,9 @@ import { TextureSize } from './data/TextureSize'; import { OnAnimationFinished } from './events/OnAnimationFinished'; import { OnCompositionFinished } from './events/OnCompositionFinished'; import { OnVideoFinished } from './events/OnVideoFinished'; -import { OnBar, OnBeat, OnSongMarker } from './events/musicEvents'; +import { OnBar, OnBeat, OnMusicFinished, OnSongMarker } from './events/musicEvents'; +import { OnSoundFinished } from './events/OnSoundFinished'; +import { MusicPlaying, SoundPlaying } from './data/MusicPlaying'; import { SongPosition } from './data/SongPosition'; import { VideoTime } from './data/VideoTime'; import { OnBehavior } from './events/OnBehavior'; @@ -196,6 +198,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ OnBeat, OnBar, OnSongMarker, + OnMusicFinished, + OnSoundFinished, OnBehavior, OnStateEntered, OnStateExited, @@ -341,6 +345,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ ReleaseSongLane, SwitchSong, SongPosition, + MusicPlaying, + SoundPlaying, VideoTime, SetBusVolume, BusVolume, diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index beb72fe7..48d33c55 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -169,9 +169,16 @@ export class Scheduler { return this.anchor; } - /** Played to its end, and none of its notes is heard any more. */ + /** + * Played to its end, and none of its notes is heard any more. Planned to its end is not yet played + * to it: its notes are planned ahead, and a last bar may end in silence (#373). + */ get done(): boolean { - return this.ended && this.voices.every((voice) => !this.host.playing(voice)); + return ( + this.ended && + this.headAt(this.host.now()) >= this.end && + this.voices.every((voice) => !this.host.playing(voice)) + ); } /** diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index da1c4aa2..e8269400 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -140,6 +140,11 @@ export interface PlaybackHost extends Omit< after(at: number, then: () => void): void; /** A playing is gone: stopped, or played to its end. */ closed?(key: number): void; + /** + * Nothing of the playings numbered `id` is left (#373): played to their end, or `stopped`. `name` + * is that of the one heard last, a Switch song's in place of the one it took over from. + */ + ended?(id: number, name: string, stopped: boolean): void; /** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */ playBaked?(key: number, bake: BakePlay, at: number): number; } @@ -167,6 +172,8 @@ export interface Playback { baked: boolean; /** How fast, round what and to where it plays, and from where it started (#338). */ time: PlayingTime; + /** A stop reached it (#373), rather than it playing to its end. */ + stopped: boolean; } /** The time of a playing (#338): its tempo, where it started, its loop and its length, in ticks. */ @@ -393,6 +400,7 @@ export class Playbacks { until: null, successor, time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length }, + stopped: false, }; this.list.push(playback); return playback; @@ -447,6 +455,7 @@ export class Playbacks { private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void { const stopping = this.list.filter(going); + for (const playback of stopping) playback.stopped = true; if (!(fade > 0)) return this.close(stopping, letRing); const now = this.host.now(); const end = now + fade; @@ -464,10 +473,27 @@ export class Playbacks { private close(going: readonly Playback[], letRing: boolean): void { if (going.length === 0) return; this.list = this.list.filter((playback) => !going.includes(playback)); + const ended = this.endedOf(going); for (const playback of going) { playback.runner.stop(letRing); this.host.closed?.(playback.key); } + for (const { id, name, stopped } of ended) this.host.ended?.(id, name, stopped); + } + + /** + * The numbers nothing is left of once `going` goes (#373). Of a switch (#338) the one taken over + * from goes first and the number lives on in the other; where both go, it is named by the newer + * one if that had started. + */ + private endedOf(going: readonly Playback[]): { id: number; name: string; stopped: boolean }[] { + return [...new Set(going.map((playback) => playback.id))].flatMap((id) => { + if (this.list.some((playback) => playback.id === id)) return []; + const ones = going.filter((playback) => playback.id === id); + const started = ones.filter((playback) => !playback.successor || playback.runner.startedAt !== null); + const last = started[started.length - 1] ?? ones[0]!; + return [{ id, name: last.name, stopped: ones.some((playback) => playback.stopped) }]; + }); } } diff --git a/src/runtime/GameRun.ts b/src/runtime/GameRun.ts index 94201618..ec5045bb 100644 --- a/src/runtime/GameRun.ts +++ b/src/runtime/GameRun.ts @@ -18,6 +18,7 @@ import { import type { Input } from './systems/Input'; import { bringObjects, keptEntities, objectsToBring } from './keptObjects'; import type { Rapier } from './physics/engine'; +import { PlayedEnds } from './systems/playedEnds'; import { World } from './World'; export interface RunServices { @@ -49,6 +50,8 @@ export class GameRun { private readonly services: RunServices, public world: World, public behaviors: BehaviorSystem, + /** What the run played, heard ending in whatever scene it is in by then (#373). */ + private readonly ends: PlayedEnds, ) {} static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun { @@ -58,7 +61,9 @@ export class GameRun { services.audio.resetMix(); services.audio.setMix(mixOf(project), videoBusesOf(project)); const world = World.instantiate(project, sceneId); - const run = new GameRun(project, input, services, world, addGameSystems(world, project, input, services)); + const ends = new PlayedEnds(); + const behaviors = addGameSystems(world, project, input, { ...services, ends }); + const run = new GameRun(project, input, services, world, behaviors, ends); if (before) run.stored.set(sceneId, before); return run; } @@ -106,6 +111,7 @@ export class GameRun { ...this.services, carried, kept, + ends: this.ends, }); } diff --git a/src/runtime/audioSwitch.ts b/src/runtime/audioSwitch.ts index c205d4f1..03cf6ed4 100644 --- a/src/runtime/audioSwitch.ts +++ b/src/runtime/audioSwitch.ts @@ -3,7 +3,7 @@ import type { AutomationTarget } from '../core/music/automation'; import type { MixBus } from '../core/music/mixer'; import type { HeardTime, LandingAt, SequencePlay } from '../core/music/playbacks'; import type { FxParams, FxPlan, FxVoice } from '../core/soundfx/nodes'; -import type { AudioSink, ClipTiming, NoteTiming } from './systems/Audio'; +import type { AudioSink, ClipTiming, EndedPlay, NoteTiming } from './systems/Audio'; /** The first number a sink diverted to gives out: whatever it numbers is told apart from the live one's. */ export const DIVERTED_IDS = 1_000_000_000; @@ -143,6 +143,15 @@ export class AudioSwitch implements AudioSink { return this.now.clock?.() ?? 0; } + /** What ended in either sink (#373): their numbers are told apart. */ + endings(): EndedPlay[] { + return this.both.flatMap((sink) => sink.endings?.() ?? []); + } + + playing(id: number): boolean { + return this.of(id)?.playing?.(id) ?? false; + } + /** Both sinks' playings: each counts its own on its own clock. */ playingTimes(): HeardTime[] { return this.both.flatMap((sink) => sink.playingTimes?.() ?? []); diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index e0fcb840..c854a0f5 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -118,6 +118,17 @@ export interface AudioSink { * picture by. A sink without it keeps no time, and nothing is held to it. */ clock?(): number; + /** + * The voices of `play` and the playings of `playSequence` that ended lately, oldest first (#373): + * once nothing of them is heard any more, after their echoes and a fade out. The last + * `ENDED_KEPT` are kept, so one followed every frame misses none. A sink without it tells of none. + */ + endings?(): readonly EndedPlay[]; + /** + * Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its bytes, + * playing, fading, or its echoes ringing. A sink without it plays nothing. + */ + playing?(id: number): boolean; /** * The patterns and songs playing, each with how far into its playing time it is heard now (#338): * what beats, bars and `Song position` go by. A sink without it plays none to be followed. @@ -183,6 +194,19 @@ export interface NoteTiming { note?: number; } +/** + * A voice or a playing that ended (#373), by its number: `name` is its file, or what stands for the + * pattern or song. `stopped` where a stop ended it rather than it playing to its end. + */ +export interface EndedPlay { + id: number; + name: string; + stopped: boolean; +} + +/** So many ends a sink keeps to be told (see `AudioSink.endings`). */ +export const ENDED_KEPT = 256; + /** When and how a clip of a song plays (#334); see `playClip`. */ export interface ClipTiming { at: number; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index efcb9eed..7a28afed 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -25,6 +25,7 @@ import { } from '../../core/graph'; import { GraphCache } from '../../core/graph/graphIndex'; import { PLAYBACK_INPUT } from '../../core/graph/nodes/events/musicEvents'; +import { VOICE_INPUT } from '../../core/graph/nodes/events/OnSoundFinished'; import { PER_CELL_INPUT } from '../../core/graph/nodes/shared'; import { keyName } from '../../core/keys'; import { graphLookup } from '../../core/model/graphLookup'; @@ -63,6 +64,7 @@ import { MusicFollower, type PlaybackOf, } from './musicPulses'; +import { musicFinishedPulses, PlayedEnds, soundFinishedPulses } from './playedEnds'; import type { CollisionSource } from './solids'; import { AnimationSystem } from './AnimationSystem'; import { CompositionSystem, type VideoPictures } from './CompositionSystem'; @@ -139,6 +141,8 @@ export interface GameSystemOptions { pictures?: VideoPictures | null; /** Where a `Break` node pauses: only the editor's Play has one. */ breakpoint?: Breakpoint | null; + /** What the run played, kept through its scenes (#373); a world without one keeps its own. */ + ends?: PlayedEnds | null; } /** Collisions, compositions, animations and videos are ticked first, so behaviors see this frame's events. */ @@ -159,13 +163,25 @@ export function addGameSystems( steersVideos = true, pictures = null, breakpoint = null, + ends = null, }: GameSystemOptions = {}, ): BehaviorSystem { const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem(); const compositions = new CompositionSystem(playsCompositions, pictures, audio); const animations = new AnimationSystem(); const videos = new VideoSystem(steersVideos); - const sources = { collisions, animations, compositions, videos, audio, clock, saves, recorder, breakpoint }; + const sources = { + collisions, + animations, + compositions, + videos, + audio, + clock, + saves, + recorder, + breakpoint, + ends, + }; const behaviors = new BehaviorSystem(project, input, sources, shouldRun); if (carried) behaviors.carryOver(carried); if (kept) { @@ -192,6 +208,8 @@ export interface EventSources { saves?: SaveArea | null; recorder?: RecordingSink | null; breakpoint?: Breakpoint | null; + /** What the run played, to hear of its end (#373). */ + ends?: PlayedEnds | null; } /** A behavior's `runWhilePaused` switch: whether it is ticked while Pause game holds the world. */ @@ -239,6 +257,7 @@ export class BehaviorSystem implements System { private readonly saves: SaveArea | null; private readonly recorder: RecordingSink | null; private readonly breakpoint: Breakpoint | null; + private readonly ends: PlayedEnds; /** What was last written to `saves`, so a frame that changed nothing writes nothing. */ private lastSaved = ''; /** Where each playing of a song or pattern was heard last frame (#338). */ @@ -263,6 +282,7 @@ export class BehaviorSystem implements System { this.saves = sources.saves ?? null; this.recorder = sources.recorder ?? null; this.breakpoint = sources.breakpoint ?? null; + this.ends = sources.ends ?? new PlayedEnds(); } /** The values `global` declarations share this run – read-only to callers, rebuilt by `start`. */ @@ -660,6 +680,8 @@ export class BehaviorSystem implements System { // Every frame, paused or not: the music goes on, and what a paused one passed is not told after. const passed = this.music.follow(heardPlayings(this.project, this.audio)); const playbackOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, PLAYBACK_INPUT, contextOf(i)); + const voiceOf: PlaybackOf = (i, node) => i.interpreter.eventInput(node, VOICE_INPUT, contextOf(i)); + const finished = this.ends.follow(this.audio.endings?.() ?? []); const kinds: (() => Pulse[])[] = [ () => ticking.flatMap((i) => @@ -678,6 +700,8 @@ export class BehaviorSystem implements System { () => ticking.flatMap((i) => beatPulses(i, passed, playbackOf)), () => ticking.flatMap((i) => barPulses(i, passed, playbackOf)), () => ticking.flatMap((i) => markerPulses(i, passed, playbackOf)), + () => ticking.flatMap((i) => musicFinishedPulses(i, finished, playbackOf)), + () => ticking.flatMap((i) => soundFinishedPulses(i, finished, voiceOf)), () => pointer.pressed ? pulsesOf(ticking.filter(pointed), 'OnClick', { x: pointer.x, y: pointer.y }) : [], () => @@ -811,6 +835,7 @@ export class BehaviorSystem implements System { clock: this.clock, recorder: this.recorder, breakpoint: this.breakpoint, + ends: this.ends, shared: this.sharedState, stateChanged: (machineId, from, to) => this.pending.push({ machineId, from, to, source: instance }), }); diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index c213cac8..a5af5f9a 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -50,6 +50,7 @@ import type { AudioSink, NoteTiming } from './Audio'; import type { Instance } from './behaviorPulses'; import type { Input } from './Input'; import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses'; +import type { PlayedEnds } from './playedEnds'; import type { RecordingSink } from '../../core/recording/recording'; import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids'; import type { VideoSystem } from './VideoSystem'; @@ -74,6 +75,8 @@ export interface ContextSources { recorder: RecordingSink | null; /** Where `Break` pauses; only the editor's Play has one. */ breakpoint: Breakpoint | null; + /** What the run played, to be told when it ends (#373); absent, nothing is. */ + ends?: PlayedEnds; shared: SharedState; /** A graph moved one of this instance's machines; its events follow between exec flows. */ stateChanged(machineId: string, from: string, to: string): void; @@ -211,8 +214,8 @@ export function behaviorContext( const found = resolve(target); return (found && run.videos?.reading(world, found)) ?? null; }, - playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) => - playSound( + playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus, sync) => { + const voice = playSound( run, assetId, volume, @@ -223,7 +226,10 @@ export function behaviorContext( tempo, bus, syncedStart(run, sync)?.at, - ), + ); + run.ends?.sound(voice, assetId); + return voice; + }, playNote: ( instrumentId, note, @@ -249,8 +255,8 @@ export function behaviorContext( fxVoiceOf(project, instrumentFx(project, instrumentId, fx), tempo, fxParams), ); }, - playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) => - playPattern( + playPattern: (patternId, volume, loop, detached, fx, fxParams, bus, sync) => { + const id = playPattern( run, patternId, volume, @@ -260,10 +266,13 @@ export function behaviorContext( fxParams, bus, syncedStart(run, sync), - ), + ); + run.ends?.playing(id); + return id; + }, stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing), - playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => - playSong(run, songId, { + playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => { + const id = playSong(run, songId, { volume, fadeIn, loop, @@ -272,7 +281,10 @@ export function behaviorContext( fxParams, bus, live, - }), + }); + run.ends?.playing(id); + return id; + }, setSongTrack: (playback, track, volumeDb, fade, quantize) => setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize), setSongLane: (playback, track, lane, value, fade, quantize) => @@ -310,6 +322,7 @@ export function behaviorContext( }, songPosition: (playback, song, pattern) => positionOf(heardPlayings(project, run.audio), playback, song, pattern), + playing: (id) => id > 0 && (run.audio.playing?.(id) ?? false), spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), addJoint: (assetId, owner, connected, at) => { const on = resolve(owner); diff --git a/src/runtime/systems/musicPulses.ts b/src/runtime/systems/musicPulses.ts index 087b3cc0..14ebd32b 100644 --- a/src/runtime/systems/musicPulses.ts +++ b/src/runtime/systems/musicPulses.ts @@ -38,13 +38,19 @@ export interface HeardPlaying { markers: readonly SongMarker[]; } +/** The song or pattern a playing stands for, by its name (`song:`); null for anything else. */ +export function playedAsset(name: string): { kind: 'song' | 'pattern'; asset: string } | null { + const split = name.indexOf(':'); + const kind = name.slice(0, split); + return kind === 'song' || kind === 'pattern' ? { kind, asset: name.slice(split + 1) } : null; +} + /** 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 played = playedAsset(time.name); + if (!played) return []; + const { kind, asset: id } = played; 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; @@ -102,7 +108,12 @@ export class MusicFollower { 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 { +export function hears( + node: GraphNode, + wanted: Value, + playing: Pick, + 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); diff --git a/src/runtime/systems/playedEnds.ts b/src/runtime/systems/playedEnds.ts new file mode 100644 index 00000000..dd99354c --- /dev/null +++ b/src/runtime/systems/playedEnds.ts @@ -0,0 +1,77 @@ +/** + * What a run played and heard the end of (#373): the sounds of `Play sound` and the playings of + * `Play pattern` and `Play song`, each told once – what `On sound finished` and `On music finished` + * go by. A run keeps one through its scenes, so a song that outlives a scene is heard ending in the + * next; what another run or a tab of the editor played is none of its business. + */ +import { asNumber, type GraphNode } from '../../core/graph'; +import { textOf } from '../../core/model/Properties'; +import type { EndedPlay } from './Audio'; +import type { Instance, Pulse } from './behaviorPulses'; +import { hears, type PlaybackOf, playedAsset } from './musicPulses'; + +/** A sound or a playing of this run that ended, and whether a stop ended it. */ +export interface Finished { + id: number; + kind: 'sound' | 'song' | 'pattern'; + /** The sound, song or pattern: of a playing the one heard last, after a Switch song too. */ + asset: string; + stopped: boolean; +} + +export class PlayedEnds { + /** What each number this run played stands for: its sound, `''` for a playing. */ + private readonly played = new Map(); + + sound(voice: number, soundId: string): void { + if (voice > 0) this.played.set(voice, soundId); + } + + playing(id: number): void { + if (id > 0) this.played.set(id, ''); + } + + /** Of `ended`, those this run played and not told yet, oldest first. */ + follow(ended: readonly EndedPlay[]): Finished[] { + return ended.flatMap(({ id, name, stopped }): Finished[] => { + const sound = this.played.get(id); + if (sound === undefined) return []; + this.played.delete(id); + if (sound !== '') return [{ id, kind: 'sound', asset: sound, stopped }]; + const played = playedAsset(name); + return played ? [{ id, ...played, stopped }] : []; + }); + } +} + +export function musicFinishedPulses( + instance: Instance, + finished: readonly Finished[], + playbackOf: PlaybackOf, +): Pulse[] { + return instance.interpreter.eventNodes('OnMusicFinished').flatMap((node) => { + const wanted = playbackOf(instance, node); + return finished.flatMap(({ id, kind, asset, stopped }) => + kind !== 'sound' && hears(node, wanted, { id, kind, asset }, ['song', 'pattern']) + ? [{ instance, node, outputs: { playback: id, stopped } }] + : [], + ); + }); +} + +/** By its number where `voice` is connected, else by the Sound picked, every one where none is. */ +export function soundFinishedPulses( + instance: Instance, + finished: readonly Finished[], + voiceOf: PlaybackOf, +): Pulse[] { + return instance.interpreter.eventNodes('OnSoundFinished').flatMap((node: GraphNode) => { + const wanted = voiceOf(instance, node); + const sound = textOf(node.params.sound ?? ''); + return finished.flatMap(({ id, kind, asset, stopped }) => + kind === 'sound' && (wanted !== null ? asNumber(wanted) === id : sound === '' || sound === asset) + ? [{ instance, node, outputs: { voice: id, stopped } }] + : [], + ); + }); +} diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 51299c03..3477007e 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -47,7 +47,13 @@ import { STREAM_RATE } from '../core/music/streamQueue'; import { type CutRange, cutChannels, cutRange, fadeGain, parseCutName } from '../core/model/sound'; import { shifterLatency } from '../core/soundfx/pitchPlayer'; import { REVERB_MAX_DECAY } from '../core/soundfx/reverb'; -import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio'; +import { + type AudioSink, + type ClipTiming, + ENDED_KEPT, + type EndedPlay, + type NoteTiming, +} from '../runtime/systems/Audio'; import type { AcidOptions, ChipOptions, PitchOptions, StreamOptions } from './fxWorklet'; import fxWorkletUrl from './fxWorklet.ts?worker&url'; import { @@ -201,6 +207,8 @@ interface Voice { released: boolean; /** Seconds a stop that lets it ring fades it out over: a bake (#340), whose notes are one sound. */ ringFade: number; + /** A stop reached it, or another took its place (#373), rather than it ending by itself. */ + stopped: boolean; } /** @@ -421,8 +429,13 @@ export class SoundLibrary implements AudioSink { level: (id, gain, at, seconds) => this.level(id, gain, at, seconds), after: (at, then) => void this.after(at, then), closed: () => this.sweep(), + ended: (id, name, stopped) => this.keepEnd({ id, name, stopped }), playBaked: (id, bake, at) => this.playBaked(id, bake, at), }); + /** The voices of `play` and the playings that ended lately, oldest first (#373). */ + private readonly ends: EndedPlay[] = []; + /** The voices of `play` whose end is still to be told. */ + private readonly told = new Set(); /** The playings whose buses are still there: a master each, and the tracks of a song into it. */ private readonly groups = new Set(); /** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */ @@ -516,7 +529,7 @@ export class SoundLibrary implements AudioSink { if (file === '' || this.hasFailed(file)) return 0; const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO }; const stream = this.streamed(file); - return this.begin({ + const id = this.begin({ id: 0, file, files: [file], @@ -529,6 +542,8 @@ export class SoundLibrary implements AudioSink { bus, ...(stream && { stream }), }); + this.told.add(id); + return id; } /** @@ -1585,6 +1600,7 @@ export class SoundLibrary implements AudioSink { vocoders: 0, released: false, ringFade: BAKE_LET_RING, + stopped: false, }; const going = new Set(voice.sources); voice.finish = (source) => { @@ -1904,8 +1920,8 @@ export class SoundLibrary implements AudioSink { // A `Stop sound` – or the destruction of the behavior that asked – that happened while the // bytes were in flight has to win, or a sound the run already ended would start afterwards. const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip); - if (silent || waiting.stopped) return; - if (this.cancelsFor(start.file, start.owner) !== askedAt) return; + const cancelled = waiting.stopped || this.cancelsFor(start.file, start.owner) !== askedAt; + if (silent || cancelled) return this.voiceEnded(id, start.file, true); const started: Start = { ...asked, fx: waiting.fx }; if (stream?.failed) delete started.stream; this.start(started); @@ -1999,7 +2015,10 @@ export class SoundLibrary implements AudioSink { 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; + if (waiting) { + waiting.stopped = true; + this.voiceEnded(id, waiting.file, true); + } this.silence( this.voices.filter((voice) => voice.id === id), letRing, @@ -2381,6 +2400,7 @@ export class SoundLibrary implements AudioSink { private silence(going: Voice[], letRing = false): void { this.stopped += going.filter((voice) => !(letRing && voice.ringing)).length; for (const voice of going) { + voice.stopped = true; if (letRing && voice.ringFade > 0) { this.cut(voice, voice.ringFade); continue; @@ -2409,6 +2429,7 @@ export class SoundLibrary implements AudioSink { for (const voice of [...older.filter((v) => v.released), ...older.filter((v) => !v.released)]) { if (count <= MAX_VOCODERS) return; count -= vocodersOf(voice); + voice.stopped = true; this.cut(voice); } } @@ -2492,6 +2513,24 @@ export class SoundLibrary implements AudioSink { voice.quiet = null; this.voices = this.voices.filter((other) => other !== voice); if (voice.bus !== '') this.sweep(); + this.voiceEnded(voice.id, voice.file, voice.stopped); + } + + /** Keeps an end for `endings` (#373), letting the oldest go past `ENDED_KEPT`. */ + private keepEnd(end: EndedPlay): void { + this.ends.push(end); + if (this.ends.length > ENDED_KEPT) this.ends.shift(); + } + + /** The end of a voice: kept where `play` started it, and only once. */ + private voiceEnded(id: number, name: string, stopped: boolean): void { + if (this.told.delete(id)) this.keepEnd({ id, name, stopped }); + } + + endings(): readonly EndedPlay[] { + // A playing heard to its end goes once it is looked for. + this.sequences.current(); + return this.ends; } /** @@ -2626,6 +2665,7 @@ export class SoundLibrary implements AudioSink { vocoders: wiring.vocoders, released: false, ringFade: 0, + stopped: false, }; const going = new Set(sources); voice.finish = (source) => { diff --git a/tests/core/audio-switch.test.ts b/tests/core/audio-switch.test.ts index 1d183545..c4290d5a 100644 --- a/tests/core/audio-switch.test.ts +++ b/tests/core/audio-switch.test.ts @@ -90,4 +90,28 @@ describe('AudioSwitch (#423)', () => { const there = audio.playSequence('song:s', {} as never, ''); expect(audio.landOn(there, () => 0)).toBe(0.5); }); + it('tells what ended in both sinks, and asks each voice of the one that numbered it (#373)', () => { + const live = { + ...sinkFrom(1), + endings: () => [{ id: 1, name: 'music', stopped: false }], + playing: (id: number) => id === 2, + }; + const aside = { + ...sinkFrom(DIVERTED_IDS), + endings: () => [{ id: DIVERTED_IDS, name: 'hit', stopped: true }], + playing: (id: number) => id === DIVERTED_IDS + 1, + }; + const audio = new AudioSwitch(live); + expect(audio.endings().map((end) => end.id)).toEqual([1]); + audio.divert(aside); + expect(audio.endings().map((end) => end.id)).toEqual([1, DIVERTED_IDS]); + expect([2, 3, DIVERTED_IDS, DIVERTED_IDS + 1].map((id) => audio.playing(id))).toEqual([ + true, + false, + false, + true, + ]); + audio.restore(); + expect(audio.playing(DIVERTED_IDS + 1)).toBe(false); + }); }); diff --git a/tests/core/event-order.test.ts b/tests/core/event-order.test.ts index 79f29fa5..061f0293 100644 --- a/tests/core/event-order.test.ts +++ b/tests/core/event-order.test.ts @@ -104,7 +104,7 @@ describe('the order rule', () => { const events = BUILT_IN_NODES.filter( (def) => def.category === 'event' && def.type !== BEHAVIOR_START && def.type !== ON_BEHAVIOR, ); - expect(events).toHaveLength(23); + expect(events).toHaveLength(25); for (const def of events) { expect( def.params.map((p) => p.key), diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 1a8713b7..87b8916b 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2586,6 +2586,27 @@ const CASES: Record = { outputs: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false }, }, ], + MusicPlaying: [ + { + name: 'answers whether the playing is still to be heard (#373)', + inputs: { playback: 4 }, + outputs: { playing: true }, + }, + { + name: 'off for one that has ended, and where "playback" is not connected', + inputs: { playback: 5 }, + outputs: { playing: false }, + }, + { name: 'unconnected is 0, which nothing plays under', inputs: {}, outputs: { playing: false } }, + ], + SoundPlaying: [ + { + name: 'answers whether the voice is still to be heard (#373)', + inputs: { voice: 6 }, + outputs: { playing: true }, + }, + { name: 'off for a voice that has ended', inputs: { voice: 7 }, outputs: { playing: false } }, + ], VideoTime: [ { name: 'answers where the video of the object stands (#410)', @@ -2688,6 +2709,8 @@ const EVENT_NODES: Record = { OnBeat: 'music-events.test.ts', OnBar: 'music-events.test.ts', OnSongMarker: 'music-events.test.ts', + OnMusicFinished: 'played-ends.test.ts', + OnSoundFinished: 'played-ends.test.ts', OnStateEntered: 'state-machine.test.ts', OnStateExited: 'state-machine.test.ts', }; diff --git a/tests/core/played-ends.test.ts b/tests/core/played-ends.test.ts new file mode 100644 index 00000000..00ee08d0 --- /dev/null +++ b/tests/core/played-ends.test.ts @@ -0,0 +1,385 @@ +/** + * A game hears of the end of what it played (#373): a playing of a pattern or song tells its sink + * once nothing of it is left, the sink keeps it, and On music finished and On sound finished run for + * what the run itself played – once, with "stopped" where a stop ended it. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, CreateSoundCommand } from '../../src/core/commands/assetCommands'; +import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands'; +import { Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; +import { INTERVAL } from '../../src/core/music/Scheduler'; +import { GameRun } from '../../src/runtime/GameRun'; +import { loadPhysics, readyPhysics } from '../../src/runtime/physics/engine'; +import { type AudioSink, type EndedPlay, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; +import { Input } from '../../src/runtime/systems/Input'; +import { PlayedEnds } from '../../src/runtime/systems/playedEnds'; +import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; + +const Q = 480; + +/** An audio clock moved by hand, its notes over as soon as they are struck; what ends is kept. */ +function fakeHost() { + const host = { + time: 0, + pumps: new Set<() => void>(), + loading: [] as (() => void)[], + timers: [] as { at: number; then: () => void }[], + ended: [] as EndedPlay[], + }; + const playbacks = new Playbacks({ + now: () => host.time, + latency: () => 0, + playing: () => false, + stop: () => {}, + wake: (pump) => { + host.pumps.add(pump); + return () => host.pumps.delete(pump); + }, + prepare: () => new Promise((resolve) => host.loading.push(resolve)), + level: () => {}, + after: (at, then) => host.timers.push({ at, then }), + ended: (id, name, stopped) => host.ended.push({ id, name, stopped }), + }); + 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(); + for (const timer of host.timers.filter((t) => t.at <= host.time + 1e-9)) { + host.timers.splice(host.timers.indexOf(timer), 1); + timer.then(); + } + } + }; + 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. */ +function bar(loop: boolean): 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: 'p', + })), + }, + tempo: 120, + length: 4 * Q, + loop: loop ? { start: 0, end: 4 * Q } : null, + needs: { layers: [], files: [] }, + strike: () => next++, + }; +} + +describe('the end of a playing', () => { + it('is told once it is played to its end, once, not stopped', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + playbacks.start(7, 'pattern:a', bar(false), ''); + await loaded(); + // Heard from 0.05 s on, so over at 2.05 s; its last note is long gone and the rest planned by then. + run(2); + playbacks.current(); + expect(host.ended).toEqual([]); + run(0.1); + playbacks.current(); + playbacks.current(); + expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: false }]); + }); + + it('with a loop never by itself, only by a stop – after its fade out', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + playbacks.start(7, 'song:a', bar(true), ''); + await loaded(); + run(9); + playbacks.current(); + expect(host.ended).toEqual([]); + playbacks.stopNamed('song:a', false, 1); + run(0.9); + expect(host.ended).toEqual([]); + run(0.2); + expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]); + }); + + it('stopped while its files load, by its owner, it is told as stopped', () => { + const { host, playbacks } = fakeHost(); + playbacks.start(7, 'pattern:a', bar(false), 'logic'); + playbacks.stopOwner('logic'); + expect(host.ended).toEqual([{ id: 7, name: 'pattern:a', stopped: true }]); + }); + + it('of a switch only once the number is over, by the song heard last (#338)', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + playbacks.start(7, 'song:a', bar(true), ''); + await loaded(); + run(1); + playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0); + await loaded(); + run(1); + expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:b']); + expect(host.ended).toEqual([]); + playbacks.stopId(7); + expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]); + }); + + it('of a switch stopped while the two cross, by the one it switched to', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + playbacks.start(7, 'song:a', bar(true), ''); + await loaded(); + run(1); + playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 2); + await loaded(); + run(0.5); + expect(playbacks.named(7).map((p) => p.name)).toEqual(['song:a', 'song:b']); + playbacks.stopId(7); + expect(host.ended).toEqual([{ id: 7, name: 'song:b', stopped: true }]); + }); + + it('of a switch stopped before it is heard, by the song heard then', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + playbacks.start(7, 'song:a', bar(true), ''); + await loaded(); + run(1); + playbacks.switchTo(7, 8, 'song:b', bar(true), '', (clock) => clock.soonest, 0); + playbacks.stopId(7); + expect(host.ended).toEqual([{ id: 7, name: 'song:a', stopped: true }]); + }); +}); + +describe('PlayedEnds', () => { + it('tells only what the run played, each once, a playing by the song or pattern it ended as', () => { + const ends = new PlayedEnds(); + ends.sound(3, 'blip'); + ends.playing(4); + ends.playing(5); + ends.playing(0); + const log: EndedPlay[] = [ + { id: 2, name: 'x.ogg', stopped: false }, + { id: 3, name: 'b.ogg', stopped: true }, + { id: 4, name: 'song:boss', stopped: false }, + { id: 5, name: 'pattern:riff', stopped: true }, + ]; + expect(ends.follow(log)).toEqual([ + { id: 3, kind: 'sound', asset: 'blip', stopped: true }, + { id: 4, kind: 'song', asset: 'boss', stopped: false }, + { id: 5, kind: 'pattern', asset: 'riff', stopped: true }, + ]); + expect(ends.follow(log)).toEqual([]); + }); +}); + +/** + * A sink that plays its patterns and songs on real playings, on the hand-moved clock, and makes a + * voice of each sound that lasts until `endVoice` or a stop. + */ +function playingSink() { + const fake = fakeHost(); + let next = 1; + const voices = new Map(); + const endVoice = (id: number, stopped: boolean) => { + const file = voices.get(id); + if (file === undefined) return; + voices.delete(id); + fake.host.ended.push({ id, name: file, stopped }); + }; + const audio: AudioSink = { + ...SILENT_AUDIO, + play: (file) => { + voices.set(next, file); + return next++; + }, + playSequence: (name, play, owner) => { + const id = next++; + fake.playbacks.start(id, name, play, owner); + return id; + }, + stopSequence: (name, letRing, fade) => fake.playbacks.stopNamed(name, letRing, fade), + stopPlayback: (id, fade) => fake.playbacks.stopId(id, false, fade), + stop: (file) => { + if (file === '') fake.playbacks.stopNamed(''); + for (const [id, of] of voices) if (file === '' || of === file) endVoice(id, true); + }, + stopVoice: (id) => endVoice(id, true), + stopOwner: (owner) => fake.playbacks.stopOwner(owner), + playing: (id) => voices.has(id) || fake.playbacks.has(id), + endings: () => (fake.playbacks.current(), fake.host.ended), + }; + return { ...fake, audio, endVoice }; +} + +/** A song and a pattern, each played without a loop on start, and a behavior that logs what ends. */ +function listening() { + const f = makeSpriteScene('Box'); + const create = (type: 'song' | 'pattern', name: string) => { + const command = new CreateAssetCommand(type, name); + f.stack.execute(command); + return command.asset.id; + }; + const song = create('song', 'Theme'); + const pattern = create('pattern', 'Riff'); + const blip = new CreateSoundCommand('Blip', `${'a1'.repeat(32)}.wav`); + f.stack.execute(blip); + const behavior = makeBehavior(f, 'Logic'); + place(f, behavior.id, { x: 0, y: 0 }); + const edit = graphEditor(f, behavior); + /** An event that logs `prefix`, its number and "stopped", run together. */ + const logs = (type: string, params: Record, prefix: string, number: string) => { + const event = edit.addNode(type, params); + const join = edit.addNode('Join'); + const first = edit.addNode('Join', { a: prefix }); + edit.connect(event, number, first, 'b'); + edit.connect(first, 'text', join, 'a'); + edit.connect(event, 'stopped', join, 'b'); + const log = edit.addNode('Log'); + edit.connect(event, 'exec', log, 'exec'); + edit.connect(join, 'text', log, 'message'); + return event; + }; + return { ...f, song, pattern, sound: blip.asset.id, edit, logs }; +} + +describe('On music finished', () => { + it('runs once when a song without a loop is over, not stopped; one with a loop never by itself', async () => { + const f = listening(); + const start = f.edit.addNode('OnStart'); + const once = f.edit.addNode('PlaySong', { song: f.song, loop: false }); + const round = f.edit.addNode('PlaySong', { song: f.song, loop: true }); + f.edit.connect(start, 'exec', once, 'exec'); + f.edit.connect(once, 'exec', round, 'exec'); + f.logs('OnMusicFinished', {}, 'end ', 'playback'); + const { audio, playbacks, run, loaded } = playingSink(); + const { world, frame } = startWorld(f, { behaviors: true, audio }); + frame(); + const [first, second] = playbacks.current().map((p) => p.id); + await loaded(); + const length = playbacks.current()[0]!.time.length / (2 * Q); + run(length - 0.5); + frame(); + expect(world.log).toEqual([]); + run(1); + frame(); + frame(); + expect(world.log).toEqual([`end ${first}false`]); + run(3 * length); + frame(); + expect(world.log).toHaveLength(1); + expect(playbacks.has(second!)).toBe(true); + }); + + it('runs with "stopped" after Stop song, and hears only what its Song, Pattern or "playback" picks', async () => { + const f = listening(); + const start = f.edit.addNode('OnStart'); + const song = f.edit.addNode('PlaySong', { song: f.song, loop: true }); + const again = f.edit.addNode('PlaySong', { song: f.song, loop: true }); + f.edit.connect(start, 'exec', song, 'exec'); + f.edit.connect(song, 'exec', again, 'exec'); + f.logs('OnMusicFinished', { pattern: f.pattern }, 'pattern ', 'playback'); + f.logs('OnMusicFinished', { song: f.song }, 'song ', 'playback'); + // The sink numbers the two playings 1 and 2. + const picked = f.logs('OnMusicFinished', {}, 'picked ', 'playback'); + f.edit.connect(f.edit.addNode('Number', { value: 1 }), 'value', picked, 'playback'); + const other = f.logs('OnMusicFinished', {}, 'other ', 'playback'); + f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', other, 'playback'); + const key = f.edit.addNode('OnKeyDown', { key: 's' }); + const stop = f.edit.addNode('StopSong', { song: f.song }); + f.edit.connect(key, 'exec', stop, 'exec'); + const { audio, playbacks, loaded } = playingSink(); + const { world, frame, input } = startWorld(f, { behaviors: true, audio }); + frame(); + expect(playbacks.current().map((p) => p.id)).toEqual([1, 2]); + await loaded(); + input.press('s'); + frame(); + frame(); + expect(world.log).toEqual(['song 1true', 'song 2true', 'picked 1true', 'other 2true']); + }); +}); + +describe('On sound finished', () => { + it('runs once for a sound this game played, by its Sound or its "voice", with "stopped" where stopped', () => { + const f = listening(); + const start = f.edit.addNode('OnStart'); + const first = f.edit.addNode('PlaySound', { sound: f.sound }); + const second = f.edit.addNode('PlaySound', { sound: f.sound }); + f.edit.connect(start, 'exec', first, 'exec'); + f.edit.connect(first, 'exec', second, 'exec'); + f.logs('OnSoundFinished', {}, 'any ', 'voice'); + f.logs('OnSoundFinished', { sound: f.sound }, 'blip ', 'voice'); + f.logs('OnSoundFinished', { sound: 'other' }, 'other ', 'voice'); + const picked = f.logs('OnSoundFinished', {}, 'second ', 'voice'); + f.edit.connect(f.edit.addNode('Number', { value: 2 }), 'value', picked, 'voice'); + f.logs('OnMusicFinished', {}, 'music ', 'playback'); + const key = f.edit.addNode('OnKeyDown', { key: 's' }); + const stop = f.edit.addNode('StopSound', { sound: f.sound }); + f.edit.connect(key, 'exec', stop, 'exec'); + const { audio, endVoice } = playingSink(); + const { world, frame, input } = startWorld(f, { behaviors: true, audio }); + frame(); + expect([audio.playing!(1), audio.playing!(2)]).toEqual([true, true]); + endVoice(1, false); + frame(); + frame(); + expect(world.log).toEqual(['any 1false', 'blip 1false']); + input.press('s'); + frame(); + frame(); + expect(world.log.slice(2)).toEqual(['any 2true', 'blip 2true', 'second 2true']); + }); + + it('hears nothing a run did not play itself', () => { + const f = listening(); + f.logs('OnSoundFinished', {}, 'any ', 'voice'); + f.logs('OnMusicFinished', {}, 'music ', 'playback'); + const { audio, playbacks } = playingSink(); + audio.play('a.wav', 1, false, 'sound-preview'); + playbacks.start(9, `song:${f.song}`, bar(false), 'pattern-preview'); + playbacks.stopId(9); + audio.stopVoice(1); + const { world, frame } = startWorld(f, { behaviors: true, audio }); + frame(); + frame(); + expect(world.log).toEqual([]); + }); +}); + +describe('a run', () => { + beforeAll(async () => { + await loadPhysics(); + }); + + it('hears a sound it played in one scene end in the next', () => { + const f = listening(); + const start = f.edit.addNode('OnStart'); + const play = f.edit.addNode('PlaySound', { sound: f.sound, detached: true }); + const load = f.edit.addNode('LoadScene'); + f.edit.connect(start, 'exec', play, 'exec'); + f.edit.connect(play, 'exec', load, 'exec'); + const next = new CreateAssetCommand('scene', 'Level 2'); + f.stack.execute(next); + load.params.scene = next.asset.id; + const hearing = makeBehavior(f, 'Ears'); + f.stack.execute(new PlaceAssetCommand(next.asset.id, hearing.id, { x: 0, y: 0 }, '')); + const ears = graphEditor(f, hearing); + const log = ears.addNode('Log', { message: 'heard' }); + ears.connect(ears.addNode('OnSoundFinished', { sound: f.sound }), 'exec', log, 'exec'); + const { audio, endVoice } = playingSink(); + const run = GameRun.start(f.project, f.sceneId, new Input(), { + audio, + physics: readyPhysics()!, + clock: { scale: 1 }, + saves: { read: () => ({}), write: () => {} }, + }); + expect(run.tick(1 / 60, 1 / 60)).toBe('scene'); + run.tick(1 / 60, 1 / 60); + endVoice(1, false); + run.tick(1 / 60, 1 / 60); + expect(run.world.log).toEqual(['heard']); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 0d5c9f32..324789cc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -455,6 +455,8 @@ export function fakeContext(overrides: Partial = {}): FakeContext { [...ctx.buses].reverse().find((change) => change.bus === bus && change.volume !== undefined)?.volume ?? 1, songPosition: () => null, + // An even number plays, an odd one has ended, so a test can ask for either. + playing: (id) => id > 0 && id % 2 === 0, loadScene: (sceneId) => ctx.loaded.push(sceneId), spawn: () => null, addJoint: () => null, diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 4950bc28..8d5d6870 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -1963,6 +1963,9 @@ describe('the patterns and songs a game plays (#336)', () => { '', ); await settle(); + // Planned to its end, but not yet played to it (#373): 10 ticks from 50 ms on. + expect(library.playbacks()).toHaveLength(1); + (library as unknown as { context: { currentTime: number } }).context.currentTime = 0.1; expect(() => library.playbacks()).not.toThrow(); expect(library.playbacks()).toEqual([]); clockTo(); @@ -3472,3 +3475,79 @@ describe('what has ended is let go', () => { expect(worklets).toHaveLength(14); }); }); + +describe('what ended, for a game to hear (#373)', () => { + it('keeps the end of a voice of play once its sound is over, once, not stopped', async () => { + const library = new SoundLibrary(false); + const voice = await play(library); + expect(library.endings()).toEqual([]); + sources[0]!.onended!(); + sources[0]!.onended!(); + expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: false }]); + }); + + it('with an echo only once the echo has faded', async () => { + const library = new SoundLibrary(false); + const echo: FxStage = { + kind: 'delay', + time: 0.25, + feedback: 0, + damping: 20000, + mix: 0.5, + pingPong: false, + }; + const voice = await play(library, FILE, {}, { pitch: 0, stages: [echo] }); + sources[0]!.onended!(); + clockTo(0.24); + expect(library.endings()).toEqual([]); + clockTo(0.25); + expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: false }]); + }); + + it('a stopped one at once, stopped – also one still waiting on its bytes, told only once', async () => { + const library = new SoundLibrary(false); + const first = await play(library); + const waiting = await play(library, OTHER, { hold: true }); + library.stopVoice(first); + library.stopVoice(waiting); + const told = [ + { id: first, name: FILE, stopped: true }, + { id: waiting, name: OTHER, stopped: true }, + ]; + expect(library.endings()).toEqual(told); + await arrive(); + clockTo(); + expect(library.endings()).toEqual(told); + }); + + it('one whose owner went while its bytes were on the way once they come, stopped', async () => { + const library = new SoundLibrary(false); + const voice = await play(library, FILE, { hold: true, owner: 'logic' }); + library.stopOwner('logic'); + expect(library.endings()).toEqual([]); + await arrive(); + expect(library.endings()).toEqual([{ id: voice, name: FILE, stopped: true }]); + expect(library.stats().playing).toBe(0); + }); + + it('keeps no end of a note, and the end of a playing by the name it plays as', async () => { + const library = new SoundLibrary(false); + library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0); + const id = library.playSequence( + 'song:s', + { + sequence: { notes: [{ tick: 0, length: 480, note: 60, velocity: 1, instrument: 'i' }] }, + tempo: 120, + length: 1920, + loop: { start: 0, end: 1920 }, + needs: { layers: [], files: [] }, + strike: () => 0, + }, + '', + ); + await settle(); + library.stop(''); + clockTo(); + expect(library.endings()).toEqual([{ id, name: 'song:s', stopped: true }]); + }); +}); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 1733f128..17aa4bb0 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -1,4 +1,4 @@ -import { edge, node } from '../lib/builders.mjs'; +import { edge, node, port as readPort, variable, written } from '../lib/builders.mjs'; import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; import { sampleZoom } from './lib/zoomTransition.mjs'; @@ -1009,6 +1009,18 @@ export async function song(t) { node('sg65-r', 'KeyIsDown', 0, 2000, { key: 'r' }), node('sg65-rif', 'Branch', 1300, 1800), node('sg65-let', 'ReleaseSongLane', 1560, 1800, { track: tracks[0], lane: 'volume', fade: 0.5 }), + // 65k6 (#373): once the stinger is over, the pattern once more, at its own tempo. The + // stinger's number is kept in a variable, as no event reads an output another one left; heard + // by it, the one started after is not heard. And the end of the song, logged. + node('sg65-keep', 'Variables', 1300, 1600, {}, [written('sting', 'number')]), + node('sg65-kept', 'Variables', 1040, 1800, {}, [readPort('sting', 'number')]), + node('sg65-fin', 'OnMusicFinished', 1300, 1400), + node('sg65-again', 'PlayPattern', 1560, 1400, { pattern: PATTERN, loop: false }), + node('sg65-jf', 'Join', 1560, 1600, { a: 'finished stinger ' }), + node('sg65-logf', 'Log', 1820, 1400), + node('sg65-fins', 'OnMusicFinished', 0, 2200, { song: id }), + node('sg65-js', 'Join', 260, 2200, { a: 'finished song ' }), + node('sg65-logs', 'Log', 520, 2200), ], edges: [ edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'), @@ -1048,9 +1060,24 @@ export async function song(t) { edge('sg65-c18', 'sg65-r', 'down', 'sg65-rif', 'condition'), edge('sg65-c19', 'sg65-rif', 'then', 'sg65-let', 'exec'), edge('sg65-c20', 'sg65-cue', 'playback', 'sg65-let', 'playback'), + edge('sg65-f0', 'sg65-sting', 'exec', 'sg65-keep', 'exec'), + edge('sg65-f00', 'sg65-sting', 'playback', 'sg65-keep', 'sting'), + edge('sg65-f1', 'sg65-kept', 'sting', 'sg65-fin', 'playback'), + edge('sg65-f2', 'sg65-fin', 'exec', 'sg65-again', 'exec'), + edge('sg65-f3', 'sg65-again', 'exec', 'sg65-logf', 'exec'), + edge('sg65-f4', 'sg65-fin', 'stopped', 'sg65-jf', 'b'), + edge('sg65-f5', 'sg65-jf', 'text', 'sg65-logf', 'message'), + edge('sg65-f6', 'sg65-fins', 'exec', 'sg65-logs', 'exec'), + edge('sg65-f7', 'sg65-fins', 'stopped', 'sg65-js', 'b'), + edge('sg65-f8', 'sg65-js', 'text', 'sg65-logs', 'message'), ], }, }); + await mutate( + `__gm.state.project.assets.find((a) => a.id === 'sg65-logic').variables = ${JSON.stringify([ + variable('sting', 'number', 0), + ])};`, + ); const playbacks = () => json('__gm.sound.playbacks()'); const heardFrom = t.consoleLog.length; await play(); @@ -1233,7 +1260,18 @@ export async function song(t) { const song = stung.find((p) => p.name === `song:${id}`); const stinger = stung.find((p) => p.name === `pattern:${PATTERN}`); const beatsIn = stinger && song ? (stinger.startedAt - song.startedAt) / 0.5 : NaN; - const alone = await waitFor(playbacks, (list) => list.length === 1, 60); + // Every pattern playing seen on the way, the one 65k6 starts after the stinger among them. + const patterns = new Map(); + const alone = await waitFor( + async () => { + const list = await playbacks(); + for (const p of list) + if (p.name === `pattern:${PATTERN}` && p.startedAt !== null) patterns.set(p.id, p); + return list; + }, + (list) => list.length === 1 && patterns.size >= 2, + 80, + ); check( 'a pattern synced to the song plays once on its next beat, to the sample, at the tempo of the song', Math.abs(beatsIn - Math.round(beatsIn)) < 1e-6 && @@ -1244,6 +1282,28 @@ export async function song(t) { alone[0].name === `song:${id}`, JSON.stringify({ pressedAt, song: song?.startedAt, stinger, beatsIn, alone: alone.map((p) => p.name) }), ); + // 65k6. The end of a playing (#373): On music finished, hearing the stinger by its number, runs + // once it is over, its "stopped" off, and Play pattern starts the pattern once more – after + // the stinger's 2 s, within a frame and the lead of the scheduler. That one it does not hear. + const finishedLines = (what) => + t.consoleLog.slice(heardFrom).filter((text) => text.includes(`finished ${what}`)); + // P held over two beats plays two stingers; the listener hears the last, by the number it left. + const lastStinger = [...patterns.values()].filter((p) => p.startedAt < stinger.startedAt + 1.5).at(-1); + const chained = [...patterns.values()].filter((p) => p.startedAt > lastStinger.startedAt + 1); + const chainedAfter = chained[0] ? chained[0].startedAt - lastStinger.startedAt : NaN; + check( + 'On music finished runs once the stinger is over, not stopped, and starts the pattern again right after it', + finishedLines('stinger').length === 1 && + finishedLines('stinger')[0].endsWith('finished stinger false') && + chained.length === 1 && + chainedAfter >= 2 && + chainedAfter < 2.4, + JSON.stringify({ + lines: finishedLines('stinger'), + patterns: [...patterns.values()].map((p) => ({ id: p.id, startedAt: p.startedAt })), + chainedAfter, + }), + ); // 65k5. A game takes a lane over (#338): L held over a beat holds the volume lane of the sound's // track at -40 dB from the next bar line on, its curve (-24 to 0 dB each round) no longer // heard; R held lets it go, back to the curve over half a second. Read from the level of the @@ -1318,6 +1378,11 @@ export async function song(t) { leftBuses.length === 0, JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }), ); + check( + 'On music finished runs once for the song after its fade out, "stopped" on – not for the switch 65k3 made', + finishedLines('song').length === 1 && finishedLines('song')[0].endsWith('finished song true'), + JSON.stringify(finishedLines('song')), + ); await mutate( `delete __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(PATTERN)}).props.tempo;`, );