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, 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"},
    ],
});
  • 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 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 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 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 gain

A 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 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. 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.

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.

On this page