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>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|