☰
AIRI 音频适配层指南:`@proj-airi/audio` 的 PCM 采集、WAV 编码与 Web Audio 播放实战
2026/10/17 2:08:14 网站建设 项目流程
  • 人工智能
  • AI Agent
  • MCP 服务
  • 工具调用
  • GUI 自动化
  • 语音
  • 数字人
  • 交互助手

【免费下载链接】airi

💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。

项目地址:https://gitcode.com/moeru-ai/airi
点击查看免费下载

@proj-airi/audio是 Moeru AI AIRI 项目(自托管 AI 伴侣与赛博生命灵魂容器)中负责浏览器音频资源与格式转换的基础包。它不关心对话轮次、角色路由或 Agent 通知,只解决三件事:把麦克风、借用 MediaStream 或音频文件变成 PCM 流;把 PCM 编码为 WAV 或提供商输入格式;把文件或流式 PCM 通过 Web Audio 播出来。读完本文,你将掌握 AIRI 音频管线的完整工作流——从 getUserMedia 到转录提供商,从 TTS 音频入队到基于音频时钟的静音回执,以及对应的测试与类型检查方法。

包职责边界:谁拥有音频资源,谁拥有编排

AIRI 将音频拆成两层,职责划分非常清晰:

  • @proj-airi/audio(本包)拥有浏览器音频资源和格式转换:麦克风/MediaStream/文件的 PCM 源、WAV 编码、Web Audio 播放、toMediaStream轨道桥接。相关实现位于 packages/audio/src/browser 与 packages/audio/src/encoding。
  • @proj-airi/pipelines-audio拥有采集区间(capture intervals)、观察窗口(observation windows)和播放组(playback groups),它不依赖任何 UI,也不接触浏览器音频 API。相关实现位于 packages/pipelines-audio/src。

一个容易混淆的点是:本包导出的入口只有@proj-airi/audio/encoding(见 packages/audio/src/index.ts 的export * from './encoding'),而浏览器能力统一从@proj-airi/audio/browser子路径导入,这在 packages/audio/package.json 的exports字段中有明确声明:./browser、.、./encoding三个子路径各自指向编译产物。音频编排能力(AudioInput、capture、Playback)则从@proj-airi/pipelines-audio导入。

用它做什么

根据 README 的定位,本包用于:

  • 打开麦克风、借用的MediaStream或音频文件,作为 PCM 源;
  • 把 PCM 编码为 WAV,或转换成提供商输入格式;
  • 通过 Web Audio 播放文件或流式 PCM;
  • 在上报静音(silence)之前,对自有播放节点做淡出处理。

明确不做什么

同样重要的边界约束(README 的 "Do not use it for" 一节):

  • 不做角色路由、聊天持久化、Agent 通知;
  • 不做 VAD 策略、唤醒词匹配、记忆检索、转录改写;
  • 不做 Vue 状态或跨窗口命令。

这些职责分属 core-agent、stage-ui 等其他模块,音频包保持纯粹的可复用性。

音频源(AudioSource):三种来源,一个接口

一个音频源就是一个AudioSource。调用它的open(signal)会返回一个 PCM 流;中止signal会释放该连接占用的资源。接口定义在 packages/pipelines-audio/src/audio-input.ts:

export interface AudioSource { open: (signal: AbortSignal) => ReadableStream<PcmBlock> }

PcmBlock是贯穿全链路的核心数据结构:它包含半开区间range(sourceId+startFrame/endFrame)、sampleRate与按平面(planar)排列的channels,且约定消费者不得修改共享的源采样(见 packages/pipelines-audio/src/audio-input.ts)。sourceId让帧坐标始终归属某一次源连接,设备切换时不会跨连接混用坐标。

三种内置源的对比(README 原表,附实现要点):

源模块是否实时(Live)轨道/数据的属主
microphoneSource(constraints, options)@proj-airi/audio/browser是源自身。停止轨道并关闭它自己的 AudioContext
mediaStreamSource(stream, context)@proj-airi/audio/browser是调用方。源只断开它自己的节点
fileSource(blob)@proj-airi/audio/encoding否调用方。每次连接由 Mediabunny 重新解码文件

