Audio Buses
An audio bus is a gain node every sound routed to it passes through, and buses nest. A clip on
alice under cast under voice is attenuated by alice, then by cast, then by voice, then
by the master volume, so a player can turn one character down without changing the rest of the
cast.
Added in 0.23.0. Every game has the three buses bgm, sound and voice whether or not it
declares anything, so a game that declares no buses behaves as it did before.
Declaring the tree
Declare the tree once, on GameConfig. The engine realizes it into the audio graph when the audio subsystem starts.
import { Game } from "narraleaf-react";
const game = new Game({
audioBuses: [
{id: "ambience", parentId: "bgm", volume: 0.6},
{id: "cast", parentId: "voice"},
{id: "alice", parentId: "cast"},
{id: "bob", parentId: "cast"},
],
});idmust be unique across the whole tree. Buses are addressed by id alone, so two buses may not share one even under different parents.parentIdomitted (ornull) hangs the bus directly off the master output.- Declaration order does not matter. A bus may name a parent declared after it.
- Naming one of
bgm,soundorvoicehere moves it or changes its volume. Nothing can remove those three ids: they appear in content written before buses existed and in every save ever written.
An unknown parent, a duplicate id, a cycle of any length, or a chain nested deeper than eight buses
throws an AudioBusError at boot.
The shape of the tree is read once. A configure() after the player has mounted does not
re-shape the graph, because re-parenting a live bus stops every sound in its subtree. Volumes
are live at all times.
Putting a clip on a bus
A Sound's type is the bus it plays on. It takes any
declared id, not only the three seeded ones. The type is
SoundBusId.
Sound.voice({src: "alice-01.mp3", type: "alice"});
Sound.bgm({src: "rain.ogg", type: "ambience"});Sound.voice(), Sound.bgm() and Sound.sound() default type rather than overwriting it, so
the type above is the one that takes effect.
A voice clip may sit anywhere under voice, and a scene's background music anywhere under bgm.
Those checks are descendant checks, so alice under cast under voice is a voice.
A bus id the engine has not been told about is accepted while the story is being built, because
a story module is usually evaluated before the host constructs its Game. A misspelled bus is
caught at play time: the manager warns once for that id and routes the clip to the sound bus
rather than going silent.
Declared volume and player volume
Every bus carries two volumes.
| Where it comes from | What it means | Persist it? | |
|---|---|---|---|
AudioBusDeclaration.volume | GameConfig.audioBuses | The author's mix: where this bus sits relative to the others in the game as shipped | No. It is game content and comes back with the game |
mixer.setVolume / getVolume | The player, at runtime | The player's control. Starts at 1, meaning "leave the author's mix alone" | Yes. This is the only half the player owns |
What reaches the gain node is the product of the two, which is getEffectiveVolume(). There is
one gain node per bus.
For a game that declares {id: "sound", volume: 0.6}:
game.audioBuses.getDeclaredVolume("sound"); // 0.6 - the author's mix
game.audioBuses.getVolume("sound"); // 1 - the player has touched nothing
game.audioBuses.getEffectiveVolume("sound"); // 0.6 - what is on the gain node
game.audioBuses.setVolume("sound", 1); // the player drags the slider to maximum
game.audioBuses.getEffectiveVolume("sound"); // 0.6 - still the author's mix, not full gainA player who has changed nothing hears the mix the author built. A slider at maximum means "no further attenuation", not "ignore the mix".
Persisting the player's volumes
Persist getVolumes(), the player's half only. The author can then re-mix a shipped title and
the new mix reaches players who already have settings saved.
// save the player's half
localStorage.setItem("mixer", JSON.stringify(game.audioBuses.getVolumes()));
// restore it - any time after `new Game(...)`
game.audioBuses.setVolumes(JSON.parse(localStorage.getItem("mixer") ?? "{}"));The mixer lives on Game, not on the audio manager, because a bus volume is a player setting rather than game state. Restoring is safe before the audio context has unlocked and before the player has mounted. Ids the tree does not contain yet are recorded and applied the moment the channels exist.
Per-character voice volume
import { Game, Sound } from "narraleaf-react";
const game = new Game({
audioBuses: [
{id: "cast", parentId: "voice"},
{id: "alice", parentId: "cast"},
{id: "bob", parentId: "cast", volume: 0.8}, // Bob was recorded hot
],
});
room.action([
alice.say("Good morning.", {
voice: Sound.voice({src: "/voice/alice/001.ogg", type: "alice"}),
}),
]);import { useState } from "react";
import { useGame } from "narraleaf-react";
function CastVolume({busId}: {busId: string}) {
const game = useGame();
const [volume, setVolume] = useState(() => game.audioBuses.getVolume(busId));
return (
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(event) => {
const next = Number(event.target.value);
setVolume(next);
game.audioBuses.setVolume(busId, next);
}}
/>
);
}Changing a bus applies to sounds that are already playing. Nothing is stopped or restarted, and the change is ramped over a few milliseconds so a dragged slider does not zipper.
game.audioBuses
The mixer, an AudioBusMixer.
setVolume
Set the player's volume for a bus. This is what a slider writes.
game.audioBuses.setVolume("alice", 0.5);id: string- The bus idvolume: number- 0 to 1, clamped- Returns
AudioBusMixer- the mixer itself
getVolume
The player's volume for a bus: what was last set, else 1. Not the declared volume and not
what is on the gain node.
id: string- The bus id- Returns
number
getDeclaredVolume
The author's mix position for a bus, from the declaration. Never written at runtime.
id: string- The bus id- Returns
number
getEffectiveVolume
What is on the bus's gain node: getDeclaredVolume(id) * getVolume(id).
id: string- The bus id- Returns
number
setVolumes
Set many player volumes at once, which is what a host calls when restoring its saved mixer state. Ids the tree does not contain are recorded anyway, so restoring before the tree is resolved is safe.
volumes: Record<string, number>- Returns
AudioBusMixer
getVolumes
The player's volumes, keyed by bus id: the half a host persists, and the shape setVolumes takes
back.
- Returns
Record<string, number>
list
Every bus with both of its numbers, parents before their children.
- Returns
AudioBusState[]- See AudioBusState
getTree
The resolved tree, resolving it on first use and caching it afterwards. Throws AudioBusError if
the declaration cannot be resolved.
const tree = game.audioBuses.getTree();
tree.getNodes(); // every bus, parents first
tree.get("alice"); // the node, or null
tree.has("alice"); // boolean
tree.isUnder("alice", "voice"); // true - inclusive at the top- Returns
AudioBusTree
onVolumeChange
Listen for a player volume change on any bus.
const token = game.audioBuses.onVolumeChange((id, volume, effectiveVolume) => {
console.log(id, volume, effectiveVolume);
});
token.cancel();listener: (id: string, volume: number, effectiveVolume: number) => void- Returns a token with
cancel()
Relationship with the volume preferences
The volume preferences keep working.
bgmVolume, soundVolume and voiceVolume are aliases onto the three seeded buses and write the
player's half. globalVolume is the master output.
getPreference("soundVolume") reads 1 at boot even in a game that declared
{id: "sound", volume: 0.6}, and means "no further attenuation".
Drive the seeded three through the preferences, and use game.audioBuses for buses the host
declared.