diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 01dd0cdd..61911e58 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -3,7 +3,7 @@ import type { CompositionPlayer } from '../model/composition'; import type { Mat } from '../model/matrix'; import type { Position, PropertyBag } from '../model/Properties'; import type { FxParams } from '../soundfx/nodes'; -import type { HeardPosition } from '../music/beats'; +import type { HeardPosition, Quantize } from '../music/beats'; import type { RecordingSettings } from '../recording/recording'; import { type ChainTransform, composeAlongChain } from '../model/walk'; import { enabledAlongChain } from '../properties/enabled'; @@ -305,8 +305,9 @@ export interface NodeContext { * 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. - * `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. Returns its number; 0 where - * there is nothing to play. + * `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. With `live` it plays its notes + * even where it has a bake, so its tracks can be turned (#338). Returns its number; 0 where there is + * nothing to play. */ playSong( songId: string, @@ -317,7 +318,18 @@ export interface NodeContext { fx: string, fxParams: FxParams, bus: string, + live: boolean, ): number; + /** + * Turns one track of a playing song (#338) to `volumeDb` on top of its own level, over `fade` + * seconds from where `quantize` lands; a playing of its bake has no tracks, which is logged. + */ + setSongTrack(playback: number, track: string, volumeDb: number, fade: number, quantize: Quantize): void; + /** + * Plays `songId` in place of a playing song (#338), `''` the same one, from the marker `marker` + * (`''` its start), where `quantize` lands: over `fade` seconds, else in a cut. It keeps its number. + */ + switchSong(playback: number, songId: string, marker: string, quantize: Quantize, fade: number): void; /** 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. */ diff --git a/src/core/graph/nodes/action/PlaySong.ts b/src/core/graph/nodes/action/PlaySong.ts index 7238e529..0c7318ff 100644 --- a/src/core/graph/nodes/action/PlaySong.ts +++ b/src/core/graph/nodes/action/PlaySong.ts @@ -33,8 +33,10 @@ export const PlaySong: NodeDef = { 'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' + 'left empty, the one the song names, and None is none. ' + '"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' + - 'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' + - 'nothing to play. "bus" is the bus all of it is heard on; left empty, the one the song names.', + 'number of this playing, for Stop song, Set song track, Switch song and Set sound FX parameters; ' + + '0 where the song has nothing to play. "bus" is the bus all of it is heard on; left empty, the ' + + 'one the song names. A song with a bake plays the bake, one sound for all its tracks; with "live ' + + 'tracks" it plays its notes instead, as the Song tab does, so Set song track can turn its tracks.', inputs: [ exec(), songPort(), @@ -45,6 +47,7 @@ export const PlaySong: NodeDef = { soundFxPort(), fxParamsPort(), playedBusPort(), + checkPort('live', 'live tracks', 'Live tracks', false), ], outputs: [exec(), num('playback')], params: [], @@ -60,6 +63,7 @@ export const PlaySong: NodeDef = { asString(inputs.fx), fxParamsOf(inputs.fxParams), asString(inputs.bus), + asBoolean(inputs.live), ) : 0; return { next: 'exec', outputs: { playback } }; diff --git a/src/core/graph/nodes/action/songSync.ts b/src/core/graph/nodes/action/songSync.ts new file mode 100644 index 00000000..0d0a4bb8 --- /dev/null +++ b/src/core/graph/nodes/action/songSync.ts @@ -0,0 +1,110 @@ +import { heldQuantize, QUANTIZE_OPTIONS } from '../../../music/beats'; +import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../../music/level'; +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asNumber, asString } from '../../values'; +import { exec, num, withDefault } from '../shared'; +import { songPort } from './PlaySong'; + +/** Where a change to the music lands (#338): at once, or on the next beat, bar or marker of the playing. */ +const quantizePort = (): PortDef => ({ + key: 'quantize', + label: 'quantize', + type: 'string', + default: 'bar', + field: { key: 'quantize', label: 'Quantize', kind: 'enum', default: 'bar', options: [...QUANTIZE_OPTIONS] }, +}); + +/** The playing a node changes: not typed in, as it is a number Play song hands out. */ +const playbackPort = (): PortDef => ({ ...num('playback'), inline: false }); + +const trackPort = (): PortDef => ({ + key: 'track', + label: 'track', + type: 'string', + field: { key: 'track', label: 'Track', kind: 'asset', default: '', assetTypes: ['song'], tracks: true }, +}); + +const markerPort = (): PortDef => ({ + key: 'marker', + label: 'marker', + type: 'string', + field: { key: 'marker', label: 'Marker', kind: 'asset', default: '', assetTypes: ['song'], markers: true }, +}); + +const QUANTIZE = + '"quantize" says where it lands: Now, or on the next beat, bar or marker of the song as it is ' + + 'heard, planned on the audio clock to the sample; Next marker in a song without markers is the ' + + 'next bar.'; + +export const SetSongTrack: NodeDef = { + type: 'SetSongTrack', + label: 'Set song track', + category: 'action', + description: + 'Turns one track of a playing song up or down, by the "playback" number Play song handed out: ' + + '"volume" in decibels on top of the level the song gives it, 0 as the song has it, -60 silent – ' + + 'what goes into the track, its sound FX and its sends alike. It gets there over "fade" seconds. ' + + `${QUANTIZE} The track is picked as "Song / Track"; a track the playing song does not have is ` + + 'left alone. A song played from its bake has no tracks to turn: switch on "live tracks" in Play ' + + 'song, else the log says so. A Switch song to the same song keeps what was turned.', + inputs: [ + exec(), + playbackPort(), + trackPort(), + withDefault( + { + ...num('volume', 'volume (dB)'), + field: { + key: 'volume', + label: 'Volume (dB)', + kind: 'number', + default: 0, + min: TRACK_MIN_DB, + max: TRACK_MAX_DB, + }, + }, + 0, + ), + withDefault(num('fade'), 1), + quantizePort(), + ], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + ctx.setSongTrack( + asNumber(inputs.playback), + asString(inputs.track), + asNumber(inputs.volume), + asNumber(inputs.fade), + heldQuantize(inputs.quantize), + ); + return { next: 'exec' }; + }, +}; + +export const SwitchSong: NodeDef = { + type: 'SwitchSong', + label: 'Switch song', + category: 'action', + description: + 'Plays another song, or the same one from a marker, in place of a playing song, by the ' + + '"playback" number Play song handed out; the playing keeps its number, so Stop song and Set ' + + 'song track go on reaching it. "song" left empty is the same song, "marker" left empty its ' + + `start. ${QUANTIZE} With "fade" 0 it is a cut on that spot; else the old song falls silent over ` + + 'that many seconds as the new one rises from it. The new song plays with the volume, loop, ' + + 'bus, sound FX and "live tracks" of its Play song. A marker the song does not have is logged, ' + + 'and nothing changes.', + inputs: [exec(), playbackPort(), songPort(), markerPort(), quantizePort(), withDefault(num('fade'), 0)], + outputs: [exec()], + params: [], + run: (ctx, inputs) => { + ctx.switchSong( + asNumber(inputs.playback), + asString(inputs.song), + asString(inputs.marker), + heldQuantize(inputs.quantize), + asNumber(inputs.fade), + ); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 83fea71f..30a53048 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -31,6 +31,7 @@ import { PlayPattern } from './action/PlayPattern'; import { StopPattern } from './action/StopPattern'; import { PlaySong } from './action/PlaySong'; import { StopSong } from './action/StopSong'; +import { SetSongTrack, SwitchSong } from './action/songSync'; import { SetZ } from './action/SetZ'; import { Spawn } from './action/Spawn'; import { StopGame } from './action/StopGame'; @@ -328,6 +329,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ StopPattern, PlaySong, StopSong, + SetSongTrack, + SwitchSong, SongPosition, SetBusVolume, BusVolume, diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index 3f1c93fb..a808003e 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -163,11 +163,14 @@ export class Scheduler { return this.ended && this.voices.every((voice) => !this.host.playing(voice)); } - /** Plays from `from` ticks of the sequence, or from where it was last started or sent. */ - start(from = this.from): void { + /** + * Plays from `from` ticks of the sequence, or from where it was last started or sent – heard from + * `at` on the audio clock where that is later than it can be (#338). + */ + start(from = this.from, at = -Infinity): void { this.stop(); this.from = Math.max(0, Math.round(from)); - this.anchor = this.host.now() + LEAD + this.ahead(); + this.anchor = Math.max(this.host.now() + LEAD + this.ahead(), at); this.until = 0; this.before = null; this.ended = false; diff --git a/src/core/music/beats.ts b/src/core/music/beats.ts index a0dcd130..004bceff 100644 --- a/src/core/music/beats.ts +++ b/src/core/music/beats.ts @@ -210,6 +210,63 @@ export function newestPassed(list: readonly T[]): T[] { return first ? list.filter((p) => p.late === first.late) : []; } +/** Where a change a game asks of its music lands (#338): at once, or on the next of these. */ +export type Quantize = 'now' | 'beat' | 'bar' | 'marker'; + +export const QUANTIZE_OPTIONS: readonly { value: Quantize; label: string }[] = [ + { value: 'now', label: 'Now' }, + { value: 'beat', label: 'Next beat' }, + { value: 'bar', label: 'Next bar' }, + { value: 'marker', label: 'Next marker' }, +]; + +export function heldQuantize(value: unknown): Quantize { + return QUANTIZE_OPTIONS.some((option) => option.value === value) ? (value as Quantize) : 'bar'; +} + +/** The spots a change waits for; a song without markers, or a pattern, waits for the next bar. */ +export function quantizeSpots( + quantize: Exclude, + meter: Meter, + markers: readonly SongMarker[], +): SpotsIn { + if (quantize === 'beat') return gridSpots(ticksPerBeat(meter)); + if (quantize === 'marker' && markers.length > 0) return tickSpots(markers.map((m) => m.tick)); + return gridSpots(ticksPerBar(meter)); +} + +/** The furthest ahead a spot is looked for on a stretch without end. */ +const LOOK_AHEAD = 600; + +/** The stretch that follows `stretch`; null after the last one of a playing that ends. */ +function stretchAfter(timeline: Timeline, stretch: Stretch): Stretch | null { + return stretch.end === Infinity ? null : stretchAt(timeline, stretch.end); +} + +/** + * The first spot at or after `elapsed` seconds of playing time, in seconds of playing time; null where + * none comes before a playing that ends does – a change then lands at once. + */ +export function nextSpot(timeline: Timeline, elapsed: number, spots: SpotsIn): number | null { + const { tempo, length } = timeline; + const low = ticksOfSeconds(Math.max(0, elapsed), tempo); + let stretch: Stretch | null = stretchAt(timeline, low); + for (let n = 0; stretch && n < MOST_STRETCHES; n++) { + const headLow = stretch.head + (Math.max(low, stretch.start) - stretch.start); + // One that runs on without end is looked through for `LOOK_AHEAD` seconds, not for ever. + let headHigh = + stretch.head + (Math.min(stretch.end, low + ticksOfSeconds(LOOK_AHEAD, tempo)) - stretch.start); + if (ends(timeline)) headHigh = Math.min(headHigh, length); + if (headHigh > headLow) { + const [first] = spots(headLow, headHigh, false, true).sort((a, b) => a - b); + if (first !== undefined) return secondsOfTicks(stretch.start + (first - stretch.head), tempo); + } + if (ends(timeline) && headHigh >= length) return null; + stretch = stretchAfter(timeline, stretch); + } + return null; +} + /** The newest bar line passed, if one is still to be told. */ export function passedBar(timeline: Timeline, before: number | null, now: number): PassedBeat | null { const [newest] = passed(timeline, before, now, gridSpots(ticksPerBar(timeline.meter))); diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index 21e9997d..7148fbcb 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -96,30 +96,59 @@ export interface Runner { readonly startedAt: number | null; /** Played to its end, and nothing of it is heard any more. */ readonly done: boolean; - start(from?: number): void; + /** Plays from `from` ticks, heard from `at` on the audio clock where that is still to come (#338). */ + start(from?: number, at?: number): void; stop(letRing?: boolean): void; } +/** What a change a game asks of a playing goes by (#338): its time, where it started, the clock now. */ +export interface PlayingClock { + time: PlayingTime; + /** The second of the audio clock its playing time 0 is heard at; null before it starts. */ + startedAt: number | null; + now: number; + /** The soonest second of the audio clock the change can be heard from. */ + soonest: number; +} + +/** The second of the audio clock a change to a playing lands at, from its clock (#338). */ +export type LandingAt = (clock: PlayingClock) => number; + +/** Seconds a hard cut between two playings takes, so it does not click. */ +export const CUT = 0.005; + +/** + * The audio side of the playings. Each is reached here by its `key`, the number its buses go by: the + * same as its `id` but where it took over another one's (#338). + */ export interface PlaybackHost extends Omit< SchedulerHost, 'play' | 'playClip' | 'ahead' | 'ramp' | 'automate' > { /** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */ - prepare(needs: SequenceNeeds, id: number): Promise; + prepare(needs: SequenceNeeds, key: number): Promise; /** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */ - ahead?(id: number): number; + ahead?(key: number): number; /** 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; + level(key: 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; + closed?(key: number): void; /** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */ - playBaked?(id: number, bake: BakePlay, at: number): number; + playBaked?(key: number, bake: BakePlay, at: number): number; } export interface Playback { id: number; + /** What its buses go by; its `id` but where it took over from another playing (#338). */ + key: number; + /** The level of the whole of it, as it was asked for. */ + volume: number; + /** The second of the audio clock another playing takes over from it at (#338); null while it is the one. */ + until: number | null; + /** It takes over from another playing of its number, once it is heard (#338). */ + successor: boolean; /** What stands for the pattern or song, as a file stands for a sound: `pattern:`, `song:`. */ name: string; owner: string; @@ -146,8 +175,11 @@ export interface PlayingTime { /** A playing and how far into its playing time it is heard now, null before it starts (#338). */ export interface HeardTime extends PlayingTime { id: number; + /** Told apart from the playing of its number it took over from (`Playback.key`). */ + key: number; name: string; heard: number | null; + baked: boolean; } /** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */ @@ -168,6 +200,112 @@ 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 playback = this.make(id, id, name, play, owner, false); + const asked = this.host.now(); + void this.host.prepare(play.needs, id).then(() => { + if (!this.list.includes(playback) || playback.fading !== null) return; + const from = play.from; + playback.time.from = + from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo)); + playback.runner.start(playback.time.from); + // From the first note heard, which is later where the buses are (#346). + const at = playback.runner.startedAt ?? this.host.now() + LEAD; + this.host.level(id, playback.volume, at, Math.max(0, play.fadeIn ?? 0)); + }); + } + + /** + * Plays `play` in place of the playing numbered `id` (#338), on its buses `key`, from `play.from` + * ticks: once its files are there, it is heard from the second `at` names for the playing it takes + * over from, which falls silent there – over `fade` seconds as this one rises over them, else in a + * cut. It keeps the number, so whatever knows the playing goes on reaching it. Asked again before + * that, the one still waiting is dropped and the playing heard now is the one to go. Where nothing + * of that number plays any more, it starts as soon as it can. + */ + switchTo( + id: number, + key: number, + name: string, + play: SequencePlay, + owner: string, + at: LandingAt, + fade: number, + ): void { + const now = this.host.now(); + const waiting = this.list.filter((p) => p.id === id && this.waits(p, now)); + this.close(waiting, false); + for (const p of this.list) + if (p.id === id && p.until !== null && p.until > now) { + p.until = null; + p.fading = null; + this.host.level(p.key, p.volume, now, 0); + } + const playback = this.make(id, key, name, play, owner, true); + void this.host.prepare(play.needs, key).then(() => { + if (!this.list.includes(playback) || playback.fading !== null) return; + const from = this.heardOne(id, playback); + const now = this.host.now(); + const ahead = this.host.ahead?.(key) ?? 0; + const soonest = now + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0); + const clock = from && { time: from.time, startedAt: from.runner.startedAt, now, soonest }; + playback.time.from = Math.max(0, Math.round(play.from ?? 0)); + playback.runner.start(playback.time.from, clock ? at(clock) : undefined); + const start = playback.runner.startedAt ?? soonest; + const over = fade > 0 ? fade : 0; + if (from) this.handOver(from, start, Math.max(over, CUT)); + this.host.level(key, playback.volume, start, over); + }); + } + + /** The playing of that number heard now, but `besides`: the one a switch takes over from. */ + private heardOne(id: number, besides: Playback): Playback | null { + return this.current().find((p) => p.id === id && p !== besides && p.until === null) ?? null; + } + + /** Falls silent from `at` over `fade` seconds and goes then: the one taken over from (#338). */ + private handOver(playback: Playback, at: number, fade: number): void { + const end = at + fade; + playback.until = at; + playback.fading = end; + this.host.level(playback.key, 0, at, fade); + this.host.after(end, () => { + if (this.list.includes(playback) && playback.fading === end) this.close([playback], false); + }); + } + + /** The clock of the playing of that number heard now (#338), and whether it is a bake; null where none is. */ + clockOf(id: number): (PlayingClock & { baked: boolean }) | null { + const playing = this.current().find((p) => p.id === id && p.until === null && !this.waits(p)); + if (!playing) return null; + const now = this.host.now(); + const { time, baked } = playing; + return { time, startedAt: playing.runner.startedAt, now, soonest: now + CUT, baked }; + } + + /** The playings of that number, the one heard now and one waiting to take over from it (#338). */ + named(id: number): readonly Readonly[] { + return this.current().filter((p) => p.id === id); + } + + /** Whether the buses `key` belong to a playing still to be heard. */ + holds(key: number): boolean { + return this.current().some((playback) => playback.key === key); + } + + /** A successor not heard yet: waiting on its files, or on the second it takes over at. */ + private waits(playback: Playback, now = this.host.now()): boolean { + const at = playback.runner.startedAt; + return playback.successor && (at === null || at > now); + } + + private make( + id: number, + key: number, + name: string, + play: SequencePlay, + owner: string, + successor: boolean, + ): Playback { const planned: ScheduledNote[] = []; const clips: ScheduledClip[] = []; const keep = (list: T[], item: T) => { @@ -175,12 +313,12 @@ export class Playbacks { if (list.length > KEPT) list.shift(); }; const strikeClip = play.strikeClip; - const ahead = () => this.host.ahead?.(id) ?? 0; + const ahead = () => this.host.ahead?.(key) ?? 0; const { bake } = play; const playBaked = this.host.playBaked?.bind(this.host); const runner: Runner = bake && playBaked - ? new BakedRun(this.host, ahead, (at) => playBaked(id, bake, at), bake.playback.end !== null) + ? new BakedRun(this.host, ahead, (at) => playBaked(key, bake, at), bake.playback.end !== null) : new Scheduler( { ...this.host, @@ -195,6 +333,7 @@ export class Playbacks { ); const playback: Playback = { id, + key, name, owner, runner, @@ -202,20 +341,13 @@ export class Playbacks { clips, fading: null, baked: runner instanceof BakedRun, + volume: play.volume ?? 1, + until: null, + successor, time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length }, }; this.list.push(playback); - const asked = this.host.now(); - void this.host.prepare(play.needs, id).then(() => { - if (!this.list.includes(playback) || playback.fading !== null) return; - const from = play.from; - playback.time.from = - from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo)); - runner.start(playback.time.from); - // From the first note heard, which is later where the buses are (#346). - const at = runner.startedAt ?? this.host.now() + LEAD; - this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0)); - }); + return playback; } /** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */ @@ -232,9 +364,15 @@ export class Playbacks { const list = this.current(); if (list.length === 0) return []; const heard = this.host.now() - this.host.latency(); - return list.map(({ id, name, runner, time }) => { + // Of a switch (#338) one is heard at a time: the old one up to where the new one is. + const told = list.filter( + ({ until, successor, runner }) => + (until === null || heard < until) && + (!successor || (runner.startedAt !== null && heard >= runner.startedAt)), + ); + return told.map(({ id, key, name, runner, time, baked }) => { const at = runner.startedAt; - return { id, name, ...time, heard: at === null ? null : heard - at }; + return { id, key, name, ...time, heard: at === null ? null : heard - at, baked }; }); } @@ -267,7 +405,7 @@ export class Playbacks { 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.level(playback.key, 0, now, fade); this.host.after(end, () => { if (this.list.includes(playback) && playback.fading === end) this.close([playback], false); }); @@ -280,7 +418,7 @@ export class Playbacks { this.list = this.list.filter((playback) => !going.includes(playback)); for (const playback of going) { playback.runner.stop(letRing); - this.host.closed?.(playback.id); + this.host.closed?.(playback.key); } } } @@ -312,10 +450,11 @@ class BakedRun implements Runner { return this.anchor !== null && this.ends && !this.host.playing(this.voice); } - start(): void { + /** From its start: a bake plays from nowhere else. Heard from `at` where that is still to come. */ + start(_from?: number, at = -Infinity): void { this.stop(); const ahead = this.ahead(); - this.anchor = this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0); + this.anchor = Math.max(this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0), at); this.voice = this.play(this.anchor); } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index d8aa3dff..334deda3 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -161,6 +161,8 @@ type Extras = K extends 'number' noFx?: boolean; /** Offers the markers of the songs, each as "Song / Marker", instead of the songs (#338). */ markers?: boolean; + /** Offers the tracks of the songs, each as "Song / Track", instead of the songs (#338). */ + tracks?: boolean; } : K extends 'texture' ? { uploadedOnly?: boolean; moving?: boolean } diff --git a/src/core/properties/assetRef.ts b/src/core/properties/assetRef.ts index a8380b90..be6e6bfc 100644 --- a/src/core/properties/assetRef.ts +++ b/src/core/properties/assetRef.ts @@ -33,7 +33,30 @@ export function assetRefLabel(project: Project | null, id: string): string { if (clip) return soundRefName(clip, markedName(clip.asset)); // A marker of a song (#338) reads "Song / Marker", as a clip does. const marker = project ? findSongMarker(project, id) : null; - return marker ? clipRefName(markedName(marker.song), marker.name) : `${id}?`; + if (marker) return clipRefName(markedName(marker.song), marker.name); + // So does a track of a song (#338). + const track = project ? findSongTrack(project, id) : null; + return track ? clipRefName(markedName(track.song), track.name) : `${id}?`; +} + +/** The first song with a track of that id (#338), with the track's name; null where none has one. */ +export function findSongTrack(project: Project, id: string): { song: Asset; name: string } | null { + if (id === '') return null; + for (const song of project.assets) { + if (song.type !== 'song') continue; + const track = song.tracks.find((t) => t.id === id); + if (track) return { song, name: track.name }; + } + return null; +} + +/** What a track field offers (#338): the tracks of every song, "Theme / Drums", in their order. */ +export function trackChoices(project: Project): { id: string; label: string }[] { + return assetRefChoices(project, ['song']).flatMap((song) => + song.type === 'song' + ? song.tracks.map((t) => ({ id: t.id, label: clipRefName(markedName(song), t.name) })) + : [], + ); } /** The marker of a song an id names (#338), with its song; null where none does. */ diff --git a/src/editor/agent/readTools.ts b/src/editor/agent/readTools.ts index 70366fab..81aba0c8 100644 --- a/src/editor/agent/readTools.ts +++ b/src/editor/agent/readTools.ts @@ -426,12 +426,13 @@ function nodeEntry({ id, def, params }: PaletteEntry): object { /** * A property's row without what only the inspector's drawing needs. `markers` says the field takes - * the id of a marker of a song (#338), as `get_asset` lists them, not the song's. + * the id of a marker of a song (#338), as `get_asset` lists them, not the song's; `tracks` the id of + * a track of one. */ function propertyRow(def: PropertyDef): object { const { key, label, kind } = def; const row: Record = { key, label, kind, default: def.default }; - for (const extra of ['options', 'min', 'max', 'assetTypes', 'markers'] as const) { + for (const extra of ['options', 'min', 'max', 'assetTypes', 'markers', 'tracks'] as const) { if (extra in def) row[extra] = (def as Record)[extra]; } return row; diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 8558fbde..34e912f2 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -19,6 +19,7 @@ import { NO_ASSET_LABEL, soundChoices, soundEntries, + trackChoices, } from '../../core/properties/assetRef'; import { NO_FX } from '../../core/properties/assetFx'; import { @@ -417,6 +418,7 @@ const REFERENCES: Record = { choices: (project, def) => { const extras = def.kind === 'asset' ? def : undefined; if (extras?.markers) return markerChoices(project); + if (extras?.tracks) return trackChoices(project); return [ ...(extras?.fromAsset ? [{ id: FROM_ASSET_BUS, label: FROM_ASSET_LABEL }] : []), ...(extras?.toComposition ? [{ id: TO_COMPOSITION_BUS, label: TO_COMPOSITION_LABEL }] : []), diff --git a/src/runtime/audioSwitch.ts b/src/runtime/audioSwitch.ts index 3616312b..3c3932ce 100644 --- a/src/runtime/audioSwitch.ts +++ b/src/runtime/audioSwitch.ts @@ -1,6 +1,6 @@ import type { EaseShare } from '../core/model/animation'; import type { MixBus } from '../core/music/mixer'; -import type { HeardTime, SequencePlay } from '../core/music/playbacks'; +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'; @@ -138,4 +138,18 @@ export class AudioSwitch implements AudioSink { playingTimes(): HeardTime[] { return this.both.flatMap((sink) => sink.playingTimes?.() ?? []); } + + switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean { + return this.of(id)?.switchSequence?.(id, name, play, at, fade) ?? false; + } + + setSongTrack( + id: number, + track: string, + gain: number, + fade: number, + at: LandingAt, + ): 'set' | 'baked' | 'none' { + return this.of(id)?.setSongTrack?.(id, track, gain, fade, at) ?? 'none'; + } } diff --git a/src/runtime/systems/Audio.ts b/src/runtime/systems/Audio.ts index e2784996..781c43eb 100644 --- a/src/runtime/systems/Audio.ts +++ b/src/runtime/systems/Audio.ts @@ -1,5 +1,5 @@ import type { EaseShare } from '../../core/model/animation'; -import type { HeardTime, SequencePlay } from '../../core/music/playbacks'; +import type { HeardTime, LandingAt, SequencePlay } from '../../core/music/playbacks'; import type { MixBus } from '../../core/music/mixer'; import type { FxParams, FxPlan, FxVoice } from '../../core/soundfx/nodes'; @@ -113,6 +113,24 @@ export interface AudioSink { * what beats, bars and `Song position` go by. A sink without it plays none to be followed. */ playingTimes?(): readonly HeardTime[]; + /** + * Plays `play` in place of the playing numbered `id` (#338), heard from the second `at` names and + * taking its number; the old one falls silent there, over `fade` seconds or in a cut. Says whether + * there was such a playing. A sink without it switches nothing. + */ + switchSequence?(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean; + /** + * Turns the track bus `track` of the playing numbered `id` to `gain` (#338), on top of its own + * level, over `fade` seconds from the second `at` names: `baked` where it plays its bake, `none` + * where there is no such playing or track. + */ + setSongTrack?( + id: number, + track: string, + gain: number, + fade: number, + at: LandingAt, + ): 'set' | 'baked' | 'none'; } /** diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index ac7fb892..6443815c 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -24,6 +24,8 @@ import { bakePlayback, validBake } from '../../core/music/bake'; import { clipFx, fxVoiceOf, sequenceNeeds } from '../../core/music/needs'; import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern'; import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler'; +import { nextSpot, type Quantize, quantizeSpots } from '../../core/music/beats'; +import { heldSongMarkers } from '../../core/music/markers'; import { dbToGain, songLength, @@ -31,8 +33,12 @@ import { songSequence, songTempo, SOUND_ITEM_MAX_DB, + TRACK_MAX_DB, + TRACK_MIN_DB, + trackBus, } from '../../core/music/song'; -import type { SequencePlay } from '../../core/music/playbacks'; +import type { LandingAt, SequencePlay } from '../../core/music/playbacks'; +import { findSongMarker } from '../../core/properties/assetRef'; import { songBuses } from '../../core/music/songBuses'; import { type MixRoute, playedBus, playedRoute, reachedBus } from '../../core/music/mixer'; import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes'; @@ -42,7 +48,7 @@ import type { World } from '../World'; import type { AudioSink, NoteTiming } from './Audio'; import type { Instance } from './behaviorPulses'; import type { Input } from './Input'; -import { heardPlayings, positionOf } from './musicPulses'; +import { type HeardPlaying, heardPlayings, positionOf } from './musicPulses'; import type { RecordingSink } from '../../core/recording/recording'; import { bodyOf, type CollisionSource, layerHolderOf, NOT_BLOCKED } from './solids'; @@ -207,8 +213,21 @@ export function behaviorContext( playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) => playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams, bus), stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing), - playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) => - playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams, bus), + playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => + playSong(run, songId, { + volume, + fadeIn, + loop, + owner: detached ? '' : entity.id, + fx, + fxParams, + bus, + live, + }), + setSongTrack: (playback, track, volumeDb, fade, quantize) => + setSongTrack(run, world, playback, track, volumeDb, heldFade(fade), quantize), + switchSong: (playback, songId, marker, quantize, fade) => + switchSong(run, world, playback, songId, marker, quantize, heldFade(fade)), stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)), stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)), soundLength: (assetId) => { @@ -434,48 +453,162 @@ function playPattern( ); } +/** How a Play song asked for a song (#338): what a Switch song plays the next one by. */ +interface SongAsk { + volume: number; + fadeIn: number; + loop: boolean; + owner: string; + fx: string; + fxParams: FxParams; + bus: string; + /** Its notes, even where it has a bake: its tracks can be turned then. */ + live: boolean; +} + +/** What each playing of a song was asked for, by its number, in the sink it plays in. */ +const songAsks = new WeakMap>(); +/** So many asks are kept before those of playings gone are let go. */ +const ASKS_KEPT = 64; + +function keepAsk(audio: AudioSink, id: number, ask: SongAsk): void { + let asks = songAsks.get(audio); + if (!asks) songAsks.set(audio, (asks = new Map())); + if (asks.size >= ASKS_KEPT) { + const playing = new Set((audio.playingTimes?.() ?? []).map((time) => time.id)); + for (const known of asks.keys()) if (!playing.has(known)) asks.delete(known); + } + asks.set(id, ask); +} + /** * 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. Its bake (#340) is played instead while - * it holds. Returns the number of the playing, 0 where there is no song or nothing in it to play. + * it holds, unless `live` (#338). Returns the number of the playing, 0 where there is no song or + * nothing in it to play. */ -function playSong( +function playSong(sources: Pick, songId: string, ask: SongAsk): number { + const play = songPlay(sources, songId, ask, 0); + const id = play ? sources.audio.playSequence(songVoiceName(songId), play, ask.owner) : 0; + if (id !== 0) keepAsk(sources.audio, id, ask); + return id; +} + +/** + * What a song plays as, asked for so (see `playSong`), from `from` ticks on (#338): a bake plays only + * from the start, so one asked to start further in plays its notes. + */ +function songPlay( { project, audio }: Pick, songId: string, - volume: number, - fadeIn: number, - loop: boolean, - owner: string, - fxId: string, - fxParams: FxParams, - bus: string, -): number { + ask: SongAsk, + from: number, +): SequencePlay | null { const song = findAssetOf(project, songId, 'song'); - if (!song) return 0; + if (!song) return null; const sequence = songSequence(project, song); const tempo = songTempo(song.props); - const fx = fxVoiceOf(project, pickedFx(fxId, song.props), tempo, fxParams); - const bake = validBake(project, song); - return audio.playSequence( - songVoiceName(songId), - { - sequence, - tempo, - length: songLength(song), - loop: loop ? songLoop(song) : null, - needs: sequenceNeeds(project, sequence), - volume: heldLevel(volume), - fadeIn: heldFade(fadeIn), - buses: songBuses(project, song), - ...(fx && { fx }), - ...heardOn(playedRoute(project, bus, songId)), - strike: (note) => strikeNote({ project, audio }, note, owner), - strikeClip: (clip) => playClip({ project, audio }, clip), - ...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, loop) } }), - }, - owner, + const fx = fxVoiceOf(project, pickedFx(ask.fx, song.props), tempo, ask.fxParams); + const bake = ask.live || from > 0 ? null : validBake(project, song); + return { + sequence, + tempo, + length: songLength(song), + loop: ask.loop ? songLoop(song) : null, + needs: sequenceNeeds(project, sequence), + volume: heldLevel(ask.volume), + fadeIn: heldFade(ask.fadeIn), + buses: songBuses(project, song), + ...(fx && { fx }), + ...heardOn(playedRoute(project, ask.bus, songId)), + strike: (note) => strikeNote({ project, audio }, note, ask.owner), + strikeClip: (clip) => playClip({ project, audio }, clip), + ...(bake && { bake: { file: bake.file, gain: bake.peak, playback: bakePlayback(bake, ask.loop) } }), + ...(from > 0 && { from }), + }; +} + +/** Where a change to a playing lands (#338): as soon as it can be heard, or on the next spot `quantize` names. */ +function landing(quantize: Quantize, playing: HeardPlaying): LandingAt { + return ({ time, startedAt, soonest }) => { + if (quantize === 'now' || startedAt === null) return soonest; + const spots = quantizeSpots(quantize, playing.timeline.meter, playing.markers); + const next = nextSpot({ ...time, meter: playing.timeline.meter }, soonest - startedAt, spots); + return next === null ? soonest : startedAt + next; + }; +} + +/** The song playing numbered `playback` as it is heard now; null for none, or a pattern. */ +function heardSong(project: Project, audio: AudioSink, playback: number): HeardPlaying | null { + if (!(playback > 0)) return null; + return heardPlayings(project, audio).find((p) => p.id === playback && p.kind === 'song') ?? null; +} + +/** Turns a track of a playing song (#338); where it plays its bake there is nothing to turn, and the log says so. */ +function setSongTrack( + { project, audio }: Pick, + world: World, + playback: number, + track: string, + volumeDb: number, + fade: number, + quantize: Quantize, +): void { + const playing = heardSong(project, audio, playback); + if (!playing || track === '') return; + const db = Number.isFinite(volumeDb) ? Math.max(TRACK_MIN_DB, Math.min(TRACK_MAX_DB, volumeDb)) : 0; + const done = audio.setSongTrack?.( + playback, + trackBus(playing.asset, track), + dbToGain(db), + fade, + landing(quantize, playing), ); + if (done === 'baked') { + const name = findAssetOf(project, playing.asset, 'song')?.name ?? playing.asset; + world.log.push(`Set song track: song "${name}" plays its bake; switch on "live tracks" in Play song`); + } +} + +/** + * Plays another song, or the same one from a marker (#338), in place of a playing song: asked for + * as that one was, where `quantize` lands for the one heard now. A marker the song does not have is + * logged, and nothing changes. + */ +function switchSong( + sources: Pick, + world: World, + playback: number, + songId: string, + markerId: string, + quantize: Quantize, + fade: number, +): void { + const { project, audio } = sources; + const playing = heardSong(project, audio, playback); + if (!playing) return; + const target = songId || playing.asset; + const song = findAssetOf(project, target, 'song'); + if (!song) return; + const marker = markerId ? heldSongMarkers(song.markers).find((m) => m.id === markerId) : undefined; + if (markerId && !marker) { + const named = findSongMarker(project, markerId)?.name ?? markerId; + world.log.push(`Switch song: song "${song.name}" has no marker "${named}"`); + return; + } + const ask = songAsks.get(audio)?.get(playback) ?? { + volume: 1, + fadeIn: 0, + loop: true, + owner: '', + fx: '', + fxParams: {}, + bus: '', + live: false, + }; + const play = songPlay(sources, target, ask, marker?.tick ?? 0); + if (play) audio.switchSequence?.(playback, songVoiceName(target), play, landing(quantize, playing), fade); } /** diff --git a/src/runtime/systems/musicPulses.ts b/src/runtime/systems/musicPulses.ts index 4cfb7fc3..087b3cc0 100644 --- a/src/runtime/systems/musicPulses.ts +++ b/src/runtime/systems/musicPulses.ts @@ -27,6 +27,8 @@ import type { Instance, Pulse } from './behaviorPulses'; /** A playing of a song or a pattern, as a game follows it. */ export interface HeardPlaying { id: number; + /** Told apart from the playing of its number a Switch song took over from (#338). */ + key: number; kind: 'song' | 'pattern'; /** The id of the song or pattern. */ asset: string; @@ -50,6 +52,7 @@ export function heardPlayings(project: Project, audio: AudioSink): HeardPlaying[ return [ { id: time.id, + key: time.key, kind, asset: id, timeline: { tempo, meter, from, loop, length }, @@ -70,7 +73,8 @@ export interface MusicPassed { /** * Follows the playings from frame to frame: each frame what each passed since the last one. One - * first seen is told what it passed no more than the limit ago, its first beat among it. + * first seen is told what it passed no more than the limit ago, its first beat among it – so is one + * a Switch song started in place of another, under the same number. */ export class MusicFollower { private before = new Map(); @@ -78,10 +82,10 @@ export class MusicFollower { follow(playings: readonly HeardPlaying[]): MusicPassed[] { const next = new Map(); const passed = playings.map((playing) => { - const { id, heard, timeline } = playing; - const before = this.before.get(id) ?? null; + const { key, heard, timeline } = playing; + const before = this.before.get(key) ?? null; if (heard === null) return { playing, beat: null, bar: null, markers: [] }; - next.set(id, heard); + next.set(key, heard); return { playing, beat: passedBeat(timeline, before, heard), diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 6d6128eb..3df3851f 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -22,7 +22,9 @@ import { BAKE_RATE } from '../core/music/bake'; import { type BakePlay, type BusLevel, + CUT, type HeardTime, + type LandingAt, Playbacks, type SequenceNeeds, type SequencePlay, @@ -745,7 +747,8 @@ export class SoundLibrary implements AudioSink { /** * What each track's bus was last set to, with whether its sound FX is there and how late, and the - * level it has now, ramps and fades included (test hook). + * level it has now, ramps and fades included – `turned` what its input is at now, where a game turned + * it (#338) (test hook). */ busLevels(): { name: string; @@ -756,11 +759,13 @@ export class SoundLibrary implements AudioSink { fx: boolean; latency: number; sends?: { to: string; tap: SendTap; gain: number; delay: number }[]; + turned?: number; }[] { return [...this.buses].map(([name, bus]) => ({ name, ...bus.set, level: bus.gain.gain.value, + ...((bus.steer !== 1 || bus.input.gain.value !== 1) && { turned: bus.input.gain.value }), into: bus.sends ? null : bus.into, fx: bus.chain !== null, latency: bus.chain?.latency ?? 0, @@ -962,6 +967,8 @@ export class SoundLibrary implements AudioSink { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 }, + steer: 1, + steerEnd: 0, automated: new Set(), into: target, sends: null, @@ -1340,23 +1347,86 @@ export class SoundLibrary implements AudioSink { /** * 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. + * through a master of its own, silent until it starts (#336): its level and its fades. */ playSequence(name: string, play: SequencePlay, owner = ''): number { if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0; const id = this.nextId++; + this.sequences.start(id, name, this.routed(id, play), owner); + return id; + } + + /** + * Plays `play` in place of the playing numbered `id` (#338), from the second `at` names, as + * `Playbacks.switchTo` does: on buses of its own, its tracks turned as a game turned those of the + * same name, and owned by whoever owns the playing. Says whether there was such a playing. + */ + switchSequence(id: number, name: string, play: SequencePlay, at: LandingAt, fade: number): boolean { + const playings = this.sequences.named(id); + const from = playings.find((playing) => playing.until === null) ?? playings[0]; + if (!from || (play.sequence.notes.length === 0 && !play.sequence.clips?.length)) return false; + const key = this.nextId++; + const routed = this.routed(key, play); + for (const [busName, bus] of this.buses) { + const track = trackOf(from.key, busName); + const into = track === null ? undefined : this.buses.get(groupBus(key, track)); + if (!into) continue; + into.steer = bus.steer; + into.input.gain.value = bus.steer; + } + this.sequences.switchTo(id, key, name, routed, from.owner, at, fade); + return true; + } + + /** + * Turns one track of a playing (#338) to `gain` over `fade` seconds from the second `at` names, on + * top of its own level: the one heard now, and one waiting to take over from it alike. A bake has + * no tracks to turn. + */ + setSongTrack( + id: number, + track: string, + gain: number, + fade: number, + at: LandingAt, + ): 'set' | 'baked' | 'none' { + const clock = this.sequences.clockOf(id); + if (!clock) return 'none'; + if (clock.baked) return 'baked'; + const when = Math.max(this.audio().currentTime, at(clock)); + let found = false; + for (const playing of this.sequences.named(id)) { + const bus = this.buses.get(groupBus(playing.key, track)); + if (!bus) continue; + found = true; + const from = bus.steer; + bus.steer = Math.max(0, gain); + // Where the last turn is through by then, nothing is planned past `when`, and Chrome's + // cancelAndHoldAtTime holds nothing: the ramp would run from that turn on, seconds early. + if (when < bus.steerEnd) holdParam(bus.input.gain, when); + else bus.input.gain.setValueAtTime(from, when); + bus.steerEnd = when + Math.max(CUT, fade); + bus.input.gain.linearRampToValueAtTime(bus.steer, bus.steerEnd); + } + return found ? 'set' : 'none'; + } + + /** + * The buses a playing is heard through (#336), `key` its master's: its tracks into it, each set as + * it was given; a note or clip naming no track goes straight in. Silent until it starts. + */ + private routed(key: number, play: SequencePlay): SequencePlay { // A bake whose file could not be had is no bake: the notes are played instead (#340). const { bake: asked, ...rest } = play; const bake = asked && !this.hasFailed(asked.file) ? asked : null; const buses = bake ? [] : (play.buses ?? []); - this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends); + this.open(key, buses, play.fx ?? null, play.bus ?? MASTER_MIX, play.sends); const tracks = new Set(buses.map((bus) => bus.name)); - const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : ''); + const into = (bus: string | undefined) => groupBus(key, bus !== undefined && tracks.has(bus) ? bus : ''); const { strike, strikeClip } = play; const chains = [...buses.flatMap((bus) => bus.fx ?? []), ...(play.fx ? [play.fx] : [])]; const needs = bake ? { layers: [], files: [bake.file] } : play.needs; - const routed: SequencePlay = { + return { ...rest, ...(bake && { bake }), needs: { ...needs, layers: [...needs.layers, ...chains.map((fx) => fx.plan)] }, @@ -1366,14 +1436,12 @@ export class SoundLibrary implements AudioSink { }), // Only a track's: the master of the playing is its level and its fades. ramp: (ramp) => { - if (tracks.has(ramp.bus)) this.rampBus(groupBus(id, ramp.bus), ramp); + if (tracks.has(ramp.bus)) this.rampBus(groupBus(key, ramp.bus), ramp); }, automate: (automation) => { - if (tracks.has(automation.bus)) this.automateBus(groupBus(id, automation.bus), automation); + if (tracks.has(automation.bus)) this.automateBus(groupBus(key, automation.bus), automation); }, }; - this.sequences.start(id, name, routed, owner); - return id; } stopSequence(name: string, letRing = false, fade = 0): void { @@ -1671,7 +1739,7 @@ export class SoundLibrary implements AudioSink { 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; + if (!this.groups.has(id) || this.sequences.holds(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; @@ -1815,7 +1883,12 @@ export class SoundLibrary implements AudioSink { return true; } const voice = this.voices.find((other) => other.id === id); - if (!voice) return this.sequences.has(id) && this.tuneMaster(id, params, time, ease); + // Every playing of that number (#338): one taking over from another is tuned alike. + if (!voice) { + const playings = this.sequences.named(id); + for (const playing of playings) this.tuneMaster(playing.key, params, time, ease); + return playings.length > 0; + } voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } }; if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime; // A longer feedback makes for a longer tail: it is counted again from where the change ends. @@ -1828,10 +1901,9 @@ export class SoundLibrary implements AudioSink { * The sound FX of a pattern or song playing (#346), on its master; one still loading starts with * the values. Says it was reached, whether or not it has a sound FX. */ - private tuneMaster(id: number, params: FxParams, time: number, ease: EaseShare): boolean { - const master = this.buses.get(groupBus(id, '')); + private tuneMaster(key: number, params: FxParams, time: number, ease: EaseShare): void { + const master = this.buses.get(groupBus(key, '')); if (master) this.tuneBus(master, params, time, ease); - return true; } private tuneBus(bus: Bus, params: FxParams, time: number, ease: EaseShare): void { @@ -3140,6 +3212,12 @@ function groupBus(id: number, track: string): string { return track === '' ? `play:${id}` : `play:${id}:${track}`; } +/** The track a bus of the playing `key` is (#338); null for its master and any other bus. */ +function trackOf(key: number, bus: string): string | null { + const head = `${groupBus(key, '')}:`; + return bus.startsWith(head) ? bus.slice(head.length) : null; +} + /** * A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan, * then the bus it goes into or straight out – or each of its sends (#350); `set` is what level and @@ -3150,6 +3228,13 @@ interface Bus { gain: GainNode; pan: StereoPannerNode; set: { gain: number; pan: number }; + /** + * What a game turns a track of its playing to (#338), on its input: on top of its level, before its + * sound FX and every send, what goes into it from other tracks too. + */ + steer: number; + /** The second of the audio clock the last turn of `steer` is through at; 0 for none. */ + steerEnd: number; /** What a lane of automation sets instead (#337), where `setBus` leaves it alone. */ automated: Set; /** Where it goes as a whole while it has no sends; null straight out. */ diff --git a/tests/core/beats.test.ts b/tests/core/beats.test.ts index a0d984d6..d57cecee 100644 --- a/tests/core/beats.test.ts +++ b/tests/core/beats.test.ts @@ -5,11 +5,14 @@ import { describe, expect, it } from 'vitest'; import { heardPosition, + heldQuantize, lateLimit, newestPassed, + nextSpot, passedBar, passedBeat, passedMarkers, + quantizeSpots, type Timeline, } from '../../src/core/music/beats'; import { COMMON_TIME } from '../../src/core/music/time'; @@ -133,3 +136,47 @@ describe('the markers it passed', () => { expect(newestPassed(passed.filter((m) => m.id === 'c')).map((m) => m.id)).toEqual(['c']); }); }); + +describe('where a change to the music lands (#338)', () => { + const markers = [ + { id: 'a', name: 'Verse', tick: BAR + Q }, + { id: 'b', name: 'Late', tick: 2 * BAR - Q }, + ]; + const at = (t: Timeline, elapsed: number, quantize: 'beat' | 'bar' | 'marker', list = markers) => + nextSpot(t, elapsed, quantizeSpots(quantize, t.meter, list)); + + it('on the next beat or bar, one it stands on being the one', () => { + expect(at(LOOPED, 0.2, 'beat')).toBeCloseTo(0.5, 9); + expect(at(LOOPED, 0.5, 'beat')).toBeCloseTo(0.5, 9); + expect(at(LOOPED, 0.2, 'bar')).toBeCloseTo(2, 9); + }); + + it('round the loop: past its last bar the next one is its start come round again', () => { + // Played time 3.9 s is the end of bar 2; the loop's start follows at 4 s. + expect(at(LOOPED, 3.9, 'bar')).toBeCloseTo(4, 9); + expect(at(LOOPED, 5.9, 'bar')).toBeCloseTo(6, 9); + }); + + it('on the next marker, round the loop too, and on the next bar in a song without markers', () => { + expect(at(LOOPED, 0, 'marker')).toBeCloseTo(2.5, 9); + expect(at(LOOPED, 2.6, 'marker')).toBeCloseTo(3.5, 9); + expect(at(LOOPED, 3.6, 'marker')).toBeCloseTo(4.5, 9); + expect(at(LOOPED, 0.2, 'marker', [])).toBeCloseTo(2, 9); + }); + + it('from where a playing started, a marker its start lies past is not come to again', () => { + expect(at({ ...LOOPED, from: BAR + 2 * Q }, 0, 'marker')).toBeCloseTo(0.5, 9); + expect(at({ ...ONCE, from: BAR + 2 * Q }, 0, 'marker', [markers[0]!])).toBeNull(); + }); + + it('nowhere at or past the end of one that ends: the change then lands at once', () => { + expect(at(ONCE, 3.2, 'bar')).toBeNull(); + expect(at(ONCE, 3.2, 'beat')).toBeCloseTo(3.5, 9); + }); + + it('takes what it does not know for the next bar', () => { + expect(heldQuantize('now')).toBe('now'); + expect(heldQuantize('soon')).toBe('bar'); + expect(heldQuantize(undefined)).toBe('bar'); + }); +}); diff --git a/tests/core/music-events.test.ts b/tests/core/music-events.test.ts index 481e0f1b..eabc52ac 100644 --- a/tests/core/music-events.test.ts +++ b/tests/core/music-events.test.ts @@ -55,8 +55,10 @@ function heardAudio(song: string, pattern: string) { const times: HeardTime[] = [ { id: 7, + key: 7, name: `song:${song}`, heard: null, + baked: false, tempo: 120, from: 0, loop: { start: BAR, end: 2 * BAR }, @@ -64,14 +66,26 @@ function heardAudio(song: string, pattern: string) { }, { id: 8, + key: 8, name: `pattern:${pattern}`, heard: null, + baked: false, tempo: 120, from: 0, loop: { start: 0, end: 3 * Q }, length: 3 * Q, }, - { id: 9, name: 'composition:c', heard: null, tempo: 120, from: 0, loop: null, length: 2 * BAR }, + { + id: 9, + key: 9, + name: 'composition:c', + heard: null, + baked: false, + tempo: 120, + from: 0, + loop: null, + length: 2 * BAR, + }, ]; return { audio: { ...SILENT_AUDIO, playingTimes: () => times }, @@ -124,6 +138,22 @@ describe('On beat and On bar', () => { expect(world.log.slice(1)).toEqual(['beat 14']); }); + it('start over with a song a Switch song plays under the same number (#338)', () => { + const f = listening(); + f.logs('OnBeat', {}, 'beat ', 'bar', 'beat'); + const { audio, at } = heardAudio(f.song, f.pattern); + const { world, frame } = startWorld(f, { behaviors: true, audio }); + at(3.01, 7); + frame(); + expect(world.log).toEqual(['beat 23']); + // Switched to: the same number on buses of its own, its first beat heard a moment ago. + const switched = audio.playingTimes().find((time) => time.id === 7)!; + switched.key = 70; + at(0.01, 7); + frame(); + expect(world.log.slice(1)).toEqual(['beat 11']); + }); + it('hear the song or pattern picked, every one where none is, a composition never', () => { const f = listening(); f.logs('OnBeat', {}, 'any ', 'playback', 'beat'); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 40670eee..d4da4360 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2548,6 +2548,34 @@ const CASES: Record = { outputs: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false }, }, ], + SetSongTrack: [ + { + name: 'turns the track of the playing it is given, on the next bar where nothing else is picked (#338)', + inputs: { playback: 4, track: 't1', volume: -12, fade: 1 }, + next: 'exec', + after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -12 dB fade 1 bar']), + }, + { + name: 'a quantize it does not know is the next bar', + inputs: { playback: 4, track: 't1', volume: -60, fade: 0, quantize: 'soon' }, + next: 'exec', + after: (ctx) => expect(ctx.songChanges).toEqual(['track 4 t1 -60 dB fade 0 bar']), + }, + ], + SwitchSong: [ + { + name: 'switches the playing it is given, in a cut on the next bar by default (#338)', + inputs: { playback: 4 }, + next: 'exec', + after: (ctx) => expect(ctx.songChanges).toEqual(['switch 4 to (same) at (start) bar fade 0']), + }, + { + name: 'to the song and marker it picks, where its quantize says', + inputs: { playback: 4, song: 'boss', marker: 'm2', quantize: 'marker', fade: 2 }, + next: 'exec', + after: (ctx) => expect(ctx.songChanges).toEqual(['switch 4 to boss at m2 marker fade 2']), + }, + ], }; /** Two distinct handles that share a name – `Equals` must still tell them apart. */ diff --git a/tests/core/song-play.test.ts b/tests/core/song-play.test.ts index d65d7e02..89415c1a 100644 --- a/tests/core/song-play.test.ts +++ b/tests/core/song-play.test.ts @@ -9,12 +9,19 @@ import { SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; -import { SetSongCommand } from '../../src/core/commands/songCommands'; +import { SetSongCommand, SetSongMarkersCommand } from '../../src/core/commands/songCommands'; import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset'; import { bakePlayback, bakePrint, bakeSpans } from '../../src/core/music/bake'; -import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; +import { + CUT, + goesBy, + type LandingAt, + type PlayingClock, + Playbacks, + type SequencePlay, +} from '../../src/core/music/playbacks'; import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler'; -import { newItem, newTrack, trackBus } from '../../src/core/music/song'; +import { dbToGain, newItem, newTrack, 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'; @@ -122,8 +129,10 @@ describe('the songs a game plays', () => { expect(playbacks.times()).toEqual([ { id: 5, + key: 5, name: 'song:a', heard: null, + baked: false, tempo: 120, from: 0, loop: { start: BAR, end: 2 * BAR }, @@ -252,6 +261,93 @@ describe('the songs a game plays', () => { }); }); +describe('a song switched to in place of another (#338)', () => { + /** Song a playing from 0.05 s on, as heard, and a clock it was asked for at 1 s. */ + async function playingA() { + const fake = fakeHost(); + const old: (ScheduledNote | ScheduledClip)[] = []; + fake.playbacks.start(5, 'song:a', song(old), ''); + await fake.loaded(); + fake.run(1); + return { ...fake, old }; + } + const levelsOf = (host: ReturnType['host']) => + host.levels.map(({ id, gain, at, seconds }) => [id, gain, +at.toFixed(6), seconds]); + + it('is heard from where it lands, under the same number; the old one falls silent there in a cut and goes', async () => { + const { host, playbacks, run, loaded } = await playingA(); + const fresh: (ScheduledNote | ScheduledClip)[] = []; + const asked: PlayingClock[] = []; + const twoBarsIn: LandingAt = (clock) => (asked.push(clock), clock.startedAt! + 2); + playbacks.switchTo(5, 9, 'song:b', song(fresh, { from: BAR }), '', twoBarsIn, 0); + await loaded(); + expect(asked.map((c) => [c.startedAt, +c.now.toFixed(9), +c.soonest.toFixed(9)])).toEqual([ + [LEAD, 1, 1 + LEAD], + ]); + expect(levelsOf(host).slice(-2)).toEqual([ + [5, 0, 2 + LEAD, CUT], + [9, 1, 2 + LEAD, 0], + ]); + // Until the clock gets there the old one is the one heard, from there on the new one. + expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 5]]); + run(1.1); + expect(playbacks.times().map((t) => [t.id, t.key, t.from, +t.heard!.toFixed(6)])).toEqual([ + [5, 9, BAR, +(host.time - 2 - LEAD).toFixed(6)], + ]); + expect([playbacks.has(5), host.closed]).toEqual([true, [5]]); + const [first] = fresh as ScheduledNote[]; + expect([first?.note, first?.at]).toEqual([61, 2 + LEAD]); + }); + + it('over its fade the old one falls as the new one rises, and goes once it is silent', async () => { + const { host, playbacks, run, loaded } = await playingA(); + playbacks.switchTo(5, 9, 'song:a', song([]), '', (clock) => clock.startedAt! + 2, 1.5); + await loaded(); + expect(levelsOf(host).slice(-2)).toEqual([ + [5, 0, 2 + LEAD, 1.5], + [9, 1, 2 + LEAD, 1.5], + ]); + // While the old one fades, only the new one is told: beats and the position go by it. + run(1.2); + expect(playbacks.times().map((t) => [t.id, t.key])).toEqual([[5, 9]]); + run(1.2); + expect(host.closed).toEqual([]); + run(0.3); + expect(host.closed).toEqual([5]); + }); + + it('asked again before it lands, drops the one waiting, and the old one is heard on at its level', async () => { + const { host, playbacks, loaded } = await playingA(); + playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0); + await loaded(); + playbacks.switchTo(5, 10, 'song:c', song([]), '', (clock) => clock.startedAt! + 4, 0); + expect(host.closed).toEqual([9]); + expect(levelsOf(host).at(-1)).toEqual([5, 1, 1, 0]); + await loaded(); + expect(levelsOf(host).slice(-2)).toEqual([ + [5, 0, 4 + LEAD, CUT], + [10, 1, 4 + LEAD, 0], + ]); + expect(playbacks.named(5).map((p) => p.key)).toEqual([5, 10]); + }); + + it('a stop of its number stops the old one and the one waiting alike', async () => { + const { host, playbacks, loaded } = await playingA(); + playbacks.switchTo(5, 9, 'song:b', song([]), '', (clock) => clock.startedAt! + 2, 0); + await loaded(); + playbacks.stopId(5); + expect([playbacks.has(5), host.closed.sort()]).toEqual([false, [5, 9]]); + }); + + it('where nothing of that number plays any more, starts as soon as it can', async () => { + const { host, playbacks, loaded } = fakeHost(); + let asked = 0; + playbacks.switchTo(5, 9, 'song:b', song([]), '', () => (asked++, 99), 0); + await loaded(); + expect([asked, levelsOf(host)]).toEqual([0, [[9, 1, LEAD, 0]]]); + }); +}); + /** * 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. @@ -319,6 +415,38 @@ function recordingAudio() { 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})`), + /** Every playing heard 0.3 s into a clock that started it at 10 s (#338). */ + clockOf: (play: SequencePlay): PlayingClock => ({ + time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length }, + startedAt: 10, + now: 10.3, + soonest: 10.31, + }), + playingTimes: () => + sink.sequences.map(({ name, play }, i) => ({ + id: 1001 + i, + key: 1001 + i, + name, + heard: 0.3, + baked: play.bake !== undefined, + tempo: play.tempo, + from: 0, + loop: play.loop, + length: play.length, + })), + switched: [] as { id: number; name: string; play: SequencePlay; at: number; fade: number }[], + switchSequence: (id: number, name: string, play: SequencePlay, at: LandingAt, fade: number) => { + const from = sink.sequences[id - 1001]!.play; + sink.switched.push({ id, name, play, at: at(sink.clockOf(from)), fade }); + return true; + }, + turned: [] as { id: number; track: string; gain: number; fade: number; at: number }[], + setSongTrack: (id: number, track: string, gain: number, fade: number, at: LandingAt) => { + const playing = sink.sequences[id - 1001]!.play; + if (playing.bake) return 'baked' as const; + sink.turned.push({ id, track, gain, fade, at: at(sink.clockOf(playing)) }); + return 'set' as const; + }, }; return sink; } @@ -427,3 +555,93 @@ describe('Stop song', () => { ]); }); }); + +describe('Set song track and Switch song (#338)', () => { + /** 90 BPM: a beat is 2/3 s, a bar 8/3 s, played from 10 s on and heard 0.3 s in. */ + const BEAT = 2 / 3; + + it('turns a track of the playing on the next beat, in decibels on top of its level', () => { + const f = playingSong(); + const turn = f.edit.addNode('SetSongTrack', { + track: f.tracks[1]!, + volume: -6, + fade: 0.5, + quantize: 'beat', + }); + f.edit.connect(f.play, 'exec', turn, 'exec'); + f.edit.connect(f.play, 'playback', turn, 'playback'); + const audio = recordingAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + expect(audio.turned.map((t) => ({ ...t, at: +t.at.toFixed(9) }))).toEqual([ + { + id: 1001, + track: trackBus(f.songId, f.tracks[1]!), + gain: dbToGain(-6), + fade: 0.5, + at: +(10 + BEAT).toFixed(9), + }, + ]); + }); + + it('a playing of its bake has no tracks: the log says to switch on "live tracks", which plays its notes', () => { + const baked = (live: boolean) => { + const f = playingSong({ live }); + const song = f.project.assets.find((a) => a.id === f.songId) as SongAsset; + const spans = bakeSpans({ tempo: 90, length: 2 * BAR, loop: { start: BAR, end: 2 * BAR } }); + const file = `${'c3'.repeat(6)}/${'d4'.repeat(32)}.ogg`; + song.bake = { file, print: bakePrint(f.project, song), peak: 0.5, bytes: 1, ...spans }; + const turn = f.edit.addNode('SetSongTrack', { track: f.tracks[0]!, volume: -60, quantize: 'now' }); + f.edit.connect(f.play, 'exec', turn, 'exec'); + f.edit.connect(f.play, 'playback', turn, 'playback'); + const audio = recordingAudio(); + const { world, frame } = startWorld(f, { behaviors: true, audio }); + frame(); + return { audio, log: world.log }; + }; + const off = baked(false); + expect([off.audio.turned, off.log]).toEqual([ + [], + ['Set song track: song "Theme" plays its bake; switch on "live tracks" in Play song'], + ]); + const on = baked(true); + expect([on.audio.sequences[0]?.play.bake, on.audio.turned[0], on.log]).toEqual([ + undefined, + expect.objectContaining({ gain: 0, at: 10.31 }), + [], + ]); + }); + + it('switches to a marker of the same song on the next marker, asked for as its Play song was', () => { + const f = playingSong(); + f.stack.execute(new SetSongMarkersCommand(f.songId, [{ id: 'm1', name: 'Chorus', tick: BAR + Q }])); + const go = f.edit.addNode('SwitchSong', { marker: 'm1', quantize: 'marker', fade: 1.5 }); + f.edit.connect(f.play, 'exec', go, 'exec'); + f.edit.connect(f.play, 'playback', go, 'playback'); + const audio = recordingAudio(); + startWorld(f, { behaviors: true, audio }).frame(); + const [switched] = audio.switched; + // Bar 2 beat 2 is 2400 ticks in, 3 1/3 s at 90 BPM. + expect([switched?.id, switched?.name, switched?.fade, switched?.at]).toEqual([ + 1001, + `song:${f.songId}`, + 1.5, + expect.closeTo(10 + 10 / 3, 9), + ]); + expect(switched?.play).toMatchObject({ from: BAR + Q, volume: 0.8, loop: { start: BAR, end: 2 * BAR } }); + expect(switched?.play.bake).toBeUndefined(); + }); + + it('logs a marker the song does not have, and switches nothing', () => { + const f = playingSong(); + const other = new CreateAssetCommand('song', 'Boss'); + f.stack.execute(other); + f.stack.execute(new SetSongMarkersCommand(other.asset.id, [{ id: 'm9', name: 'Rage', tick: 0 }])); + const go = f.edit.addNode('SwitchSong', { marker: 'm9' }); + f.edit.connect(f.play, 'exec', go, 'exec'); + f.edit.connect(f.play, 'playback', go, 'playback'); + const audio = recordingAudio(); + const { world, frame } = startWorld(f, { behaviors: true, audio }); + frame(); + expect([audio.switched, world.log]).toEqual([[], ['Switch song: song "Theme" has no marker "Rage"']]); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index d4682bbb..37eed3b0 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -276,7 +276,10 @@ export type FakeContext = NodeContext & { fx: string; fxParams: Readonly>; bus?: string; + live?: boolean; }[]; + /** What Set song track and Switch song asked of a playing (#338), one line each. */ + songChanges: string[]; silenced: string[]; tuned: { voice: number; fxParams: Readonly>; time: number; ease: EaseShare }[]; /** What buses the context was told to change (#325): a volume or FX parameters, in order. */ @@ -378,8 +381,25 @@ export function fakeContext(overrides: Partial = {}): FakeContext { stopPattern: (patternId, letRing) => ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`), songs: [], - playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) => - ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams, ...(bus && { bus }) }), + playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus, live) => + ctx.songs.push({ + songId, + volume, + fadeIn, + loop, + detached, + fx, + fxParams, + ...(bus && { bus }), + ...(live && { live }), + }), + songChanges: [], + setSongTrack: (playback, track, volumeDb, fade, quantize) => + ctx.songChanges.push(`track ${playback} ${track} ${volumeDb} dB fade ${fade} ${quantize}`), + switchSong: (playback, songId, marker, quantize, fade) => + ctx.songChanges.push( + `switch ${playback} to ${songId || '(same)'} at ${marker || '(start)'} ${quantize} fade ${fade}`, + ), 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. diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index ebea64d6..1f1f4c9c 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -18,7 +18,7 @@ import { import type { AutomationTarget } from '../../src/core/music/automation'; import { dbToGain } from '../../src/core/music/level'; import type { MixBus } from '../../src/core/music/mixer'; -import type { BakePlay, SequencePlay } from '../../src/core/music/playbacks'; +import { type BakePlay, CUT, type LandingAt, type SequencePlay } from '../../src/core/music/playbacks'; import type { ScheduledAutomation, ScheduledNote } from '../../src/core/music/Scheduler'; import type { LfoOptions, PitchOptions, StreamOptions } from '../../src/viewport/fxWorklet'; import { saturationCurve } from '../../src/core/soundfx/saturation'; @@ -1982,6 +1982,101 @@ describe('the patterns and songs a game plays (#336)', () => { library.time(); expect(resumes).toBe(2); }); + + /** A bus of the library by its name, with the params a test reads. */ + const busOf = (library: SoundLibrary, name: string) => + ( + library as unknown as { buses: Map } + ).buses.get(name); + + it('turns a track of a playing on its input from where it lands, on top of its level (#338)', async () => { + const library = new SoundLibrary(false); + const id = library.playSequence( + 'song:s', + sequence(() => 0), + '', + ); + await settle(); + const landed: (number | null)[] = []; + const at: LandingAt = (clock) => (landed.push(clock.startedAt), clock.startedAt! + 2); + expect(library.setSongTrack(id, 'song:s:t', 0.25, 1, at)).toBe('set'); + const track = busOf(library, `play:${id}:song:s:t`)!; + expect([landed, track.input.gain.ramps]).toEqual([ + [0.05], + [ + ['set', 1, 2.05], + ['linear', 0.25, 3.05], + ], + ]); + // Its own level is the song's, left alone. + expect(track.gain.gain.value).toBe(0.25); + // A turn after that one is through starts where it left the track, not where it is now. + library.setSongTrack(id, 'song:s:t', 0.5, 0, (clock) => clock.startedAt! + 4); + expect(track.input.gain.ramps.slice(2)).toEqual([ + ['set', 0.25, 4.05], + ['linear', 0.5, expect.closeTo(4.055, 9)], + ]); + expect(library.setSongTrack(id, 'song:s:gone', 0.5, 0, at)).toBe('none'); + expect(library.setSongTrack(id + 99, 'song:s:t', 0.5, 0, at)).toBe('none'); + }); + + it('a playing of its bake has no tracks to turn (#338)', () => { + const library = new SoundLibrary(false); + const bake = { file: 'baked.ogg', gain: 0.5, playback: { end: 2, until: 2, round: null } }; + const id = library.playSequence( + 'song:s', + sequence(() => 0, { bake }), + '', + ); + expect(library.setSongTrack(id, 'song:s:t', 0.5, 0, () => 0)).toBe('baked'); + }); + + it('switches a playing on buses of its own, its tracks turned as they were, the old one gone once silent (#338)', async () => { + const library = new SoundLibrary(false); + const id = library.playSequence( + 'song:s', + sequence(() => 0), + 'logic', + ); + await settle(); + library.setSongTrack(id, 'song:s:t', 0.25, 0, () => 0.5); + const at: LandingAt = (clock) => clock.startedAt! + 2; + expect( + library.switchSequence( + id, + 'song:s', + sequence(() => 0, { from: 480 }), + at, + 0, + ), + ).toBe(true); + const fresh = library + .busLevels() + .find((bus) => bus.name.endsWith(':song:s:t') && bus.name !== `play:${id}:song:s:t`); + expect(busOf(library, fresh!.name)!.input.gain.value).toBe(0.25); + await settle(); + expect(library.playbacks().map((p) => [p.id, p.owner])).toEqual([ + [id, 'logic'], + [id, 'logic'], + ]); + expect(busOf(library, `play:${id}`)!.gain.gain.ramps.slice(-2)).toEqual([ + ['set', 0, 2.05], + ['linear', 0, 2.05 + CUT], + ]); + clockTo(); + expect(library.playbacks().map((p) => p.id)).toEqual([id]); + // The old one's buses are let go, though a playing of its number plays on. + expect(library.busLevels().filter((bus) => bus.name.startsWith(`play:${id}`))).toEqual([]); + expect( + library.switchSequence( + id + 99, + 'song:s', + sequence(() => 0), + at, + 0, + ), + ).toBe(false); + }); }); describe('the automation of the tracks of a song (#337)', () => { diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 474492c2..dcacccc2 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -937,7 +937,10 @@ export async function song(t) { { ...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} }; - song.markers = [{ id: 'sg65-mark', name: 'Round', tick: ${BAR} }];`); + song.markers = [ + { id: 'sg65-mark', name: 'Round', tick: ${BAR} }, + { id: 'sg65-half', name: 'Half', tick: ${BAR + BAR / 2} }, + ];`); await showTab('scene'); // 65k2 hears it through these (#338): each beat logs bar, beat, how late it is told and where // Song position stands then, each marker its name. @@ -969,6 +972,20 @@ export async function song(t) { node('sg65-marker', 'OnSongMarker', 0, 800, { song: id }), node('sg65-jm', 'Join', 200, 800, { a: 'marker ' }), node('sg65-logm', 'Log', 400, 800), + // 65k3 (#338): on a beat with W held, Switch song to "Half" on the next bar; with V held, the + // track of the pattern turned down to silence there. + node('sg65-cue', 'OnBeat', 0, 1000, { song: id }), + node('sg65-w', 'KeyIsDown', 0, 1200, { key: 'w' }), + node('sg65-wif', 'Branch', 260, 1000), + node('sg65-switch', 'SwitchSong', 520, 1000, { marker: 'sg65-half', quantize: 'bar', fade: 0 }), + node('sg65-v', 'KeyIsDown', 0, 1400, { key: 'v' }), + node('sg65-vif', 'Branch', 520, 1200), + node('sg65-turn', 'SetSongTrack', 780, 1200, { + track: tracks[1], + volume: -60, + fade: 0, + quantize: 'bar', + }), ], edges: [ edge('sg65-e3', 'sg65-start', 'exec', 'sg65-play', 'exec'), @@ -988,6 +1005,14 @@ export async function song(t) { edge('sg65-m1', 'sg65-marker', 'name', 'sg65-jm', 'b'), edge('sg65-m2', 'sg65-marker', 'exec', 'sg65-logm', 'exec'), edge('sg65-m3', 'sg65-jm', 'text', 'sg65-logm', 'message'), + edge('sg65-c1', 'sg65-cue', 'exec', 'sg65-wif', 'exec'), + edge('sg65-c2', 'sg65-w', 'down', 'sg65-wif', 'condition'), + edge('sg65-c3', 'sg65-wif', 'then', 'sg65-switch', 'exec'), + edge('sg65-c4', 'sg65-cue', 'playback', 'sg65-switch', 'playback'), + edge('sg65-c5', 'sg65-wif', 'else', 'sg65-vif', 'exec'), + edge('sg65-c6', 'sg65-v', 'down', 'sg65-vif', 'condition'), + edge('sg65-c7', 'sg65-vif', 'then', 'sg65-turn', 'exec'), + edge('sg65-c8', 'sg65-cue', 'playback', 'sg65-turn', 'playback'), ], }, }); @@ -1066,11 +1091,96 @@ export async function song(t) { beats.every((b) => b.late >= 0 && b.late <= 0.15 && Math.abs(b.fraction * 0.5 - b.late) < 0.02), JSON.stringify({ beats, markersHeard: markersHeard.length }), ); + // 65k3. Music follows the game (#338): W held over a beat asks Switch song for "Half", bar 2 beat 3, + // on the next bar. The playing keeps its number on buses of its own, heard from that bar line + // to the sample; On beat starts over from the marker. V held turns the pattern's track down to + // silence on the bar after, on its input, its level as the song has it. + const clockNow = () => json('__gm.sound.clock()'); + const firstPlaying = (await playbacks())[0]; + const linesBefore = (await heardLines()).length; + await t.holdKey('w', 600); + const pair = await waitFor(playbacks, (list) => list.length === 2, 20); + const landed = await waitFor( + playbacks, + (list) => list.length === 1 && list[0].startedAt !== firstPlaying?.startedAt, + 40, + ); + const switchedTo = landed[0]; + const beatsSince = (lines) => + lines.slice(linesBefore).flatMap((text) => { + const m = /\bbeat (\d+)\.(\d+) /.exec(text); + return m ? [`${m[1]}.${m[2]}`] : []; + }); + // Only the jump to the marker hears 2.3 right after 2.4: the loop goes from 2.4 to 2.1. + const jumpIn = (beats) => beats.findIndex((b, i) => b === '2.4' && beats[i + 1] === '2.3') + 1; + const sinceSwitch = beatsSince( + await waitFor( + heardLines, + (lines) => { + const beats = beatsSince(lines); + const at = jumpIn(beats); + return at > 0 && beats.length > at + 1; + }, + 40, + ), + ); + const fromHalf = jumpIn(sinceSwitch) || -1; + const bars = switchedTo && firstPlaying ? (switchedTo.startedAt - firstPlaying.startedAt) / 2 : NaN; + check( + 'Switch song plays the song from its marker on the next bar line, to the sample, under the same number; On beat goes on from the marker', + pair.length === 2 && + pair.every((p) => p.id === firstPlaying?.id) && + switchedTo?.id === firstPlaying?.id && + Math.abs(bars - Math.round(bars)) < 1e-6 && + Math.round(bars) >= 1 && + fromHalf >= 0 && + sinceSwitch[fromHalf + 1] === '2.4', + JSON.stringify({ + firstPlaying, + pair: pair.map((p) => [p.id, p.startedAt]), + switchedTo, + bars, + sinceSwitch, + }), + ); + const newMaster = (await json('__gm.song.buses()')).find((b) => /^play:\d+$/.test(b.name))?.name; + const pattern = `${newMaster}:${busOf(tracks[1])}`; + const turnedOf = async () => { + const at = await clockNow(); + const bus = (await json('__gm.song.buses()')).find((b) => b.name === pattern); + return { at, turned: bus?.turned ?? 1, gain: bus?.gain }; + }; + // Read from before V on until it is silent: a cut, so 1 up to a bar line and 0 from it, nothing + // between. The switched song started on beat 3 of bar 2, so its bar lines lie 1 s after it, then + // every 2 s. + const turns = [await turnedOf()]; + await t.keyDown('v'); + const silent = await waitFor( + async () => (turns.push(await turnedOf()), turns.at(-1)), + (r) => r.turned === 0, + 40, + ); + await t.keyUp('v'); + const lastOn = turns.findLast((r) => r.turned === 1); + const firstBar = (switchedTo?.startedAt ?? 0) + 1; + const barLine = lastOn && firstBar + Math.max(0, Math.ceil((lastOn.at - 0.05 - firstBar) / 2)) * 2; + check( + 'Set song track turns the pattern’s track to silence on the next bar line, its own level left as the song has it', + newMaster !== `play:${firstPlaying?.id}` && + silent.turned === 0 && + turns.every((r) => r.turned === 1 || r.turned === 0) && + barLine !== undefined && + silent.at >= barLine - 0.05 && + silent.at < barLine + 0.5 && + Math.abs(silent.gain - 10 ** (-6 / 20)) < 1e-6, + JSON.stringify({ newMaster, barLine, turns }), + ); 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)); + // Every playing's, the one 65k3 switched to among them. + const leftBuses = (await json('__gm.song.buses()')).filter((b) => /^play:/.test(b.name)); await stop(); check( 'Stop song fades it out over its second, playing on meanwhile, then it and its buses are gone',