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. That is what lets a player turn one character down without touching 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 says nothing about buses behaves exactly as it did before.
Declaring the tree
The tree is declared once, on GameConfig, and 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 it: those three ids appear in content written before buses existed and in every save ever written.
What is rejected — loudly, at boot, as an AudioBusError — is an unknown parent, a duplicate id, a
cycle of any length, or a chain nested deeper than eight buses.
The shape of the tree is read once. Re-parenting a live bus would mean removing a channel,
which stops every sound in its subtree, so a configure() after the player has mounted does not
re-shape the graph. Volumes, on the other hand, 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 instead: the manager warns once for that id and routes the clip to the
sound bus rather than going silent.
Two numbers, and neither overwrites the other
This is the part worth reading twice. 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 actually reaches the gain node is the product of the two — getEffectiveVolume(). There is
still exactly one gain node per bus, because two gain stages in series compute the same thing one
multiplication does.
So 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 intent, not full gainA player who has changed nothing hears the mix the author built. A player who pushes the slider to maximum gets the author's mix back rather than a bus at full gain — "maximum" means "no further attenuation from me", not "ignore the mix".
Why the split matters to a host
Because a host persists getVolumes() — the player's half only — the author can re-mix a
shipped title and the new mix still reaches players who already have settings saved. Persisting the
effective volume instead would pin every returning player to the old mix forever, and there would be
no way to tell a deliberate player choice apart from a value that merely happened to be the shipping
default.
// 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
The whole point of the tree, end to end:
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 — a bus is a gain node the clip is routed through, so nothing is stopped, searched for, or restarted. 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. Deliberately 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 actually on the bus's gain node: getDeclaredVolume(id) * getVolume(id).
id: string- The bus id- Returns
number
setVolumes
Set many player volumes at once — 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
Just 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 are untouched and
keep working. bgmVolume, soundVolume and voiceVolume are aliases onto the three seeded buses
and write the player's half; globalVolume is the master output.
So getPreference("soundVolume") still reads 1 at boot even in a game that declared
{id: "sound", volume: 0.6}, and still means "no further attenuation" — reading a declared mix back
out of a preference would have made the preference mean two different things depending on whether
the host declared that bus.
Drive the seeded three through the preferences; use game.audioBuses for buses the host declared.