diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 08a8b989..68539b4e 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -277,6 +277,17 @@ export interface NodeContext { playPattern(patternId: string, volume: number, loop: boolean, detached: boolean): number; /** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */ stopPattern(patternId: string, letRing: boolean): void; + /** + * Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round + * the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the + * whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice. + * Returns its number; 0 where there is nothing to play. + */ + playSong(songId: string, volume: number, fadeIn: number, loop: boolean, detached: boolean): number; + /** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */ + stopSong(songId: string, fadeOut: number): void; + /** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */ + stopPlayback(playback: number, fadeOut: number): void; /** * How many seconds one run of that sound asset lasts, so a change can span it; 0 where it does not * exist or has not been loaded yet. diff --git a/src/core/graph/nodes/action/PlaySong.ts b/src/core/graph/nodes/action/PlaySong.ts new file mode 100644 index 00000000..518b327b --- /dev/null +++ b/src/core/graph/nodes/action/PlaySong.ts @@ -0,0 +1,60 @@ +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asBoolean, asNumber, asString } from '../../values'; +import { exec, num, withDefault } from '../shared'; + +export const songPort = (): PortDef => ({ + key: 'song', + label: 'song', + type: 'string', + field: { key: 'song', label: 'Song', kind: 'asset', default: '', assetTypes: ['song'] }, +}); + +export const PlaySong: NodeDef = { + type: 'PlaySong', + label: 'Play song', + category: 'action', + description: + 'Plays a song asset at its tempo, its tracks with the volume, pan, mute and solo they have. With ' + + '"loop" it plays from its start and then round and round the loop set on its ruler – an intro ' + + 'once, then the loop – until a Stop song ends it; without, once to its end. "volume" is the ' + + 'level of the whole song, "fade in" the seconds it rises there from silence. Each play is a ' + + 'playing of its own: firing this twice plays it twice, side by side. It belongs to this object ' + + 'like a sound and stops when the object is destroyed or the scene is left, unless "detached" ' + + 'is on. "playback" is the number of this playing, for Stop song; 0 where the song has nothing ' + + 'to play.', + inputs: [ + exec(), + songPort(), + withDefault(num('volume'), 1), + withDefault(num('fadeIn', 'fade in'), 0), + { + key: 'loop', + label: 'loop', + type: 'boolean', + default: true, + field: { key: 'loop', label: 'Loop', kind: 'boolean', default: true }, + }, + { + key: 'detached', + label: 'detached', + type: 'boolean', + default: false, + field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false }, + }, + ], + outputs: [exec(), num('playback')], + params: [], + run: (ctx, inputs) => { + const song = asString(inputs.song); + const playback = song + ? ctx.playSong( + song, + asNumber(inputs.volume), + asNumber(inputs.fadeIn), + asBoolean(inputs.loop), + asBoolean(inputs.detached), + ) + : 0; + return { next: 'exec', outputs: { playback } }; + }, +}; diff --git a/src/core/graph/nodes/action/StopSong.ts b/src/core/graph/nodes/action/StopSong.ts new file mode 100644 index 00000000..8e044b92 --- /dev/null +++ b/src/core/graph/nodes/action/StopSong.ts @@ -0,0 +1,33 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asNumber, asString } from '../../values'; +import { exec, num, withDefault } from '../shared'; +import { songPort } from './PlaySong'; + +export const StopSong: NodeDef = { + type: 'StopSong', + label: 'Stop song', + category: 'action', + description: + 'Stops every playing of a song, however many Play song started. With no song picked it stops ' + + 'every song, and leaves patterns and sounds alone. With "playback" connected it stops only that ' + + 'one playing, the number Play song handed out, and nothing where it is 0. "fade out" is the ' + + 'seconds it falls silent over before it ends; 0 ends it at once.', + inputs: [ + exec(), + songPort(), + // Not typed in: unconnected it is null, and only then does the song decide. + { ...num('playback'), inline: false }, + withDefault(num('fadeOut', 'fade out'), 0), + ], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + const fade = asNumber(inputs.fadeOut); + if (inputs.playback === null || inputs.playback === undefined) ctx.stopSong(asString(inputs.song), fade); + else { + const playback = asNumber(inputs.playback); + if (playback > 0) ctx.stopPlayback(playback, fade); + } + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 446548bd..edba5278 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -27,6 +27,8 @@ import { SetSoundFxParameters } from './action/SetSoundFxParameters'; import { PlayNote } from './action/PlayNote'; import { PlayPattern } from './action/PlayPattern'; import { StopPattern } from './action/StopPattern'; +import { PlaySong } from './action/PlaySong'; +import { StopSong } from './action/StopSong'; import { SetZ } from './action/SetZ'; import { Spawn } from './action/Spawn'; import { StopGame } from './action/StopGame'; @@ -312,6 +314,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ InstrumentParameterValues, PlayPattern, StopPattern, + PlaySong, + StopSong, LoadScene, Log, StopGame, diff --git a/src/core/music/needs.ts b/src/core/music/needs.ts new file mode 100644 index 00000000..af444d3f --- /dev/null +++ b/src/core/music/needs.ts @@ -0,0 +1,35 @@ +import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile'; +import { findAssetOf, type Project } from '../model/Project'; +import { soundFxBuildFor } from '../soundfx/compile'; +import { DRY_VOICE, type FxPlan, type FxVoice } from '../soundfx/nodes'; +import type { SequenceNeeds } from './playbacks'; +import type { Sequence } from './sequence'; + +/** The sound FX a clip goes through; one that does not compile plays it dry, as `Play sound` does. */ +export function clipFx(project: Project, fxId: string, tempo: number): FxVoice { + const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined; + const plan = fx && soundFxBuildFor(fx).compiled; + return plan ? { plan, params: {}, tempo } : DRY_VOICE; +} + +/** Every layer of every note and every file and FX of every clip a sequence plays. */ +export function sequenceNeeds(project: Project, sequence: Sequence): SequenceNeeds { + const sources = instrumentSourcesOf(project); + const seen = new Set(); + const layers: FxPlan[] = []; + for (const { instrument, note } of sequence.notes) { + const key = `${instrument}\u0000${note}`; + const asset = findAssetOf(project, instrument, 'instrument'); + if (seen.has(key) || !asset) continue; + seen.add(key); + layers.push(...instrumentBuildFor(asset, note, sources).layers); + } + const files = new Set(); + for (const clip of sequence.clips ?? []) { + const file = findAssetOf(project, clip.sound, 'sound')?.file; + if (file) files.add(file); + const fx = clipFx(project, clip.fx, 120); + if (fx !== DRY_VOICE) layers.push(fx.plan); + } + return { layers, files: [...files] }; +} diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index 696e6949..a9bdd603 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -1,37 +1,72 @@ import type { FxPlan } from '../soundfx/nodes'; -import { type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler'; -import type { Sequence } from './sequence'; +import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler'; +import type { LoopRange, Sequence } from './sequence'; -/** What a game asks to hear of a pattern: its notes, how fast, how long, and whether round and round. */ +/** What a sequence is made of: loaded before the first of its notes and clips is planned. */ +export interface SequenceNeeds { + layers: readonly FxPlan[]; + files: readonly string[]; +} + +/** What a track of a song is set to: its bus, its level and its pan (#334). */ +export interface BusLevel { + name: string; + gain: number; + pan: number; +} + +/** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */ export interface SequencePlay { sequence: Sequence; tempo: number; - /** In ticks: where it ends, and with `loop` where it starts again from 0. */ + /** In ticks: where it ends when it does not loop. */ length: number; - loop: boolean; - /** What its notes are made of, loaded before the first one is planned. */ - layers: readonly FxPlan[]; + /** What it runs round once the playhead gets there, from the start on; null plays it once to `length`. */ + loop: LoopRange | null; + needs: SequenceNeeds; + /** The level of the whole playing, 1 where left out, and the seconds it rises to it from silence. */ + volume?: number; + fadeIn?: number; + /** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */ + buses?: readonly BusLevel[]; /** Starts one note as the player was asked to, and gives back the number of its voice. */ strike(note: ScheduledNote): number; + strikeClip?(clip: ScheduledClip): number; } -export interface PlaybackHost extends Omit { - prepare(layers: readonly FxPlan[]): Promise; +export interface PlaybackHost extends Omit { + prepare(needs: SequenceNeeds): Promise; + /** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */ + level(id: number, gain: number, at: number, seconds: number): void; + /** Calls `then` once the audio clock reaches `at`. */ + after(at: number, then: () => void): void; + /** A playing is gone: stopped, or played to its end. */ + closed?(id: number): void; } export interface Playback { id: number; - /** What stands for the pattern, as a file stands for a sound. */ + /** What stands for the pattern or song, as a file stands for a sound: `pattern:`, `song:`. */ name: string; owner: string; scheduler: Scheduler; - /** The last notes planned, newest last, for a check to hold against the clock. */ + /** The last notes and clips planned, newest last, for a check to hold against the clock. */ planned: ScheduledNote[]; + clips: ScheduledClip[]; + /** The second of the audio clock a fade out ends it at; null while it is not fading. */ + fading: number | null; } +/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */ +export function goesBy(playing: string, name: string): boolean { + return name === '' || playing === name || (name.endsWith(':') && playing.startsWith(name)); +} + +const KEPT = 64; + /** - * The patterns a game plays (#336 ahead of songs): each on a scheduler of its own, numbered like a - * voice, owned like one and stopped like one. One played to its end goes by itself. + * The patterns and songs a game plays (#336): each on a scheduler of its own, numbered like a voice, + * owned like one and stopped like one. One played to its end goes by itself. */ export class Playbacks { private list: Playback[] = []; @@ -41,50 +76,78 @@ export class Playbacks { /** Starts once its files are there; a stop that came first wins. */ start(id: number, name: string, play: SequencePlay, owner: string): void { const planned: ScheduledNote[] = []; - const strike = (note: ScheduledNote) => { - planned.push(note); - if (planned.length > 64) planned.shift(); - return play.strike(note); + const clips: ScheduledClip[] = []; + const keep = (list: T[], item: T) => { + list.push(item); + if (list.length > KEPT) list.shift(); }; - const scheduler = new Scheduler({ ...this.host, play: strike }, () => play.sequence, { - tempo: play.tempo, - loop: play.loop ? { start: 0, end: play.length } : null, - end: play.length, - }); - const playback = { id, name, owner, scheduler, planned }; + const strikeClip = play.strikeClip; + const scheduler = new Scheduler( + { + ...this.host, + play: (note) => (keep(planned, note), play.strike(note)), + ...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }), + }, + () => play.sequence, + { tempo: play.tempo, loop: play.loop, end: play.length }, + ); + const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null }; this.list.push(playback); - void this.host.prepare(play.layers).then(() => { - if (this.list.includes(playback)) scheduler.start(0); + void this.host.prepare(play.needs).then(() => { + if (!this.list.includes(playback) || playback.fading !== null) return; + scheduler.start(0); + this.host.level(id, play.volume ?? 1, this.host.now() + LEAD, Math.max(0, play.fadeIn ?? 0)); }); } - /** Whether that one is still to be heard: waiting on its files, playing, or its last notes ringing. */ + /** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */ has(id: number): boolean { return this.current().some((playback) => playback.id === id); } - /** Those whose name is `name`, every one where it is `''`. */ - stopNamed(name: string, letRing = false): void { - this.stopWhere((playback) => name === '' || playback.name === name, letRing); + /** Those that go by `name` (see `goesBy`); with `fade` they fall silent over that many seconds first. */ + stopNamed(name: string, letRing = false, fade = 0): void { + this.stopWhere((playback) => goesBy(playback.name, name), letRing, fade); } - stopId(id: number, letRing = false): void { - this.stopWhere((playback) => playback.id === id, letRing); + stopId(id: number, letRing = false, fade = 0): void { + this.stopWhere((playback) => playback.id === id, letRing, fade); } /** An empty owner is no one, as for a voice: those were started to outlive whatever started them. */ stopOwner(owner: string): void { - if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false); + if (owner !== '') this.stopWhere((playback) => playback.owner === owner, false, 0); } /** What is playing, oldest first (test hook). */ current(): readonly Readonly[] { - this.list = this.list.filter((playback) => !playback.scheduler.done); + const gone = this.list.filter((playback) => playback.scheduler.done); + if (gone.length > 0) this.close(gone, false); return this.list; } - private stopWhere(going: (playback: Playback) => boolean, letRing: boolean): void { - for (const playback of this.list.filter(going)) playback.scheduler.stop(letRing); - this.list = this.list.filter((playback) => !going(playback)); + private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void { + const stopping = this.list.filter(going); + if (!(fade > 0)) return this.close(stopping, letRing); + const now = this.host.now(); + const end = now + fade; + for (const playback of stopping) { + if (playback.fading !== null && playback.fading <= end) continue; + playback.fading = end; + this.host.level(playback.id, 0, now, fade); + this.host.after(end, () => { + if (this.list.includes(playback) && playback.fading === end) this.close([playback], false); + }); + } + } + + /** Out of the list before anyone hears of it, so whoever is told finds it gone. */ + private close(going: readonly Playback[], letRing: boolean): void { + if (going.length === 0) return; + this.list = this.list.filter((playback) => !going.includes(playback)); + for (const playback of going) { + playback.scheduler.stop(letRing); + this.host.closed?.(playback.id); + } } } diff --git a/src/core/music/song.ts b/src/core/music/song.ts index 81d8a7a1..37785510 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -10,6 +10,7 @@ import type { SongAsset } from '../model/Asset'; import { type PropertyBag, textOf } from '../model/Properties'; import { findAssetOf, type Project } from '../model/Project'; import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern'; +import type { BusLevel } from './playbacks'; import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence'; import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time'; @@ -126,6 +127,14 @@ export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { ga return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan }; } +/** The level and pan each track's bus is set to. */ +export function songBuses(song: { id: string; tracks: readonly SongTrack[] }): BusLevel[] { + return song.tracks.map((track) => ({ + name: trackBus(song.id, track.id), + ...trackLevel(track, song.tracks), + })); +} + /** * The part of an item from `tick` on, which is where it is cut by a split or by its left edge: what * it plays starts as far in as the cut is from its start. Null where `tick` is not inside it. diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a32b2c75..ce8db7fc 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -67,8 +67,10 @@ 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 patterns and songs a game plays, with the notes and clips last planned of each. */ + playbacks: () => soundLibrary.playbacks(), + /** Whether the audio clock is held by the pause of the toolbar or runs. */ + clockState: () => soundLibrary.clockState(), /** 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/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 2b38186c..ffd826b0 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -2,18 +2,16 @@ import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { SONG_GRID_KEY, SONG_GRID_OPTIONS, + songBuses, songLoop, songMeter, songSequence, songTempo, - type SongTrack, - trackBus, - trackLevel, } from '../../core/music/song'; import type { Asset, SongAsset } from '../../core/model/Asset'; import { textOf } from '../../core/model/Properties'; import { Button, Dropdown, Label, Menu } from '../../ui'; -import { type BusLevel, Music } from '../../viewport/music'; +import { Music } from '../../viewport/music'; import { activateAsset } from '../actions'; import { AssetTabEditor } from '../AssetTabEditor'; import { claimTransport, releaseTransport } from '../music/transport'; @@ -56,7 +54,7 @@ export class SongEditor extends AssetTabEditor { }; timeline.onTracksPreview = (tracks) => { const asset = this.asset(); - if (asset && this.isPlaying) this.music.setBuses(busesOf(asset.id, tracks)); + if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks })); }; timeline.onOpenPattern = (patternId) => activateAsset(state, patternId); this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]); @@ -87,7 +85,7 @@ export class SongEditor extends AssetTabEditor { claimTransport(this.halt); this.playingId = asset.id; this.told = this.settingsOf(asset); - this.music.setBuses(busesOf(asset.id, asset.tracks)); + this.music.setBuses(songBuses(asset)); void this.music.play(songSequence(this.state.project, asset), { tempo: songTempo(asset.props), meter: songMeter(asset.props), @@ -144,7 +142,7 @@ export class SongEditor extends AssetTabEditor { return; } this.music.edit(songSequence(this.state.project, asset)); - this.music.setBuses(busesOf(asset.id, asset.tracks)); + this.music.setBuses(songBuses(asset)); const settings = this.settingsOf(asset); if (settings === this.told) return; this.told = settings; @@ -174,8 +172,3 @@ export class SongEditor extends AssetTabEditor { this.follow(asset); } } - -/** The level and pan each track's bus is set to. */ -export function busesOf(songId: string, tracks: readonly SongTrack[]): BusLevel[] { - return tracks.map((track) => ({ name: trackBus(songId, track.id), ...trackLevel(track, tracks) })); -} diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index 655f9117..27f6a6da 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -36,14 +36,25 @@ export interface AudioSink { 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. + * A sound as a clip of a song (#334): from `clip.at` on the audio clock, `clip.offset` seconds into + * it, round and round for `clip.duration` seconds, on the bus `clip.bus`. + */ + playClip(file: string, clip: ClipTiming, fx?: FxVoice): number; + /** + * Plays the notes and clips of a pattern or a song on the audio clock, each struck through + * `play.strike`. `name` stands for it 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; + /** + * Stops every playing of that name, every one of a kind for `pattern:` or `song:`, every one for + * `''`. With `letRing` its notes fall away in their release, with `fade` it falls silent over that + * many seconds first. + */ + stopSequence(name: string, letRing?: boolean, fade?: number): void; + /** One playing by its number, falling silent over `fade` seconds; a voice by its number is cut. */ + stopPlayback(id: number, fade: number): 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. @@ -90,11 +101,22 @@ export interface NoteTiming { bus?: string; } +/** When and how a clip of a song plays (#334); see `playClip`. */ +export interface ClipTiming { + at: number; + offset: number; + duration: number; + volume: number; + bus: string; +} + export const SILENT_AUDIO: AudioSink = { play: () => 0, playNote: () => 0, + playClip: () => 0, playSequence: () => 0, stopSequence: () => {}, + stopPlayback: () => {}, release: () => {}, tune: () => {}, length: () => 0, diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index abd8df7c..25db7646 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -19,7 +19,10 @@ 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 { clipFx, sequenceNeeds } from '../../core/music/needs'; import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern'; +import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler'; +import { songBuses, songLength, songLoop, songSequence, songTempo } from '../../core/music/song'; import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes'; import type { Entity } from '../Entity'; import { PhysicsSystem } from '../physics/PhysicsSystem'; @@ -176,8 +179,11 @@ export function behaviorContext( }), playPattern: (patternId, volume, loop, detached) => playPattern(run, patternId, volume, loop, detached ? '' : entity.id), - stopPattern: (patternId, letRing) => - run.audio.stopSequence(patternId && patternVoiceName(patternId), letRing), + stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing), + playSong: (songId, volume, fadeIn, loop, detached) => + playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id), + stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)), + stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)), soundLength: (assetId) => { const file = soundFileOf(project, assetId); return file ? run.audio.length(file) : 0; @@ -311,35 +317,106 @@ function playPattern( 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; + const level = heldLevel(volume); + const length = patternLength(asset); 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 }, - ), + length, + loop: loop ? { start: 0, end: length } : null, + needs: sequenceNeeds(project, sequence), + strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner), }, owner, ); } +/** + * A song played by a game (#336): its tracks at their level, pan, mute and solo, at its tempo. With + * `loop` it plays from its start and then runs round the loop of its ruler, else once to its end. + * `volume` and `fadeIn` are the level of the whole playing. Returns the number of the playing, 0 + * where there is no song or nothing in it to play. + */ +function playSong( + { project, audio }: Pick, + songId: string, + volume: number, + fadeIn: number, + loop: boolean, + owner: string, +): number { + const song = findAssetOf(project, songId, 'song'); + if (!song) return 0; + const sequence = songSequence(project, song); + return audio.playSequence( + songVoiceName(songId), + { + sequence, + tempo: songTempo(song.props), + length: songLength(song), + loop: loop ? songLoop(song) : null, + needs: sequenceNeeds(project, sequence), + volume: heldLevel(volume), + fadeIn: heldFade(fadeIn), + buses: songBuses(song), + strike: (note) => strikeNote({ project, audio }, note, owner), + strikeClip: (clip) => playClip({ project, audio }, clip), + }, + owner, + ); +} + +/** A note the scheduler planned (#332), struck through its instrument at its volume, on its bus. */ +export function strikeNote( + sources: Pick, + note: ScheduledNote, + owner: string, +): number { + const struck = { + note: note.note, + velocity: note.velocity, + volume: note.volume ?? 1, + duration: note.duration, + }; + const timing = { at: note.at, tempo: note.tempo, ...(note.bus && { bus: note.bus }) }; + return playNote(sources, note.instrument, struck, owner, {}, timing); +} + +/** A clip of a song (#334): its sound at its own volume times the item's, through its sound FX. */ +export function playClip( + { project, audio }: Pick, + clip: ScheduledClip, +): number { + const asset = findAssetOf(project, clip.sound, 'sound'); + if (!asset?.file) return 0; + const volume = Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume)); + return audio.playClip( + asset.file, + { at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus }, + clipFx(project, clip.fx, clip.tempo), + ); +} + +function heldLevel(volume: number): number { + return Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1; +} + +function heldFade(seconds: number): number { + return Number.isFinite(seconds) ? Math.max(0, seconds) : 0; +} + +/** `pattern:` alone stands for every pattern (see `goesBy`). */ export function patternVoiceName(patternId: string): string { return `pattern:${patternId}`; } +/** `song:` alone stands for every song. */ +export function songVoiceName(songId: string): string { + return `song:${songId}`; +} + /** 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 e9f36793..49ad2499 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -17,10 +17,10 @@ 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 { type BusLevel, Playbacks, type SequencePlay } from '../core/music/playbacks'; +import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler'; import { DEFAULT_TEMPO } from '../core/music/time'; -import type { AudioSink, NoteTiming } from '../runtime/systems/Audio'; +import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio'; import type { PitchOptions } from './pitchWorklet'; import pitchWorklet from './pitchWorklet.ts?worker&url'; import { wake } from './wake'; @@ -96,6 +96,8 @@ interface Voice { file: string; /** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */ owner: string; + /** The bus it is heard on, `''` straight out. */ + bus: string; /** When it started on the audio clock, what its Envelopes count from. */ at: number; sources: Source[]; @@ -184,6 +186,7 @@ interface Start { */ interface Pending { id: number; + bus: string; fx: Layered; timed: { params: FxParams; time: number; ease: EaseShare }[]; /** Stopped by its number before it started. */ @@ -247,17 +250,27 @@ export class SoundLibrary implements AudioSink { /** The tracks of the songs played so far (#334), by name. */ private readonly buses = new Map(); private readonly peaked = new Map(); - /** The patterns a game plays, each on a scheduler of its own. */ + /** + * The patterns and songs a game plays, each on a scheduler of its own. What it asks after are its + * own voices – asking `playing`, which counts the playings too, would ask the list it is in. + */ private readonly sequences = new Playbacks({ now: () => this.time(), latency: () => this.latency(), - playing: (id) => this.playing(id), + playing: (id) => this.sounding(id), stop: (voices, letRing) => { for (const id of voices) this.stopVoice(id, letRing); }, wake, - prepare: (layers) => this.prepare(layers), + prepare: ({ layers, files }) => this.prepare(layers, files), + level: (id, gain, at, seconds) => this.level(id, gain, at, seconds), + after: (at, then) => void this.after(at, then), + closed: () => this.sweep(), }); + /** 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. */ + private held = false; /** Where the files are served from. Set once at start-up, before anything plays. */ configure(base: string): void { @@ -345,39 +358,119 @@ export class SoundLibrary implements AudioSink { return name === '' ? this.audio().destination : this.bus(name).gain; } - private bus(name: string): Bus { + private bus(name: string, out?: AudioNode): Bus { const known = this.buses.get(name); if (known) return known; const context = this.audio(); const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } }; - made.gain.connect(made.pan).connect(context.destination); + made.gain.connect(made.pan).connect(out ?? context.destination); this.buses.set(name, made); return made; } - /** A sequence without notes plays nothing, and is given no number. */ + /** + * A sequence without notes or clips plays nothing, and is given no number. Each playing is heard + * through a master of its own, silent until it starts (#336): its level and its fades. The tracks + * of a song go into it, each set as it was given; a note or clip naming no track goes straight in. + */ playSequence(name: string, play: SequencePlay, owner = ''): number { - if (play.sequence.notes.length === 0) return 0; + if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0; const id = this.nextId++; - this.sequences.start(id, name, play, owner); + const buses = play.buses ?? []; + this.open(id, buses); + const tracks = new Set(buses.map((bus) => bus.name)); + const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : ''); + const { strike, strikeClip } = play; + const routed: SequencePlay = { + ...play, + strike: (note) => strike({ ...note, bus: into(note.bus) }), + ...(strikeClip && { + strikeClip: (clip: ScheduledClip) => strikeClip({ ...clip, bus: into(clip.bus) }), + }), + }; + this.sequences.start(id, name, routed, owner); return id; } - stopSequence(name: string, letRing = false): void { - this.sequences.stopNamed(name, letRing); + stopSequence(name: string, letRing = false, fade = 0): void { + this.sequences.stopNamed(name, letRing, fade); } - /** 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 }) => ({ + stopPlayback(id: number, fade: number): void { + if (this.sequences.has(id)) this.sequences.stopId(id, false, fade); + else this.stopVoice(id); + } + + /** The patterns and songs playing, oldest first, with the notes and clips last planned of each (test hook). */ + playbacks(): { + id: number; + name: string; + owner: string; + playing: boolean; + fading: number | null; + planned: ScheduledNote[]; + clips: ScheduledClip[]; + }[] { + return this.sequences.current().map(({ id, name, owner, scheduler, fading, planned, clips }) => ({ id, name, owner, playing: scheduler.playing, + fading, planned: [...planned], + clips: [...clips], })); } + private open(id: number, tracks: readonly BusLevel[]): void { + const master = this.bus(groupBus(id, '')); + master.gain.gain.value = 0; + master.set = { gain: 0, pan: 0 }; + for (const { name, gain, pan } of tracks) { + const track = this.bus(groupBus(id, name), master.gain); + track.gain.gain.value = Math.max(0, gain); + track.pan.pan.value = Math.max(-1, Math.min(1, pan)); + track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value }; + } + this.groups.add(id); + } + + /** The master of a playing, from where it stands at `at` to `gain`, there `seconds` later. */ + private level(id: number, gain: number, at: number, seconds: number): void { + const master = this.buses.get(groupBus(id, '')); + if (!master) return; + const param = master.gain.gain; + const from = param.value; + param.cancelScheduledValues(at); + param.setValueAtTime(seconds > 0 ? from : gain, at); + if (seconds > 0) param.linearRampToValueAtTime(gain, at + seconds); + master.set = { ...master.set, gain }; + } + + /** + * Lets go of the buses of every playing that is gone and has nothing left on them – a fade later, + * so a voice cut a moment ago is not cut off mid-fade. + */ + private sweep(): void { + if (this.groups.size === 0) return; + for (const id of [...this.groups]) { + if (!this.groups.has(id) || this.sequences.has(id)) continue; + const on = (bus: string) => bus === groupBus(id, '') || bus.startsWith(`${groupBus(id, '')}:`); + if (this.voices.some((voice) => on(voice.bus))) continue; + if (this.pending.some((waiting) => !waiting.stopped && on(waiting.bus))) continue; + this.groups.delete(id); + const names = [...this.buses.keys()].filter(on); + this.after(this.audio().currentTime + FADE, () => { + for (const name of names) { + const bus = this.buses.get(name); + bus?.gain.disconnect(); + bus?.pan.disconnect(); + this.buses.delete(name); + } + }); + } + } + /** * 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 @@ -449,6 +542,7 @@ export class SoundLibrary implements AudioSink { const askedAt = this.cancelsFor(start.file, start.owner); const waiting: Pending = { id, + bus: start.bus ?? '', fx: { ...start.fx, params: { ...start.fx.params } }, timed: [], stopped: false, @@ -500,13 +594,13 @@ 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. A pattern counts as one. */ + /** Whether a voice is still heard – or waiting on its bytes to be. A pattern or song counts as one. */ playing(id: number): boolean { - return ( - this.voices.some((voice) => voice.id === id) || - this.pending.some((other) => other.id === id) || - this.sequences.has(id) - ); + return this.sounding(id) || this.sequences.has(id); + } + + private sounding(id: number): boolean { + return this.voices.some((voice) => voice.id === id) || this.pending.some((other) => other.id === id); } /** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */ @@ -544,8 +638,12 @@ export class SoundLibrary implements AudioSink { this.stop(''); } - /** A pause holds the sounds where they are; resuming carries them on mid-note. */ + /** + * A pause holds the sounds where they are; resuming carries them on mid-note. Until then nothing + * wakes the clock – a pattern or song reading it every few milliseconds would undo the pause. + */ setSuspended(suspended: boolean): void { + this.held = suspended; const context = this.context; if (!context) return; if (suspended) void context.suspend(); @@ -596,10 +694,15 @@ export class SoundLibrary implements AudioSink { return this.context?.currentTime ?? 0; } + /** `running`, `suspended` by a pause, or `none` before there is a context (test hook). */ + clockState(): string { + return this.context?.state ?? 'none'; + } + /** Seconds on the audio clock, opening the context where there is none yet: what the scheduler plans by (#332). */ time(): number { const context = this.audio(); - if (context.state === 'suspended') void context.resume(); + if (context.state === 'suspended' && !this.held) void context.resume(); return context.currentTime; } @@ -617,9 +720,11 @@ export class SoundLibrary implements AudioSink { * Loads every file and the worklet these notes need, so the first note the scheduler plans is * not late waiting on its bytes (#332). */ - async prepare(layers: readonly FxPlan[]): Promise { + async prepare(layers: readonly FxPlan[], files: readonly string[] = []): Promise { await Promise.all([ - ...[...new Set(layers.flatMap(filesOf))].filter((file) => file !== '').map((file) => this.load(file)), + ...[...new Set([...layers.flatMap(filesOf), ...files])] + .filter((file) => file !== '') + .map((file) => this.load(file)), layers.some(pitches) ? this.pitchWorklet() : null, ]); } @@ -772,6 +877,7 @@ export class SoundLibrary implements AudioSink { voice.quiet?.(); voice.quiet = null; this.voices = this.voices.filter((other) => other !== voice); + if (voice.bus !== '') this.sweep(); } /** The longest tail of its layers: they are heard side by side. */ @@ -809,7 +915,7 @@ export class SoundLibrary implements AudioSink { const context = this.audio(); // A context starts suspended where the page has not been interacted with; without this the // voice would run silently and end as if it had played. - if (context.state === 'suspended') void context.resume(); + if (context.state === 'suspended' && !this.held) void context.resume(); const when = Math.max(at, context.currentTime); // A clip whose bytes came too late is over already, or starts as far in as it would be by now. if (clip && clip.end <= when) return; @@ -850,6 +956,7 @@ export class SoundLibrary implements AudioSink { id, file, owner, + bus, at: when, sources, ending: new Set(), @@ -1082,6 +1189,11 @@ export class SoundLibrary implements AudioSink { } } +/** The bus of a game's playing (#336): its master for `''`, else the track of that name inside it. */ +function groupBus(id: number, track: string): string { + return track === '' ? `play:${id}` : `play:${id}:${track}`; +} + /** A track of a song (#334): its level, then its pan, then out; `set` is what they glide to. */ interface Bus { gain: GainNode; @@ -1089,15 +1201,6 @@ interface Bus { set: { gain: number; pan: number }; } -/** When and how a clip of a song plays (#334); see `playClip`. */ -export interface ClipTiming { - at: number; - offset: number; - duration: number; - volume: number; - bus: string; -} - /** What `stats` says about the last voice: enough for a check to tell which effects it went through. */ interface LastVoice { file: string; diff --git a/src/viewport/music.ts b/src/viewport/music.ts index 8da22f31..53b73729 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -4,14 +4,11 @@ import { Scheduler, type SchedulerOptions, } from '../core/music/Scheduler'; +import { sequenceNeeds } from '../core/music/needs'; +import type { BusLevel } from '../core/music/playbacks'; import type { Sequence } from '../core/music/sequence'; -import { instrumentBuildFor, instrumentSourcesOf } from '../core/instrument/compile'; -import { findAssetOf, type Project } from '../core/model/Project'; -import { resolveAssetProps } from '../core/properties/resolve'; -import { volumeOf } from '../core/properties/sounds'; -import { soundFxBuildFor } from '../core/soundfx/compile'; -import { DRY_VOICE, type FxPlan, type FxVoice } from '../core/soundfx/nodes'; -import { playNote } from '../runtime/systems/behaviorContext'; +import type { Project } from '../core/model/Project'; +import { playClip, strikeNote } from '../runtime/systems/behaviorContext'; import { soundLibrary } from './SoundLibrary'; import { wake } from './wake'; @@ -27,13 +24,6 @@ export interface PlannedClip extends ScheduledClip { planned: number; } -/** What a track of a song is set to: its bus, its level and its pan (#334). */ -export interface BusLevel { - name: string; - gain: number; - pan: number; -} - /** * The scheduler on the sound library's clock (#332), its notes played by the instruments of the * project: what the Pattern tab (#333) and the Song tab (#334) play, and what the check scripts drive @@ -99,80 +89,22 @@ export class Music { } private playOne(note: ScheduledNote): number { - const struck = { - note: note.note, - velocity: note.velocity, - volume: note.volume ?? 1, - duration: note.duration, - }; - const voice = playNote( - { project: this.project(), audio: soundLibrary }, - note.instrument, - struck, - '', - {}, - { - at: note.at, - tempo: note.tempo, - ...(note.bus && { bus: note.bus }), - }, - ); + const voice = strikeNote({ project: this.project(), audio: soundLibrary }, note, ''); this.planned.push({ ...note, voice, planned: soundLibrary.clock() }); if (this.planned.length > 256) this.planned.shift(); return voice; } - /** A sound at its own volume times the item's, through its sound FX where it has one. */ private playClip(clip: ScheduledClip): number { - const project = this.project(); - const asset = findAssetOf(project, clip.sound, 'sound'); - const volume = asset - ? Math.max(0, Math.min(1, volumeOf(resolveAssetProps(project, asset)) * clip.volume)) - : 0; - const voice = asset?.file - ? soundLibrary.playClip( - asset.file, - { at: clip.at, offset: clip.offset, duration: clip.duration, volume, bus: clip.bus }, - this.fxOf(clip.fx, clip.tempo), - ) - : 0; + const voice = playClip({ project: this.project(), audio: soundLibrary }, clip); this.clips.push({ ...clip, voice, planned: soundLibrary.clock() }); if (this.clips.length > 256) this.clips.shift(); return voice; } - /** A sound FX that does not compile plays the sound dry, as `Play sound` does. */ - private fxOf(fxId: string, tempo: number): FxVoice { - const fx = fxId ? findAssetOf(this.project(), fxId, 'soundfx') : undefined; - const plan = fx && soundFxBuildFor(fx).compiled; - return plan ? { plan, params: {}, tempo } : DRY_VOICE; - } - /** Every file and worklet the sequence needs, so they are there before the first note is due. */ private async prepare(sequence: Sequence): Promise { - const project = this.project(); - const plans: FxPlan[] = [...this.layersOf(sequence)]; - const files = new Set(); - for (const clip of sequence.clips ?? []) { - const file = findAssetOf(project, clip.sound, 'sound')?.file; - if (file) files.add(file); - const fx = this.fxOf(clip.fx, 120); - if (fx !== DRY_VOICE) plans.push(fx.plan); - } - await Promise.all([soundLibrary.prepare(plans), ...[...files].map((file) => soundLibrary.load(file))]); - } - - /** The layers of every note the sequence has. */ - private layersOf(sequence: Sequence): FxPlan[] { - const project = this.project(); - const sources = instrumentSourcesOf(project); - const seen = new Set(); - return sequence.notes.flatMap(({ instrument, note }) => { - const key = `${instrument}\u0000${note}`; - const asset = findAssetOf(project, instrument, 'instrument'); - if (seen.has(key) || !asset) return []; - seen.add(key); - return instrumentBuildFor(asset, note, sources).layers; - }); + const { layers, files } = sequenceNeeds(this.project(), sequence); + await soundLibrary.prepare(layers, files); } } diff --git a/tests/core/keep-on-scene-load.test.ts b/tests/core/keep-on-scene-load.test.ts index 6a992e4e..fe20dd3c 100644 --- a/tests/core/keep-on-scene-load.test.ts +++ b/tests/core/keep-on-scene-load.test.ts @@ -58,6 +58,8 @@ function start(f: Fixture, audio?: AudioSink) { playNote: () => 0, playSequence: () => 0, stopSequence: () => {}, + playClip: () => 0, + stopPlayback: () => {}, release: () => {}, tune: () => {}, length: () => 0, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index cdde22c8..67cd018b 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2133,6 +2133,57 @@ const CASES: Record = { after: (ctx) => expect(ctx.silenced).toEqual([]), }, ], + PlaySong: [ + { + name: 'no song is a no-op that still continues the flow', + inputs: { song: '' }, + next: 'exec', + outputs: { playback: 0 }, + after: (ctx) => expect(ctx.songs).toEqual([]), + }, + { + name: 'hands the song on with its volume and fade in, looping and owned unless asked otherwise', + inputs: { song: 'song-1', volume: 0.5, fadeIn: 2, loop: true }, + next: 'exec', + outputs: { playback: 1 }, + after: (ctx) => + expect(ctx.songs).toEqual([ + { songId: 'song-1', volume: 0.5, fadeIn: 2, loop: true, detached: false }, + ]), + }, + { + name: 'plays once and detached when asked to', + inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true }, + next: 'exec', + after: (ctx) => expect(ctx.songs[0]).toMatchObject({ loop: false, detached: true }), + }, + ], + StopSong: [ + { + name: 'stops the song it names over its fade out', + inputs: { song: 'song-1', fadeOut: 1.5 }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual(['song song-1 (fade 1.5)']), + }, + { + name: 'naming none stops every song', + inputs: { song: '', fadeOut: 0 }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual(['song (fade 0)']), + }, + { + name: 'with a playback connected stops only that one, whatever song is named', + inputs: { song: 'song-1', playback: 4, fadeOut: 2 }, + next: 'exec', + after: (ctx) => expect(ctx.silenced).toEqual(['playback 4 (fade 2)']), + }, + { + name: 'a connected playback of 0 – a Play song that played nothing – stops nothing', + inputs: { song: 'song-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 index c7b78d41..56923e47 100644 --- a/tests/core/pattern-play.test.ts +++ b/tests/core/pattern-play.test.ts @@ -27,6 +27,9 @@ function fakeHost() { stopped: [] as { voices: number[]; letRing: boolean }[], pumps: new Set<() => void>(), loading: [] as (() => void)[], + levels: [] as { id: number; gain: number; at: number; seconds: number }[], + timers: [] as { at: number; then: () => void }[], + closed: [] as number[], }; const playbacks = new Playbacks({ now: () => host.time, @@ -41,12 +44,19 @@ function fakeHost() { return () => host.pumps.delete(pump); }, prepare: () => new Promise((resolve) => host.loading.push(resolve)), + level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }), + after: (at, then) => host.timers.push({ at, then }), + closed: (id) => host.closed.push(id), }); 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 () => { @@ -71,8 +81,8 @@ function bar(struck: ScheduledNote[], loop: boolean, sounding = new Set( }, tempo: 120, length: 4 * Q, - loop, - layers: [], + loop: loop ? { start: 0, end: 4 * Q } : null, + needs: { layers: [], files: [] }, strike: (note) => { struck.push(note); sounding.add(next); @@ -210,12 +220,12 @@ describe('Play pattern', () => { 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).toMatchObject({ tempo: 90, length: 2 * 4 * Q, loop: { start: 0, end: 2 * 4 * Q } }); 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); + expect(asked?.play.needs.layers.length).toBe(2); }); it('strikes each note through the instrument at the node’s volume, owned like the playing', () => { @@ -223,7 +233,7 @@ describe('Play pattern', () => { const audio = recordingAudio(); startWorld(f, { behaviors: true, audio }).frame(); const [asked] = audio.sequences; - expect([asked?.owner, asked?.play.loop]).toEqual(['', false]); + expect([asked?.owner, asked?.play.loop]).toEqual(['', null]); 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 }, @@ -250,7 +260,7 @@ describe('Stop pattern', () => { 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.stoppedSequences).toEqual([`pattern:${f.patternId} (let ring)`, 'pattern:']); expect(audio.stoppedVoices).toEqual([1001]); }); }); diff --git a/tests/core/song-play.test.ts b/tests/core/song-play.test.ts new file mode 100644 index 00000000..3aa40be3 --- /dev/null +++ b/tests/core/song-play.test.ts @@ -0,0 +1,325 @@ +/** + * A game plays a song through Play song and ends it through Stop song (#336): from its start, then + * round the loop of its ruler, its tracks on buses of their own, the whole of it faded in and out. + */ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateSoundCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands'; +import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset'; +import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; +import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler'; +import { newItem, trackBus } from '../../src/core/music/song'; +import { type AudioSink, type ClipTiming, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; +import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; + +const FILE = `${'b2'.repeat(32)}.wav`; +const Q = 480; +const BAR = 4 * Q; + +/** An audio clock moved by hand, its timers run as it passes them. */ +function fakeHost() { + const host = { + time: 0, + sounding: new Set(), + pumps: new Set<() => void>(), + loading: [] as (() => void)[], + levels: [] as { id: number; gain: number; at: number; seconds: number }[], + timers: [] as { at: number; then: () => void }[], + closed: [] as number[], + }; + const playbacks = new Playbacks({ + now: () => host.time, + latency: () => 0, + playing: (voice) => host.sounding.has(voice), + stop: (voices) => { + 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)), + level: (id, gain, at, seconds) => host.levels.push({ id, gain, at, seconds }), + after: (at, then) => host.timers.push({ at, then }), + closed: (id) => host.closed.push(id), + }); + 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 }; +} + +/** + * Two bars at 120 BPM, two seconds each: a note on the first beat of each and a clip on the second + * bar. Looped, the second bar is its loop. + */ +function song(struck: (ScheduledNote | ScheduledClip)[], options: Partial = {}): SequencePlay { + let next = 100; + const voice = () => next++; + return { + sequence: { + notes: [0, BAR].map((tick, i) => ({ tick, length: Q, note: 60 + i, velocity: 1, instrument: 'piano' })), + clips: [{ tick: BAR, length: Q, sound: 'drum', fx: '', offset: 0, volume: 1, bus: 'track' }], + }, + tempo: 120, + length: 2 * BAR, + loop: { start: BAR, end: 2 * BAR }, + needs: { layers: [], files: [] }, + strike: (note) => (struck.push(note), voice()), + strikeClip: (clip) => (struck.push(clip), voice()), + ...options, + }; +} + +const what = (struck: (ScheduledNote | ScheduledClip)[]) => + struck.map((s) => ('note' in s ? `note ${s.note}` : `clip ${s.sound}`)); + +describe('the songs a game plays', () => { + it('plays the intro once, then runs round the loop, clips with the notes', async () => { + const { playbacks, run, loaded } = fakeHost(); + const struck: (ScheduledNote | ScheduledClip)[] = []; + playbacks.start(5, 'song:a', song(struck), ''); + await loaded(); + run(9); + expect(what(struck).slice(0, 6)).toEqual([ + 'note 60', + 'note 61', + 'clip drum', + 'note 61', + 'clip drum', + 'note 61', + ]); + const notes = struck.filter((s): s is ScheduledNote => 'note' in s); + expect(notes[1]!.at - notes[0]!.at).toBeCloseTo(2, 9); + expect(notes[2]!.at - notes[1]!.at).toBeCloseTo(2, 9); + expect(playbacks.current()[0]?.clips[0]).toMatchObject({ sound: 'drum', bus: 'track' }); + }); + + it('rises from silence to its volume over its fade in, from its first note on', async () => { + const { host, playbacks, loaded } = fakeHost(); + playbacks.start(5, 'song:a', song([], { volume: 0.5, fadeIn: 3 }), ''); + host.time = 1; + await loaded(); + expect(host.levels).toEqual([{ id: 5, gain: 0.5, at: 1 + LEAD, seconds: 3 }]); + }); + + it('fades out over its fade, playing on meanwhile, then is gone; a hard stop then ends it at once', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + const struck: (ScheduledNote | ScheduledClip)[] = []; + playbacks.start(5, 'song:a', song(struck), ''); + playbacks.start(6, 'song:a', song([]), ''); + await loaded(); + run(1); + playbacks.stopNamed('song:a', false, 4); + expect( + host.levels.slice(-2).map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(9), seconds]), + ).toEqual([ + [5, 0, 1, 4], + [6, 0, 1, 4], + ]); + run(2); + expect([playbacks.has(5), playbacks.current()[0]?.fading]).toEqual([true, expect.closeTo(5, 9)]); + expect(struck.length).toBeGreaterThan(1); + playbacks.stopId(6); + expect(playbacks.current().map((p) => p.id)).toEqual([5]); + run(2.1); + expect([playbacks.has(5), host.pumps.size, host.closed]).toEqual([false, 0, [6, 5]]); + }); + + it('never starts when a fade out began while its files were loading', async () => { + const { host, playbacks, run, loaded } = fakeHost(); + const struck: (ScheduledNote | ScheduledClip)[] = []; + playbacks.start(5, 'song:a', song(struck), ''); + playbacks.stopId(5, false, 1); + await loaded(); + run(2); + expect([struck.length, host.pumps.size, playbacks.has(5)]).toEqual([0, 0, false]); + }); + + it('goes by its own name, by its kind and by none – a kind never reaches the other', async () => { + expect(['song:a', 'song:', '', 'pattern:', 'song:b'].map((name) => goesBy('song:a', name))).toEqual([ + true, + true, + true, + false, + false, + ]); + const { playbacks, loaded } = fakeHost(); + playbacks.start(1, 'song:a', song([]), ''); + playbacks.start(2, 'pattern:p', song([]), ''); + await loaded(); + playbacks.stopNamed('song:'); + expect(playbacks.current().map((p) => p.id)).toEqual([2]); + }); +}); + +/** + * A scene whose behavior plays a song on start: on its first track a two-note pattern at bar 1, on + * its second a sound at bar 2, the second track at -6 dB and panned left, the third soloed. + */ +function playingSong(params: Record = {}) { + 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', 'Riff'); + f.stack.execute(pattern); + f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', lead.id)); + f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1)); + f.stack.execute( + new SetPatternNotesCommand(pattern.asset.id, [ + { id: 'n1', tick: 0, length: Q, note: 60, velocity: 1 }, + { id: 'n2', tick: Q, length: Q, note: 64, velocity: 1 }, + ]), + ); + const create = new CreateAssetCommand('song', 'Theme'); + f.stack.execute(create); + const id = create.asset.id; + const asset = () => f.project.assets.find((a) => a.id === id) as SongAsset; + f.stack.execute(new SetAssetPropertyCommand(id, 'tempo', 90)); + const [one, two, three] = asset().tracks; + f.stack.execute( + new SetSongCommand(id, { + tracks: asset().tracks.map((t, i) => (i === 1 ? { ...t, volume: -6, pan: -1 } : t)), + items: [ + newItem({ track: one!.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }), + newItem({ track: two!.id, kind: 'sound', asset: sound.asset.id, tick: BAR, length: BAR }), + ], + }), + ); + f.stack.execute(new SetSongLoopCommand(id, { start: BAR, end: 2 * BAR })); + 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('PlaySong', { song: id, volume: 0.8, fadeIn: 2, ...params }); + edit.connect(start, 'exec', play, 'exec'); + return { ...f, songId: id, tracks: [one!.id, two!.id, three!.id], lead, edit, play }; +} + +function recordingAudio() { + const sink = { + ...SILENT_AUDIO, + sequences: [] as { name: string; play: SequencePlay; owner: string }[], + notes: [] as { name: string; volume: number; owner: string; bus?: string; at?: number }[], + clips: [] as { file: string; clip: ClipTiming }[], + stopped: [] as string[], + 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, ...timing }); + return 1; + }) satisfies AudioSink['playNote'], + playClip: (file: string, clip: ClipTiming) => (sink.clips.push({ file, clip }), 2), + stopSequence: (name: string, _letRing = false, fade = 0) => sink.stopped.push(`${name} (fade ${fade})`), + stopPlayback: (id: number, fade: number) => sink.stopped.push(`playback ${id} (fade ${fade})`), + }; + return sink; +} + +describe('Play song', () => { + it('hands the song to the player: its tempo, intro then loop, its tracks and the level of the whole', () => { + const f = playingSong(); + 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, asked?.owner]).toEqual([`song:${f.songId}`, logic.id]); + expect(asked?.play).toMatchObject({ + tempo: 90, + length: 4 * BAR, + loop: { start: BAR, end: 2 * BAR }, + volume: 0.8, + fadeIn: 2, + }); + expect(asked?.play.sequence.notes.map((n) => [n.tick, n.note])).toEqual([ + [0, 60], + [Q, 64], + ]); + expect(asked?.play.needs.files).toEqual([FILE]); + expect(asked?.play.buses?.map((b) => [b.name, +b.gain.toFixed(3), b.pan])).toEqual([ + [trackBus(f.songId, f.tracks[0]!), 1, 0], + [trackBus(f.songId, f.tracks[1]!), 0.501, -1], + [trackBus(f.songId, f.tracks[2]!), 1, 0], + [expect.any(String), 1, 0], + ]); + }); + + it('strikes notes on their track, owned like the playing, and clips at their volume on theirs', () => { + const f = playingSong({ detached: true, loop: false, volume: 3 }); + const audio = recordingAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + const [asked] = audio.sequences; + expect([asked?.owner, asked?.play.loop, asked?.play.volume]).toEqual(['', null, 1]); + const bus = trackBus(f.songId, f.tracks[1]!); + asked!.play.strike({ + at: 3, + duration: 0.25, + note: 60, + velocity: 1, + instrument: f.lead.id, + tempo: 90, + bus, + }); + asked!.play.strikeClip!({ + at: 4, + duration: 2, + offset: 0.5, + sound: f.project.assets.find((a) => a.type === 'sound')!.id, + fx: '', + volume: 0.5, + bus, + tempo: 90, + }); + expect(audio.notes).toEqual([ + { name: `instrument:${f.lead.id}`, volume: 1, owner: '', at: 3, tempo: 90, bus }, + ]); + expect(audio.clips).toEqual([ + { file: FILE, clip: { at: 4, offset: 0.5, duration: 2, volume: 0.5, bus } }, + ]); + }); +}); + +describe('Stop song', () => { + it('stops one song over its fade, every song for none, and one playing by its number', () => { + const f = playingSong(); + const one = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 1.5 }); + const all = f.edit.addNode('StopSong', { song: '' }); + const byNumber = f.edit.addNode('StopSong', { song: f.songId, fadeOut: 2 }); + 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.stopped).toEqual([ + `song:${f.songId} (fade 1.5)`, + 'song: (fade 0)', + 'playback 1001 (fade 2)', + ]); + }); +}); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 8806af5a..dd606903 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -409,6 +409,8 @@ function fakeAudio(): AudioSink & { playNote: () => 0, playSequence: () => 0, stopSequence: () => {}, + playClip: () => 0, + stopPlayback: () => {}, release: () => {}, length: () => 0.25, stop: () => {}, diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index 68621ecc..bcf34fc3 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -45,6 +45,8 @@ function fakeAudio(): AudioSink & { playNote: () => 0, playSequence: () => 0, stopSequence: () => {}, + playClip: () => 0, + stopPlayback: () => {}, release: () => {}, tune: () => {}, length: () => 0, diff --git a/tests/helpers.ts b/tests/helpers.ts index 955f5032..b378bfa0 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -228,6 +228,8 @@ export type FakeContext = NodeContext & { }[]; /** 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 }[]; + /** The songs the context was asked to play, numbered the same way. */ + songs: { songId: string; volume: number; fadeIn: 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. */ @@ -292,6 +294,11 @@ export function fakeContext(overrides: Partial = {}): FakeContext { ctx.patterns.push({ patternId, volume, loop, detached }), stopPattern: (patternId, letRing) => ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`), + songs: [], + playSong: (songId, volume, fadeIn, loop, detached) => + ctx.songs.push({ songId, volume, fadeIn, loop, detached }), + stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`), + stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`), // 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/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index eed28ea6..fdc83616 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -7,6 +7,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { FxEnvelopePlan, FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes'; +import type { SequencePlay } from '../../src/core/music/playbacks'; +import type { ScheduledNote } from '../../src/core/music/Scheduler'; import type { PitchOptions } from '../../src/viewport/pitchWorklet'; import { SoundLibrary } from '../../src/viewport/SoundLibrary'; @@ -1413,3 +1415,95 @@ describe('clips and tracks of a song (#334)', () => { expect(library.peaks(FILE)).toBe(peaks); }); }); + +describe('the patterns and songs a game plays (#336)', () => { + /** A sequence of a note on a track and one on a track the song does not have, both at its start. */ + function sequence( + strike: (note: ScheduledNote) => number, + fields: Partial = {}, + ): SequencePlay { + const note = { tick: 0, length: 480, velocity: 1, instrument: 'i' }; + return { + sequence: { + notes: [ + { ...note, note: 60, bus: 'song:s:t' }, + { ...note, note: 62, bus: 'song:s:gone' }, + ], + }, + tempo: 120, + length: 1920, + loop: null, + needs: { layers: [], files: [] }, + volume: 0.5, + fadeIn: 2, + buses: [{ name: 'song:s:t', gain: 0.25, pan: -1 }], + strike, + ...fields, + }; + } + + it('hears each playing through a master of its own, silent until it starts, its tracks into it', async () => { + const library = new SoundLibrary(); + const struck: ScheduledNote[] = []; + const id = library.playSequence( + 'song:s', + sequence((note) => (struck.push(note), 0)), + 'logic', + ); + expect(library.busLevels()).toEqual([ + { name: `play:${id}`, gain: 0, pan: 0 }, + { name: `play:${id}:song:s:t`, gain: 0.25, pan: -1 }, + ]); + expect(wires).toEqual(['gain > panner', 'panner > destination', 'gain > panner', 'panner > gain']); + await settle(); + expect(struck.map((note) => note.bus)).toEqual([`play:${id}:song:s:t`, `play:${id}`]); + // It rises from silence with its first note, 50 ms ahead, to its volume over its fade in. + expect(gains[0]!.ramps).toEqual([ + ['set', 0, 0.05], + ['linear', 0.5, 2.05], + ]); + library.stopSequence('song:', false, 0); + expect(library.playbacks()).toEqual([]); + clockTo(); + expect(library.busLevels()).toEqual([]); + expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain', 'panner', 'gain', 'panner']); + }); + + it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => { + const library = new SoundLibrary(); + library.playSequence( + 'pattern:p', + sequence(() => 77, { length: 10, buses: [] }), + '', + ); + await settle(); + expect(() => library.playbacks()).not.toThrow(); + expect(library.playbacks()).toEqual([]); + clockTo(); + expect(library.busLevels()).toEqual([]); + }); + + it('a playing without notes or clips is none', () => { + const library = new SoundLibrary(); + const empty = sequence(() => 1, { sequence: { notes: [], clips: [] } }); + expect(library.playSequence('song:s', empty, '')).toBe(0); + expect(library.busLevels()).toEqual([]); + }); + + it('nothing that reads the clock wakes it while the toolbar holds it', () => { + const library = new SoundLibrary(); + library.time(); + let resumes = 0; + (library as unknown as { context: { resume: () => Promise } }).context.resume = () => { + resumes += 1; + return Promise.resolve(); + }; + contextState = 'suspended'; + library.setSuspended(true); + library.time(); + expect(resumes).toBe(0); + library.setSuspended(false); + library.time(); + expect(resumes).toBe(2); + }); +}); diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index 984e7108..d095a0e0 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -361,7 +361,7 @@ export async function pattern(t) { edges: [edge('pt64-e3', 'pt64-start', 'exec', 'pt64-play', 'exec')], }, }); - const patterns = () => json('__gm.sound.patterns()'); + const patterns = () => json('__gm.sound.playbacks()'); await evaluate('__gm.sound.stopAll()'); await play(); const game = await waitFor(patterns, (list) => list[0]?.planned.length >= 6, 40); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index e1794908..5c1818c7 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.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'; /** * Songs and their tracks (#334). "+ → Song" opens the tab on four empty tracks. A pattern and a @@ -34,6 +34,9 @@ export async function song(t) { drag, shot, showTab, + play, + stop, + clickToolbar, } = t; const waitFor = async (read, done, tries = 25) => { let value = await read(); @@ -346,6 +349,103 @@ export async function song(t) { JSON.stringify(opened), ); + // 65k. A game plays the song through Play song (#336): the clip once as the intro, then round the + // loop of its ruler, each track on a bus inside a master of the playing. The pause of the + // toolbar holds the clock; S fires Stop song, which fades it out, then it is gone. + await evaluate('__gm.sound.stopAll()'); + await mutate(` + const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}); + song.tracks.forEach((track, i) => Object.assign(track, { mute: false, solo: false, volume: i === 1 ? -6 : 0, pan: i === 1 ? -0.5 : 0 })); + const base = song.items[0]; + song.items = [ + { ...base, id: 'sg65-i1', track: song.tracks[0].id, kind: 'sound', asset: ${JSON.stringify(SOUND)}, tick: 0, length: ${BAR}, offset: 0 }, + { ...base, id: 'sg65-i2', track: song.tracks[1].id, kind: 'pattern', asset: ${JSON.stringify(PATTERN)}, tick: ${BAR}, length: ${BAR}, offset: 0 }, + ]; + song.loop = { start: ${BAR}, end: ${2 * BAR} };`); + await showTab('scene'); + const rig = await scaffoldBehavior(t, { + assetId: 'sg65-logic', + name: 'Music 65', + boxId: 'sg65-box', + logicId: 'sg65-brain', + y: LANES.sound, + graph: { + nodes: [ + node('sg65-start', 'OnStart', 0, 0), + node('sg65-play', 'PlaySong', 260, 0, { song: id, volume: 0.8, fadeIn: 1, loop: true }), + node('sg65-s', 'OnKeyDown', 0, 200, { key: 's' }), + node('sg65-stop', 'StopSong', 260, 200, { song: id, fadeOut: 1 }), + ], + edges: [ + edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'), + edge('sg65-e4', 'sg65-s', 'exec', 'sg65-stop', 'exec'), + ], + }, + }); + const playbacks = () => json('__gm.sound.playbacks()'); + await play(); + const game = await waitFor(playbacks, (list) => list[0]?.planned.length >= 4, 60); + const [run] = game; + const master = `play:${run?.id}`; + const gameNotes = run?.planned ?? []; + const gameClip = run?.clips[0]; + check( + 'Play song plays the clip once as the intro, then the pattern round the loop, 2 s a round, on their tracks', + game.length === 1 && + run.name === `song:${id}` && + run.owner !== '' && + run.clips.length === 1 && + gameClip?.sound === SOUND && + gameClip.bus === busOf(tracks[0]) && + gameNotes.every((n) => n.bus === busOf(tracks[1]) && n.tempo === 120) && + Math.abs(gameNotes[0].at - gameClip.at - 2) < 1e-6 && + Math.abs(gameNotes[1].at - gameNotes[0].at - 1) < 1e-6 && + Math.abs(gameNotes[2].at - gameNotes[0].at - 2) < 1e-6, + JSON.stringify({ game: game.map((g) => ({ ...g, planned: g.planned.length })), gameNotes }), + ); + const gameBuses = await json('__gm.song.buses()'); + const gameBus = (name) => gameBuses.find((b) => b.name === name); + check( + 'the playing has a master at its volume, the tracks inside it as they are set', + gameBus(master)?.gain === 0.8 && + gameBus(`${master}:${busOf(tracks[0])}`)?.gain === 1 && + Math.abs(gameBus(`${master}:${busOf(tracks[1])}`)?.gain - 10 ** (-6 / 20)) < 1e-6 && + gameBus(`${master}:${busOf(tracks[1])}`)?.pan === -0.5, + JSON.stringify(gameBuses), + ); + await clickToolbar('pause'); + await sleep(400); + const heldClock = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }'); + await sleep(300); + const heldLater = await json('{ state: __gm.sound.clockState(), at: __gm.sound.clock() }'); + await clickToolbar('pause'); + await sleep(200); + const resumed = await json('__gm.sound.clockState()'); + check( + 'the pause of the toolbar holds the clock while the song plays, and resuming lets it run', + heldClock.state === 'suspended' && + heldLater.state === 'suspended' && + heldLater.at === heldClock.at && + resumed === 'running', + JSON.stringify({ heldClock, heldLater, resumed }), + ); + await press('s'); + const fading = await waitFor(playbacks, (list) => list[0]?.fading !== null); + const fadedOut = await waitFor(playbacks, (list) => list.length === 0, 40); + await sleep(100); + const leftBuses = (await json('__gm.song.buses()')).filter((b) => b.name.startsWith(master)); + await stop(); + check( + 'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone', + fading.length === 1 && + fading[0].fading !== null && + fading[0].playing && + fadedOut.length === 0 && + leftBuses.length === 0, + JSON.stringify({ fading: fading.map((g) => g.fading), fadedOut, leftBuses }), + ); + const left = await rig.remove(); + // 65j. The step takes back everything it made. await evaluate('__gm.sound.stopAll()'); await mutate(` @@ -358,6 +458,9 @@ export async function song(t) { 'the step leaves nothing of its own behind', (await json( `__gm.state.project.assets.filter((a) => a.id.startsWith('sg65') || a.type === 'song').length`, - )) === 0, + )) === 0 && + left.objects === 0 && + left.assets === 0, + JSON.stringify(left), ); }