/** * A sound FX on a sound, an instrument, a pattern and a song (#352): what plays it goes through it * unless a Play node or an item of a song picks another, or None. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateSoundCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset'; import type { AssetType } from '../../src/core/model/AssetTypes'; import type { PropertyBag } from '../../src/core/model/Properties'; import { sequenceNeeds } from '../../src/core/music/needs'; import type { SequencePlay } from '../../src/core/music/playbacks'; import { itemBus, newItem, songSequence, trackItemsBus } from '../../src/core/music/song'; import { songBuses } from '../../src/core/music/songBuses'; import { ASSET_FX_KEY, NO_FX, pickedFx } from '../../src/core/properties/assetFx'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { soundFxBuildFor } from '../../src/core/soundfx/compile'; import { DRY_VOICE, type FxParams, type FxPlan, type FxVoice } from '../../src/core/soundfx/nodes'; import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio'; import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; const FILE = `${'c4'.repeat(32)}.wav`; const Q = 480; const BAR = 4 * Q; /** A sound FX of one Gain of `db`. */ function gainFx(f: ReturnType, name: string, db: number): SoundFxAsset { const made = new CreateAssetCommand('soundfx', name); f.stack.execute(made); const fx = made.asset as SoundFxAsset; const edit = graphEditor(f, fx as unknown as BehaviorAsset); const input = edit.addNode('SoundFxInput'); const gain = edit.addNode('SoundFxGain', { gain: db }); const output = edit.addNode('SoundFxOutput'); edit.wire([ [input, 'out', gain, 'in'], [gain, 'out', output, 'in'], ]); return fx; } /** * A sound, an instrument, a pattern and a song, each through a sound FX of its own: Sound -1 dB, * Lead -2, Riff -3, Theme -4, and Other -5 for a pick. The song has a sound item and a pattern item. */ function scene() { const f = makeSpriteScene('Box'); const [soundFx, leadFx, riffFx, themeFx, other] = [-1, -2, -3, -4, -5].map((db, i) => gainFx(f, ['Sound', 'Lead', 'Riff', 'Theme', 'Other'][i]!, db), ) as [SoundFxAsset, SoundFxAsset, SoundFxAsset, SoundFxAsset, SoundFxAsset]; const sound = new CreateSoundCommand('Blip', FILE); f.stack.execute(sound); const ins = new CreateAssetCommand('instrument', 'Lead'); f.stack.execute(ins); const lead = ins.asset as InstrumentAsset; lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id; const pattern = new CreateAssetCommand('pattern', 'Riff'); f.stack.execute(pattern); f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', lead.id)); f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1)); f.stack.execute( new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n', tick: 0, length: Q, note: 60, velocity: 1 }]), ); const created = new CreateAssetCommand('song', 'Theme'); f.stack.execute(created); const songId = created.asset.id; const song = () => f.project.assets.find((a) => a.id === songId) as SongAsset; const track = song().tracks[0]!.id; const clip = newItem({ track, kind: 'sound', asset: sound.asset.id, tick: 0, length: BAR }); const riff = newItem({ track, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }); f.stack.execute(new SetSongCommand(songId, { items: [clip, riff] })); for (const [id, fx] of [ [sound.asset.id, soundFx], [lead.id, leadFx], [pattern.asset.id, riffFx], [songId, themeFx], ] as const) f.stack.execute(new SetAssetPropertyCommand(id, ASSET_FX_KEY, fx.id)); const setItems = (clipFx: string, riffFx: string) => f.stack.execute( new SetSongCommand(songId, { items: song().items.map((i) => ({ ...i, fx: i.kind === 'sound' ? clipFx : riffFx })), }), ); const behavior = makeBehavior(f, 'Logic'); place(f, behavior.id, { x: 0, y: 0 }, place(f, f.spriteId, { x: 0, y: 0 })); const edit = graphEditor(f, behavior); const plan = (fx: SoundFxAsset) => soundFxBuildFor(fx).compiled!; return { ...f, fx: { soundFx, leadFx, riffFx, themeFx, other }, plan, sound: sound.asset.id, lead, pattern: pattern.asset.id, song, songId, track, riff: riff.id, setItems, edit, }; } function recording() { const sounds: FxVoice[] = []; const notes: { layers: readonly FxPlan[]; params: FxParams }[] = []; const sequences: { name: string; play: SequencePlay }[] = []; const sink: AudioSink = { ...SILENT_AUDIO, play: (_file, _volume, _loop, _owner, fx) => (sounds.push(fx ?? DRY_VOICE), 3), playNote: (_name, layers, params) => (notes.push({ layers, params }), 7), playSequence: (name, play) => (sequences.push({ name, play }), 500), }; return Object.assign(sink, { sounds, notes, sequences }); } /** The gains of a layer's stages, as its plan has them. */ function gains(layer: FxPlan | undefined): number[] { return (layer?.stages ?? []).flatMap((stage) => stage.kind === 'gain' && 'db' in stage ? [stage.db.value] : [], ); } describe('the Sound FX row (#352)', () => { it('stands last on a sound, an instrument, a pattern and a song, where Bus stood before #351', () => { for (const type of ['sound', 'instrument', 'pattern', 'song'] as AssetType[]) { const keys = schemaFor(type).map((def) => def.key); expect(keys.indexOf(ASSET_FX_KEY), type).toBe(keys.length - 1); expect(schemaFor(type).find((def) => def.key === ASSET_FX_KEY)).toMatchObject({ kind: 'asset', default: '', assetTypes: ['soundfx'], }); } }); it('is the one a pick leaves empty, none for None, and the pick itself for any other', () => { const props = { [ASSET_FX_KEY]: 'own' }; expect(pickedFx('', props)).toBe('own'); expect(pickedFx(NO_FX, props)).toBe(''); expect(pickedFx('picked', props)).toBe('picked'); expect(pickedFx('', {})).toBe(''); }); }); describe('the sound FX of what a song plays (#352)', () => { it('takes the one of the sound and of the pattern for an item that picks none', () => { const f = scene(); const { clips, notes } = songSequence(f.project, f.song()); expect(clips?.map((c) => c.fx)).toEqual([f.fx.soundFx.id]); expect(notes.map((n) => n.bus)).toEqual([itemBus(f.songId, { id: f.riff, track: f.track })]); const bus = songBuses(f.project, f.song()).find( (b) => b.name === itemBus(f.songId, { id: f.riff, track: f.track }), ); expect(bus?.fx?.plan).toBe(f.plan(f.fx.riffFx)); }); it('takes the one an item picks instead, and none for None', () => { const f = scene(); f.setItems(f.fx.other.id, f.fx.other.id); expect(songSequence(f.project, f.song()).clips?.map((c) => c.fx)).toEqual([f.fx.other.id]); const picked = songBuses(f.project, f.song()).find((b) => b.name.endsWith(f.riff)); expect(picked?.fx?.plan).toBe(f.plan(f.fx.other)); f.setItems(NO_FX, NO_FX); const { clips, notes } = songSequence(f.project, f.song()); expect(clips?.map((c) => c.fx)).toEqual(['']); // Without a sound FX the pattern item has no bus of its own; its notes go in with the track's. expect(notes.map((n) => n.bus)).toEqual([trackItemsBus(f.songId, f.track)]); expect(songBuses(f.project, f.song()).some((b) => b.name.endsWith(f.riff))).toBe(false); }); it('loads the sound FX of an instrument with the layers of its notes', () => { const f = scene(); const { layers } = sequenceNeeds(f.project, songSequence(f.project, f.song())); expect(layers).toContain(f.plan(f.fx.leadFx)); }); }); describe('the sound FX of what a Play node plays (#352)', () => { /** What one Play node plays on a scene of its own, its params given the scene. */ function played(type: string, params: (f: ReturnType) => PropertyBag) { const f = scene(); const start = f.edit.addNode('OnStart'); const play = f.edit.addNode(type, params(f)); f.edit.wire([[start, 'exec', play, 'exec']]); const audio = recording(); startWorld(f, { behaviors: true, audio }).frame(); return Object.assign(audio, { f }); } const planOf = (f: ReturnType, name: keyof ReturnType['fx']) => f.plan(f.fx[name]); it('plays a sound through its own unless the node picks another or None', () => { const own = played('PlaySound', (f) => ({ sound: f.sound })); expect(own.sounds[0]?.plan).toBe(planOf(own.f, 'soundFx')); const other = played('PlaySound', (f) => ({ sound: f.sound, fx: f.fx.other.id })); expect(other.sounds[0]?.plan).toBe(planOf(other.f, 'other')); expect(played('PlaySound', (f) => ({ sound: f.sound, fx: NO_FX })).sounds[0]).toBe(DRY_VOICE); }); it('plays a note through the sound FX of its instrument after what the instrument does', () => { const own = played('PlayNote', (f) => ({ instrument: f.lead.id, note: 60 })).notes[0]; expect(gains(own?.layers[0])).toEqual([-2]); const picked = played('PlayNote', (f) => ({ instrument: f.lead.id, note: 60, soundFx: f.fx.other.id })) .notes[0]; expect(gains(picked?.layers[0])).toEqual([-5]); const none = played('PlayNote', (f) => ({ instrument: f.lead.id, note: 60, soundFx: NO_FX })).notes[0]; expect(gains(none?.layers[0])).toEqual([]); }); it('plays a pattern and a song through theirs, and every note of them through the instrument’s', () => { const pattern = played('PlayPattern', (f) => ({ pattern: f.pattern })); expect(pattern.sequences[0]?.play.fx?.plan).toBe(planOf(pattern.f, 'riffFx')); expect( played('PlayPattern', (f) => ({ pattern: f.pattern, fx: NO_FX })).sequences[0]?.play.fx, ).toBeUndefined(); const song = played('PlaySong', (f) => ({ song: f.songId })); expect(song.sequences[0]?.play.fx?.plan).toBe(planOf(song.f, 'themeFx')); const other = played('PlaySong', (f) => ({ song: f.songId, fx: f.fx.other.id })); expect(other.sequences[0]?.play.fx?.plan).toBe(planOf(other.f, 'other')); const asked = pattern.sequences[0]!.play; asked.strike({ ...asked.sequence.notes[0]!, at: 0, duration: 0.1, tempo: 120 }); expect(gains(pattern.notes.at(-1)?.layers[0])).toEqual([-2]); }); });