A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.
- Menu -> Master routing opens a tab with one graph per project. It has
a node per sound, instrument, pattern, song and bus, named after its
asset, and one Master. Embedded assets have none, their song routes
them.
- The nodes follow the assets. Every edit settles the routing in the
same undo step (KeepRoutingCommand), so an undone delete brings the
links of its asset back. A new asset gets a node and a link to the
master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
song. Out of a sound, an instrument or a pattern a link always takes
the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
project is read, but nothing is added there. The editor adds the
nodes when it opens a project. An asset without a node plays on the
master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
A sound or instrument with more gets a bus of its own with the sends.
A pattern or song sends from the master of its playing, which is
where its Mode taps. Buses of the mix can have sends now too.
Found:
- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
this change. HEAD passed it 2 of 2. Not fixed here.
Tests:
- Unit master-routing (new, 10), sound-library (2 new), buses
rewritten onto the routing, 5 lists without the Bus row. 5 counter
checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
Counter check with the Sound tab ignoring the routing: the routing
check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 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 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<typeof makeSpriteScene>, 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<typeof scene>) => 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<typeof scene>, name: keyof ReturnType<typeof scene>['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]);
|
||
});
|
||
});
|