/** * Master and buses (#325): what the sound library is set up with, and which bus a sound, a note, a * pattern or a song is heard on – where each goes is the master routing's (#351). */ import { describe, expect, it } from 'vitest'; import { buildAsset, CreateAssetCommand, CreateSoundCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { SetMasterFxCommand, SetMasterVolumeCommand } from '../../src/core/commands/projectCommands'; import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { MASTER_MIX, type MixBus, mixBusName, mixOf, playedBus, reachedBus, sameMix, } from '../../src/core/music/mixer'; import { MASTER_BUS } from '../../src/core/properties/buses'; import { schemaForAsset } from '../../src/core/properties/PropertySchema'; import type { FxParams, FxVoice } from '../../src/core/soundfx/nodes'; import { GameRun } from '../../src/runtime/GameRun'; import { SILENT_AUDIO } from '../../src/runtime/systems/Audio'; import { Input } from '../../src/runtime/systems/Input'; import { graphEditor, makeBehavior, makeProject, makeSpriteScene, place, roundTrip, routingOf, startWorld, } from '../helpers'; const FILE = `${'a1'.repeat(32)}.wav`; function withBuses(...names: string[]) { const f = makeProject(); const ids = names.map((name) => { const command = new CreateAssetCommand('bus', name); f.stack.execute(command); return command.asset.id; }); const routing = routingOf(f); return { ...f, ids, into: (bus: string, target: string) => routing.only(bus, target) }; } /** A sound FX that compiles: Input, a Gain, Output. */ function workingFx(f: ReturnType): string { const command = new CreateAssetCommand('soundfx', 'Squash'); f.stack.execute(command); const edit = graphEditor(f, command.asset as SoundFxAsset as unknown as BehaviorAsset); const input = edit.addNode('SoundFxInput'); const gain = edit.addNode('SoundFxGain', { gain: -6 }); const output = edit.addNode('SoundFxOutput'); edit.connect(input, 'out', gain, 'in'); edit.connect(gain, 'out', output, 'in'); return command.asset.id; } describe('the bus asset (#325)', () => { it('is not placeable and starts at full volume, without a sound FX', () => { expect(isPlaceableType('bus')).toBe(false); const f = withBuses('Music'); const bus = f.project.assets[0]!; expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, fx: '' } }); expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx']); expect(roundTrip(f.project)).toEqual(f.project); }); it('leaves where a sound, an instrument, a pattern, a song and a bus go to the routing (#351)', () => { const f = withBuses('Music'); for (const type of ['sound', 'instrument', 'pattern', 'song'] as const) { const command = new CreateAssetCommand(type, type); f.stack.execute(command); expect(schemaForAsset(f.project, command.asset).map((def) => def.key)).not.toContain('bus'); } expect(schemaForAsset(f.project, f.project.assets[0]!).map((def) => def.key)).not.toContain('into'); }); }); describe('the master (#325)', () => { it('is a project setting at full volume without a sound FX, kept through a round trip', () => { const f = makeProject(); expect([f.project.masterVolume, f.project.masterFx]).toEqual([1, '']); f.stack.execute(new SetMasterVolumeCommand(2)); f.stack.execute(new SetMasterFxCommand('fx-1')); expect([f.project.masterVolume, f.project.masterFx]).toEqual([1, 'fx-1']); f.stack.execute(new SetMasterVolumeCommand(0.4)); expect(roundTrip(f.project)).toMatchObject({ masterVolume: 0.4, masterFx: 'fx-1' }); f.stack.undo(); f.stack.undo(); expect([f.project.masterVolume, f.project.masterFx]).toEqual([1, '']); }); it('reads as full volume without a sound FX from a project written before there was one', () => { const f = makeProject(); const written = roundTrip(f.project) as unknown as Record; delete written.masterVolume; delete written.masterFx; expect(roundTrip(written as never)).toMatchObject({ masterVolume: 1, masterFx: '' }); }); }); describe('the mix (#325)', () => { it('is the master first, then every bus into the one it names, with its volume and its sound FX', () => { const f = withBuses('Music', 'Drums'); const [music, drums] = f.ids as [string, string]; const fx = workingFx(f); f.into(drums, music); f.stack.execute(new SetAssetPropertyCommand(drums, 'volume', 0.5)); f.stack.execute(new SetAssetPropertyCommand(music, 'fx', fx)); f.stack.execute(new SetMasterVolumeCommand(0.8)); const mix = mixOf(f.project); expect(mix.map(({ name, volume, into }) => ({ name, volume, into }))).toEqual([ { name: MASTER_MIX, volume: 0.8, into: null }, { name: `mix:${music}`, volume: 1, into: MASTER_MIX }, { name: `mix:${drums}`, volume: 0.5, into: `mix:${music}` }, ]); expect(mix.map((bus) => bus.fx !== null)).toEqual([false, true, false]); }); it('is the same mix again while nothing of it changed, another once a volume does', () => { const f = withBuses('Music'); const before = mixOf(f.project); expect(sameMix(before, mixOf(f.project))).toBe(true); f.stack.execute(new SetAssetPropertyCommand(f.ids[0]!, 'volume', 0.2)); expect(sameMix(before, mixOf(f.project))).toBe(false); }); it('plays on the bus a node picks, else the one the routing sends its asset to, else the master', () => { const f = withBuses('Music', 'SFX'); const [music, sfx] = f.ids as [string, string]; const sound = new CreateSoundCommand('Jump', FILE); f.stack.execute(sound); const id = sound.asset.id; expect(playedBus(f.project, '', id)).toBe(MASTER_MIX); f.into(id, music); expect(playedBus(f.project, '', id)).toBe(`mix:${music}`); expect(playedBus(f.project, sfx, id)).toBe(`mix:${sfx}`); expect(playedBus(f.project, MASTER_BUS, id)).toBe(MASTER_MIX); // A pick of a bus that was deleted leaves it on the master. expect(playedBus(f.project, 'gone', id)).toBe(MASTER_MIX); }); it('reaches the master for an empty pick, and no bus for one that is not there', () => { const f = withBuses('Music'); expect(reachedBus(f.project, '')).toBe(MASTER_MIX); expect(reachedBus(f.project, MASTER_BUS)).toBe(MASTER_MIX); expect(reachedBus(f.project, f.ids[0]!)).toBe(mixBusName(f.ids[0]!)); expect(reachedBus(f.project, 'gone')).toBeNull(); }); }); /** What a run asked of the buses, beside the voices it played and on which bus. */ function busSink() { const sink = { ...SILENT_AUDIO, calls: [] as string[], play: (file: string, _volume: number, _loop: boolean, _owner: string, _fx?: FxVoice, bus = '') => { sink.calls.push(`play ${file} on ${bus}`); return 1; }, setMix: (buses: readonly MixBus[]) => void sink.calls.push(`mix ${buses.map((bus) => bus.name).join(' ')}`), resetMix: () => void sink.calls.push('reset'), setMixVolume: (bus: string, volume: number, time = 0) => void sink.calls.push(`volume ${bus} ${volume} ${time}`), mixVolume: (bus: string) => (bus === MASTER_MIX ? 0.7 : 0.2), tuneMix: (bus: string, params: FxParams) => void sink.calls.push(`tune ${bus} ${JSON.stringify(params)}`), }; return sink; } /** A scene whose behavior runs `type` on its first frame, its result in a variable "Got". */ function runOnStart(type: string, params: Record, f = makeSpriteScene()) { const behavior = makeBehavior(f, 'Logic', ['Got']); place(f, behavior.id, { x: 0, y: 0 }); const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const node = edit.addNode(type, params); edit.connect(start, 'exec', node, 'exec'); return { ...f, behavior, edit, node }; } describe('buses in a running game (#325)', () => { it('plays a sound on the bus the routing sends it to, or on the one Play sound picks', () => { const f = makeSpriteScene(); const bus = new CreateAssetCommand('bus', 'SFX'); const sound = new CreateSoundCommand('Jump', FILE); f.stack.execute(bus); f.stack.execute(sound); routingOf(f).only(sound.asset.id, bus.asset.id); runOnStart('PlaySound', { sound: sound.asset.id, volume: 1 }, f); const asset = busSink(); startWorld(f, { behaviors: true, audio: asset }).frame(); expect(asset.calls).toEqual([`play ${FILE} on mix:${bus.asset.id}`]); const picked = makeSpriteScene(); const pickedSound = new CreateSoundCommand('Jump', FILE); picked.stack.execute(pickedSound); runOnStart('PlaySound', { sound: pickedSound.asset.id, volume: 1, bus: MASTER_BUS }, picked); const master = busSink(); startWorld(picked, { behaviors: true, audio: master }).frame(); expect(master.calls).toEqual([`play ${FILE} on ${MASTER_MIX}`]); }); it('sets the volume of the master for an empty pick, and of the bus picked, over its time', () => { const f = runOnStart('SetBusVolume', { bus: '', volume: 0.25, time: 2 }); const sink = busSink(); startWorld(f, { behaviors: true, audio: sink }).frame(); expect(sink.calls).toEqual([`volume ${MASTER_MIX} 0.25 2`]); const gone = runOnStart('SetBusVolume', { bus: 'gone', volume: 0.25 }); const none = busSink(); startWorld(gone, { behaviors: true, audio: none }).frame(); expect(none.calls).toEqual([]); }); it('changes the sound FX of a bus with Set sound FX parameters and no voice', () => { const f = runOnStart('SetSoundFxParameters', { voice: 0, bus: MASTER_BUS }); const sink = busSink(); startWorld(f, { behaviors: true, audio: sink }).frame(); expect(sink.calls).toEqual([`tune ${MASTER_MIX} {}`]); }); it('reads the volume a bus is at, the master for an empty pick and 0 for a bus that is not there', () => { const f = runOnStart('Log', {}); const read = f.edit.addNode('BusVolume', { bus: '' }); f.edit.connect(read, 'volume', f.node, 'message'); const gone = f.edit.addNode('BusVolume', { bus: 'gone' }); const second = f.edit.addNode('Log'); f.edit.connect(f.node, 'exec', second, 'exec'); f.edit.connect(gone, 'volume', second, 'message'); const started = startWorld(f, { behaviors: true, audio: busSink() }); started.frame(); expect(started.world.log).toEqual(['0.7', '0']); }); it('starts every run with the buses as their assets have them, and ends it so', () => { const f = makeSpriteScene(); f.stack.execute(new CreateAssetCommand('bus', 'Music')); const sink = busSink(); const run = GameRun.start(f.project, f.sceneId, new Input(), { audio: sink, physics: undefined as never, clock: { scale: 1 }, saves: { read: () => ({}), write: () => {} }, }); expect(sink.calls).toEqual(['reset', `mix ${MASTER_MIX} mix:${f.project.assets.at(-1)!.id}`]); run.end(); expect(sink.calls.at(-1)).toBe('reset'); }); });