A sound FX could only be put on a single sound. Now a track, a pattern item, a Play pattern and a Play song send everything they play through one chain of it, as an insert in a DAW. A Play note gets one of its own. - Track: an FX box in the head next to M and S opens a menu of the project's sound FX, and the track's inspector has the row too. Order: items, track FX, volume, pan. - Pattern item: Sound FX in its inspector. Its notes play on a bus of their own into the track's bus. - Play pattern and Play song take fx and fx params. The chain sits on the master of the playing, after the tracks and before the level and the fades. Set sound FX parameters reaches it by the playback. - Play note takes fx and fx params next to the instrument's params. The FX parameters go by "fx:" so the two sets stay apart. Set sound FX parameters on a note changes a name both have in both. - A Pitch on a bus is a new PitchShifter: the phase vocoder of the PitchPlayer, fed while it plays. It cannot look ahead and is exactly one frame late at every pitch, 2048 samples. - That latency is made up. The other way of a Mix is held back by a delay of the same length. Every note and clip starts as much earlier as the buses it passes are late. The scheduler plans that much further ahead, and the first note waits that much after Play. - The Song tab waits for the chains before it plays and lets its buses go a moment after Stop. A playing in a game lets them go after its echoes and what the shifters still hold. - songBuses moved to its own file. Kept in song.ts it pulled the FX compiler into the node registry through an import cycle. Decisions of the user: one chain for the whole (1a), FX everywhere asked for (2a), Set sound FX parameters on playbacks (3a), and a pitch shifter of its own with exact latency compensation for a Pitch on a bus. Tests: - Unit pitch-shifter (new): 11 green. At 0 semitones the output is the input 2048 samples later, off by at most 1.2e-7. - Unit sound-library: 84 green, 8 new. Counter-checks (no early start, no Mix delay, chain made every time, tune misses the master) each turned their test red. - Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1 new, its counter-check red. - e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run. Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz and passed a click 2048 samples later. - Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
10 KiB
TypeScript
232 lines
10 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 } 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];
|
||
expect(songBuses(f.project, f.song())).toEqual([
|
||
{ name: trackBus(f.songId, one), gain: 1, pan: 0, fx: { plan, params: {}, tempo: 120 } },
|
||
{ name: trackBus(f.songId, two), gain: 1, pan: 0 },
|
||
{
|
||
name: itemBus(f.songId, f.through),
|
||
gain: 1,
|
||
pan: 0,
|
||
into: trackBus(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, trackBus(f.songId, two)],
|
||
]);
|
||
expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackBus(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: trackBus(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],
|
||
[itemBus(f.songId, f.through), trackBus(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 } },
|
||
]);
|
||
});
|
||
});
|