NarraLeaf

音频总线

音频总线树的配置、作者与玩家两级音量,以及 AudioBusMixer 接口

音频总线是一个增益节点,路由到它的每个音频都会经过它,并且总线可以嵌套。位于 voicecastalice 的一段音频依次被 alicecastvoice 衰减,最后再经过主音量,因此玩家可以只调低某 一个角色,而不影响其他角色

0.23.0 起可用。无论是否声明,每个游戏都有 bgmsoundvoice 三条总线,因此不声明任何 总线的游戏行为与以前一致

声明总线树

总线树在 GameConfig 上声明一次,引擎在音频子系统 启动时把它落实到音频图上

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 在整棵树中必须唯一。总线只按 id 寻址,因此即使父节点不同,两条总线也不能共用一个 id
  • 省略 parentId(或设为 null)会把总线直接挂在主输出下
  • 声明顺序无关紧要。总线可以指向在它之后声明的父节点
  • 在这里写 bgmsoundvoice移动它或改变它的音量。这三个 id 无法被移除:它们出现在 总线出现之前写下的内容里,也出现在此前写下的每一个存档里

未知的父节点、重复的 id、任意长度的环,或者嵌套超过八层的链,都会在启动时抛出 AudioBusError

树的形状只读取一次。 播放器挂载之后调用 configure() 不会重塑音频图,因为给活动总线换父节点 会中断它整个子树里的所有声音。音量则任何时候都是实时生效的

把音频放到总线上

Soundtype 就是它播放所在的总线。它接受任何已 声明的 id,不限于三条内置总线,类型是 SoundBusId

Sound.voice({src: "alice-01.mp3", type: "alice"});
Sound.bgm({src: "rain.ogg", type: "ambience"});

Sound.voice()Sound.bgm()Sound.sound() 只为 type 提供默认值而不是覆盖它,因此上面写 的 type 才是生效的那个

语音可以位于 voice 下的任意位置,场景的背景音乐可以位于 bgm 下的任意位置。这些判断是后代判断, 因此 voicecast 下的 alice 属于语音

构建故事时,引擎尚未知晓的总线 id 会被接受,因为故事模块通常在宿主构造 Game 之前就已求值。拼错 的总线在播放时才会被发现:管理器为该 id 告警一次,并把这段音频路由到 sound 总线,而不是静音

声明音量与玩家音量

每条总线都带两个音量

来自哪里含义要持久化吗?
AudioBusDeclaration.volumeGameConfig.audioBuses作者的混音:这条总线在成品游戏中相对于其他总线所处的位置不。它是游戏内容,会随游戏一起回来
mixer.setVolume / getVolume玩家,在运行时玩家的控制。初始为 1,意思是「别动作者的混音」要。 这是唯一属于玩家的那一半

到达增益节点的是两者的乘积,也就是 getEffectiveVolume()。每条总线只有一个增益节点

对于声明了 {id: "sound", volume: 0.6} 的游戏:

game.audioBuses.getDeclaredVolume("sound");  // 0.6 - 作者的混音
game.audioBuses.getVolume("sound");          // 1   - 玩家什么都没动
game.audioBuses.getEffectiveVolume("sound"); // 0.6 - 增益节点上的值

game.audioBuses.setVolume("sound", 1);       // 玩家把滑块拉到最大
game.audioBuses.getEffectiveVolume("sound"); // 0.6 - 仍是作者的混音,而不是满增益

什么都没改过的玩家听到的是作者做好的混音。滑块拉到最大表示「我不再额外衰减」,而不是「忽略混音」

持久化玩家音量

持久化 getVolumes(),也就是玩家的那一半。这样作者重新混音已发布的作品后,新的混音仍能到达已经 保存过设置的玩家

// 保存玩家的那一半
localStorage.setItem("mixer", JSON.stringify(game.audioBuses.getVolumes()));

// 恢复它 - 在 `new Game(...)` 之后的任何时候
game.audioBuses.setVolumes(JSON.parse(localStorage.getItem("mixer") ?? "{}"));

混音器位于 Game 上而不是音频管理器上,因为总线音量是玩家 设置而非游戏状态。在音频上下文解锁之前、播放器挂载之前恢复都是安全的。树中尚不存在的 id 会被记录下来, 并在对应通道出现的那一刻应用

按角色分别控制语音音量

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 录得偏大
    ],
});

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);
            }}
        />
    );
}

改变总线音量会作用于正在播放的声音。没有任何东西被停止或重新播放,变化会在几毫秒内渐变完成,因此 拖动滑块不会产生爆音

game.audioBuses

混音器,类型为 AudioBusMixer

setVolume

设置某条总线的玩家音量。滑块写入的就是它

game.audioBuses.setVolume("alice", 0.5);
  • id: string - 总线 id
  • volume: number - 0 到 1,会被钳制
  • 返回 AudioBusMixer - 混音器本身

getVolume

某条总线的玩家音量:上一次设置的值,未设置过则为 1不是声明音量,也不是增益节点上的值

  • id: string - 总线 id
  • 返回 number

getDeclaredVolume

某条总线在作者混音中的位置,来自声明。运行时永不写入

  • id: string - 总线 id
  • 返回 number

getEffectiveVolume

总线增益节点上的值:getDeclaredVolume(id) * getVolume(id)

  • id: string - 总线 id
  • 返回 number

setVolumes

一次设置多条总线的玩家音量,宿主恢复已保存的混音器状态时调用它。树中不存在的 id 也会被记录,因此在树 解析之前恢复是安全的

  • volumes: Record<string, number>
  • 返回 AudioBusMixer

getVolumes

按总线 id 索引的玩家音量:宿主要持久化的那一半,也是 setVolumes 接受的形状

  • 返回 Record<string, number>

list

每条总线及其两个音量,父节点排在子节点之前

getTree

已解析的总线树,首次调用时解析并缓存。声明无法解析时抛出 AudioBusError

const tree = game.audioBuses.getTree();

tree.getNodes();              // 所有总线,父节点在前
tree.get("alice");            // 节点,或 null
tree.has("alice");            // boolean
tree.isUnder("alice", "voice"); // true - 顶端包含自身
  • 返回 AudioBusTree

onVolumeChange

监听任意总线上的玩家音量变化

const token = game.audioBuses.onVolumeChange((id, volume, effectiveVolume) => {
    console.log(id, volume, effectiveVolume);
});

token.cancel();
  • listener: (id: string, volume: number, effectiveVolume: number) => void
  • 返回带 cancel() 的 token

与音量偏好设置的关系

音量偏好设置照常工作。bgmVolumesoundVolumevoiceVolume 是三条内置总线的别名,写入的是玩家的那一半;globalVolume 是主输出

即使游戏声明了 {id: "sound", volume: 0.6}getPreference("soundVolume") 在启动时读到的仍是 1,含义是「我不再额外衰减」

三条内置总线通过偏好设置驱动,宿主自行声明的总线用 game.audioBuses

本页目录