Files
feuerwerk/tests/core/asset-fx.test.ts
T
adminandClaude Opus 5.5 a295d233ed Give sounds, instruments, patterns and songs a sound FX (#352)
A sound FX could only be picked where something was played: on a Play
node, a track or an item of a song. A sound that should always sound
through the same effect needed that pick everywhere it was used. Now
the asset says it once, and every place that plays it follows.

- New row Sound FX on a sound, an instrument, a pattern and a song,
  right before Bus. A sound goes through it on every voice, an
  instrument on every note after its graph, a pattern on all its
  notes together and a song on its output.
- The fx input of Play sound, Play note, Play pattern and Play song
  and the Sound FX of a song item now read "From the asset" while
  empty. None is a pick of its own, stored as `#none` like `#master`.
  A pick replaces the FX of the asset, it does not come on top.
- A pattern item's Instrument reads "From the asset" instead of None.
  The meaning is unchanged.
- A double-click on a sound item opens the sound, as one on a pattern
  item opens the pattern.
- The tabs play through the FX of their asset. The Pattern and Song
  tab put it on a bus of their own between the output and the bus of
  the asset. The keys of the Pattern tab play there too.
- Notes of a pattern or song go through the FX of their instrument.
  Its chain is loaded with the other needs of the sequence.

Decided without asking:
- Old items and Play nodes with an empty fx now mean "From the
  asset". No asset has an FX yet, so nothing sounds different.
- The label is "From the asset" as on Loop and Bus, not "From asset".
- A sound used as a Sample, as the impulse of a Reverb or as the test
  sound of the Sound FX tab plays without its own FX.

Findings:
- In e2e, `__gm.inspectorField('Sound FX')` and `('Instrument')` find
  the group of the Assets panel first. The check asks for the last
  match.

Tests:
- Unit asset-fx (new): 8 green. Six counter-checks each turned red.
- Field lists of sound, pattern and song: Sound FX added.
- e2e 65 (melektaus-atx): ALL PASSED, 46 checks (3 new). Counter-check
  without the FX bus of the tab: that check red.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests.
- Not checked: how it sounds, the Sound, Instrument and Pattern tab
  in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:02:26 +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 right before Bus on a sound, an instrument, a pattern and a song', () => {
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.indexOf('bus') - 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]);
});
});