Files
feuerwerk/tests/core/asset-fx.test.ts
T
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
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>
2026-10-09 09:18:46 +02:00

232 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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]);
});
});