麦克风源:同步 getUserMedia 保住用户手势

microphoneSource的实现(packages/audio/src/browser/sources.ts)有一个容易被忽视的关键点:open会同步调用getUserMedia并resume()自己专属的AudioContext,因此可以放在 click handler 里以保留用户手势。它对外暴露stream与echoCancellation两个只读属性,供回声策略和设备标签查询。连接关闭时依次停止所有轨道、关闭该源的 AudioContext。

借用 MediaStream:只动自己的节点

mediaStreamSource(packages/audio/src/browser/sources.ts)把调用方传入的流接入一个AudioWorkletNode('airi-capture')。它只读取采样(图中用一个值为 0 的GainNode静音),清理时只断开自己的节点,绝不停止调用方拥有的轨道。如果流里没有 live 的音频轨道,open会直接抛错。

采集内核:AudioWorklet 在渲染线程外复制采样

airi-capture处理器定义在 packages/audio/src/browser/capture.worklet.ts:在每个渲染量子(render quantum)里把输入平面slice()出来,连同startFrame与sampleRate一起通过postMessage转移出去(使用转移列表避免拷贝)。这样编解码和语音策略都运行在渲染线程之外,且每个AudioContext只会addModule一次(见sources.ts里的workletsWeakMap 缓存)。

文件源:每次连接重新解码

fileSource(packages/audio/src/encoding/media-file.ts 附近的实现)基于 Mediabunny 的BlobSource解码,每个消费者打开自己的流、拥有独立的帧坐标,天然保留背压——文件的读取者决定解码速度。README 给出的转录用法如下:

const events = transcriber.transcribe({ audio: fileSource(recording).open(signal), signal })

解码器在文件结束、解码失败、流被取消或signal中止时统一释放;因为 abort 可能发生在没有任何 read 挂起时,释放逻辑专门在start/cancel阶段注册监听而不能只依赖下一次 pull。

实时源不能等待读者:用 AudioInput 共享

实时源(麦克风)无法等待慢读者。要把一个实时源分享给多个消费者(例如同时喂给录音、转录和 VAD 检测器),需要把它包进AudioInput。每个消费者用各自的signal订阅,第一个订阅者打开源,最后一个离开的订阅者停止轨道;连接关闭后如果权限结果迟到返回,其轨道会被立即停止(该行为有专门测试覆盖,见 packages/audio/src/browser.browser.test.ts 中的 "stops a late permission result" 用例)。

录音 + WAV 编码的完整示例

README 给出的经典组合用法如下,capture来自@proj-airi/pipelines-audio:

import { microphoneSource } from '@proj-airi/audio/browser' import { encodeWav } from '@proj-airi/audio/encoding' import { AudioInput, capture } from '@proj-airi/pipelines-audio' const input = new AudioInput(microphoneSource({ audio: { echoCancellation: true } }), { historyMs: 360 }) // Start this in the click handler. getUserMedia starts before the first await. const recording = capture(input) const wav = encodeWav(recording.stream, { sampleRate: 16000, channels: 1 }) // Call finish when the control is released. Call cancel to discard the interval. await recording.finish() showPreview(await wav)

关键点:

  • capture(input)立即订阅,recording.stream是一个区间内的连续 PCM 流;
  • encodeWav(stream, { sampleRate, channels })边到达边编码为 16-bit WAV,sampleRate/channels由WavOptions声明(packages/audio/src/encoding/media-file.ts),提供商通常期望 16 kHz 单声道;
  • recording.finish()密封已接受的音频并关闭流,recording.cancel(reason)则丢弃该区间并对流报错;两者都只影响本次 capture,输入的其他订阅者不受影响;
  • capture还提供started: Promise<boolean>(首个块到达时 resolve true,无音频结束时 resolve false)与done: Promise<Outcome<AudioRange | undefined>>,Outcome取值为finished/cancelled/failed三态。

