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>
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 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]);
|
||
});
|
||
});
|