Files
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

261 lines
11 KiB
TypeScript
Raw Permalink 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.
/**
* Master and buses (#325): what the sound library is set up with, and which bus a sound, a note, a
* pattern or a song is heard on – where each goes is the master routing's (#351).
*/
import { describe, expect, it } from 'vitest';
import {
buildAsset,
CreateAssetCommand,
CreateSoundCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { SetMasterFxCommand, SetMasterVolumeCommand } from '../../src/core/commands/projectCommands';
import type { BehaviorAsset, SoundFxAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import {
MASTER_MIX,
type MixBus,
mixBusName,
mixOf,
playedBus,
reachedBus,
sameMix,
} from '../../src/core/music/mixer';
import { MASTER_BUS } from '../../src/core/properties/buses';
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
import type { FxParams, FxVoice } from '../../src/core/soundfx/nodes';
import { GameRun } from '../../src/runtime/GameRun';
import { SILENT_AUDIO } from '../../src/runtime/systems/Audio';
import { Input } from '../../src/runtime/systems/Input';
import {
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
roundTrip,
routingOf,
startWorld,
} from '../helpers';
const FILE = `${'a1'.repeat(32)}.wav`;
function withBuses(...names: string[]) {
const f = makeProject();
const ids = names.map((name) => {
const command = new CreateAssetCommand('bus', name);
f.stack.execute(command);
return command.asset.id;
});
const routing = routingOf(f);
return { ...f, ids, into: (bus: string, target: string) => routing.only(bus, target) };
}
/** A sound FX that compiles: Input, a Gain, Output. */
function workingFx(f: ReturnType<typeof makeProject>): string {
const command = new CreateAssetCommand('soundfx', 'Squash');
f.stack.execute(command);
const edit = graphEditor(f, command.asset as SoundFxAsset as unknown as BehaviorAsset);
const input = edit.addNode('SoundFxInput');
const gain = edit.addNode('SoundFxGain', { gain: -6 });
const output = edit.addNode('SoundFxOutput');
edit.connect(input, 'out', gain, 'in');
edit.connect(gain, 'out', output, 'in');
return command.asset.id;
}
describe('the bus asset (#325)', () => {
it('is not placeable and starts at full volume, without a sound FX', () => {
expect(isPlaceableType('bus')).toBe(false);
const f = withBuses('Music');
const bus = f.project.assets[0]!;
expect(buildAsset('bus', 'SFX')).toMatchObject({ type: 'bus', props: { volume: 1, fx: '' } });
expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx']);
expect(roundTrip(f.project)).toEqual(f.project);
});
it('leaves where a sound, an instrument, a pattern, a song and a bus go to the routing (#351)', () => {
const f = withBuses('Music');
for (const type of ['sound', 'instrument', 'pattern', 'song'] as const) {
const command = new CreateAssetCommand(type, type);
f.stack.execute(command);
expect(schemaForAsset(f.project, command.asset).map((def) => def.key)).not.toContain('bus');
}
expect(schemaForAsset(f.project, f.project.assets[0]!).map((def) => def.key)).not.toContain('into');
});
});
describe('the master (#325)', () => {
it('is a project setting at full volume without a sound FX, kept through a round trip', () => {
const f = makeProject();
expect([f.project.masterVolume, f.project.masterFx]).toEqual([1, '']);
f.stack.execute(new SetMasterVolumeCommand(2));
f.stack.execute(new SetMasterFxCommand('fx-1'));
expect([f.project.masterVolume, f.project.masterFx]).toEqual([1, 'fx-1']);
f.stack.execute(new SetMasterVolumeCommand(0.4));
expect(roundTrip(f.project)).toMatchObject({ masterVolume: 0.4, masterFx: 'fx-1' });
f.stack.undo();
f.stack.undo();
expect([f.project.masterVolume, f.project.masterFx]).toEqual([1, '']);
});
it('reads as full volume without a sound FX from a project written before there was one', () => {
const f = makeProject();
const written = roundTrip(f.project) as unknown as Record<string, unknown>;
delete written.masterVolume;
delete written.masterFx;
expect(roundTrip(written as never)).toMatchObject({ masterVolume: 1, masterFx: '' });
});
});
describe('the mix (#325)', () => {
it('is the master first, then every bus into the one it names, with its volume and its sound FX', () => {
const f = withBuses('Music', 'Drums');
const [music, drums] = f.ids as [string, string];
const fx = workingFx(f);
f.into(drums, music);
f.stack.execute(new SetAssetPropertyCommand(drums, 'volume', 0.5));
f.stack.execute(new SetAssetPropertyCommand(music, 'fx', fx));
f.stack.execute(new SetMasterVolumeCommand(0.8));
const mix = mixOf(f.project);
expect(mix.map(({ name, volume, into }) => ({ name, volume, into }))).toEqual([
{ name: MASTER_MIX, volume: 0.8, into: null },
{ name: `mix:${music}`, volume: 1, into: MASTER_MIX },
{ name: `mix:${drums}`, volume: 0.5, into: `mix:${music}` },
]);
expect(mix.map((bus) => bus.fx !== null)).toEqual([false, true, false]);
});
it('is the same mix again while nothing of it changed, another once a volume does', () => {
const f = withBuses('Music');
const before = mixOf(f.project);
expect(sameMix(before, mixOf(f.project))).toBe(true);
f.stack.execute(new SetAssetPropertyCommand(f.ids[0]!, 'volume', 0.2));
expect(sameMix(before, mixOf(f.project))).toBe(false);
});
it('plays on the bus a node picks, else the one the routing sends its asset to, else the master', () => {
const f = withBuses('Music', 'SFX');
const [music, sfx] = f.ids as [string, string];
const sound = new CreateSoundCommand('Jump', FILE);
f.stack.execute(sound);
const id = sound.asset.id;
expect(playedBus(f.project, '', id)).toBe(MASTER_MIX);
f.into(id, music);
expect(playedBus(f.project, '', id)).toBe(`mix:${music}`);
expect(playedBus(f.project, sfx, id)).toBe(`mix:${sfx}`);
expect(playedBus(f.project, MASTER_BUS, id)).toBe(MASTER_MIX);
// A pick of a bus that was deleted leaves it on the master.
expect(playedBus(f.project, 'gone', id)).toBe(MASTER_MIX);
});
it('reaches the master for an empty pick, and no bus for one that is not there', () => {
const f = withBuses('Music');
expect(reachedBus(f.project, '')).toBe(MASTER_MIX);
expect(reachedBus(f.project, MASTER_BUS)).toBe(MASTER_MIX);
expect(reachedBus(f.project, f.ids[0]!)).toBe(mixBusName(f.ids[0]!));
expect(reachedBus(f.project, 'gone')).toBeNull();
});
});
/** What a run asked of the buses, beside the voices it played and on which bus. */
function busSink() {
const sink = {
...SILENT_AUDIO,
calls: [] as string[],
play: (file: string, _volume: number, _loop: boolean, _owner: string, _fx?: FxVoice, bus = '') => {
sink.calls.push(`play ${file} on ${bus}`);
return 1;
},
setMix: (buses: readonly MixBus[]) =>
void sink.calls.push(`mix ${buses.map((bus) => bus.name).join(' ')}`),
resetMix: () => void sink.calls.push('reset'),
setMixVolume: (bus: string, volume: number, time = 0) =>
void sink.calls.push(`volume ${bus} ${volume} ${time}`),
mixVolume: (bus: string) => (bus === MASTER_MIX ? 0.7 : 0.2),
tuneMix: (bus: string, params: FxParams) => void sink.calls.push(`tune ${bus} ${JSON.stringify(params)}`),
};
return sink;
}
/** A scene whose behavior runs `type` on its first frame, its result in a variable "Got". */
function runOnStart(type: string, params: Record<string, string | number | boolean>, f = makeSpriteScene()) {
const behavior = makeBehavior(f, 'Logic', ['Got']);
place(f, behavior.id, { x: 0, y: 0 });
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const node = edit.addNode(type, params);
edit.connect(start, 'exec', node, 'exec');
return { ...f, behavior, edit, node };
}
describe('buses in a running game (#325)', () => {
it('plays a sound on the bus the routing sends it to, or on the one Play sound picks', () => {
const f = makeSpriteScene();
const bus = new CreateAssetCommand('bus', 'SFX');
const sound = new CreateSoundCommand('Jump', FILE);
f.stack.execute(bus);
f.stack.execute(sound);
routingOf(f).only(sound.asset.id, bus.asset.id);
runOnStart('PlaySound', { sound: sound.asset.id, volume: 1 }, f);
const asset = busSink();
startWorld(f, { behaviors: true, audio: asset }).frame();
expect(asset.calls).toEqual([`play ${FILE} on mix:${bus.asset.id}`]);
const picked = makeSpriteScene();
const pickedSound = new CreateSoundCommand('Jump', FILE);
picked.stack.execute(pickedSound);
runOnStart('PlaySound', { sound: pickedSound.asset.id, volume: 1, bus: MASTER_BUS }, picked);
const master = busSink();
startWorld(picked, { behaviors: true, audio: master }).frame();
expect(master.calls).toEqual([`play ${FILE} on ${MASTER_MIX}`]);
});
it('sets the volume of the master for an empty pick, and of the bus picked, over its time', () => {
const f = runOnStart('SetBusVolume', { bus: '', volume: 0.25, time: 2 });
const sink = busSink();
startWorld(f, { behaviors: true, audio: sink }).frame();
expect(sink.calls).toEqual([`volume ${MASTER_MIX} 0.25 2`]);
const gone = runOnStart('SetBusVolume', { bus: 'gone', volume: 0.25 });
const none = busSink();
startWorld(gone, { behaviors: true, audio: none }).frame();
expect(none.calls).toEqual([]);
});
it('changes the sound FX of a bus with Set sound FX parameters and no voice', () => {
const f = runOnStart('SetSoundFxParameters', { voice: 0, bus: MASTER_BUS });
const sink = busSink();
startWorld(f, { behaviors: true, audio: sink }).frame();
expect(sink.calls).toEqual([`tune ${MASTER_MIX} {}`]);
});
it('reads the volume a bus is at, the master for an empty pick and 0 for a bus that is not there', () => {
const f = runOnStart('Log', {});
const read = f.edit.addNode('BusVolume', { bus: '' });
f.edit.connect(read, 'volume', f.node, 'message');
const gone = f.edit.addNode('BusVolume', { bus: 'gone' });
const second = f.edit.addNode('Log');
f.edit.connect(f.node, 'exec', second, 'exec');
f.edit.connect(gone, 'volume', second, 'message');
const started = startWorld(f, { behaviors: true, audio: busSink() });
started.frame();
expect(started.world.log).toEqual(['0.7', '0']);
});
it('starts every run with the buses as their assets have them, and ends it so', () => {
const f = makeSpriteScene();
f.stack.execute(new CreateAssetCommand('bus', 'Music'));
const sink = busSink();
const run = GameRun.start(f.project, f.sceneId, new Input(), {
audio: sink,
physics: undefined as never,
clock: { scale: 1 },
saves: { read: () => ({}), write: () => {} },
});
expect(sink.calls).toEqual(['reset', `mix ${MASTER_MIX} mix:${f.project.assets.at(-1)!.id}`]);
run.end();
expect(sink.calls.at(-1)).toBe('reset');
});
});