capture 的连续性与背压保护

从 packages/pipelines-audio/src/capture.ts 的pump可以看到:capture内部以from位置重放保留历史(让检测触发前就已开始的语音也能进入区间),并做两项强约束——区间内出现sourceId变化或帧号不连续就判为 "Audio source has a gap" 而失败;当stream未读音频超过maxBufferedMs(默认 60 秒)时该 capture 单独失败,而不是无限增长内存。这与AudioInput.subscribe的maxBufferedMs背压策略(packages/pipelines-audio/src/audio-input.ts)一脉相承:实时源不能等慢读者,慢的那个读者独自失败,源和其他读者继续。

桥接只认 MediaStream 的 API:toMediaStream

很多浏览器 API(如 Web Speech recognition)只接受MediaStream而不接受 PCM 流。此时用toMediaStream(frames, context, signal)(packages/audio/src/browser/media-stream.ts)把 PCM 灌进新建轨道:

const { media, done } = toMediaStream(pcmStream, audioContext, signal)
  • done在 PCM 流结束且音频实际播放完成后 resolve;中止signal立即停止输出;
  • 返回的轨道属于本次调用,settle 时会自动停止;
  • 若播放期间AudioContext被关闭,输出会失败并释放轨道——测试 "fails native output and releases its tracks when the audio context closes" 验证了这一点(packages/audio/src/browser.browser.test.ts)。

播放:Playback 组 + BrowserPlayback 驱动

播放层的分工同样是“编排 + 驱动”:

  • Playback(来自@proj-airi/pipelines-audio)管理独立队列组;
  • BrowserPlayback(来自@proj-airi/audio/browser)实现PlaybackDriver,用audioContext.currentTime作为播放时钟(packages/audio/src/browser/playback.ts)。

README 给出的用法:

import { BrowserPlayback } from '@proj-airi/audio/browser' import { Playback } from '@proj-airi/pipelines-audio' const playback = new Playback(new BrowserPlayback(audioContext)) const group = playback.openGroup('answer') void group.enqueue({ id: 'first', audio: audioBlob }) const receipt = await group.stop({ fadeMs: 100 })

三个值得强调的语义(README 原意 + 源码印证):

  1. stop 回执跟随音频时钟与源完成事件,不依赖 UI 定时器。AudioOutput(packages/audio/src/browser/audio-output.ts)按上下文时钟逐个背靠背调度AudioBufferSourceNode,onended事件驱动的渲染区间、以及淡出用的linearRampToValueAtTime(0, end)都锚定在context.currentTime上;浏览器挂起或关闭上下文时会走contextChanged路径让待决播放和静音回执失败,而不是报出假静音。
  2. startAtMs与回执interval都基于该时钟,单位毫秒。PlayedAudio.interval给出首末渲染采样的时钟时刻,调用方可以据此安排后续输出,例如“在上一条结尾 2 秒后开始下一条”。
  3. 调用方拥有audioContext,并在消费者结束之后关闭它。BrowserPlayback只借用人家的上下文,不负责关闭。

此外,播放组(OutputGroup,见 packages/pipelines-audio/src/playback.ts)还具备:finish()排空队列;stop({ fadeMs })关闭入场、丢弃排队中的 clip 并等待真实静音;单个 clip 的中止只取消它自己的音频,不牵连同组其余队列;组间互不混流、不互相调度声道,PlaybackReceipt汇总每个实际起播 clip 的throughMs与interval。

PCM 与 WAV:格式转换的底层细节

Float32 ↔ PCM16 互转

packages/audio/src/encoding/wav.ts 提供两个纯函数:

  • toPCM16FromFloat32(samples):把归一化 Float32 采样转成小端有符号 PCM16 字节,越界值会被 clamp 到 [-1, 1](负半轴乘0x8000,正半轴乘0x7FFF);
  • toFloat32FromPCM16(pcmBytes):反向转换,并要求输入必须包含完整的 16-bit 样本,否则抛TypeError。

