- 人工智能
- AI Agent
- MCP 服务
- 工具调用
- GUI 自动化
- 语音
- 数字人
- 交互助手
【免费下载链接】airi
💖🧸 自托管、归你拥有的 Grok 风格 AI 伴侣与 waifu / 赛博生命灵魂容器,目标是接近 Neuro-sama 的高度;支持实时语音聊天、Minecraft 和 Factorio 游玩,支持 Web / macOS / Windows。
@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 原意 + 源码印证):
- stop 回执跟随音频时钟与源完成事件,不依赖 UI 定时器。
AudioOutput(packages/audio/src/browser/audio-output.ts)按上下文时钟逐个背靠背调度AudioBufferSourceNode,onended事件驱动的渲染区间、以及淡出用的linearRampToValueAtTime(0, end)都锚定在context.currentTime上;浏览器挂起或关闭上下文时会走contextChanged路径让待决播放和静音回执失败,而不是报出假静音。 startAtMs与回执interval都基于该时钟,单位毫秒。PlayedAudio.interval给出首末渲染采样的时钟时刻,调用方可以据此安排后续输出,例如“在上一条结尾 2 秒后开始下一条”。- 调用方拥有
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。
相关推荐
paseo 双向音频实战指南:基于 expo-two-way-audio 的 PCM 实时语音采集与播放
paseo 双向音频实战指南:基于 expo two way audio 的 PCM 实时语音采集与播放 导读 :本文围绕 paseo 仓库中 packages
Super Productivity Web Audio:音频处理与播放
Super Productivity Web Audio:音频处理与播放 引言:为什么生产力工具需要音频反馈? 在现代生产力应用中,音频反馈(Audio Fee
前端桌面应用移动开发NAudio RawSourceWaveStream 实战指南:裸 PCM 与压缩音频的播放、WAV 封装与格式转换
NAudio RawSourceWaveStream 实战指南:裸 PCM 与压缩音频的播放、WAV 封装与格式转换 RawSourceWaveStream 是
音视频音频处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考