Mix sounds through buses and a master (#325)
game-maker / gate (push) Successful in 3m36s
game-maker / suite (push) Failing after 14m30s

Until now every sound went straight to the speakers. There was no
point where all of them came together, so nothing could turn the
music down against the effects, and a compressor or a shared echo
over everything had nowhere to sit. The bus technique of #346 was
there already, only the end of the chain was missing.

- Bus asset (Audio): Volume, Sound FX and Into. Into is empty for
  the master and never offers the bus itself nor a bus that already
  goes into it. The list is worked out when the menu opens, so a
  change made meanwhile counts. A loop that is in a project anyway
  sends the buses on it into the master.
- The master is no asset. Its volume and sound FX are two new rows
  under Master in Project settings, one undo step each. A project
  written before has 1 and none, which is how it sounded.
- Sound, instrument, pattern and song have a row Bus, empty for the
  master. Play sound, Play note, Play pattern and Play song have an
  input bus: From the asset, Master or a bus. Inside a song or a
  pattern only the bus of the song or pattern counts.
- Set bus volume (bus, volume, time, easing) and Bus volume. An
  empty bus is the master. The volume set holds for the whole run,
  across scenes, over that of the asset. GameRun resets it at start
  and end. Set sound FX parameters without a voice now reaches the
  sound FX of the bus picked in its new bus input.
- SoundLibrary: the master is the bus "mix", each bus "mix:<id>".
  Every bus, the playing masters of #346 included, ends there, and
  only the master is heard. setMix sets them up once a context
  exists, so an editor nobody listened to opens no audio device. The
  same mix again changes nothing, which lets the editor hand it over
  on every change of the project.
- The tabs play as the game does: Sound, Sound FX, Instrument,
  Pattern and Song tab through the bus of their asset and the master.
- Pause game does nothing to the buses (the user's answer 4b). A
  game that wants quiet in its pause menu sets a bus volume itself.
- Streaming long music files is #349, split off as asked.

Finding on the way: the Into menu first worked from a list made when
the inspector row was built. After another bus was sent into the
open one, e2e 34 saw it still offered. It is now asked on open.

The SoundLibrary tests expected voices to end at the destination.
They now end in the master, which adds its input, level and pan to
every wiring and two gains to every list. Those expectations are
updated, nothing else in them changed.

Tests:
- Unit buses (new): 15 green. Ten counter-checks, each red on its
  own: loop check, bus picked, reset at run start, bus of Set sound
  FX parameters, master volume in the settings, and in SoundLibrary
  default into the master, voices into the master, run volume over
  the mix, no context before a play, a playing on its bus.
- Unit sound-library: 90 green (5 new). Node catalog: 416 green
  (9 new cases).
- e2e 34 (melektaus-atx): ALL PASSED, 75 checks (3 new). The voice
  of the Sound tab was heard on mix:<bus>, that bus into the other,
  that one at 0.5 into the master. Master volume 0.6 via the dialog,
  undo back to 1.
- Gate (melektaus-atx): CHECK EXIT 0, 262 files, 4072 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 19:13:32 +02:00
1 parent a848cf8469
commit 3a52f2290f
53 files changed
+1431 -147

No files matched your search

+2
View File
@@ -111,6 +111,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'pattern', notes: [] };
case 'song':
return { ...base, type: 'song', tracks: defaultTracks(), items: [] };
case 'bus':
return { ...base, type: 'bus' };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
}
+35
View File
@@ -8,6 +8,7 @@ import {
} from '../model/playSettings';
import type { Project } from '../model/Project';
import { clampSymbolScale } from '../model/symbolScale';
import { clampMasterVolume } from '../model/master';
import type { Command } from './Command';
export class RenameProjectCommand implements Command {
@@ -126,3 +127,37 @@ export class SetGameLinkZoomCommand implements Command {
project.gameLinkZoom = this.previous;
}
}
/** The level of the master bus (#325), kept inside 0–1 like a loaded project's. */
export class SetMasterVolumeCommand implements Command {
readonly label = 'Master volume';
private previous = 1;
constructor(private readonly volume: number) {}
execute(project: Project): void {
this.previous = project.masterVolume;
project.masterVolume = clampMasterVolume(this.volume);
}
undo(project: Project): void {
project.masterVolume = this.previous;
}
}
/** The sound FX of the master bus (#325), by id; '' for none. */
export class SetMasterFxCommand implements Command {
readonly label = 'Master FX';
private previous = '';
constructor(private readonly fx: string) {}
execute(project: Project): void {
this.previous = project.masterFx;
project.masterFx = this.fx;
}
undo(project: Project): void {
project.masterFx = this.previous;
}
}
+23 -5
View File
@@ -240,7 +240,8 @@ export interface NodeContext {
* the one case where the rule is in the way: the death sound of an object that destroys itself.
* `fx` names a sound FX asset the voice is played through (#311); `''` plays it as it is.
* `fxParams` are values for its parameters; one not given is the default the FX declares.
* `tempo` is what a Delay synced to beats counts by (#332).
* `tempo` is what a Delay synced to beats counts by (#332). `bus` is the bus it is heard on
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names.
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
*/
playSound(
@@ -251,6 +252,7 @@ export interface NodeContext {
fx: string,
fxParams: FxParams,
tempo: number,
bus: string,
): number;
/**
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
@@ -258,6 +260,7 @@ export interface NodeContext {
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's.
* `bus` as for `playSound`, `''` for the one the instrument names.
* Returns the number of the voice, 0 where nothing plays.
*/
playNote(
@@ -271,12 +274,13 @@ export interface NodeContext {
tempo: number,
fx: string,
fxParams: FxParams,
bus: string,
): number;
/**
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
* one chain (#346), `fxParams` values for its parameters. Returns its number, for `stopVoice` and
* `tuneSound`; 0 where there is nothing to play.
* one chain (#346), `fxParams` values for its parameters, `bus` as for `playSound`. Returns its
* number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
*/
playPattern(
patternId: string,
@@ -285,6 +289,7 @@ export interface NodeContext {
detached: boolean,
fx: string,
fxParams: FxParams,
bus: string,
): number;
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
stopPattern(patternId: string, letRing: boolean): void;
@@ -292,8 +297,8 @@ export interface NodeContext {
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
* `fx` and `fxParams` as for `playPattern`, after its tracks. Returns its number; 0 where there is
* nothing to play.
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. Returns its number; 0 where
* there is nothing to play.
*/
playSong(
songId: string,
@@ -303,6 +308,7 @@ export interface NodeContext {
detached: boolean,
fx: string,
fxParams: FxParams,
bus: string,
): number;
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
stopSong(songId: string, fadeOut: number): void;
@@ -329,6 +335,18 @@ export interface NodeContext {
* quick glide.
*/
tuneSound(voice: number, fxParams: FxParams, time: number, ease: EaseShare): void;
/**
* The same for the sound FX of a bus (#325): a bus asset or `MASTER_BUS`. A bus that is not there
* is not reached.
*/
tuneBus(bus: string, fxParams: FxParams, time: number, ease: EaseShare): void;
/**
* Sets the volume of a bus (#325), `''` the master, over `time` seconds along `ease`. It holds
* for the rest of the run, over the volume of its asset and across scenes.
*/
setBusVolume(bus: string, volume: number, time: number, ease: EaseShare): void;
/** The volume a bus is at, `''` the master: what a game set, else that of its asset; 0 for none. */
busVolume(bus: string): number;
/**
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
+4 -2
View File
@@ -2,7 +2,7 @@ import { MIDDLE_C } from '../../../instrument/notes';
import { heldTempo } from '../../../music/time';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, soundFxPort, withDefault } from '../shared';
import { exec, num, playedBusPort, soundFxPort, withDefault } from '../shared';
import { fxParamsOf, tempoPort } from './PlaySound';
export const PlayNote: NodeDef = {
@@ -10,7 +10,7 @@ export const PlayNote: NodeDef = {
label: 'Play note',
category: 'action',
description:
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does, "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute.',
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does, "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names.',
inputs: [
exec(),
{
@@ -46,6 +46,7 @@ export const PlayNote: NodeDef = {
tempoPort(),
{ ...soundFxPort(), key: 'soundFx' },
{ key: 'soundFxParams', label: 'fx params', type: 'fxparams' },
playedBusPort(),
],
outputs: [exec(), num('voice')],
params: [],
@@ -63,6 +64,7 @@ export const PlayNote: NodeDef = {
heldTempo(asNumber(inputs.tempo)),
asString(inputs.soundFx),
fxParamsOf(inputs.soundFxParams),
asString(inputs.bus),
)
: 0;
return { next: 'exec', outputs: { voice } };
+5 -2
View File
@@ -1,6 +1,6 @@
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, soundFxPort, withDefault } from '../shared';
import { exec, num, playedBusPort, soundFxPort, withDefault } from '../shared';
import { fxParamsOf } from './PlaySound';
export const patternPort = (): PortDef => ({
@@ -23,7 +23,8 @@ export const PlayPattern: NodeDef = {
'"detached" is on. "fx" sends all of it through one sound FX, its notes together: an LFO runs on ' +
'across them, an echo repeats the whole. "fx params" takes values for its parameters from a Sound ' +
'FX parameters node. "playback" is the number of this playing, for Stop pattern and for Set sound ' +
'FX parameters; 0 where the pattern has no instrument or no notes.',
'FX parameters; 0 where the pattern has no instrument or no notes. "bus" is the bus all of it is ' +
'heard on; left empty, the one the pattern names.',
inputs: [
exec(),
patternPort(),
@@ -44,6 +45,7 @@ export const PlayPattern: NodeDef = {
},
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
playedBusPort(),
],
outputs: [exec(), num('playback')],
params: [],
@@ -57,6 +59,7 @@ export const PlayPattern: NodeDef = {
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
asString(inputs.bus),
)
: 0;
return { next: 'exec', outputs: { playback } };
+4 -2
View File
@@ -1,6 +1,6 @@
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString } from '../../values';
import { exec, num, soundFxPort, withDefault } from '../shared';
import { exec, num, playedBusPort, soundFxPort, withDefault } from '../shared';
import { fxParamsOf } from './PlaySound';
export const songPort = (): PortDef => ({
@@ -24,7 +24,7 @@ export const PlaySong: NodeDef = {
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
'"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' +
'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' +
'nothing to play.',
'nothing to play. "bus" is the bus all of it is heard on; left empty, the one the song names.',
inputs: [
exec(),
songPort(),
@@ -46,6 +46,7 @@ export const PlaySong: NodeDef = {
},
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
playedBusPort(),
],
outputs: [exec(), num('playback')],
params: [],
@@ -60,6 +61,7 @@ export const PlaySong: NodeDef = {
asBoolean(inputs.detached),
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
asString(inputs.bus),
)
: 0;
return { next: 'exec', outputs: { playback } };
+5 -2
View File
@@ -3,7 +3,7 @@ import { DEFAULT_TEMPO, heldTempo, MAX_TEMPO, MIN_TEMPO } from '../../../music/t
import type { FxParams } from '../../../soundfx/nodes';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asBoolean, asNumber, asString, type Value } from '../../values';
import { exec, num, soundFxPort, soundPort, withDefault } from '../shared';
import { exec, num, playedBusPort, soundFxPort, soundPort, withDefault } from '../shared';
/**
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
@@ -66,7 +66,8 @@ export const PlaySound: NodeDef = {
'"time" of Set sound FX parameters so a change spans the sound; 0 where nothing was played or ' +
'the sound has not been loaded yet. A pitch never changes it. "tail" is that length and the ' +
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet. ' +
'"tempo" is what a Delay of its FX synced to beats counts by, in quarter notes a minute.',
'"tempo" is what a Delay of its FX synced to beats counts by, in quarter notes a minute. "bus" is ' +
'the bus it is heard on; left empty, the one the sound names.',
inputs: [
exec(),
soundPort(),
@@ -76,6 +77,7 @@ export const PlaySound: NodeDef = {
soundFxPort(),
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
tempoPort(),
playedBusPort(),
],
outputs: [exec(), num('voice'), num('length'), num('tail')],
params: [],
@@ -90,6 +92,7 @@ export const PlaySound: NodeDef = {
asString(inputs.fx),
fxParamsOf(inputs.fxParams),
heldTempo(asNumber(inputs.tempo)),
asString(inputs.bus),
)
: 0;
const length = voice ? ctx.soundLength(sound) : 0;
@@ -1,7 +1,7 @@
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { asNumber, asString } from '../../values';
import { curvesOf, easingOfParams, easingParams, idleEasing } from '../easing';
import { exec, num } from '../shared';
import { busPort, exec, num } from '../shared';
import { fxParamsOf } from './PlaySound';
import { easeShareOf } from '../../../model/animation';
@@ -23,26 +23,29 @@ export const SetSoundFxParameters: NodeDef = {
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
'reached. A pitch keeps the length of the sound here too, but is heard about 20 ms after it is set. ' +
'time: seconds the values take to get there, along the chosen easing and on the clock of the ' +
'sound itself, so a sweep sounds the same every time; 0 is a quick glide.',
'sound itself, so a sweep sounds the same every time; 0 is a quick glide. Without a voice it changes ' +
'the sound FX of "bus" instead, the master or a bus asset, for everything heard on it.',
inputs: [
exec(),
{ ...num('voice'), inline: false },
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
num('time'),
busPort('None', true),
],
outputs: [exec()],
params: easingParams(),
idleParams: idleEasing,
run: (ctx, inputs, params, _def, _entry, lookup) => {
const voice = asNumber(inputs.voice);
const bus = asString(inputs.bus);
const time = asNumber(inputs.time);
if (voice > 0)
ctx.tuneSound(
voice,
fxParamsOf(inputs.fxParams),
Number.isFinite(time) && time > 0 ? time : 0,
easeShareOf(easingOfParams(params), curvesOf(lookup)),
);
const glide = [
fxParamsOf(inputs.fxParams),
Number.isFinite(time) && time > 0 ? time : 0,
easeShareOf(easingOfParams(params), curvesOf(lookup)),
] as const;
if (voice > 0) ctx.tuneSound(voice, ...glide);
else if (bus !== '') ctx.tuneBus(bus, ...glide);
return { next: 'exec' };
},
};
+46
View File
@@ -0,0 +1,46 @@
import { easeShareOf } from '../../../model/animation';
import type { NodeDef } from '../../NodeRegistry';
import { asNumber, asString } from '../../values';
import { curvesOf, easingOfParams, easingParams, idleEasing } from '../easing';
import { busPort, exec, num, withDefault } from '../shared';
/** The `bus` of Set bus volume and Bus volume: empty is the master. */
const masterOrBus = () => busPort('Master', false);
export const SetBusVolume: NodeDef = {
type: 'SetBusVolume',
label: 'Set bus volume',
category: 'action',
description:
'Sets how loud a bus is, 0 to 1, over "time" seconds along the chosen easing; 0 is a quick glide. ' +
'Left empty, "bus" is the master. The volume holds for the rest of the run, the next scene ' +
'included, over the one the bus asset has; Play starts with that of the asset again. To keep it ' +
'between runs, as an options menu does, keep it in the savegame and set it again at the start.',
inputs: [exec(), masterOrBus(), withDefault(num('volume'), 1), num('time')],
outputs: [exec()],
params: easingParams(),
idleParams: idleEasing,
run: (ctx, inputs, params, _def, _entry, lookup) => {
const time = asNumber(inputs.time);
ctx.setBusVolume(
asString(inputs.bus),
asNumber(inputs.volume),
Number.isFinite(time) && time > 0 ? time : 0,
easeShareOf(easingOfParams(params), curvesOf(lookup)),
);
return { next: 'exec' };
},
};
export const BusVolume: NodeDef = {
type: 'BusVolume',
label: 'Bus volume',
category: 'data',
description:
'How loud a bus is, 0 to 1: what Set bus volume set it to, else the volume of the bus asset. ' +
'Left empty, "bus" is the master. 0 for a bus that does not exist.',
inputs: [masterOrBus()],
outputs: [num('volume')],
params: [],
read: (ctx, _params, input) => ({ volume: ctx.busVolume(asString(input('bus'))) }),
};
+3
View File
@@ -24,6 +24,7 @@ import { RaycastAll } from './action/RaycastAll';
import { RestartGame } from './action/RestartGame';
import { SetPosition } from './action/SetPosition';
import { SetSoundFxParameters } from './action/SetSoundFxParameters';
import { BusVolume, SetBusVolume } from './action/busNodes';
import { PlayNote } from './action/PlayNote';
import { PlayPattern } from './action/PlayPattern';
import { StopPattern } from './action/StopPattern';
@@ -316,6 +317,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
StopPattern,
PlaySong,
StopSong,
SetBusVolume,
BusVolume,
LoadScene,
Log,
StopGame,
+14
View File
@@ -102,6 +102,20 @@ export const soundFxPort = (): PortDef => ({
field: { key: 'fx', label: 'FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
});
/**
* The `bus` input of a node that plays or reaches a bus (#325). `none` is what an empty pick means
* there; `master` offers the master as a pick of its own where empty means something else.
*/
export const busPort = (none: string, master: boolean): PortDef => ({
key: 'bus',
label: 'bus',
type: 'string',
field: { key: 'bus', label: 'Bus', kind: 'asset', default: '', assetTypes: ['bus'], none, master },
});
/** The `bus` input of a Play node: empty is the bus its asset names. */
export const playedBusPort = (): PortDef => busPort('From the asset', true);
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
return {
type,
+9
View File
@@ -354,6 +354,14 @@ export interface SongAsset extends AssetBase {
loop?: LoopRange;
}
/**
* Where sounds are mixed on their way out (#325): its volume, sound FX and the bus it goes into are
* properties (see `properties/buses.ts`). Sounds, instruments, patterns and songs name one.
*/
export interface BusAsset extends AssetBase {
type: 'bus';
}
/**
* A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a
* sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say
@@ -610,6 +618,7 @@ export type Asset =
| InstrumentAsset
| PatternAsset
| SongAsset
| BusAsset
| ParticlesAsset;
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
+6 -1
View File
@@ -28,6 +28,7 @@ export const ASSET_TYPES = [
'instrument',
'pattern',
'song',
'bus',
'particles',
] as const;
@@ -63,6 +64,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
instrument: 'Instrument',
pattern: 'Pattern',
song: 'Song',
bus: 'Bus',
particles: 'Particles',
};
@@ -76,7 +78,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* A struct is a shape that variables and fields take on (#253), nothing that exists on its own.
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
* sound is played through (#311), heard but never seen, an instrument what a note is played
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334).
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is
* where sounds are mixed on their way out (#325).
*/
export type PlaceableAssetType = Exclude<
AssetType,
@@ -97,6 +100,7 @@ export type PlaceableAssetType = Exclude<
| 'instrument'
| 'pattern'
| 'song'
| 'bus'
| 'variables'
| 'struct'
>;
@@ -119,6 +123,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'instrument',
'pattern',
'song',
'bus',
'variables',
'struct',
]);
+9
View File
@@ -7,6 +7,7 @@ import {
type SceneAsset,
} from './Asset';
import { DEFAULT_LAYER_NAME } from './collisionLayers';
import { DEFAULT_MASTER_VOLUME } from './master';
import {
DEFAULT_GAME_LINK_ZOOM,
DEFAULT_GAME_SCALE,
@@ -53,6 +54,12 @@ export interface Project {
* written before there were any has just "Default", which every camera sees.
*/
renderLayers: string[];
/**
* The level and the sound FX id of the master bus (#325), what every bus ends in. A project
* written before there was one has 1 and none, which is how it always sounded.
*/
masterVolume: number;
masterFx: string;
/**
* Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project
* written before there was one reads as 1, the size the icons always had.
@@ -81,6 +88,8 @@ export function createEmptyProject(id: string, name: string): Project {
plugins: [],
collisionLayers: [DEFAULT_LAYER_NAME],
renderLayers: [DEFAULT_LAYER_NAME],
masterVolume: DEFAULT_MASTER_VOLUME,
masterFx: '',
symbolScale: DEFAULT_SYMBOL_SCALE,
playFrom: DEFAULT_PLAY_FROM,
whilePlaying: DEFAULT_WHILE_PLAYING,
+22
View File
@@ -0,0 +1,22 @@
/**
* The master bus of a project (#325): where every bus ends and every sound naming none is heard.
* Its level and its sound FX are project settings, not an asset, so there is always exactly one.
*/
export const DEFAULT_MASTER_VOLUME = 1;
/** Keeps a level inside 0–1; anything that is not a finite number is the default. */
export function clampMasterVolume(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_MASTER_VOLUME;
return Math.min(1, Math.max(0, value));
}
/** The level as stored, read off untrusted JSON. A project written before there was one has 1. */
export function masterVolumeOf(raw: unknown): number {
return clampMasterVolume((raw as { masterVolume?: unknown } | null)?.masterVolume);
}
/** The id of the sound FX of the master, '' for none. */
export function masterFxOf(raw: unknown): string {
const fx = (raw as { masterFx?: unknown } | null)?.masterFx;
return typeof fx === 'string' ? fx : '';
}
+81
View File
@@ -0,0 +1,81 @@
import { findAssetOf, type Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import { BUS_FX_KEY, busInto, busOf, busVolumeOf, MASTER_BUS } from '../properties/buses';
import { coerceAs } from '../properties/kinds';
import type { FxVoice } from '../soundfx/nodes';
import { fxVoiceOf } from './needs';
import { DEFAULT_TEMPO } from './time';
/** What the sound library calls the master bus (#325); every other bus of the mix is `mix:<id>`. */
export const MASTER_MIX = 'mix';
/** One bus of the mix as the sound library sets it up: its level, its sound FX and where it goes. */
export interface MixBus {
name: string;
volume: number;
/** The bus it goes into; null for the master, which is heard. */
into: string | null;
fx: FxVoice | null;
}
/** The name of a bus of the mix by the id of its asset; the master for '' and for `MASTER_BUS`. */
export function mixBusName(busId: string): string {
return busId === '' || busId === MASTER_BUS ? MASTER_MIX : `mix:${busId}`;
}
/**
* The master and every bus of the project (#325), each into the bus it names – or the master, where
* that is missing or would make a loop. A sound FX that does not compile is none.
*/
export function mixOf(project: Project): MixBus[] {
const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO);
const buses = project.assets.flatMap((asset) =>
asset.type === 'bus'
? [
{
name: mixBusName(asset.id),
volume: busVolumeOf(asset.props),
into: mixBusName(busInto(project, asset.id)),
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')),
},
]
: [],
);
return [
{ name: MASTER_MIX, volume: project.masterVolume, into: null, fx: fxOf(project.masterFx) },
...buses,
];
}
/** Whether two mixes set the buses up the same: the same plan of a sound FX is the same object. */
export function sameMix(a: readonly MixBus[], b: readonly MixBus[]): boolean {
return (
a.length === b.length &&
a.every((bus, i) => {
const other = b[i]!;
return (
bus.name === other.name &&
bus.volume === other.volume &&
bus.into === other.into &&
bus.fx?.plan === other.fx?.plan &&
bus.fx?.tempo === other.fx?.tempo
);
})
);
}
/**
* The bus of the mix something is heard on: the one a node picked, else the one its asset names.
* A pick naming no bus any more is the master, as a deleted bus would leave it.
*/
export function playedBus(project: Project, picked: string, assetProps: PropertyBag): string {
const id = picked !== '' ? picked : busOf(assetProps);
if (id === MASTER_BUS || id === '') return MASTER_MIX;
return findAssetOf(project, id, 'bus') ? mixBusName(id) : MASTER_MIX;
}
/** The bus of the mix a node reaches where an empty pick is the master: Set bus volume, Bus volume. */
export function reachedBus(project: Project, picked: string): string | null {
if (picked === '' || picked === MASTER_BUS) return MASTER_MIX;
return findAssetOf(project, picked, 'bus') ? mixBusName(picked) : null;
}
+2
View File
@@ -35,6 +35,8 @@ export interface SequencePlay {
buses?: readonly BusLevel[];
/** The sound FX the whole playing goes through (#346), after its tracks and before its level. */
fx?: FxVoice;
/** The bus of the mix the whole playing is heard on (#325); the master where left out. */
bus?: string;
/** Starts one note as the player was asked to, and gives back the number of its voice. */
strike(note: ScheduledNote): number;
strikeClip?(clip: ScheduledClip): number;
+31 -3
View File
@@ -139,13 +139,17 @@ import {
SONG_TEMPO_KEY,
} from '../music/song';
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
import { BUS_FX_KEY, BUS_INTO_KEY, BUS_KEY, BUS_VOLUME_KEY } from './buses';
import { Theme } from '../../ui';
/**
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
* produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out
* `assetTypes` an asset picker offers every placeable asset; `none` is what it calls an empty pick,
* `master` offers the master bus as a choice of its own, and `intoFor` names the bus the row is
* the Into of, which leaves out that bus and every one going into it when the picker opens (#325);
* `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
*/
@@ -154,7 +158,7 @@ type Extras<K extends FlatKind> = K extends 'number'
: K extends 'enum'
? { options: { value: string; label: string }[] }
: K extends 'asset'
? { assetTypes?: readonly AssetType[] }
? { assetTypes?: readonly AssetType[]; none?: string; master?: boolean; intoFor?: string }
: K extends 'texture'
? { uploadedOnly?: boolean }
: K extends 'layer' | 'layers'
@@ -489,6 +493,16 @@ const SLICE_SCALE_ROW: PropertyDef = {
step: 0.1,
};
/** The bus a sound, an instrument, a pattern or a song is heard on (#325). */
const BUS_ROW: PropertyDef = {
key: BUS_KEY,
label: 'Bus',
kind: 'asset',
default: '',
assetTypes: ['bus'],
none: 'Master',
};
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [
@@ -719,11 +733,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
options: FADE_CURVES,
},
]),
BUS_ROW,
],
// The effects are the asset's graph (see SoundFxAsset), not a property.
soundfx: [],
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
instrument: [],
instrument: [BUS_ROW],
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
pattern: [
@@ -765,6 +780,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
max: MAX_TEMPO,
step: 1,
},
BUS_ROW,
],
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
song: [
@@ -795,6 +811,13 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
default: DEFAULT_SONG_GRID,
options: SONG_GRID_OPTIONS,
},
BUS_ROW,
],
// Where sounds are mixed (#325): how loud, through which sound FX, and on into which bus.
bus: [
{ key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
{ key: BUS_FX_KEY, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
{ ...BUS_ROW, key: BUS_INTO_KEY, label: 'Into' },
],
// How many particles and for how long; what one of them does is the asset's graph, whose Property
// nodes add their rows after these – see `schemaForBag`.
@@ -1143,6 +1166,11 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert
// Nothing but declarations: the starting values live in `props` under the very same keys.
if (asset.type === 'variables' || asset.type === 'struct') return asset.variables;
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
// A bus never goes into itself, nor into one that already goes into it.
if (asset.type === 'bus')
return FULL_SCHEMAS.bus.map((def) =>
def.key === BUS_INTO_KEY && def.kind === 'asset' ? { ...def, intoFor: asset.id } : def,
);
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
if (asset.type === 'particles')
return sourceRows(
+3
View File
@@ -1,8 +1,10 @@
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
import { MASTER_BUS } from './buses';
export const NO_ASSET_LABEL = 'None';
export const MASTER_LABEL = 'Master';
/**
* The asset's name, "None" for the empty id, `<id>?` when it no longer resolves – a dangling
@@ -10,6 +12,7 @@ export const NO_ASSET_LABEL = 'None';
*/
export function assetRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_ASSET_LABEL;
if (id === MASTER_BUS) return MASTER_LABEL;
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
}
+59
View File
@@ -0,0 +1,59 @@
import { findAssetOf, type Project } from '../model/Project';
import type { PropertyBag } from '../model/Properties';
import { coerceAs } from './kinds';
/** The bus a sound, an instrument, a pattern or a song is heard on (#325); empty is the master. */
export const BUS_KEY = 'bus';
/** How loud a bus is, 0–1. */
export const BUS_VOLUME_KEY = 'volume';
/** The sound FX all of a bus goes through, before its volume. */
export const BUS_FX_KEY = 'fx';
/** The bus a bus goes into; empty is the master. */
export const BUS_INTO_KEY = 'into';
/**
* What a node picks to name the master where empty means something else: "from the asset" on a
* Play node, "no bus" on Set sound FX parameters. No asset id has a `#`.
*/
export const MASTER_BUS = '#master';
export function busOf(bag: PropertyBag): string {
return coerceAs(bag[BUS_KEY], 'asset', '');
}
export function busVolumeOf(bag: PropertyBag): number {
return Math.max(0, Math.min(1, coerceAs(bag[BUS_VOLUME_KEY], 'number', 1)));
}
/** The bus a bus says it goes into, where that is a bus; '' for the master. */
function namedInto(project: Project, busId: string): string {
const bus = findAssetOf(project, busId, 'bus');
const into = bus ? coerceAs(bus.props[BUS_INTO_KEY], 'asset', '') : '';
return into !== '' && findAssetOf(project, into, 'bus') ? into : '';
}
/** Whether following the buses on from `from` comes to `to`. */
function reaches(project: Project, from: string, to: string): boolean {
const seen = new Set<string>();
for (let at = from; at !== '' && !seen.has(at); at = namedInto(project, at)) {
if (at === to) return true;
seen.add(at);
}
return false;
}
/**
* The bus a bus goes into, '' for the master. One that would come round to itself goes into the
* master instead, so however a project was written the buses never run in a loop.
*/
export function busInto(project: Project, busId: string): string {
const into = namedInto(project, busId);
return into !== '' && reaches(project, into, busId) ? '' : into;
}
/** The buses a bus cannot go into: itself and every one that already goes into it. */
export function busesInto(project: Project, busId: string): string[] {
return project.assets
.filter((asset) => asset.type === 'bus' && reaches(project, asset.id, busId))
.map((asset) => asset.id);
}
+3
View File
@@ -57,6 +57,7 @@ import { renderLayersOf } from '../model/renderLayers';
import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings';
import { sceneVisibilityOf } from '../model/sceneVisibility';
import { symbolScaleOf } from '../model/symbolScale';
import { masterFxOf, masterVolumeOf } from '../model/master';
import { decodeCells, encodeCells } from './tilemapCells';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
@@ -325,6 +326,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
plugins: pluginsOf(r),
collisionLayers: collisionLayersOf(r),
renderLayers: renderLayersOf(r),
masterVolume: masterVolumeOf(r),
masterFx: masterFxOf(r),
symbolScale: symbolScaleOf(r),
playFrom: playFromOf(r),
whilePlaying: whilePlayingOf(r),
+7
View File
@@ -1,6 +1,7 @@
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder } from '../core/model/Project';
import { mixOf } from '../core/music/mixer';
import type { Role } from '../core/store/AuthStore';
import type { ProjectStore } from '../core/store/ProjectStore';
import { pluginsOf } from '../core/store/projectPlugins';
@@ -36,6 +37,7 @@ import { ScenePane } from './viewport/ScenePane';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { leaveFullscreen, onFullscreenChange } from '../viewport/fullscreen';
import { soundLibrary } from '../viewport/SoundLibrary';
import { TAB_OF_ASSET } from './assetTabs';
import { attachAutosave } from './autosave';
import { PasswordDialog } from './auth/PasswordDialog';
@@ -548,12 +550,17 @@ export class Editor implements PlayHost {
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
// The tabs play through the buses and the master as the game would (#325).
const syncMix = () => void soundLibrary.setMix(mixOf(state.project));
syncMix();
state.on('project', () => {
syncMix();
this.play.syncFromModel();
this.editorRun.invalidate();
this.syncDocuments();
});
state.on('load', () => {
syncMix();
this.editorRun.invalidate();
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
+29 -16
View File
@@ -6,9 +6,12 @@ import {
SetGameLinkZoomCommand,
SetLayerNamesCommand,
SetPlayFromCommand,
SetMasterFxCommand,
SetMasterVolumeCommand,
SetSymbolScaleCommand,
SetWhilePlayingCommand,
} from '../core/commands/projectCommands';
import { assetRefChoices } from '../core/properties/assetRef';
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore';
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
@@ -323,7 +326,7 @@ export class ProjectSession {
/**
* Name, the scene Play starts, how closely the panels follow it, the zoom of the game link, the
* symbol scale, the collision layers (#152) and the render layers (#302) in one dialog
* symbol scale, the collision layers (#152), the render layers (#302) and the master (#325) in one dialog
* (#183). Each is an undo step of its own – one each, and only for what actually changed.
*/
openProjectSettings(): ProjectSettingsDialog {
@@ -336,23 +339,33 @@ export class ProjectSession {
collisionLayers: [...project.collisionLayers],
renderLayers: [...project.renderLayers],
symbolScale: project.symbolScale,
masterVolume: project.masterVolume,
masterFx: project.masterFx,
};
const fxChoices = assetRefChoices(project, ['soundfx']).map(({ id, name }) => ({ id, name }));
return this.settingsDialog.open(
new ProjectSettingsDialog(before, (settings) => {
const change = settingsChanges(before, settings);
if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name));
if (change.collisionLayers !== undefined)
this.state.execute(new SetLayerNamesCommand('collisionLayers', change.collisionLayers));
if (change.renderLayers !== undefined)
this.state.execute(new SetLayerNamesCommand('renderLayers', change.renderLayers));
if (change.symbolScale !== undefined)
this.state.execute(new SetSymbolScaleCommand(change.symbolScale));
if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom));
if (change.whilePlaying !== undefined)
this.state.execute(new SetWhilePlayingCommand(change.whilePlaying));
if (change.gameLinkZoom !== undefined)
this.state.execute(new SetGameLinkZoomCommand(change.gameLinkZoom));
}),
new ProjectSettingsDialog(
before,
(settings) => {
const change = settingsChanges(before, settings);
if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name));
if (change.collisionLayers !== undefined)
this.state.execute(new SetLayerNamesCommand('collisionLayers', change.collisionLayers));
if (change.renderLayers !== undefined)
this.state.execute(new SetLayerNamesCommand('renderLayers', change.renderLayers));
if (change.symbolScale !== undefined)
this.state.execute(new SetSymbolScaleCommand(change.symbolScale));
if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom));
if (change.whilePlaying !== undefined)
this.state.execute(new SetWhilePlayingCommand(change.whilePlaying));
if (change.gameLinkZoom !== undefined)
this.state.execute(new SetGameLinkZoomCommand(change.gameLinkZoom));
if (change.masterVolume !== undefined)
this.state.execute(new SetMasterVolumeCommand(change.masterVolume));
if (change.masterFx !== undefined) this.state.execute(new SetMasterFxCommand(change.masterFx));
},
fxChoices,
),
);
}
+85 -2
View File
@@ -1,5 +1,6 @@
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale';
import { clampMasterVolume } from '../core/model/master';
import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack, type Widget } from '../ui';
import {
GAME_LINK_ZOOMS,
@@ -19,6 +20,14 @@ export interface ProjectSettings {
collisionLayers: string[];
renderLayers: string[];
symbolScale: number;
masterVolume: number;
masterFx: string;
}
/** A sound FX the master can be given: its id and its name. */
export interface FxChoice {
id: string;
name: string;
}
/**
@@ -39,9 +48,12 @@ export function settingsChanges(
playFrom?: PlayFrom;
whilePlaying?: WhilePlaying;
gameLinkZoom?: GameLinkZoom;
masterVolume?: number;
masterFx?: string;
} {
const name = after.name.trim() || before.name;
const symbolScale = clampSymbolScale(after.symbolScale);
const masterVolume = clampMasterVolume(after.masterVolume);
return {
...(name === before.name ? {} : { name }),
...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers)
@@ -54,6 +66,8 @@ export function settingsChanges(
...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }),
...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }),
...(after.gameLinkZoom === before.gameLinkZoom ? {} : { gameLinkZoom: after.gameLinkZoom }),
...(masterVolume === before.masterVolume ? {} : { masterVolume }),
...(after.masterFx === before.masterFx ? {} : { masterFx: after.masterFx }),
};
}
@@ -138,7 +152,8 @@ class LayerNameRows {
/**
* Everything about the project that is not an asset (#183): its name, which scene Play starts and
* how closely the panels follow it (#265), the zoom of the game link (#279), the size of the scene
* view's symbol icons, the collision layers (#152) and the render layers (#302). The top bar carries
* view's symbol icons, the collision layers (#152), the render layers (#302) and the master bus
* (#325). The top bar carries
* icons only, so this is the one place they are edited.
*/
export class ProjectSettingsDialog extends FormDialog {
@@ -147,21 +162,31 @@ export class ProjectSettingsDialog extends FormDialog {
private whilePlaying: WhilePlaying;
private gameLinkZoom: GameLinkZoom;
private symbolScale: number;
private masterVolume: number;
private masterFx: string;
private readonly nameField: TextField;
private readonly playFromButton: Button;
private readonly whilePlayingButton: Button;
private readonly gameLinkZoomButton: Button;
private readonly symbolScaleField: NumberField;
private readonly masterVolumeField: NumberField;
private readonly masterFxButton: Button;
private readonly collisionLayers: LayerNameRows;
private readonly renderLayers: LayerNameRows;
constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) {
constructor(
settings: ProjectSettings,
onApply: (settings: ProjectSettings) => void,
private readonly fxChoices: readonly FxChoice[] = [],
) {
super(340, 'Project settings');
this.projectName = settings.name;
this.playFrom = settings.playFrom;
this.whilePlaying = settings.whilePlaying;
this.gameLinkZoom = settings.gameLinkZoom;
this.symbolScale = clampSymbolScale(settings.symbolScale);
this.masterVolume = clampMasterVolume(settings.masterVolume);
this.masterFx = settings.masterFx;
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
// which is exactly what happened here.
const fit = () => {
@@ -237,6 +262,29 @@ export class ProjectSettingsDialog extends FormDialog {
}),
);
this.content.add(new Label('Master', { style: 'bodyBold' }));
this.content.add(new Label('Where every bus ends, and every sound that names none.', { style: 'muted' }));
this.masterVolumeField = new NumberField(this.masterVolume, {
width: 150,
min: 0,
max: 1,
step: 0.05,
decimals: 2,
onInput: (value) => {
this.masterVolume = value;
},
onChange: (value) => {
this.masterVolume = value;
},
});
this.content.add(this.formRow(this.fieldLabel('Volume'), this.masterVolumeField));
this.masterFxButton = new Button(this.fxLabel(this.masterFx), {
width: 150,
align: 'left',
onClick: () => this.openMasterFxMenu(),
});
this.content.add(this.formRow(this.fieldLabel('Sound FX'), this.masterFxButton));
this.content.add(new Label('Collision layers', { style: 'bodyBold' }));
this.content.add(
new Label('Two colliders meet only when each one names the layer of the other.', {
@@ -260,6 +308,8 @@ export class ProjectSettingsDialog extends FormDialog {
collisionLayers: this.collisionLayers.names,
renderLayers: this.renderLayers.names,
symbolScale: this.symbolScale,
masterVolume: this.masterVolume,
masterFx: this.masterFx,
}),
);
this.collisionLayers.build();
@@ -283,6 +333,8 @@ export class ProjectSettingsDialog extends FormDialog {
whilePlaying: WhilePlaying;
gameLinkZoom: GameLinkZoom;
symbolScale: number;
masterVolume: number;
masterFx: string;
} {
return {
name: this.projectName,
@@ -290,6 +342,8 @@ export class ProjectSettingsDialog extends FormDialog {
whilePlaying: this.whilePlaying,
gameLinkZoom: this.gameLinkZoom,
symbolScale: this.symbolScale,
masterVolume: this.masterVolume,
masterFx: this.masterFx,
};
}
@@ -313,6 +367,15 @@ export class ProjectSettingsDialog extends FormDialog {
return this.symbolScaleField;
}
/** The master volume field and the master FX switch, so a check can use them. */
get masterVolumeControl(): NumberField {
return this.masterVolumeField;
}
get masterFxControl(): Button {
return this.masterFxButton;
}
/** A label and a field that takes the rest of the row – the layer rows are built the same way. */
private labelledRow(text: string, field: TextField): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
@@ -359,4 +422,24 @@ export class ProjectSettingsDialog extends FormDialog {
})),
).openBelow(this.gameLinkZoomButton);
}
/** A sound FX by its name; one that is gone shows its id, like a dangling pick in the inspector. */
private fxLabel(id: string): string {
if (id === '') return 'None';
return this.fxChoices.find((choice) => choice.id === id)?.name ?? `${id}?`;
}
private openMasterFxMenu(): void {
const entry = (id: string, label: string) => ({
label,
current: this.masterFx === id,
onSelect: () => {
this.masterFx = id;
this.masterFxButton.setText(this.fxLabel(id));
},
});
new Menu([entry('', 'None'), ...this.fxChoices.map((choice) => entry(choice.id, choice.name))]).openBelow(
this.masterFxButton,
);
}
}
+5
View File
@@ -346,6 +346,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
const dialog = editor.projectSettingsDialog();
return dialog ? centreOf(dialog.symbolScaleControl) : null;
},
/** Where to click the master's volume field and its sound FX switch inside that dialog (#325). */
projectSettingsMasterAt: () => {
const dialog = editor.projectSettingsDialog();
return dialog && { volume: centreOf(dialog.masterVolumeControl), fx: centreOf(dialog.masterFxControl) };
},
/** The "General settings" dialog (#309) as its field stands, null while shut. */
generalSettingsDialog: () => editor.generalSettingsDialog()?.values() ?? null,
/** Where to click the zoom field inside that dialog. */
+3
View File
@@ -71,6 +71,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing),
/** The patterns and songs a game plays, with the notes and clips last planned of each. */
playbacks: () => soundLibrary.playbacks(),
/** Every bus there is, the master and those of the project among them (#325), and how loud one is. */
buses: () => soundLibrary.busLevels(),
mixVolume: (name: string) => soundLibrary.mixVolume(name),
/** Whether the audio clock is held by the pause of the toolbar or runs. */
clockState: () => soundLibrary.clockState(),
/** The second of the audio clock a voice started at, and the rate that clock runs at (#346). */
+19 -8
View File
@@ -14,8 +14,10 @@ import {
assetChoiceLabel,
assetRefChoices,
assetRefLabel,
MASTER_LABEL,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
import { keyName } from '../../core/keys';
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
@@ -390,11 +392,17 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
asset: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project, def) =>
assetRefChoices(project, def.kind === 'asset' ? def.assetTypes : undefined).map((asset) => ({
id: asset.id,
label: assetChoiceLabel(asset),
})),
choices: (project, def) => {
const extras = def.kind === 'asset' ? def : undefined;
// Asked as the menu opens: another bus may have been sent into this one meanwhile.
const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []);
return [
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
...assetRefChoices(project, extras?.assetTypes)
.filter((asset) => !left.has(asset.id))
.map((asset) => ({ id: asset.id, label: assetChoiceLabel(asset) })),
];
},
},
texture: {
none: NO_TEXTURE_LABEL,
@@ -429,6 +437,7 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
export function referenceLabel(def: PropertyDef, project: Project | null, id: string): string | undefined {
if (id === '' && def.kind === 'asset' && def.none) return def.none;
return isKindWith('reference', def.kind) ? REFERENCES[def.kind].label(project, id) : undefined;
}
@@ -442,10 +451,12 @@ function referenceField(
// The current id is kept here rather than read back off the label: the label is a name, two
// assets may share one (a frame's label repeats its sheet's), and a dangling reference has none.
let current = textOf(value);
const button = new Dropdown(ref.label(ctx.project(), current));
const none = def.kind === 'asset' && def.none ? def.none : ref.none;
const labelOf = (id: string) => (id === '' ? none : ref.label(ctx.project(), id));
const button = new Dropdown(labelOf(current));
button.onClick = () => {
const items: MenuItem[] = [
{ label: ref.none, current: current === '', onSelect: () => handlers.onChange('') },
{ label: none, current: current === '', onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while the
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
...ref.choices(ctx.project(), def).map((choice) => ({
@@ -461,7 +472,7 @@ function referenceField(
grow: true,
setValue: (v) => {
current = textOf(v);
button.setText(ref.label(ctx.project(), current));
button.setText(labelOf(current));
},
};
}
+2 -1
View File
@@ -20,7 +20,7 @@ import {
} from '../../core/instrument/compile';
import { MIDDLE_C, noteName } from '../../core/instrument/notes';
import type { Asset, InstrumentAsset } from '../../core/model/Asset';
import { playNote } from '../../runtime/systems/behaviorContext';
import { instrumentBus, playNote } from '../../runtime/systems/behaviorContext';
import {
Button,
Dropdown,
@@ -164,6 +164,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
struck,
PREVIEW_OWNER,
{ ...this.values },
{ bus: instrumentBus(this.state.project, asset.id, '') },
);
if (voice) this.voices.push(voice);
return voice;
+1
View File
@@ -41,6 +41,7 @@ export const ASSET_GROUPS: Record<AssetType, { name: string; category: AssetCate
instrument: { name: 'Instrument', category: 'Audio' },
pattern: { name: 'Pattern', category: 'Audio' },
song: { name: 'Song', category: 'Audio' },
bus: { name: 'Bus', category: 'Audio' },
};
export interface AssetGroup {
+2
View File
@@ -5,6 +5,7 @@
import type { AssetType } from '../../core/model/AssetTypes';
import animation from './icons/animation.svg?raw';
import behavior from './icons/behavior.svg?raw';
import bus from './icons/bus.svg?raw';
import camera from './icons/camera.svg?raw';
import clip from './icons/clip.svg?raw';
import collider from './icons/collider.svg?raw';
@@ -42,6 +43,7 @@ import vectorart from './icons/vectorart.svg?raw';
export const ASSET_ICON_SVG: Record<AssetType, string> = {
animation,
behavior,
bus,
camera,
clip,
collider,
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- Three lines joining into one: where sounds are mixed on their way out. -->
<path d="M-6.5 -5 C-2 -5 -1.5 0 2 0 M-6.5 0 H2 M-6.5 5 C-2 5 -1.5 0 2 0 M2 0 H5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" />
<path d="M4 -2.5 L7 0 L4 2.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 395 B

+7 -1
View File
@@ -21,6 +21,7 @@ import { findAssetOf } from '../../core/model/Project';
import { playNote } from '../../runtime/systems/behaviorContext';
import { Button, Dropdown, Label, Menu, type Widget } from '../../ui';
import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport';
@@ -50,7 +51,11 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
readonly ghostsButton: Button;
/** Where the ghost notes come from: song, track and bar. */
readonly ghostSource = new Label('', { style: 'muted' });
readonly music = new Music(() => this.state.project);
/** Heard on the bus of the asset and the master, as in the game (#325). */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
);
/** The pattern playing, `''` while none is. */
private playingId = '';
/** What the scheduler was last told, so only a change is passed on. */
@@ -168,6 +173,7 @@ export class PatternEditor extends AssetTabEditor<PatternAsset, PianoRoll> {
struck,
PREVIEW_OWNER,
{},
{ bus: playedBus(this.state.project, '', asset?.props ?? {}) },
);
}
+6 -1
View File
@@ -21,6 +21,7 @@ import { textOf } from '../../core/model/Properties';
import type { SoundStore } from '../../core/store/SoundStore';
import { Button, Dialog, Dropdown, Label, Menu, ui } from '../../ui';
import { Music } from '../../viewport/music';
import { playedBus } from '../../core/music/mixer';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
type InputDevice,
@@ -68,7 +69,11 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly recordButton: Button;
readonly inputButton = new Dropdown('Default input', { width: INPUT_WIDTH });
readonly meter = new TakeMeter();
readonly music = new Music(() => this.state.project);
/** Heard on the bus of the asset and the master, as in the game (#325). */
readonly music = new Music(
() => this.state.project,
() => playedBus(this.state.project, '', this.asset()?.props ?? {}),
);
/** The song playing, `''` while none is. */
private playingId = '';
private take: Take | null = null;
+7 -2
View File
@@ -15,6 +15,8 @@ import {
} from '../../core/properties/sounds';
import { Button, Dropdown, Label, Menu, NumberField } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { playedBus } from '../../core/music/mixer';
import { DRY_VOICE } from '../../core/soundfx/nodes';
import { AssetTabEditor } from '../AssetTabEditor';
import { claimTransport, releaseTransport } from '../music/transport';
import { HEADER_ICON_SVG } from '../panels/headerIcons';
@@ -148,8 +150,11 @@ export class SoundEditor extends AssetTabEditor<SoundAsset, WaveCanvas> {
if (!asset?.file) return;
this.stop();
claimTransport(this.halt);
const volume = volumeOf(resolveAssetProps(this.state.project, asset));
this.voice = soundLibrary.play(playedSoundOf(this.state.project, asset), volume, false, PREVIEW_OWNER);
const props = resolveAssetProps(this.state.project, asset);
// Through its bus and the master, as the game hears it (#325).
const bus = playedBus(this.state.project, '', props);
const file = playedSoundOf(this.state.project, asset);
this.voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, DRY_VOICE, bus);
this.playingId = this.voice ? asset.id : '';
this.syncPlayButton();
}
+7 -5
View File
@@ -6,6 +6,7 @@ import { volumeOf } from '../../core/properties/sounds';
import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile';
import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import { playedBus } from '../../core/music/mixer';
import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle';
@@ -101,11 +102,12 @@ export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
const sound = findAssetOf(this.state.project, this.previewSoundId, 'sound');
const plan = asset && soundFxBuildFor(asset).compiled;
if (!sound?.file || !plan) return;
const volume = volumeOf(resolveAssetProps(this.state.project, sound));
const voice = soundLibrary.play(playedSoundOf(this.state.project, sound), volume, false, PREVIEW_OWNER, {
plan,
params: { ...this.values },
});
const props = resolveAssetProps(this.state.project, sound);
const fx = { plan, params: { ...this.values } };
// On the bus of the sound, as the game would play it there (#325).
const bus = playedBus(this.state.project, '', props);
const file = playedSoundOf(this.state.project, sound);
const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, bus);
if (voice) this.voices.push(voice);
}
+5
View File
@@ -5,6 +5,7 @@
import type { SaveArea } from '../core/graph/savedValues';
import type { SceneAsset } from '../core/model/Asset';
import { findScene, type Project } from '../core/model/Project';
import { mixOf } from '../core/music/mixer';
import type { AudioSink } from './systems/Audio';
import { addGameSystems, type BehaviorSystem, type TimeScaled } from './systems/BehaviorSystem';
import type { Input } from './systems/Input';
@@ -40,6 +41,9 @@ export class GameRun {
static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun {
const scene = findScene(project, sceneId);
const before = scene && structuredClone<StoredObjects>({ objects: scene.objects, groups: scene.groups });
// Every run starts with the buses as their assets have them (#325).
services.audio.resetMix();
services.audio.setMix(mixOf(project));
const world = World.instantiate(project, sceneId);
const run = new GameRun(project, input, services, world, addGameSystems(world, project, input, services));
if (before) run.stored.set(sceneId, before);
@@ -99,6 +103,7 @@ export class GameRun {
*/
end(): void {
this.world.destroy();
this.services.audio.resetMix();
for (const [sceneId, before] of this.stored) {
const scene = findScene(this.project, sceneId);
if (scene) restore(scene, before);
+23 -3
View File
@@ -1,5 +1,6 @@
import type { EaseShare } from '../../core/model/animation';
import type { SequencePlay } from '../../core/music/playbacks';
import type { MixBus } from '../../core/music/mixer';
import type { FxParams, FxPlan, FxVoice } from '../../core/soundfx/nodes';
/**
@@ -17,9 +18,10 @@ export interface AudioSink {
* with it, because a sound is something a thing in the world is doing, not a thing of its own.
* `''` means the voice has no owner and outlives whatever started it. `fx` is the sound FX the
* voice passes on its way out (#311), with the values of its parameters; `DRY_VOICE` passes none.
* Returns the number of the voice, never used twice, or 0 where nothing is going to play.
* Returns the number of the voice, never used twice, or 0 where nothing is going to play. `bus` is
* the bus of the mix it is heard on (#325), the master where left out.
*/
play(file: string, volume: number, loop: boolean, owner: string, fx?: FxVoice): number;
play(file: string, volume: number, loop: boolean, owner: string, fx?: FxVoice, bus?: string): number;
/**
* Starts one note of an instrument (#331), as `play` starts a sound: `layers` are what the note is
* made of, played side by side as one voice, each way reading the file of its own source. `name`
@@ -88,6 +90,19 @@ export interface AudioSink {
stopVoice(voice: number, letRing?: boolean): void;
/** Silences every voice started by that owner – what destroying a behavior does. */
stopOwner(owner: string): void;
/**
* The master and the buses of the project (#325), set as a run starts. What a game set a level to
* holds over it until `resetMix`.
*/
setMix(buses: readonly MixBus[]): void;
/** The level a game sets a bus of the mix to, over `time` seconds along `ease`. */
setMixVolume(bus: string, volume: number, time?: number, ease?: EaseShare): void;
/** The level a bus of the mix is at: what a game set, else that of its asset. */
mixVolume(bus: string): number;
/** Every level back to that of its asset: a run starts afresh. */
resetMix(): void;
/** Hands new values to the sound FX of a bus of the mix, as `tune` does to a voice. */
tuneMix(bus: string, params: FxParams, time?: number, ease?: EaseShare): void;
}
/**
@@ -97,7 +112,7 @@ export interface AudioSink {
export interface NoteTiming {
at?: number;
tempo?: number;
/** The track of a song it is heard on (#334); straight out where left out. */
/** The bus it is heard on: a track of a song (#334) or one of the mix (#325); the master where left out. */
bus?: string;
}
@@ -124,4 +139,9 @@ export const SILENT_AUDIO: AudioSink = {
stop: () => {},
stopVoice: () => {},
stopOwner: () => {},
setMix: () => {},
setMixVolume: () => {},
mixVolume: () => 1,
resetMix: () => {},
tuneMix: () => {},
};
+32 -8
View File
@@ -31,6 +31,7 @@ import {
SOUND_ITEM_MAX_DB,
} from '../../core/music/song';
import { songBuses } from '../../core/music/songBuses';
import { playedBus, reachedBus } from '../../core/music/mixer';
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
import type { Entity } from '../Entity';
import { PhysicsSystem } from '../physics/PhysicsSystem';
@@ -179,23 +180,23 @@ export function behaviorContext(
// eslint-disable-next-line no-console
console.log(`[behavior ${entity.name}]`, message);
},
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo),
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams) =>
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo, bus),
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams, bus) =>
playNote(
run,
instrumentId,
{ note, velocity, volume, duration },
detached ? '' : entity.id,
params,
{ tempo },
{ tempo, bus: instrumentBus(project, instrumentId, bus) },
fxVoiceOf(project, fx, tempo, fxParams),
),
playPattern: (patternId, volume, loop, detached, fx, fxParams) =>
playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams),
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams, bus),
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) =>
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams, bus),
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
soundLength: (assetId) => {
@@ -211,6 +212,18 @@ export function behaviorContext(
},
stopVoice: (voice, letRing) => run.audio.stopVoice(voice, letRing),
tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, withScoped(fxParams), time, ease),
tuneBus: (bus, fxParams, time, ease) => {
const reached = reachedBus(project, bus);
if (reached) run.audio.tuneMix(reached, fxParams, time, ease);
},
setBusVolume: (bus, volume, time, ease) => {
const reached = reachedBus(project, bus);
if (reached) run.audio.setMixVolume(reached, volume, time, ease);
},
busVolume: (bus) => {
const reached = reachedBus(project, bus);
return reached ? run.audio.mixVolume(reached) : 0;
},
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
addJoint: (assetId, owner, connected, at) => {
const on = resolve(owner);
@@ -268,6 +281,7 @@ function playSound(
fxId: string,
fxParams: FxParams,
tempo: number,
bus: string,
): number {
const asset = findAssetOf(project, assetId, 'sound');
if (!asset?.file) return 0;
@@ -281,9 +295,15 @@ function playSound(
loop ?? loopsOf(props),
owner,
plan ? { plan, params: fxParams, tempo } : DRY_VOICE,
playedBus(project, bus, props),
);
}
/** The bus of the mix a note of that instrument is heard on, by the pick of a node (#325). */
export function instrumentBus(project: Project, instrumentId: string, picked: string): string {
return playedBus(project, picked, findAssetOf(project, instrumentId, 'instrument')?.props ?? {});
}
/**
* One note of an instrument (#331), as the sink understands it: the layers the instrument makes of
* it, the velocity as loudness and as the value of its `velocity`, and the voice's volume held to
@@ -345,6 +365,7 @@ function playPattern(
owner: string,
fxId: string,
fxParams: FxParams,
bus: string,
): number {
const asset = findAssetOf(project, patternId, 'pattern');
if (!asset) return 0;
@@ -364,6 +385,7 @@ function playPattern(
loop: loop ? { start: 0, end: length } : null,
needs: sequenceNeeds(project, sequence),
...(fx && { fx }),
bus: playedBus(project, bus, asset.props),
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
},
owner,
@@ -385,6 +407,7 @@ function playSong(
owner: string,
fxId: string,
fxParams: FxParams,
bus: string,
): number {
const song = findAssetOf(project, songId, 'song');
if (!song) return 0;
@@ -403,6 +426,7 @@ function playSong(
fadeIn: heldFade(fadeIn),
buses: songBuses(project, song),
...(fx && { fx }),
bus: playedBus(project, bus, song.props),
strike: (note) => strikeNote({ project, audio }, note, owner),
strikeClip: (clip) => playClip({ project, audio }, clip),
},
+142 -22
View File
@@ -23,6 +23,7 @@ import {
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
import type { EaseShare } from '../core/model/animation';
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
import { MASTER_MIX, type MixBus, sameMix } from '../core/music/mixer';
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
@@ -299,6 +300,13 @@ export class SoundLibrary implements AudioSink {
private held = false;
/** Counts the times buses were let go, so a later use of one can keep it. */
private leavings = 0;
/**
* The master and the buses of the project (#325) as last set, made when the context is: setting
* them must not open an audio device for an editor nobody has listened to yet.
*/
private mix: readonly MixBus[] = [];
/** The levels a running game set its buses to, over those of their assets; until `resetMix`. */
private readonly mixVolumes = new Map<string, number>();
/** Where the files are served from. Set once at start-up, before anything plays. */
configure(base: string): void {
@@ -309,10 +317,21 @@ export class SoundLibrary implements AudioSink {
* The number is handed out at once, before the bytes are there: a graph can tune a voice in the
* same frame it asked for it, and the voice then starts with those values.
*/
play(file: string, volume: number, loop: boolean, owner = '', fx: FxVoice = DRY_VOICE): number {
play(file: string, volume: number, loop: boolean, owner = '', fx: FxVoice = DRY_VOICE, bus = ''): number {
if (file === '' || this.hasFailed(file)) return 0;
const layered = { layers: [fx.plan], params: fx.params, tempo: fx.tempo ?? DEFAULT_TEMPO };
return this.begin({ id: 0, file, files: [file], volume, loop, owner, fx: layered, duration: 0, at: 0 });
return this.begin({
id: 0,
file,
files: [file],
volume,
loop,
owner,
fx: layered,
duration: 0,
at: 0,
bus,
});
}
/**
@@ -385,7 +404,7 @@ export class SoundLibrary implements AudioSink {
const waits: Promise<void>[] = [];
for (const { name, gain, pan, into, fx } of levels) {
if (into !== undefined) this.bus(into);
this.bus(name, into ?? null);
this.route(name, this.bus(name, into ?? null), into ?? null);
this.setBus(name, gain, pan);
waits.push(this.setFx(this.bus(name), fx ?? null));
}
@@ -417,7 +436,11 @@ export class SoundLibrary implements AudioSink {
busLatency(name: string): number {
let late = 0;
const seen = new Set<Bus>();
for (let bus = this.buses.get(name); bus && !seen.has(bus); bus = this.buses.get(bus.into ?? '')) {
for (
let bus = this.buses.get(name || MASTER_MIX);
bus && !seen.has(bus);
bus = this.buses.get(bus.into ?? '')
) {
seen.add(bus);
late += bus.chain?.latency ?? 0;
}
@@ -442,12 +465,15 @@ export class SoundLibrary implements AudioSink {
}));
}
/** Where the voices of a track go in; straight out for `''`. */
/** Where the voices of a bus go in; the master for `''` (#325). */
private busInput(name: string): AudioNode {
return name === '' ? this.audio().destination : this.bus(name).input;
return this.bus(name || MASTER_MIX).input;
}
/** A bus by its name, made on its first use into `into`, or straight out; it is not to go any more. */
/**
* A bus by its name, made on its first use into `into`, or into the master; only the master is
* heard straight out (#325). It is not to go any more.
*/
private bus(name: string, into: string | null = null): Bus {
const known = this.buses.get(name);
if (known) {
@@ -455,24 +481,108 @@ export class SoundLibrary implements AudioSink {
return known;
}
const context = this.audio();
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
const made: Bus = {
input: context.createGain(),
gain: context.createGain(),
pan: context.createStereoPanner(),
set: { gain: 1, pan: 0 },
into,
into: target,
fx: null,
chain: null,
leaving: 0,
};
made.input
.connect(made.gain)
.connect(made.pan)
.connect(into ? this.bus(into).input : context.destination);
made.input.connect(made.gain).connect(made.pan).connect(this.outOf(target));
this.buses.set(name, made);
return made;
}
private outOf(into: string | null): AudioNode {
return into ? this.bus(into).input : this.audio().destination;
}
/** Sends a bus on into another one where it went somewhere else; the master stays heard. */
private route(name: string, bus: Bus, into: string | null): void {
const target = name === MASTER_MIX ? null : (into ?? MASTER_MIX);
if (bus.into === target) return;
bus.pan.disconnect();
bus.pan.connect(this.outOf(target));
bus.into = target;
}
/**
* The master and the buses of the project (#325): each into the bus it names, at its level unless
* a running game set another, through its sound FX. A bus whose asset is gone is let go. Applied
* once there is a context; done once the chains are there. The same mix again changes nothing, so
* the editor can hand it over on every change of the project.
*/
async setMix(buses: readonly MixBus[]): Promise<void> {
if (sameMix(this.mix, buses)) return;
this.mix = buses;
if (this.context) await this.applyMix();
}
private async applyMix(): Promise<void> {
const names = new Set(this.mix.map((bus) => bus.name));
const waits: Promise<void>[] = [];
for (const { name, volume, into, fx } of this.mix) {
const bus = this.bus(name, into);
this.route(name, bus, into);
this.glideLevel(bus, this.mixVolumes.get(name) ?? volume);
waits.push(this.setFx(bus, fx));
}
const gone = [...this.buses.keys()].filter(
(name) => name.startsWith(`${MASTER_MIX}:`) && !names.has(name),
);
for (const name of gone) this.mixVolumes.delete(name);
this.releaseBuses(gone);
await Promise.all(waits);
}
/** A quick glide of the level of a bus to `gain`. */
private glideLevel(bus: Bus, gain: number, time = 0, ease: EaseShare = 'linear'): void {
const to = Math.max(0, gain);
move(bus.gain.gain, to, this.audio().currentTime, time, false, ease);
bus.set = { ...bus.set, gain: to };
}
/**
* The level a running game sets a bus of the mix to (#325), over `time` seconds along `ease`; it
* holds until `resetMix`, whatever the mix is set to meanwhile.
*/
setMixVolume(name: string, volume: number, time = 0, ease: EaseShare = 'linear'): void {
const level = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
this.mixVolumes.set(name, level);
if (this.context || this.buses.has(name)) this.glideLevel(this.bus(name), level, Math.max(0, time), ease);
}
/** The level a bus of the mix is at: what a game set it to, else that of its asset. */
mixVolume(name: string): number {
return this.mixVolumes.get(name) ?? this.mix.find((bus) => bus.name === name)?.volume ?? 1;
}
/** Back to the levels of the assets, as a new run starts and an old one ends. */
resetMix(): void {
const set = [...this.mixVolumes.keys()];
this.mixVolumes.clear();
if (!this.context) return;
for (const name of set) {
const bus = this.buses.get(name);
if (bus) this.glideLevel(bus, this.mixVolume(name));
}
}
/**
* The sound FX of a bus of the mix (#325), as `tune` changes those of a voice. Says whether there
* is such a bus.
*/
tuneMix(name: string, params: FxParams, time = 0, ease: EaseShare = 'linear'): boolean {
const bus = this.buses.get(name);
if (!bus) return this.mix.some((known) => known.name === name);
this.tuneBus(bus, params, time, ease);
return true;
}
/**
* Sets the sound FX of a bus (#346), its chain made again only where the FX is another or counts by
* another tempo. Done once the chain is there, after the worklet it needs.
@@ -631,7 +741,7 @@ export class SoundLibrary implements AudioSink {
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
const id = this.nextId++;
const buses = play.buses ?? [];
this.open(id, buses, play.fx ?? null);
this.open(id, buses, play.fx ?? null, play.bus ?? MASTER_MIX);
const tracks = new Set(buses.map((bus) => bus.name));
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
const { strike, strikeClip } = play;
@@ -682,9 +792,9 @@ export class SoundLibrary implements AudioSink {
* The buses of a playing, its tracks into its master and a pattern item's into its track (#346),
* each with the sound FX it was given. Their chains are made once the worklet is there (`ready`).
*/
private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null): void {
private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null, into: string): void {
const masterName = groupBus(id, '');
const master = this.bus(masterName);
const master = this.bus(masterName, into);
master.gain.gain.value = 0;
master.set = { gain: 0, pan: 0 };
master.fx = fx;
@@ -875,15 +985,19 @@ export class SoundLibrary implements AudioSink {
*/
private tuneMaster(id: number, params: FxParams, time: number, ease: EaseShare): boolean {
const master = this.buses.get(groupBus(id, ''));
const fx = master?.fx;
if (!master || !fx) return true;
master.fx = { ...fx, params: { ...fx.params, ...params } };
if (master) this.tuneBus(master, params, time, ease);
return true;
}
private tuneBus(bus: Bus, params: FxParams, time: number, ease: EaseShare): void {
const fx = bus.fx;
if (!fx) return;
bus.fx = { ...fx, params: { ...fx.params, ...params } };
// The chain stays: `setFx` would see the same plan, and the knobs move it.
const levels = levelsOf(fxChainOf(fx.plan, master.fx.params, fx.tempo));
if (master.chain && glideKnobs(master.chain.knobs, levels, this.audio().currentTime, time, ease))
const levels = levelsOf(fxChainOf(fx.plan, bus.fx.params, fx.tempo));
if (bus.chain && glideKnobs(bus.chain.knobs, levels, this.audio().currentTime, time, ease))
this.tunedAt = this.audio().currentTime;
this.tuned += 1;
return true;
}
/**
@@ -1308,6 +1422,7 @@ export class SoundLibrary implements AudioSink {
duration: Math.max(0, ...files.map((name) => this.duration(name) ?? 0)),
at: when,
envelopes: wiring.envelopes.size,
bus,
};
const voice: Voice = {
id,
@@ -1577,7 +1692,10 @@ export class SoundLibrary implements AudioSink {
}
private audio(): AudioContext {
return (this.context ??= new AudioContext());
if (this.context) return this.context;
this.context = new AudioContext();
if (this.mix.length > 0) void this.applyMix();
return this.context;
}
/** What a name plays, a cut made from its file the first time; null while the file is not loaded. */
@@ -1707,6 +1825,8 @@ interface LastVoice {
at: number;
/** How many Envelopes shape it (#335). */
envelopes: number;
/** The bus it went into, `''` for the master (#325). */
bus: string;
}
/** The stages that run on the worklet. */
+21 -6
View File
@@ -5,6 +5,7 @@ import {
type SchedulerOptions,
} from '../core/music/Scheduler';
import { sequenceNeeds } from '../core/music/needs';
import { MASTER_MIX } from '../core/music/mixer';
import type { BusLevel } from '../core/music/playbacks';
import type { Sequence } from '../core/music/sequence';
import type { Project } from '../core/model/Project';
@@ -41,7 +42,14 @@ export class Music {
readonly planned: PlannedNote[] = [];
readonly clips: PlannedClip[] = [];
constructor(private readonly project: () => Project) {}
/**
* `output` is the bus of the mix what it plays is heard on (#325): that of the pattern or song in
* the tab, so it sounds as it would in the game. The master where left out.
*/
constructor(
private readonly project: () => Project,
private readonly output: () => string = () => MASTER_MIX,
) {}
/** Loads what the notes and clips need, then plays from `from` ticks. */
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
@@ -56,7 +64,11 @@ export class Music {
{
now: () => soundLibrary.time(),
latency: () => soundLibrary.latency(),
ahead: () => Math.max(0, ...this.buses.map((bus) => soundLibrary.busLatency(bus.name))),
ahead: () =>
Math.max(
soundLibrary.busLatency(this.output()),
...this.buses.map((bus) => soundLibrary.busLatency(bus.name)),
),
play: (note) => this.playOne(note),
playClip: (clip) => this.playClip(clip),
playing: (voice) => soundLibrary.playing(voice),
@@ -82,8 +94,9 @@ export class Music {
* (#346). A start waits for the chains.
*/
setBuses(levels: readonly BusLevel[]): void {
this.buses = levels;
this.busesReady = soundLibrary.setBuses(levels);
const output = this.output();
this.buses = levels.map((level) => ({ ...level, into: level.into ?? output }));
this.busesReady = soundLibrary.setBuses(this.buses);
}
/** Its buses go once what is on them has faded, unless the next start uses them again. */
@@ -103,14 +116,16 @@ export class Music {
}
private playOne(note: ScheduledNote): number {
const voice = strikeNote({ project: this.project(), audio: soundLibrary }, note, '');
const heard = { ...note, bus: note.bus || this.output() };
const voice = strikeNote({ project: this.project(), audio: soundLibrary }, heard, '');
this.planned.push({ ...note, voice, planned: soundLibrary.clock() });
if (this.planned.length > 256) this.planned.shift();
return voice;
}
private playClip(clip: ScheduledClip): number {
const voice = playClip({ project: this.project(), audio: soundLibrary }, clip);
const heard = { ...clip, bus: clip.bus || this.output() };
const voice = playClip({ project: this.project(), audio: soundLibrary }, heard);
this.clips.push({ ...clip, voice, planned: soundLibrary.clock() });
if (this.clips.length > 256) this.clips.shift();
return voice;
+289
View File
@@ -0,0 +1,289 @@
/**
* Master and buses (#325): which bus a bus goes into, what the sound library is set up with, and
* which bus a sound, a note, a pattern or a song is heard on.
*/
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 { BUS_INTO_KEY, BUS_KEY, busesInto, busInto, 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,
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 into = (bus: string, target: string) =>
f.stack.execute(new SetAssetPropertyCommand(bus, BUS_INTO_KEY, target));
return { ...f, ids, into };
}
/** 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, into the master', () => {
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: '', into: '' } });
expect(schemaForAsset(f.project, bus).map((def) => def.key)).toEqual(['volume', 'fx', 'into']);
expect(roundTrip(f.project)).toEqual(f.project);
});
it('names the master as the empty pick of Into and of the Bus of a sound, an instrument, a pattern and a song', () => {
const f = withBuses('Music');
const into = schemaForAsset(f.project, f.project.assets[0]!).find((def) => def.key === BUS_INTO_KEY);
expect(into).toMatchObject({ kind: 'asset', assetTypes: ['bus'], none: 'Master' });
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).find((def) => def.key === BUS_KEY)).toMatchObject({
none: 'Master',
assetTypes: ['bus'],
});
}
});
it('offers as Into neither itself nor a bus that already goes into it', () => {
const f = withBuses('Master music', 'Music', 'Drums', 'SFX');
const [top, music, drums, sfx] = f.ids as [string, string, string, string];
f.into(music, top);
f.into(drums, music);
const rows = schemaForAsset(
f.project,
f.project.assets.find((a) => a.id === top)!,
);
expect(rows.find((d) => d.key === BUS_INTO_KEY)).toMatchObject({ intoFor: top });
expect(busesInto(f.project, top)).toEqual([top, music, drums]);
expect(busesInto(f.project, drums)).toEqual([drums]);
expect(busesInto(f.project, sfx)).toEqual([sfx]);
});
it('sends a bus that would come round to itself into the master, whatever the file says', () => {
const f = withBuses('A', 'B', 'C');
const [a, b, c] = f.ids as [string, string, string];
f.into(a, b);
f.into(b, c);
f.into(c, b);
// B and C go round each other: both go into the master. A goes into B, which is no loop.
expect([busInto(f.project, a), busInto(f.project, b), busInto(f.project, c)]).toEqual([b, '', '']);
f.into(a, a);
expect(busInto(f.project, a)).toBe('');
f.into(a, 'gone');
expect(busInto(f.project, a)).toBe('');
});
});
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 its asset names, else the master', () => {
const f = withBuses('Music', 'SFX');
const [music, sfx] = f.ids as [string, string];
expect(playedBus(f.project, '', { bus: music })).toBe(`mix:${music}`);
expect(playedBus(f.project, sfx, { bus: music })).toBe(`mix:${sfx}`);
expect(playedBus(f.project, MASTER_BUS, { bus: music })).toBe(MASTER_MIX);
expect(playedBus(f.project, '', {})).toBe(MASTER_MIX);
// A bus that was deleted leaves it on the master.
expect(playedBus(f.project, '', { bus: 'gone' })).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 its asset names, 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);
f.stack.execute(new SetAssetPropertyCommand(sound.asset.id, BUS_KEY, 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');
});
});
+2
View File
@@ -153,6 +153,8 @@ describe('CommandStack', () => {
plugins: [],
collisionLayers: ['Default'],
renderLayers: ['Default'],
masterVolume: 1,
masterFx: '',
symbolScale: 1,
playFrom: 'start' as const,
whilePlaying: 'live' as const,
+5
View File
@@ -67,6 +67,11 @@ function start(f: Fixture, audio?: AudioSink) {
tail: () => 0,
stopVoice: () => {},
stopOwner: (owner: string) => stoppedOwners.push(owner),
setMix: () => {},
setMixVolume: () => {},
mixVolume: () => 1,
resetMix: () => {},
tuneMix: () => {},
};
const run = GameRun.start(f.project, f.sceneId, new Input(), {
audio: sink,
+66
View File
@@ -18,6 +18,7 @@ import {
worldPositionOf,
} from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { MASTER_BUS } from '../../src/core/properties/buses';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
interface Case {
@@ -2033,6 +2034,12 @@ const CASES: Record<string, Case[]> = {
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.fxParams).toEqual({ depth: -12 }),
},
{
name: 'hands on the bus it picks, the master among them (#325)',
inputs: { sound: 'snd-1', volume: 1, loop: '', bus: MASTER_BUS },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.bus).toBe(MASTER_BUS),
},
],
PlayNote: [
{
@@ -2087,6 +2094,12 @@ const CASES: Record<string, Case[]> = {
next: 'exec',
after: (ctx) => expect(ctx.notes[0]?.tempo).toBe(90),
},
{
name: 'hands on the bus it picks (#325)',
inputs: { instrument: 'ins-1', note: 60, bus: 'bus-1' },
next: 'exec',
after: (ctx) => expect(ctx.notes[0]?.bus).toBe('bus-1'),
},
],
InstrumentParameterValues: [
{
@@ -2098,6 +2111,12 @@ const CASES: Record<string, Case[]> = {
{ name: 'with none picked the bundle is empty', outputs: { params: {} } },
],
PlayPattern: [
{
name: 'hands on the bus it picks (#325)',
inputs: { pattern: 'pat-1', bus: 'bus-1' },
next: 'exec',
after: (ctx) => expect(ctx.patterns[0]?.bus).toBe('bus-1'),
},
{
name: 'no pattern is a no-op that still continues the flow',
inputs: { pattern: '' },
@@ -2155,6 +2174,12 @@ const CASES: Record<string, Case[]> = {
},
],
PlaySong: [
{
name: 'hands on the bus it picks (#325)',
inputs: { song: 'song-1', bus: 'bus-1' },
next: 'exec',
after: (ctx) => expect(ctx.songs[0]?.bus).toBe('bus-1'),
},
{
name: 'no song is a no-op that still continues the flow',
inputs: { song: '' },
@@ -2270,6 +2295,24 @@ const CASES: Record<string, Case[]> = {
},
],
SetSoundFxParameters: [
{
name: 'without a voice reaches the bus it picks instead (#325)',
inputs: { voice: 0, bus: MASTER_BUS, fxParams: { depth: 3 }, time: 2 },
next: 'exec',
after: (ctx) => {
expect(ctx.tuned).toEqual([]);
expect(ctx.buses).toEqual([{ bus: MASTER_BUS, fxParams: { depth: 3 }, time: 2, ease: 'linear' }]);
},
},
{
name: 'a voice wins over a bus, and with neither nothing is reached',
inputs: { voice: 4, bus: 'bus-1', fxParams: { depth: 3 } },
next: 'exec',
after: (ctx) => {
expect(ctx.tuned.map((t) => t.voice)).toEqual([4]);
expect(ctx.buses).toEqual([]);
},
},
{
name: 'hands the numbers of the bundle to the voice it is given',
inputs: { voice: 4, fxParams: { depth: 3, label: 'x' } },
@@ -2395,6 +2438,29 @@ const CASES: Record<string, Case[]> = {
outputs: { scale: 0.25 },
},
],
SetBusVolume: [
{
name: 'sets the volume of the bus it picks over its time, empty being the master (#325)',
inputs: { bus: '', volume: 0.3, time: 1.5 },
next: 'exec',
after: (ctx) => expect(ctx.buses).toEqual([{ bus: '', volume: 0.3, time: 1.5, ease: 'linear' }]),
},
{
name: 'a time below 0 is a quick glide, along the easing it is given',
inputs: { bus: 'bus-1', volume: 0, time: -1 },
params: { ease: 'step' },
next: 'exec',
after: (ctx) => expect(ctx.buses).toEqual([{ bus: 'bus-1', volume: 0, time: 0, ease: 'step' }]),
},
],
BusVolume: [
{
name: 'answers the volume of the bus it picks, as the run left it (#325)',
context: { busVolume: (bus: string) => (bus === 'bus-1' ? 0.4 : 1) },
inputs: { bus: 'bus-1' },
outputs: { volume: 0.4 },
},
],
};
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
+1
View File
@@ -55,6 +55,7 @@ describe('pattern asset (#333)', () => {
'beats',
'beat',
'tempo',
'bus',
]);
});
+7 -1
View File
@@ -69,7 +69,13 @@ describe('the song asset (#334)', () => {
const f = makeSong(1);
expect(f.song().tracks.map((t) => t.name)).toEqual(['Track 1']);
expect(f.song().items).toEqual([]);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual(['tempo', 'beats', 'beat', 'grid']);
expect(schemaForAsset(f.project, f.song()).map((d) => d.key)).toEqual([
'tempo',
'beats',
'beat',
'grid',
'bus',
]);
expect(songGrid(f.song().props)).toBe(BAR);
f.stack.execute(new SetAssetPropertyCommand(f.song().id, 'grid', '1/8T'));
expect(songGrid(f.song().props)).toBe(160);
+5
View File
@@ -474,6 +474,11 @@ function fakeAudio(): AudioSink & {
tail: () => 0,
stopVoice: () => {},
stopOwner: () => {},
setMix: () => {},
setMixVolume: () => {},
mixVolume: () => 1,
resetMix: () => {},
tuneMix: () => {},
};
return sink;
}
+7
View File
@@ -38,6 +38,7 @@ import {
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 {
@@ -77,6 +78,11 @@ function fakeAudio(): AudioSink & {
stop: (file: string) => sink.stopped.push(file),
stopVoice: () => {},
stopOwner: (owner: string) => sink.stoppedOwners.push(owner),
setMix: () => {},
setMixVolume: () => {},
mixVolume: () => 1,
resetMix: () => {},
tuneMix: () => {},
};
return sink;
}
@@ -132,6 +138,7 @@ describe('the sound asset', () => {
FADE_IN_CURVE_KEY,
FADE_OUT_KEY,
FADE_OUT_CURVE_KEY,
BUS_KEY,
]);
expect(cutOf(asset.props)).toEqual({
start: 0,
+11
View File
@@ -13,6 +13,8 @@ const before: ProjectSettings = {
collisionLayers: ['Default', 'Enemies'],
renderLayers: ['Default'],
symbolScale: 1,
masterVolume: 1,
masterFx: '',
};
describe('project settings', () => {
@@ -70,4 +72,13 @@ describe('project settings', () => {
expect(settingsChanges(before, { ...before, gameLinkZoom: 'fit' }).gameLinkZoom).toBe('fit');
expect(settingsChanges(before, { ...before, gameLinkZoom: 'auto' }).gameLinkZoom).toBeUndefined();
});
it('writes the master only where it changed, its volume held to 0..1 (#325)', () => {
expect(settingsChanges(before, { ...before, masterVolume: 0.5, masterFx: 'fx1' })).toEqual({
masterVolume: 0.5,
masterFx: 'fx1',
});
expect(settingsChanges(before, { ...before, masterVolume: 3 }).masterVolume).toBeUndefined();
expect(settingsChanges(before, { ...before, masterVolume: -1 }).masterVolume).toBe(0);
});
});
+28 -6
View File
@@ -214,6 +214,8 @@ export type FakeContext = NodeContext & {
fx: string;
fxParams: Readonly<Record<string, number>>;
tempo: number;
/** The bus picked (#325); left out for none. */
bus?: string;
}[];
/** The notes the context was asked to play (#331); a voice's number is how many have been played. */
notes: {
@@ -227,6 +229,7 @@ export type FakeContext = NodeContext & {
tempo: number;
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
}[];
/** The patterns the context was asked to play; a playing's number is how many have been played. */
patterns: {
@@ -236,6 +239,7 @@ export type FakeContext = NodeContext & {
detached: boolean;
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
}[];
/** The songs the context was asked to play, numbered the same way. */
songs: {
@@ -246,9 +250,18 @@ export type FakeContext = NodeContext & {
detached: boolean;
fx: string;
fxParams: Readonly<Record<string, number>>;
bus?: string;
}[];
silenced: string[];
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
/** What buses the context was told to change (#325): a volume or FX parameters, in order. */
buses: {
bus: string;
volume?: number;
fxParams?: Readonly<Record<string, number>>;
time: number;
ease: EaseShare;
}[];
/** The scenes the context was asked to load, in order. */
loaded: string[];
/** The time scale and the pause the context holds, as the game nodes left them. */
@@ -301,8 +314,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
silenced: [],
loaded: [],
// The number of a voice is how many have been played: unique, and never 0.
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo }),
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo, bus) =>
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo, ...(bus && { bus }) }),
notes: [],
playNote: (
instrumentId,
@@ -315,6 +328,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
tempo,
fx,
fxParams,
bus,
) =>
ctx.notes.push({
instrumentId,
@@ -327,15 +341,16 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
tempo,
fx,
fxParams,
...(bus && { bus }),
}),
patterns: [],
playPattern: (patternId, volume, loop, detached, fx, fxParams) =>
ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams }),
playPattern: (patternId, volume, loop, detached, fx, fxParams, bus) =>
ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams, ...(bus && { bus }) }),
stopPattern: (patternId, letRing) =>
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
songs: [],
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) =>
ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams }),
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams, bus) =>
ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams, ...(bus && { bus }) }),
stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`),
stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`),
// Every sound lasts a quarter of a second, as if it had been loaded.
@@ -347,6 +362,13 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
stopVoice: (voice, letRing) => ctx.silenced.push(`voice ${voice}${letRing ? ' (let ring)' : ''}`),
tuned: [],
tuneSound: (voice, fxParams, time, ease) => ctx.tuned.push({ voice, fxParams, time, ease }),
buses: [],
tuneBus: (bus, fxParams, time, ease) => ctx.buses.push({ bus, fxParams, time, ease }),
setBusVolume: (bus, volume, time, ease) => ctx.buses.push({ bus, volume, time, ease }),
// The last volume set, else that of an asset nobody touched.
busVolume: (bus) =>
[...ctx.buses].reverse().find((change) => change.bus === bus && change.volume !== undefined)?.volume ??
1,
loadScene: (sceneId) => ctx.loaded.push(sceneId),
spawn: () => null,
addJoint: () => null,
+2
View File
@@ -135,6 +135,8 @@ describe('render layers (#302)', () => {
collisionLayers: ['Default'],
renderLayers: ['Default'],
symbolScale: 1,
masterVolume: 1,
masterFx: '',
};
expect(settingsChanges(before, { ...before, renderLayers: ['Default'] })).toEqual({});
expect(settingsChanges(before, { ...before, renderLayers: ['Default', 'UI'] })).toEqual({
+141 -38
View File
@@ -6,14 +6,16 @@
* The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
FxChipPlan,
FxEnvelopePlan,
FxPlan,
FxStage,
FxStagePlan,
FxVoice,
import {
DRY_VOICE,
type FxChipPlan,
type FxEnvelopePlan,
type FxPlan,
type FxStage,
type FxStagePlan,
type FxVoice,
} from '../../src/core/soundfx/nodes';
import type { MixBus } from '../../src/core/music/mixer';
import type { SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledNote } from '../../src/core/music/Scheduler';
import type { PitchOptions } from '../../src/viewport/fxWorklet';
@@ -105,6 +107,14 @@ let loadModule: () => Promise<void> = () => Promise.resolve();
/** Whether a module has loaded: before that a worklet node cannot be made, as in a browser. */
let moduleLoaded = false;
/**
* The master every voice ends in (#325): made with the first voice that needs it, its input, level
* and pan out to the speakers, before the voice is connected into it.
*/
const INTO_MASTER = ['gain > gain', 'gain > panner', 'panner > destination', 'gain > gain'];
/** What `busLevels` says of that master while nothing has set it. */
const MASTER_LEVEL = { name: 'mix', gain: 1, pan: 0, into: null, fx: false, latency: 0 };
const sources: FakeSource[] = [];
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
const wires: string[] = [];
@@ -485,12 +495,13 @@ describe('the sound library', () => {
duration: 1.5,
at: 0,
envelopes: 0,
bus: '',
});
});
it('plays a dry voice straight into its volume, without loading the pitch worklet', async () => {
await play(new SoundLibrary());
expect(wires).toEqual(['source > gain', 'gain > destination']);
expect(wires).toEqual(['source > gain', ...INTO_MASTER]);
expect(sources[0]?.playbackRate.value).toBe(1);
expect(modules).toEqual([]);
});
@@ -505,7 +516,7 @@ describe('the sound library', () => {
],
};
await play(library, FILE, {}, fx);
expect(wires).toEqual(['pitch > gain', 'gain > filter', 'filter > gain', 'gain > destination']);
expect(wires).toEqual(['pitch > gain', 'gain > filter', 'filter > gain', ...INTO_MASTER]);
expect(filters).toMatchObject([
{ type: 'lowshelf', frequency: { value: 80 }, gain: { value: 6 }, Q: { value: 1 } },
]);
@@ -540,7 +551,7 @@ describe('the sound library', () => {
const crush: Chain = { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 0.5 }] };
const voice = await play(library, FILE, {}, crush);
expect(modules).toHaveLength(1);
expect(wires).toEqual(['source > crush', 'crush > gain', 'gain > destination']);
expect(wires).toEqual(['source > crush', 'crush > gain', ...INTO_MASTER]);
const node = worklets[0]!;
expect(node.options.parameterData).toEqual({ bits: 4, rate: 8000, mix: 0.5 });
expect(library.levels(FILE)).toEqual([[4, 8000, 0.5]]);
@@ -592,7 +603,7 @@ describe('the sound library', () => {
],
};
const voice = await play(library, FILE, {}, { plan, params: {} });
expect(wires).toEqual(['source > phaser', 'phaser > flanger', 'flanger > gain', 'gain > destination']);
expect(wires).toEqual(['source > phaser', 'phaser > flanger', 'flanger > gain', ...INTO_MASTER]);
const [phaser, flanger] = worklets as [FakeWorklet, FakeWorklet];
expect(phaser.options.parameterData).toEqual({
frequency: 800,
@@ -625,7 +636,7 @@ describe('the sound library', () => {
const library = new SoundLibrary();
await play(library, FILE, {}, { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
expect(worklets).toHaveLength(0);
expect(wires).toEqual(['source > gain', 'gain > gain', 'gain > destination']);
expect(wires).toEqual(['source > gain', 'gain > gain', ...INTO_MASTER]);
expect(library.stats()).toMatchObject({ started: 1, worklet: false });
});
@@ -675,9 +686,9 @@ describe('the sound library', () => {
'gain > gain',
'gain > filter',
'filter > gain',
'gain > destination',
...INTO_MASTER,
]);
expect(gains.map((g) => g.value)).toEqual([1, 1, 0.75, expect.closeTo(0.5), 0.25]);
expect(gains.map((g) => g.value)).toEqual([1, 1, 0.75, expect.closeTo(0.5), 0.25, 1, 1]);
expect(sources.map((s) => s.started)).toEqual([true]);
expect(library.stats()).toMatchObject({ playing: 1, started: 1, last: { pitches: [12, 0], stages: 2 } });
@@ -996,10 +1007,10 @@ describe('the sound library', () => {
'gain > delay', // at the feedback
'gain > gain', // the fade into the echo
'gain > gain',
'gain > destination',
...INTO_MASTER,
]);
// The voice, the fade made before it, the echo's in, out, feedback and mix.
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5]);
// The voice, the fade made before it, the echo's in, out, feedback and mix, then the master's two.
expect(gains.map((g) => g.value)).toEqual([1, 1, 1, 1, 0.4, 0.5, 1, 1]);
});
it('wires a ping-pong echo into the left only, crossing sides on every round', async () => {
@@ -1156,7 +1167,8 @@ describe('the sound library', () => {
library.tune(voice, { time: 0.5, dull: 1, wet: 0.2 }, 2);
expect(delays[0]!.delayTime.ramps).toEqual([['linear', 0.5, 2]]);
expect(filters[0]!.frequency.ramps).toEqual([['exponential', expect.closeTo(500), 2]]);
expect(gains.at(-1)!.ramps).toEqual([['linear', 0.2, 2]]);
// The mix of the echo, made before the master's two.
expect(gains.at(-3)!.ramps).toEqual([['linear', 0.2, 2]]);
expect(library.levels(FILE)).toEqual([[0.25, 0.4, 20000, 0.5]]);
});
});
@@ -1188,15 +1200,9 @@ describe('notes of an instrument (#331)', () => {
expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 2 });
expect(library.stats().last).toMatchObject({ file: 'instrument:i', layers: 2, duration: 1.5 });
expect(sources.map((source) => source.started)).toEqual([true, true]);
// Voice gain first, then a fade per way at the sound's volume.
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0.5]);
expect(wires).toEqual([
'source > gain',
'gain > gain',
'source > gain',
'gain > gain',
'gain > destination',
]);
// Voice gain first, then a fade per way at the sound's volume, then the master's two.
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0.5, 1, 1]);
expect(wires).toEqual(['source > gain', 'gain > gain', 'source > gain', 'gain > gain', ...INTO_MASTER]);
});
it('a release fades out the ways that end with the note and lets a one-shot play on', async () => {
@@ -1365,8 +1371,8 @@ describe('notes with an Envelope (#335)', () => {
expect(driver().started).toBe(true);
expect(driver().offset.ramps).toEqual([['linear', 1, 0.1]]);
expect(driver().offset.glides).toEqual([0.5]);
// Voice gain, the Sample's volume, and the Envelope's gain, which is all the curve.
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0]);
// Voice gain, the Sample's volume, and the Envelope's gain, which is all the curve; then the master's.
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0, 1, 1]);
expect(wires).toContain('clock > shaper');
expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([0, 1]);
});
@@ -1435,7 +1441,8 @@ describe('notes with an Envelope (#335)', () => {
const cut = await note(library, way(true));
library.stopVoice(cut);
expect(gains[3]!.ramps).toEqual([['linear', 0, 0.01]]);
// After the first note's three and the master's two.
expect(gains[5]!.ramps).toEqual([['linear', 0, 0.01]]);
expect(library.playing(cut)).toBe(false);
});
@@ -1638,7 +1645,9 @@ describe('clips and tracks of a song (#334)', () => {
expect(gains[1]!.glides).toEqual([0.5]);
expect(panners[0]!.glides).toEqual([-0.25]);
library.setBus('song:s:t', 0, 1);
expect(panners).toHaveLength(1);
// The track's and the master's it goes into (#325); no new one for the same track.
expect(panners).toHaveLength(2);
expect(wires).toContain('panner > gain');
expect([gains[1]!.glides, panners[0]!.glides]).toEqual([
[0.5, 0],
[-0.25, 1],
@@ -1711,14 +1720,19 @@ describe('the patterns and songs a game plays (#336)', () => {
sequence((note) => (struck.push(note), 0)),
'logic',
);
// The master of the project is made on the way, the one all of it is heard through (#325).
expect(library.busLevels()).toEqual([
{ name: `play:${id}`, gain: 0, pan: 0, into: null, fx: false, latency: 0 },
MASTER_LEVEL,
{ name: `play:${id}`, gain: 0, pan: 0, into: 'mix', fx: false, latency: 0 },
{ name: `play:${id}:song:s:t`, gain: 0.25, pan: -1, into: `play:${id}`, fx: false, latency: 0 },
]);
expect(wires).toEqual([
'gain > gain',
'gain > panner',
'gain > gain',
'gain > panner',
'panner > destination',
'panner > gain',
'gain > gain',
'gain > panner',
'panner > gain',
@@ -1733,7 +1747,7 @@ describe('the patterns and songs a game plays (#336)', () => {
library.stopSequence('song:', false, 0);
expect(library.playbacks()).toEqual([]);
clockTo();
expect(library.busLevels()).toEqual([]);
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual([
'gain',
'gain',
@@ -1755,7 +1769,7 @@ describe('the patterns and songs a game plays (#336)', () => {
expect(() => library.playbacks()).not.toThrow();
expect(library.playbacks()).toEqual([]);
clockTo();
expect(library.busLevels()).toEqual([]);
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
});
it('a playing without notes or clips is none', () => {
@@ -1928,7 +1942,7 @@ describe('a sound FX on a bus (#346)', () => {
clockTo();
expect(shifter.posted).toEqual(['stop']);
expect(shifter.disconnected).toBe(true);
expect(library.busLevels()).toEqual([]);
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
});
it('makes the chain of a track in the Song tab once, and again only for another sound FX', async () => {
@@ -1946,7 +1960,8 @@ describe('a sound FX on a bus (#346)', () => {
expect(library.busLatency('song:s:t')).toBeCloseTo(LATE, 12);
await library.setBuses([{ ...track, fx: undefined }]);
expect(library.busLevels()).toEqual([
{ name: 'song:s:t', gain: 1, pan: 0, into: null, fx: false, latency: 0 },
MASTER_LEVEL,
{ name: 'song:s:t', gain: 1, pan: 0, into: 'mix', fx: false, latency: 0 },
]);
});
@@ -1956,10 +1971,98 @@ describe('a sound FX on a bus (#346)', () => {
library.releaseBuses(['song:s:t']);
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
clockTo();
expect(library.busLevels().map((bus) => bus.name)).toEqual(['song:s:t']);
expect(library.busLevels().map((bus) => bus.name)).toEqual(['mix', 'song:s:t']);
library.releaseBuses(['song:s:t']);
clockTo();
expect(library.busLevels()).toEqual([]);
expect(library.busLevels()).toEqual([MASTER_LEVEL]);
expect(worklets[0]!.posted).toEqual(['stop']);
});
});
describe('the master and the buses of the project (#325)', () => {
const bus = (name: string, volume: number, into: string | null, fx: FxVoice | null = null): MixBus => ({
name,
volume,
into,
fx,
});
const mix = [bus('mix', 0.5, null), bus('mix:a', 1, 'mix'), bus('mix:b', 0.25, 'mix:a')];
const levels = (library: SoundLibrary) =>
library.busLevels().map(({ name, gain, into }) => ({ name, gain, into }));
it('makes nothing before there is a context, then the master and every bus into the one it names', async () => {
const library = new SoundLibrary();
await library.setMix(mix);
expect(wires).toEqual([]);
await play(library);
expect(levels(library)).toEqual([
{ name: 'mix', gain: 0.5, into: null },
{ name: 'mix:a', gain: 1, into: 'mix' },
{ name: 'mix:b', gain: 0.25, into: 'mix:a' },
]);
expect(wires.filter((wire) => wire.endsWith('> destination'))).toEqual(['panner > destination']);
});
it('plays a voice on the bus it is given, and a playing too', async () => {
const library = new SoundLibrary();
await library.setMix(mix);
library.play(FILE, 1, false, '', DRY_VOICE, 'mix:b');
await settle();
await arrive();
expect(library.stats().last?.bus).toBe('mix:b');
const id = library.playSequence(
'pattern:p',
{
sequence: { notes: [{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60 }] },
tempo: 120,
length: 1920,
loop: null,
needs: { layers: [], files: [] },
bus: 'mix:a',
strike: () => 0,
},
'',
);
expect(library.busLevels().find((level) => level.name === `play:${id}`)?.into).toBe('mix:a');
});
it('sends a bus on into another one when the mix says so, and lets one go whose asset is gone', async () => {
const library = new SoundLibrary();
await library.setMix(mix);
await play(library);
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, into: 'mix' }]);
expect(levels(library).at(-1)).toEqual({ name: 'mix:b', gain: 0.25, into: 'mix' });
await library.setMix([mix[0]!, mix[1]!]);
clockTo();
expect(levels(library).map((level) => level.name)).toEqual(['mix', 'mix:a']);
});
it('holds the volume a game sets over that of the asset until it is reset, gliding there over its time', async () => {
const library = new SoundLibrary();
await library.setMix(mix);
await play(library);
library.setMixVolume('mix:b', 0.8, 1.5);
expect(gains.some((gain) => gain.ramps.some((ramp) => ramp[0] === 'linear' && ramp[1] === 0.8))).toBe(
true,
);
expect(library.mixVolume('mix:b')).toBe(0.8);
await library.setMix([mix[0]!, mix[1]!, { ...mix[2]!, volume: 0.1 }]);
expect([library.mixVolume('mix:b'), levels(library).at(-1)!.gain]).toEqual([0.8, 0.8]);
library.resetMix();
expect([library.mixVolume('mix:b'), levels(library).at(-1)!.gain]).toEqual([0.1, 0.1]);
expect(library.mixVolume('mix')).toBe(0.5);
});
it('changes the sound FX of a bus of the mix, and says so only for one that is there', async () => {
const library = new SoundLibrary();
const plan: FxPlan = {
pitch: [],
stages: [{ kind: 'crush', bits: { value: 8, param: 'b' }, rate: { value: 8000 }, mix: { value: 1 } }],
};
await play(library);
await library.setMix([bus('mix', 1, null, { plan, params: {}, tempo: 120 })]);
expect(library.tuneMix('mix', { b: 3 }, 0.5)).toBe(true);
expect(worklets[0]!.parameters.get('bits')!.ramps).toEqual([['linear', 3, 0.5]]);
expect(library.tuneMix('mix:gone', { b: 3 })).toBe(false);
});
});
+80
View File
@@ -1724,6 +1724,86 @@ export async function sound(t) {
);
await shot('34-sound-tab');
// 34h-3. Buses and the master (#325). "+ → Bus" makes one into the master; its Into never offers
// itself nor a bus that goes into it; the Sound tab plays a sound on its bus, and the
// master's volume is a project setting, one undo step.
for (let i = 0; i < 2; i++) {
await clickText('+');
await pickAssetType(t, 'Bus');
await sleep(150);
}
const [busA, busB] = await assetsOfType('bus');
const intoMenu = async () => {
await select({ kind: 'asset', assetId: busA.id });
const at = await json("__gm.inspectorField('Into', true)");
await click(at.x, at.y);
const offered = await json('__gm.overlayTexts()');
await press('Escape');
return offered;
};
const offeredBefore = await intoMenu();
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busB.id)}).props.into = ${JSON.stringify(busA.id)};`);
const offeredAfter = await intoMenu();
check(
'"+ → Bus" makes buses into the master, and Into offers neither the bus itself nor one going into it',
[busA, busB].every((bus) => bus.props.volume === 1 && bus.props.fx === '' && bus.props.into === '') &&
JSON.stringify(offeredBefore) === JSON.stringify(['Search…', 'Master', `${busB.name} (bus)`]) &&
JSON.stringify(offeredAfter) === JSON.stringify(['Search…', 'Master']),
JSON.stringify({ busA, busB, offeredBefore, offeredAfter }),
);
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(busA.id)}).props.volume = 0.5;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = ${JSON.stringify(busB.id)};`);
await evaluate(`__gm.state.openAsset(${JSON.stringify(droneId)})`);
const startedBefore = (await stats()).started;
await click(tabPlay.x, tabPlay.y);
await waitFor(stats, (s) => s.started > startedBefore, 10);
const buses = await json('__gm.sound.buses()');
const heardOn = (await stats()).last?.bus;
const levelOf = (name) => buses.find((bus) => bus.name === name);
check(
'the Sound tab plays the sound on its bus, into the bus that one names, on into the master',
heardOn === `mix:${busB.id}` &&
levelOf(`mix:${busB.id}`)?.into === `mix:${busA.id}` &&
levelOf(`mix:${busA.id}`)?.into === 'mix' &&
levelOf(`mix:${busA.id}`)?.gain === 0.5 &&
levelOf('mix')?.into === null,
JSON.stringify({ heardOn, buses }),
);
await t.clickToolbar('menu');
await clickText('Project settings…');
const masterAt = await json('__gm.projectSettingsMasterAt()');
await click(masterAt.volume.x, masterAt.volume.y);
await press('a', { ctrl: true });
await t.typeText('0.6');
await press('Enter');
await clickText('Apply');
await sleep(200);
const mastered = {
project: await json('__gm.state.project.masterVolume'),
heard: await json("__gm.sound.mixVolume('mix')"),
};
await t.clickToolbar('undo');
await sleep(200);
const undone = {
project: await json('__gm.state.project.masterVolume'),
heard: await json("__gm.sound.mixVolume('mix')"),
};
check(
'the master volume is a project setting the mix follows, and undo gives the old one back',
mastered.project === 0.6 && mastered.heard === 0.6 && undone.project === 1 && undone.heard === 1,
JSON.stringify({ masterAt, mastered, undone }),
);
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === ${JSON.stringify(droneId)}).props.bus = '';
p.assets = p.assets.filter((a) => a.type !== 'bus');`);
// 34i. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set(['sn-logic', ...${JSON.stringify(both.map((a) => a.id))}]);