对应的边界行为都有 Vitest 用例锁定(packages/audio/src/encoding/wav.test.ts):越界输入(如 ±2)会被钳制到 ±32768/±32767,非完整样本输入会被拒绝。

WAV 封装与流式编码

toWav/toWavFromPCM16手工构造标准 44 字节 RIFF/WAVE 头(fmt+data,16-bit PCM、单/双声道可选),并把 PCM16 数据填入;toWAVBase64则直接返回 base64。而面向实时录音的encodeWav走的是边到达边编码的流式路径:它用 Mediabunny 的AudioSampleSource对每个PcmBlock做重采样与混音(transform: { sampleRate, numberOfChannels }),平面数据先经过planar()展平为 interleaved Float32,最后产出Blob { type: 'audio/wav' }(见 packages/audio/src/encoding/media-file.ts 的encodeWav)。

面向提供商的流式 PCM16:Pcm16Encoder

如果提供商要的是裸 PCM16 字节而非 WAV 容器,可用Pcm16Encoder(packages/audio/src/encoding/pcm-stream.ts,由 packages/audio/src/encoding/index.ts 导出)。它在每次 reader pull 时推进一次转换,绝不整段收集:必要时用@alexanderolsen/libsamplerate-js做 SRC_SINC_FASTEST 单声道重采样,逐块混音、转 PCM16 并enqueue。实现里有一处值得注意的细节:由于该库的 wrapper 没有暴露end_of_input标志,文件结束时用零扩展(zero extension)冲刷重采样滤波器的尾延迟,同时保持“接受的时长不超发”。

测试与类型检查(Checks)

README 明确给出质量门槛,按以下命令执行:

pnpm -F @proj-airi/audio typecheck pnpm -F @proj-airi/audio test:run

其中typecheck对应tsc --noEmit(见 packages/audio/package.json 的 scripts)。测试分层明确:

  • Node 测试覆盖 WAV 编码与文件解码,如 packages/audio/src/encoding/wav.test.ts、packages/audio/src/encoding/media-file.test.ts;
  • 浏览器测试覆盖 Web Audio、麦克风共享、迟到权限、轨道清理,集中在 packages/audio/src/browser.browser.test.ts 与 packages/audio/src/encoding/pcm-stream.browser.test.ts;
  • 编排层的公开测试则覆盖 capture 独立性、延迟历史回放、源缺口、文件终结、取消与播放回执(见 packages/pipelines-audio/src 下的audio-input.test.ts、capture相关测试与playback.test.ts)。

一个典型用例可以直观展示“最后订阅者离开才停麦”的行为(packages/audio/src/browser.browser.test.ts):两个订阅者先后abort,第一个离开时轨道仍为live,第二个离开后轨道才变为ended且microphone.stream回到undefined。

小结

@proj-airi/audio用清晰的接口切分了 AIRI 的音频落地:AudioSource统一“能产生 PCM 的东西”,AudioInput+capture解决实时源的多消费者共享与区间采集,BrowserPlayback+Playback把播放编排锚定在音频时钟上,encodeWav/Pcm16Encoder则完成面向文件与提供商的格式转换。配合@proj-airi/pipelines-audio的观察窗口、播放组与流控(maxBufferedMs背压),这套设计既能支撑实时语音聊天,也能服务于离线文件转录,而所有音频之外的对话状态都交给上层 Agent 管线处理。

  • 人工智能
  • AI Agent
  • MCP 服务
  • 工具调用
  • GUI 自动化
  • 语音
  • 数字人
  • 交互助手

【免费下载链接】airi

💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。

项目地址:https://gitcode.com/moeru-ai/airi
点击查看免费下载
上一篇:VTracer完全指南:5分钟掌握开源图像矢量化神器
下一篇:efinance 实战手册:用 Python 一行代码获取股票、基金、债券、期货全市场行情数据

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询