技术博客

录音与麦克风输入:AudioEffectCapture 的回采

第 131 / 149 篇 · 2026-05-14 · godot · 音频系统麦克风实战

游戏侧拿到麦克风数据有三条路:Web 平台走浏览器的 getUserMedia 桥,移动端用原生插件,剩下的桌面和通用场景都走引擎内置的 AudioEffectCapture。它不是 Input 那样的"事件"接口,而是一个挂在音频总线上的效果器——理解了这一点,它的行为(缓冲、丢帧、线程边界)就全是必然推论。

为什么录音是个"效果器"

看 AudioStreamPlayer3D 的衰减实现(八-3)就能猜到 Godot 音频架构的思路:一切都要进混音器才有意义。麦克风数据进来后走 capture → 混音的通路,AudioEffectCapture 的设计就是在这条通路上加一个"分流点":音频数据经过它时,照常往后送(它是 pass-through 的,不改变声音),同时往自己的环形缓冲里拷一份。游戏线程从缓冲里 get_buffer() 把数据捞走。

这个设计的好处是录音和监听天然同步——耳机里听到的和脚本拿到的是同一份时钟域的数据,做"跟着唱"的音游判定(八-6 的时间戳体系直接复用)不会有音画错位。代价是你必须先理解总线:capture 挂在哪条 bus 上,就收哪条 bus 的混合结果,麦克风输入本身要先被一条播放链送进来才能被截获。

开通两道工序:项目设置里开 audio/driver/enable_input(4.x 的 ProjectSettings 里叫 input/enable_audio_input),代码里把 AudioServer 的 input 链激活:

func _ready():
    AudioServer.input_device = AudioServer.get_input_device_list()[0]
    var bus := AudioServer.get_bus_index("Record")
    var capture := AudioEffectCapture.new()
    AudioServer.add_bus_effect(bus, capture)
    AudioServer.set_bus_effect_enabled(bus, 0, true)
    capture.set_buffer_length(2.0)   # 秒,默认 0.5

然后需要一个 AudioStreamPlayer 把 input 播放进这条 bus:stream = AudioStreamMicrophone.new(),play()。这一步常被漏掉——没有播放链,capture 的缓冲永远是空的。

环形缓冲:容量公式与丢帧

AudioEffectCapture 内部是一段固定容量的 AudioFrame 环形缓冲,容量由 buffer_length(秒)× 当前 mix rate 决定。音频线程每次混音写入 frames,游戏线程 get_buffer(frames) 读走。写满了没人读,新来的帧直接丢弃——没有背压、没有阻塞,音频线程不可能为脚本让路(它的时延预算是死的:一个 mix buffer 周期内必须返回,否则全系统爆音)。

所以容量必须盖住"游戏线程两次读之间的最坏间隔"。每帧读一次、帧率 $F$、音频采样率 $f_s$,缓冲至少要

$$N \geq \frac{f_s \times T_{\text{worst}}}{1} + \text{margin}$$

$T_{\text{worst}}$ 是两次 _process 的最长间隔——不是平均帧时长,是 spike 帧。60fps 游戏 spike 到 200ms 不稀奇,48kHz 下这就是 9600 帧;默认 0.5 秒缓冲只有 24000 帧,看着富余,但如果你 await 等了一个动画或者切场景时 _process 停了 2 秒,必丢。丢多少?get_frames_available() 帮你算:读出速率跟不上写入速率时这个值会顶死在容量上限——出现持续顶死就是丢帧现场,把 buffer_length 加大或者读得更勤。

func _process(_dt):
    if capture.can_get_buffer(512):
        var frames := capture.get_buffer(512)   # PackedVector2Array
        for f in frames:
            var mono := (f.x + f.y) * 0.5
            rms += mono * mono
        rms = sqrt(rms / 512)

get_buffer 一次取多少是个权衡:取大了单次持锁久(八-10 讲锁),取小了调用频繁。512 帧(48kHz 下 10.7ms)是和混音周期对齐的安全粒度。

拿到数据之后:电平、 onset 与 FFT

最常见的需求无非三类。一是电平/RMS:如上,把 512 帧平方平均开根号,得到当前响度,做个会跟着声音跳动的 UI 绰绰有余。二是** onset 检测(音游收声):对相邻两个窗口的 log 能量做差,超过自适应阈值判为敲击——注意 onset 的实际发生时刻要往回推一个窗口,因为检测有 10.7ms 的算法延迟,判定显示要做时间补偿。三是频谱**:GDScript 里没有现成 FFT,要么自己写个 radix-2(256 点足够做频谱玩具),要么只取低频段做几个 bandpass 能量近似——对"麦克风音量驱动粒子"这类玩法,bands 比全谱实用。

AudioEffectCapture:音频线程写入,游戏线程读取 麦克风硬件 OS 采集线程 AudioStreamMicrophone 经 Record bus 播放 AudioEffectCapture pass-through + 拷贝 监听输出 数据照常流走 环形缓冲buffer_length × mix_rate 帧 待读 空 音频线程每混音周期写入 游戏线程(_process,每帧) can_get_buffer(512) 守卫 → get_buffer(512) RMS / onset / bandpass 分析,别在此做重活 丢帧判据:get_frames_available() 持续顶在容量上限 = 读取跟不上 容量预算 N ≥ f_s × 两次读取的最坏间隔,spike 按 200ms 估

两个易错点

第一,get_buffer() 返回的是 PackedVector2Array,x/y 即左右声道浮点采样——直接用索引访问做运算没问题,但别在热循环里对它做 append/resize 这类写操作,触发 COW 复制得不偿失。第二,录音权限在导出后才真正考验配置:Android 需要 RECORD_AUDIO 权限声明,macOS 要 entitlements 里的麦克风用途字符串,Web 导出是浏览器弹权限框——这些都通过导出的平台设置配,和代码无关,但缺了就是收不到数据且没有报错。

本文基于 Godot 4.2–4.4;AudioEffectCapture 在 3.4 引入,4.x 接口未变。