NarraLeaf

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"},
    ],
});
  • id must be unique across the whole tree — buses are addressed by id alone, so two buses may not share one even under different parents.
  • parentId omitted (or null) 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, sound or voice here 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 fromWhat it meansPersist it?
AudioBusDeclaration.volumeGameConfig.audioBusesThe author's mix — where this bus sits relative to the others in the game as shippedNo. It is game content and comes back with the game
mixer.setVolume / getVolumeThe player, at runtimeThe 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 gain

A 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 id
  • volume: 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.

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.

On this page