/** * A sound FX on what plays many notes (#346): a track, a pattern item, a whole Play pattern or Play * song goes through one chain on a bus of its own; a Play note through one of its own, its parameters * apart from the instrument's. */ 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 { soundFxBuildFor } from '../../src/core/soundfx/compile'; import { VELOCITY_PARAM } from '../../src/core/instrument/nodes'; import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset'; import type { SequencePlay } from '../../src/core/music/playbacks'; import { itemBus, newItem, newTrack, songSequence, trackBus, trackItemsBus } from '../../src/core/music/song'; import { songBuses } from '../../src/core/music/songBuses'; import { fxChainOf, type FxParams, type FxPlan } 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 "Wobble" – a Gain fed by its parameter `depth` (-3 by default), then a Pitch of 12 – an * instrument, a pattern of one note, and a song: on its first track Wobble, on its second the pattern * through Wobble, the pattern through an FX that is gone, and a sound through Wobble by itself. */ function scene() { const f = makeSpriteScene('Box'); const made = new CreateAssetCommand('soundfx', 'Wobble'); f.stack.execute(made); const fx = made.asset as SoundFxAsset; const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset); const input = fxEdit.addNode('SoundFxInput'); input.ports = [{ key: 'depth', kind: 'number', default: -3 }]; const gain = fxEdit.addNode('SoundFxGain'); const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 }); const output = fxEdit.addNode('SoundFxOutput'); fxEdit.wire([ [input, 'out', gain, 'in'], [input, 'depth', gain, 'gain'], [gain, 'out', pitch, 'in'], [pitch, 'out', output, 'in'], ]); 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 [one, two] = [{ ...song().tracks[0]!, fx: fx.id }, newTrack(2)]; const through = newItem({ track: two.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR }); const gone = newItem({ track: two.id, kind: 'pattern', asset: pattern.asset.id, tick: BAR, length: BAR }); const clip = newItem({ track: two.id, kind: 'sound', asset: sound.asset.id, tick: 0, length: BAR }); f.stack.execute( new SetSongCommand(songId, { tracks: [one, two], items: [ { ...through, fx: fx.id }, { ...gone, fx: 'gone' }, { ...clip, fx: fx.id }, ], }), ); 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 params = edit.addNode('SoundFxParameters', { soundFxAsset: fx.id, depth: -9 }); params.ports = [{ key: 'depth', kind: 'number' }]; return { ...f, fx, lead, song, songId, tracks: [one.id, two.id], through, gone, pattern, edit, params }; } function recording() { const sequences: { name: string; play: SequencePlay }[] = []; const notes: { layers: readonly FxPlan[]; params: FxParams }[] = []; const tuned: { voice: number; params: FxParams }[] = []; const sink: AudioSink = { ...SILENT_AUDIO, playSequence: (name, play) => (sequences.push({ name, play }), 500), playNote: (_name, layers, params) => (notes.push({ layers, params }), 7), tune: (voice, params) => void tuned.push({ voice, params }), }; return Object.assign(sink, { sequences, notes, tuned }); } describe('a sound FX on the buses of a song (#346)', () => { it('gives a track its sound FX, and a pattern item with one a bus of its own into its track', () => { const f = scene(); const plan = soundFxBuildFor(f.fx).compiled!; const [one, two] = f.tracks as [string, string]; const out = [{ gain: 1, tap: 'post' }]; expect(songBuses(f.project, f.song())).toEqual([ { name: trackBus(f.songId, one), gain: 1, pan: 0, sends: out, fx: { plan, params: {}, tempo: 120 } }, { name: trackBus(f.songId, two), gain: 1, pan: 0, sends: out }, { name: trackItemsBus(f.songId, one), gain: 1, pan: 0, into: trackBus(f.songId, one) }, { name: trackItemsBus(f.songId, two), gain: 1, pan: 0, into: trackBus(f.songId, two) }, { name: itemBus(f.songId, f.through), gain: 1, pan: 0, into: trackItemsBus(f.songId, two), fx: { plan, params: {}, tempo: 120 }, }, ]); const { notes, clips } = songSequence(f.project, f.song()); // The item through Wobble on its own bus, the one whose FX is gone on its track's, like the sound. expect(notes.map((n) => [n.tick, n.bus])).toEqual([ [0, itemBus(f.songId, f.through)], [BAR, trackItemsBus(f.songId, two)], ]); expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackItemsBus(f.songId, two), f.fx.id]]); }); it('keeps a pattern item whose sound FX does not compile on a bus of its own that passes it on', () => { const f = scene(); f.fx.graph.edges = f.fx.graph.edges.filter( (e) => e.to.port !== 'in' || e.from.port !== 'out' || e.to.node !== f.fx.graph.nodes.find((n) => n.type === 'SoundFxOutput')!.id, ); expect(soundFxBuildFor(f.fx).compiled).toBeNull(); const item = songBuses(f.project, f.song()).find((bus) => bus.name === itemBus(f.songId, f.through)); expect(item).toEqual({ name: itemBus(f.songId, f.through), gain: 1, pan: 0, into: trackItemsBus(f.songId, f.tracks[1]!), }); }); it('hands Play song its buses and its own sound FX with the values given, at its tempo', () => { const f = scene(); const start = f.edit.addNode('OnStart'); const play = f.edit.addNode('PlaySong', { song: f.songId, fx: f.fx.id }); f.edit.wire([ [start, 'exec', play, 'exec'], [f.params, 'params', play, 'fxParams'], ]); const audio = recording(); startWorld(f, { behaviors: true, audio }).frame(); const [asked] = audio.sequences; const plan = soundFxBuildFor(f.fx).compiled!; expect(asked?.play.fx).toEqual({ plan, params: { depth: -9 }, tempo: 120 }); expect(asked?.play.buses?.map((bus) => [bus.name, bus.into ?? '', 'fx' in bus])).toEqual([ [trackBus(f.songId, f.tracks[0]!), '', true], [trackBus(f.songId, f.tracks[1]!), '', false], [trackItemsBus(f.songId, f.tracks[0]!), trackBus(f.songId, f.tracks[0]!), false], [trackItemsBus(f.songId, f.tracks[1]!), trackBus(f.songId, f.tracks[1]!), false], [itemBus(f.songId, f.through), trackItemsBus(f.songId, f.tracks[1]!), true], ]); }); it('hands Play pattern its sound FX the same way, and none where it names none', () => { const f = scene(); const start = f.edit.addNode('OnStart'); const withFx = f.edit.addNode('PlayPattern', { pattern: f.pattern.asset.id, fx: f.fx.id }); const without = f.edit.addNode('PlayPattern', { pattern: f.pattern.asset.id }); f.edit.wire([ [start, 'exec', withFx, 'exec'], [withFx, 'exec', without, 'exec'], [f.params, 'params', withFx, 'fxParams'], ]); const audio = recording(); startWorld(f, { behaviors: true, audio }).frame(); expect(audio.sequences.map((s) => s.play.fx?.params ?? null)).toEqual([{ depth: -9 }, null]); }); }); describe('a sound FX of a Play note (#346)', () => { it('sends the note through it, its Pitch on the sound itself, its parameters apart from the instrument’s', () => { const f = scene(); const start = f.edit.addNode('OnStart'); const play = f.edit.addNode('PlayNote', { instrument: f.lead.id, note: 60, soundFx: f.fx.id }); f.edit.wire([ [start, 'exec', play, 'exec'], [f.params, 'params', play, 'soundFxParams'], ]); const audio = recording(); startWorld(f, { behaviors: true, audio }).frame(); const [note] = audio.notes; const [layer] = note!.layers; expect(layer).toMatchObject({ pitch: [{ value: 12 }], stages: [{ kind: 'gain', db: { value: -3, param: 'fx:depth' } }], source: { file: FILE }, }); expect(note!.params).toEqual({ [VELOCITY_PARAM]: 1, 'fx:depth': -9 }); const chain = fxChainOf(layer!, note!.params); expect(chain).toMatchObject({ pitch: 12 }); expect(chain.stages[0]).toMatchObject({ kind: 'gain' }); expect('gain' in chain.stages[0]! && chain.stages[0].gain).toBeCloseTo(10 ** (-9 / 20), 9); }); it('is reached by Set sound FX parameters, which also reaches a playing of a song by its number', () => { const f = scene(); const start = f.edit.addNode('OnStart'); const note = f.edit.addNode('PlayNote', { instrument: f.lead.id, soundFx: f.fx.id }); const song = f.edit.addNode('PlaySong', { song: f.songId, fx: f.fx.id }); const setNote = f.edit.addNode('SetSoundFxParameters'); const setSong = f.edit.addNode('SetSoundFxParameters'); f.edit.wire([ [start, 'exec', note, 'exec'], [note, 'exec', song, 'exec'], [song, 'exec', setNote, 'exec'], [setNote, 'exec', setSong, 'exec'], [note, 'voice', setNote, 'voice'], [song, 'playback', setSong, 'voice'], [f.params, 'params', setNote, 'fxParams'], [f.params, 'params', setSong, 'fxParams'], ]); const audio = recording(); startWorld(f, { behaviors: true, audio }).frame(); // A note's FX goes by its scope; a song's master and a sound have no scoped names to reach. expect(audio.tuned).toEqual([ { voice: 7, params: { depth: -9, 'fx:depth': -9 } }, { voice: 500, params: { depth: -9, 'fx:depth': -9 } }, ]); }); });