Every track of a song went straight into its output. Now the Song tab has a second view, Routing: a graph with a node per track and one Output, the links between them sends. A track can go into other tracks, into several at once, and a link has its own volume and the point of the track it taps. - The song asset has a graph like an instrument (SongTrack, SongOutput). heldSongGraph keeps it in step with the tracks on load and in SetSongCommand: a node per track named after it, one output, a new track and every track of an old song linked to the output. Links of gone tracks, doubled links and links closing a loop are dropped. - A link carries an optional send (volume in dB, tap). The inspector shows Volume and Mode for a picked link of a song: Post volume (Post pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand. - NodeDef has two new flags. `fixed` nodes are never added or deleted by hand. Links between `acyclic` nodes may not close a loop, checked in canConnect, dropTargetPort and ConnectCommand. - songBuses gives each track its sends instead of one `into`, and a bus `song:<song>:<track>:#items` for its own items. That bus is what solo closes on a track that only passes a soloed one on. - Mute and solo now act on the sends (trackGates). A muted track passes nothing on, on any tap. A soloed track is heard with what goes into it, through the tracks it goes through, their own items silent. Only sends on a way through a soloed track stay open, so a track going into it and into the output is heard through it only. - SoundLibrary: a bus has either `into` or sends. Each send is tap, delay, level, target. busLatency takes the latest way (arrivalOf), and each send is held back so all ways of a track arrive together (sendDelays). Taps are wired again whenever a chain comes or goes. - The Song tab has Timeline and Routing in its header. The routing uses GraphCanvas with an empty palette. A track linked nowhere shows "Goes nowhere". Delete takes links, never a track node. - Game playings route the same way, the output being the master of the playing. Decided without asking: a send ranges -60 to +12 dB like a track, default 0 dB Post. Mute closes the sends, the track's own level stays. Found on the way: the inspector built the Mode row with the send as it was when the rows were made. Setting Volume and then Mode wrote the old volume back. Both rows now read the current send when they change. Tests: - Unit song-routing (new): 13 green. Sound library: 4 new, 94 green. 11 counter-probes (loop on load, canConnect, solo, solo past the soloed track, mute, preFx latency, graph sync, delete guard, delays, taps, glide), each red. - Existing tests adjusted to the new bus names and send shape: song, bus-fx, song-play, graph registry, edge description. - e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first run caught the inspector bug. Counter-probe with the old closure: 2 FAILED. - Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red (registry rule, edge shape), fixed and rerun locally: 56 green. - Not rerun after the per-send solo fix: e2e 65 and the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
/**
|
||
* 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 } },
|
||
]);
|
||
});
|
||
});
|