Files
feuerwerk/tests/core/sounds.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

488 lines
18 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 is an asset holding an uploaded file. Nothing wears it and nothing places it – a graph
* plays it, and what the asset says about volume and looping is what a bare `Play sound` does.
*/
import { describe, expect, it } from 'vitest';
import {
buildAsset,
CreateAssetCommand,
CreateSoundCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import type { BehaviorAsset, SoundAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import {
cutChannels,
cutName,
cutRange,
FADE_CURVES,
fadeGain,
isSoundFile,
parseCutName,
soundFileOf,
} from '../../src/core/model/sound';
import { assetRefChoices } from '../../src/core/properties/assetRef';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { playedSoundById, playedSoundOf } from '../../src/core/properties/playedSound';
import {
cutOf,
END_KEY,
FADE_IN_CURVE_KEY,
FADE_OUT_CURVE_KEY,
FADE_IN_KEY,
FADE_OUT_KEY,
LOOP_KEY,
loopsOf,
REVERSE_KEY,
START_KEY,
VOLUME_KEY,
volumeOf,
} from '../../src/core/properties/sounds';
import { BUS_KEY } from '../../src/core/properties/buses';
import { deserializeProject } from '../../src/core/store/serialize';
import type { AudioSink } from '../../src/runtime/systems/Audio';
import {
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
const FILE = `${'a1'.repeat(32)}.wav`;
/** Records what it was asked to play instead of making a noise – the AudioSink a test wants. */
function fakeAudio(): AudioSink & {
played: { file: string; volume: number; loop: boolean; owner: string }[];
stopped: string[];
stoppedOwners: string[];
} {
const sink = {
played: [] as { file: string; volume: number; loop: boolean; owner: string }[],
stopped: [] as string[],
stoppedOwners: [] as string[],
play: (file: string, volume: number, loop: boolean, owner: string) =>
sink.played.push({ file, volume, loop, owner }),
playNote: () => 0,
playSequence: () => 0,
stopSequence: () => {},
playClip: () => 0,
stopPlayback: () => {},
release: () => {},
tune: () => {},
length: () => 0,
tail: () => 0,
stop: (file: string) => sink.stopped.push(file),
stopVoice: () => {},
stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
setMix: () => {},
setMixVolume: () => {},
mixVolume: () => 1,
resetMix: () => {},
tuneMix: () => {},
};
return sink;
}
function makeSound(name = 'Jump', file = FILE) {
const fixture = makeProject();
const command = new CreateSoundCommand(name, file);
fixture.stack.execute(command);
return { ...fixture, sound: command.asset };
}
/** A scene whose one behavior plays `sound` on start, with whatever the node itself carries. */
function playingScene(params: Record<string, string | number | boolean> = {}) {
const f: SceneFixture & { behavior?: BehaviorAsset } = makeSpriteScene('Box');
const create = new CreateSoundCommand('Jump', FILE);
f.stack.execute(create);
const behavior = makeBehavior(f, 'Logic');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, behavior.id, { x: 0, y: 0 }, box);
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const play = edit.addNode('PlaySound', { sound: create.asset.id, volume: 1, ...params });
edit.connect(start, 'exec', play, 'exec');
return { ...f, sound: create.asset, behavior, play };
}
describe('sound files', () => {
it('accepts only a content-addressed name in a supported format', () => {
expect(isSoundFile(FILE)).toBe(true);
expect(isSoundFile(`${'b3'.repeat(32)}.mp3`)).toBe(true);
expect(isSoundFile(`${'b3'.repeat(32)}.m4a`)).toBe(true);
expect(isSoundFile(`${'a1'.repeat(32)}.png`)).toBe(false);
expect(isSoundFile(`${'a1'.repeat(32)}.WAV`)).toBe(false);
expect(isSoundFile('jump.wav')).toBe(false);
expect(isSoundFile('')).toBe(false);
});
});
describe('the sound asset', () => {
it('is not placeable, starts without a file, and is full volume and one-shot by default', () => {
expect(isPlaceableType('sound')).toBe(false);
const asset = buildAsset('sound', 'Sound');
expect(asset).toMatchObject({ type: 'sound', file: '' });
expect(volumeOf(asset.props)).toBe(1);
expect(loopsOf(asset.props)).toBe(false);
expect(schemaFor('sound').map((def) => def.key)).toEqual([
VOLUME_KEY,
LOOP_KEY,
START_KEY,
END_KEY,
REVERSE_KEY,
FADE_IN_KEY,
FADE_IN_CURVE_KEY,
FADE_OUT_KEY,
FADE_OUT_CURVE_KEY,
'fx',
BUS_KEY,
]);
expect(cutOf(asset.props)).toEqual({
start: 0,
end: 0,
fadeIn: 0.005,
fadeOut: 0.005,
curveIn: 'linear',
curveOut: 'linear',
reverse: false,
});
});
it('refuses a file name the server could not have produced', () => {
const fixture = makeProject();
expect(() => fixture.stack.execute(new CreateSoundCommand('Bad', 'jump.wav'))).toThrow(
'is not a stored sound file',
);
expect(fixture.project.assets).toHaveLength(0);
});
it('survives a round trip and drops a file name it cannot trust', () => {
const s = makeSound();
expect(roundTrip(s.project)).toEqual(s.project);
const repaired = deserializeProject({
name: 'P',
assets: [
{ id: 's1', type: 'sound', name: 'Local', file: '../../etc/passwd' },
{ id: 's2', type: 'sound', name: 'Gone' },
],
});
expect(repaired.assets.map((a) => (a as SoundAsset).file)).toEqual(['', '']);
});
it('resolves to a file, and to none when the id is unset, dangling or of another type', () => {
const s = makeSound();
expect(soundFileOf(s.project, s.sound.id)).toBe(FILE);
expect(soundFileOf(s.project, '')).toBe('');
expect(soundFileOf(s.project, 'no-such-asset')).toBe('');
const text = new CreateAssetCommand('text', 'Title');
s.stack.execute(text);
expect(soundFileOf(s.project, text.asset.id)).toBe('');
});
it('is what a sound picker offers, and nothing else is', () => {
const s = makeSound();
s.stack.execute(new CreateAssetCommand('text', 'Title'));
expect(assetRefChoices(s.project, ['sound']).map((a) => a.id)).toEqual([s.sound.id]);
});
});
describe('the cut of a sound (#343)', () => {
it('is no cut at all over the whole file, and a name of its own otherwise', () => {
const linear = { curveIn: 'linear', curveOut: 'linear', reverse: false } as const;
const whole = { start: 0, end: 0, fadeIn: 0.005, fadeOut: 0.005, ...linear };
expect(cutName(FILE, whole)).toBe(FILE);
expect(cutName('', { ...whole, start: 1 })).toBe('');
const cut = {
start: 0.25,
end: 1.5,
fadeIn: 0.005,
fadeOut: 0.2,
curveIn: 'sCurve',
curveOut: 'equalPower',
reverse: false,
} as const;
const name = cutName(FILE, cut);
expect(name).toBe(`${FILE}#0.25~1.5~0.005~0.2~sCurve~equalPower`);
expect(parseCutName(name)).toEqual({ file: FILE, cut });
expect(parseCutName(FILE)).toEqual({ file: FILE, cut: null });
expect(parseCutName(`${FILE}#x~-2`)).toEqual({
file: FILE,
cut: { start: 0, end: 0, fadeIn: 0, fadeOut: 0, ...linear },
});
});
it('keeps at least a sample inside the file, and ramps only the edges inside it, each by its own fade', () => {
// 10 samples at 10 Hz: one sample per tenth of a second.
const fades = (fadeIn: number, fadeOut = fadeIn) => ({
fadeIn,
fadeOut,
curveIn: 'linear' as const,
curveOut: 'linear' as const,
reverse: false,
});
expect(cutRange(10, 10, { start: 0.2, end: 0.8, ...fades(0.2) })).toEqual({
from: 2,
to: 8,
fadeIn: 2,
fadeOut: 2,
curveIn: 'linear',
curveOut: 'linear',
reverse: false,
});
expect(cutRange(10, 10, { start: 0.1, end: 0.9, ...fades(0.1, 0.3) })).toMatchObject({
fadeIn: 1,
fadeOut: 3,
});
expect(cutRange(10, 10, { start: 0, end: 0.5, ...fades(0.2) })).toMatchObject({ fadeIn: 0, fadeOut: 2 });
expect(cutRange(10, 10, { start: 0.5, end: 0, ...fades(0.2) })).toMatchObject({
to: 10,
fadeIn: 2,
fadeOut: 0,
});
// Past the end, an end before the start, and fades longer than half of what is kept.
expect(cutRange(10, 10, { start: 5, end: 0, ...fades(0) })).toMatchObject({ from: 9, to: 10 });
expect(cutRange(10, 10, { start: 0.6, end: 0.3, ...fades(0) })).toMatchObject({ from: 6, to: 7 });
expect(cutRange(10, 10, { start: 0.2, end: 0.6, ...fades(1, 0.1) })).toMatchObject({
fadeIn: 2,
fadeOut: 1,
});
});
it('cuts every channel alike, rising from silence and falling to it at an inner edge', () => {
const left = new Float32Array(10).fill(1);
const right = new Float32Array(10).fill(-0.5);
const [l, r] = cutChannels([left, right], {
from: 2,
to: 8,
fadeIn: 2,
fadeOut: 2,
curveIn: 'linear',
curveOut: 'linear',
reverse: false,
});
expect(Array.from(l!)).toEqual([0, 0.5, 1, 1, 0.5, 0]);
expect(Array.from(r!)).toEqual([-0, -0.25, -0.5, -0.5, -0.25, -0]);
expect(Array.from(left)).toEqual(new Array(10).fill(1));
});
it('shapes each ramp by its own curve, the end running it backwards (#345)', () => {
for (const { value } of FADE_CURVES) {
expect(fadeGain(value, 0)).toBeCloseTo(0, 12);
expect(fadeGain(value, 1)).toBeCloseTo(1, 12);
}
expect(fadeGain('linear', 0.5)).toBe(0.5);
expect(fadeGain('equalPower', 0.5)).toBeCloseTo(Math.SQRT1_2, 12);
expect(fadeGain('sCurve', 0.25)).toBeCloseTo(1 - fadeGain('sCurve', 0.75), 12);
expect(fadeGain('exponential', 0.5)).toBe(0.25);
expect(fadeGain('logarithmic', 0.5)).toBe(0.75);
const [kept] = cutChannels([new Float32Array(12).fill(1)], {
from: 0,
to: 12,
fadeIn: 4,
fadeOut: 4,
curveIn: 'exponential',
curveOut: 'logarithmic',
reverse: false,
});
expect(Array.from(kept!)).toEqual([0, 0.0625, 0.25, 0.5625, 1, 1, 1, 1, 0.9375, 0.75, 0.4375, 0]);
});
it('is what every way of playing the asset is given, its fades in milliseconds on the asset', () => {
const f = playingScene();
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, START_KEY, 0.5));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_IN_KEY, 25));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_KEY, 40));
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, FADE_OUT_CURVE_KEY, 'equalPower'));
const name = `${FILE}#0.5~0~0.025~0.04~linear~equalPower`;
expect(playedSoundOf(f.project, f.sound)).toBe(name);
expect(playedSoundById(f.project, f.sound.id)).toBe(name);
expect(playedSoundById(f.project, '')).toBe('');
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.played).toMatchObject([{ file: name }]);
});
it('plays backwards from its end, the fade in ramping there (#321)', () => {
const fades = { fadeIn: 0.2, fadeOut: 0.2, curveIn: 'linear', curveOut: 'linear' } as const;
// Kept up to the middle of a 10-sample file: only the end lies inside, and it is heard first.
expect(cutRange(10, 10, { start: 0, end: 0.5, ...fades, reverse: true })).toMatchObject({
from: 0,
to: 5,
fadeIn: 2,
fadeOut: 0,
reverse: true,
});
const ramp = Float32Array.from({ length: 10 }, (_, i) => i);
const [kept] = cutChannels([ramp], {
from: 2,
to: 8,
fadeIn: 2,
fadeOut: 1,
curveIn: 'linear',
curveOut: 'linear',
reverse: true,
});
expect(Array.from(kept!)).toEqual([0, 3, 5, 4, 3, 0]);
expect(Array.from(ramp)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
const f = playingScene();
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, true));
const name = `${FILE}#0~0~0.005~0.005~linear~linear~reverse`;
expect(playedSoundOf(f.project, f.sound)).toBe(name);
expect(parseCutName(name).cut).toMatchObject({ start: 0, end: 0, reverse: true });
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, REVERSE_KEY, false));
expect(playedSoundOf(f.project, f.sound)).toBe(FILE);
});
});
describe('Play sound', () => {
it('plays the asset as the project describes it', () => {
const f = playingScene();
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.played).toMatchObject([{ file: FILE, volume: 1, loop: false }]);
});
it('scales the asset volume rather than replacing it, and clamps the product', () => {
const f = playingScene({ volume: 0.5 });
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, VOLUME_KEY, 0.4));
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.played[0]?.volume).toBeCloseTo(0.2);
const loud = playingScene({ volume: 10 });
const loudAudio = fakeAudio();
startWorld(loud, { behaviors: true, audio: loudAudio }).frame();
expect(loudAudio.played[0]?.volume).toBe(1);
});
it('loops as the asset says while the node says nothing', () => {
const asset = playingScene();
asset.stack.execute(new SetAssetPropertyCommand(asset.sound.id, LOOP_KEY, true));
const assetAudio = fakeAudio();
startWorld(asset, { behaviors: true, audio: assetAudio }).frame();
expect(assetAudio.played[0]?.loop).toBe(true);
const node = playingScene({ loop: 'true' });
const nodeAudio = fakeAudio();
startWorld(node, { behaviors: true, audio: nodeAudio }).frame();
expect(nodeAudio.played[0]?.loop).toBe(true);
});
/** The reason the node asks for three answers rather than a checkbox: music played as a one-shot. */
it('plays a looping asset once when the node says off', () => {
const f = playingScene({ loop: 'false' });
f.stack.execute(new SetAssetPropertyCommand(f.sound.id, LOOP_KEY, true));
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.played[0]?.loop).toBe(false);
});
it('takes a boolean arriving over an edge literally', () => {
const on = playingScene();
const onEdit = graphEditor(on, on.behavior);
onEdit.connect(onEdit.addNode('Boolean', { value: true }), 'value', on.play, 'loop');
const onAudio = fakeAudio();
startWorld(on, { behaviors: true, audio: onAudio }).frame();
expect(onAudio.played[0]?.loop).toBe(true);
const off = playingScene();
off.stack.execute(new SetAssetPropertyCommand(off.sound.id, LOOP_KEY, true));
const offEdit = graphEditor(off, off.behavior);
offEdit.connect(offEdit.addNode('Boolean', { value: false }), 'value', off.play, 'loop');
const offAudio = fakeAudio();
startWorld(off, { behaviors: true, audio: offAudio }).frame();
expect(offAudio.played[0]?.loop).toBe(false);
});
it('plays nothing for an id that resolves to no sound, and does not stop the flow', () => {
const f = playingScene();
const edit = graphEditor(f, f.behavior);
const log = edit.addNode('Log', { message: 'after' });
edit.connect(f.play, 'exec', log, 'exec');
f.play.params.sound = 'no-such-asset';
const audio = fakeAudio();
const { world, frame } = startWorld(f, { behaviors: true, audio });
frame();
expect(audio.played).toEqual([]);
expect(world.log).toEqual(['after']);
});
});
/**
* A sound is something a thing in the world is *doing*, so it ends when that thing does – the
* `Stop sound` you would otherwise have to remember in every `On destroy`.
*/
describe('a voice belongs to the behavior that started it', () => {
it('names that behavior as its owner, and nobody when the node is detached', () => {
const owned = playingScene();
const ownedAudio = fakeAudio();
const run = startWorld(owned, { behaviors: true, audio: ownedAudio });
run.frame();
const logic = run.world.entities.find((e) => e.name === 'Logic')!;
expect(ownedAudio.played[0]?.owner).toBe(logic.id);
const free = playingScene({ detached: true });
const freeAudio = fakeAudio();
startWorld(free, { behaviors: true, audio: freeAudio }).frame();
expect(freeAudio.played[0]?.owner).toBe('');
});
it('is silenced when the behavior is destroyed, after On destroy has had its say', () => {
const f = playingScene();
const edit = graphEditor(f, f.behavior);
const key = edit.addNode('OnKeyDown', { key: 'x' });
const owner = edit.addNode('Owner');
const destroy = edit.addNode('Destroy');
edit.wire([
[key, 'exec', destroy, 'exec'],
[owner, 'object', destroy, 'object'],
]);
const audio = fakeAudio();
const { world, input, frame } = startWorld(f, { behaviors: true, audio });
frame();
const logic = world.entities.find((e) => e.name === 'Logic')!;
expect(audio.stoppedOwners).toEqual([]);
input.press('x');
frame();
expect(audio.stoppedOwners).toEqual([logic.id]);
});
});
describe('Stop sound', () => {
it('silences one sound by its file, and everything when it names none', () => {
const f = playingScene();
const edit = graphEditor(f, f.behavior);
const stop = edit.addNode('StopSound', { sound: f.sound.id });
const all = edit.addNode('StopSound', { sound: '' });
edit.connect(f.play, 'exec', stop, 'exec');
edit.connect(stop, 'exec', all, 'exec');
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
expect(audio.stopped).toEqual([FILE, '']);
});
it('silences nothing at all for an id that resolves to no sound', () => {
const f = playingScene();
const edit = graphEditor(f, f.behavior);
const stop = edit.addNode('StopSound', { sound: 'no-such-asset' });
edit.connect(f.play, 'exec', stop, 'exec');
const audio = fakeAudio();
startWorld(f, { behaviors: true, audio }).frame();
// Not `['']`: a dangling reference must not turn into "stop everything".
expect(audio.stopped).toEqual([]);
});
});