起点:没有过渡期
很多架构演进故事的开头都是「我们先做了一个简陋的版本跑通,再一步步重构」。firefly-studio 不是这样。它的第一行代码落地的就是完整的 Electron 三层结构——主进程、预加载脚本、React 渲染进程——外加一个 C++ 原生模块(native/build/Release/firefly_native.node),由主进程通过 node-addon-api 加载。
这不是因为作者喜欢重架构,而是因为数字人说话这件事在浏览器里要同时满足几组现实约束、代价不小——持续持有设备句柄、后台不丢音频、本地模型推理——所以 firefly-studio 在初始化那天就把这部分复杂度一次性付清:
- 设备句柄所有权。 数字人要持续持有麦克风、随时响应语音。浏览器对音频设备的持有是「软性」的——后台 Tab 会被节流、权限可能在用户离开页面后悄悄失效,数字人于是「听不见」了。Electron 主进程用 CoreAudio 直接持有音频设备句柄,生命周期与应用绑定,不存在「耳朵丢了对焦就回来」的问题。
- 音频处理低延迟。 口型要跟着声音走,差几十毫秒就「慢半拍」,爆破音尤其明显。JS 主线程既要跑 three.js 渲染又要算音频特征,一旦特征变复杂帧率就崩。C++ 原生模块把 RMS 能量计算、VAD 检测下沉到主进程,不挤占渲染线程。
- 本地离线隐私。 语音识别和语音合成要走本地模型(Whisper.cpp / Piper),音频和文本不出本机。这决定了 ASR/TTS 必须跑在桌面端,而不是调用某个云端 API。
所以回看第一版代码,它一点都不「简陋」:Electron 37 + electron-vite、React 18 + three.js + @react-three/fiber + @pixiv/three-vrm(VRM 数字人)、C++17 音频引擎、Whisper.cpp 本地 ASR、Piper 本地 TTS。复杂度不是演进出来的,是被代价一次性锁定的。
主进程加载原生模块的入口非常朴素——src/main/index.js 里的 loadNative() 用 createRequire 解析绝对路径去 require 那个 .node 文件,按 out/main/../../native 与 app 根 / cwd 三组基础目录、各试 Release 与 Debug 共至多 6 条候选路径;全部失败就抛错并列出尝试过的路径,提示先执行 npm run native:build(它用 node-gyp 直接针对当前 Electron 版本与架构编译,而非 electron-rebuild)。这一段没有任何「先试试浏览器」的中间态。
第一版到底做了什么
把第一版的执行路径拆开,它已经做了四件事:
- C++ 采集与特征提取——CoreAudio 采麦克风,原生模块按固定帧(默认 20ms / 16kHz 单声道)回调 PCM;采集链路先过 RNNoise 实时降噪(默认开启,需把 16kHz 上采样到 RNNoise 要求的 48kHz / 480 样本、处理完再降采样回来),降噪后的音频才算 RMS 能量与 VAD;降噪初始化失败或处理抛异常会自动降级关闭、回退原始音频。
- 能量驱动口型——渲染进程的
Stage组件拿audioEnergy(0~1)直接驱动 VRM 模型的口型 blendshape,声音越大嘴张得越开。 - VAD 阈值判定——原生模块的
IsSpeech用能量是否超过阈值来判断「有没有人在说话」,超过就把这一帧标成语音。 - 本地语音链路——说话结束拼接成段,送 Whisper.cpp 转写,文本进 LLM,回复再经 Piper 合成、CoreAudio 播放。
核心的音频能量计算在 native/src/audio_engine.cc 里,真实代码长这样:
double AudioEngine::ComputeRms(const int16_t* data, size_t len) {
double sum_sq = 0.0;
for (size_t i = 0; i < len; ++i) {
double v = static_cast<double>(data[i]) / 32768.0; // 归一化到 [-1, 1]
sum_sq += v * v;
}
double rms = std::sqrt(sum_sq / static_cast<double>(len));
// 一阶低通平滑:smoothed = alpha * new + (1 - alpha) * old
smoothed_energy_ = smoothing_alpha_ * rms + (1.0 - smoothing_alpha_) * smoothed_energy_;
return smoothed_energy_;
}
bool AudioEngine::IsSpeech(const int16_t* data, size_t len) {
double energy = ComputeRms(data, len);
return energy >= speech_threshold_; // 简单阈值判定
}
渲染侧拿到结果靠 useMic 这个 hook(真实 API):
const { sampleRate = 16000, frameMs = 20, threshold = 0.02,
silenceMs = 700, minSpeechMs = 300 } = options
window.electronAPI.mic.onFrame((frame) => {
setVolume(frame.rms) // 0~1 实时音量
setIsSpeaking(frame.isSpeech)
})
这里要澄清一个常见的误读:有人说「第一版没有时间维度、没有平滑」。其实 ComputeRms 里从一开始就有一阶低通平滑(smoothing_alpha_ = 0.2),所以它输出的 smoothed_energy_ 是一条带惯性的曲线,不是逐帧抖动的点。smoothed_energy_ 这个字段,就是「时间维度的惯性」这条需求在代码里最早的化石。
但第一版确实有两个真实的不完美,而且都被后来的 bug 验证了:
- VAD 是简单阈值,没有状态机。
IsSpeech只回答「这一帧超没超阈值」,不回答「一句话从哪开始、到哪结束」。真正决定「语音段边界」的是上层mic_capture里的状态机(靠silenceMs/minSpeechMs判断起止),而这层后来真的出了 bug(见下节 P0-001)。 - 口型只靠能量,不区分音素。
Stage拿的是audioEnergy一个标量,能分「大声 / 小声」,分不出「啊」和「嗯」。这是当前真实存在的上限,不是被某个转向解决了——文章早期设想的「VAD + ASR 语义驱动口型」在这个项目里目前并没有落地,口型至今仍是能量驱动。
真实的坑与重构
firefly-studio 没有发生「从浏览器到 Electron」「从 JS 到 C++」这种架构转向——它一直就是 Electron + C++。真正发生的是在已锁定的骨架上修真实的 bug、补真实的体验。这些在 docs/TODO.md 里有据可查:
- P0-001:VAD 状态机语音结束检测不正确。 现象是「麦克风采集正常,但语音识别不触发」。根因在
mic_capture那层状态机的 end-detection 逻辑——silenceMs没算对,一句话说完了系统还在等,于是 ASR 一直不触发。修复方式是重构 VAD 状态机、修正结束判定。这正是「阈值判定」和「状态机判定」之间那道缝的真实爆发点。 - P0-002:TTS 语音不播放。 LLM 返回了文字、数字人嘴也在动,但没声音。根因是 TTS 服务没正确启动或 PCM 播放链路有错。修复 TTS 启动逻辑与 PCM 播放。
- P1-003:流式 TTS 体验。 原流程是「LLM 文字先显示、语音才出来」,观感割裂。改成先合成第一段、文字与口型一起出现,后续段落边合成边播放。
- P0-004:音乐闪避(ducking)提前恢复。 数字人说话时音乐降音量,但长文本时音乐在说完前就恢复了。根因是闪避定时器与 TTS 播放进度不同步,改为按 TTS 实际播放进度动态 ducking。
- P1-005:VAD 灵敏度优化。 VAD 状态切换过于频繁导致分段不准。补了「连续 N 帧确认语音 / 连续 N 帧静音才确认结束」,并引入自适应阈值(根据环境噪音基线动态调整
speech_threshold_)。
这些比「三次转向」更接地气:它们不是推倒重来,而是在 audio_engine 的 smoothed_energy_、useMic 的 silenceMs/minSpeechMs、TTS 的播放进度之间反复打磨。每一处修复都落在旧骨架的具体坐标上。
未曾采用
有些方向在论证阶段就被否了,而且否得很具体——它们都记录在 docs/ARCHITECTURE.md 的「设计决策与权衡」一节:
方向一:云端 ASR,绕开 Whisper.cpp 本地推理
第一个被认真比较的是「用云端语音识别 API」还是「本地跑 Whisper.cpp」。最终选本地,理由很硬:麦克风音频不出本机(隐私)、断网也能用(离线)、没有 API 调用费用(成本)、本地推理延迟可预测。代价是准确率在嘈杂环境下略低于云端、要自己下载模型(base 约 140MB)、占本地 CPU/内存。对「本地优先」定位的数字人来说,隐私和离线这两条是碰不得的约束,所以云端 ASR 直接出局。
方向二:纯 JS / WebAudio 处理音频,绕开 C++ 原生模块
另一个方向是「音频特征全用 JS + Web Audio API 算,不写 C++」。否掉的理由是性能与延迟:实时音频的 RMS/VAD 下沉到 C++ 后,不挤占 React 渲染线程;CoreAudio 这类原生音频框架在 C/C++ 里才好用;更重要的是——C++ 能在 VSCode 里用 lldb 直接打断点单步跟踪,这是调试音频管线时不可替代的能力。代价是构建链变重(C++ 原生模块要用 node-gyp 直接针对 Electron ABI 编译,而非 electron-rebuild)且目前只支持 macOS。音频处理的低延迟需求压过了构建复杂度,所以 C++ 原生模块留下。
方向三:WebSocket 替代 SSE
LLM 流式输出最初也考虑过 WebSocket。最终选 SSE,因为流式输出只需要「服务端 → 客户端」单向推送,SSE 基于 HTTP、实现简单、浏览器原生支持、还自带自动重连。代价是单向、只能传文本、部分代理会缓冲。对「打字机式输出」这个场景,单向足够,所以 WebSocket 不值得引入。
方向四:云端 TTS,绕开 Piper
TTS 同样比过云端与本地。选本地 Piper 的理由和 ASR 同构:文本不上传(隐私)、MIT 许可证可商用、离线可用、基于 ONNX runtime 延迟低。代价是音色选择少(目前主要是中文小雅、英文 Amy)、音质不如部分在线服务。隐私与离线再次压过「音色丰富度」。
这四个被否的方向有一个共同点:它们在某个局部维度更省事(不用下模型、不用写 C++、不用双向协议、音色更多),但每一个都碰到了更高的约束——隐私/离线、低延迟、简单性。被否不是因为「不好」,而是因为「它输给了哪个约束」很清楚。
沉淀
firefly-studio 最终交付的代码只能回答「走到了哪」,而这一节想回答「为什么能走到这」。把它的演进史(其实是没有演进、只有 day-1 锁定 + 持续打磨)回看一遍,一条可迁移的原则浮现出来:工程结构不是被「设计」出来的,而是被「代价」逼出来的——而且有些代价在初始化那天就付清了。
从真实架构里能抽出的,是「约束的优先级」
第一版把复杂度一次性付清,不是因为作者未卜先知,而是因为它认清了几条碰不得的硬约束:
- 设备句柄的持有权(谁真正拥有麦克风 / 音频流)—— 第一优先,它决定了进程模型:必须桌面化、必须在主进程持 CoreAudio 句柄。
- 延迟预算(从声音发出到口型/播放的可接受时延)—— 它决定了音频特征必须下沉到 C++,且 ASR/TTS 必须本地推理。
- 隐私与离线(音频/文本不出本机、断网可用)—— 它决定了 Whisper.cpp / Piper 而非云端 API。
- 构建链复杂度与代码可读性(node-gyp 直编原生模块 + 仅 macOS)—— 它被牺牲的次数最多,但从未被完全忽略。
可迁移的原则:先承认「代价有优先级」,再谈架构
firefly-studio 的复盘给出的答案很朴素:在动手分层之前,先搞清楚哪些约束是碰不得的、哪些是可以妥协的(上一节列出的四条优先级就是这套判断的落点)。设备句柄 / 延迟预算 / 隐私离线是碰不得的硬约束,剩下的构建链复杂度、跨平台、音色丰富度——才是可以谈判的空间。
代码库里的痕迹,是约束优先级的化石:useMic 的 silenceMs / minSpeechMs,是「VAD 状态机」这个需求的化石;loadNative() 被 memoize、首次 IPC 命中时才真正 require、加载失败整条音频/ASR/mic/IPC 链路直接抛错,是「设备句柄所有权」这个约束的化石。(audio_engine 的 smoothed_energy_ 一阶低通作为「时间维度惯性」的化石,在前面那段音频引擎代码旁已经点过。)读代码库的方式,从「这个函数是干什么的」变成「这个函数当初是为了化解哪个约束冲突而存在的」。
最后一条:可迁移的不是 Electron + C++,而是被代价校准过的判断力
如果把 firefly-studio 当成「Electron + C++ 做数字人」的模板去复制,是错的——它的技术栈是被音频设备的物理特性、macOS 的平台限制、本地优先的定位这些具体约束逼出来的。换一个场景(比如纯云端助手、或离线批处理工具),这套结构立刻变成负担。
真正可迁移的是「在约束的优先级上做取舍」的判断力。任何一个系统,最终形态都是「硬约束的必然结果」加上「软约束的妥协产物」。把这两者分清楚,别人问你「为什么这么设计」时,你给出的是一个不是「因为最佳实践」、而是「因为这几个约束碰不得,其余都可以让」的答案。
firefly-studio 从第一天起就没有成为一个「漂亮的架构」,它成为一个「被代价校准过的系统」。两者之间的差别,就是这一节想沉淀的东西。
为什么 firefly-studio 从一开始就用 Electron 主进程加载 C++ 原生模块,而不是先试纯浏览器方案?
因为数字人「持续听、跟着说」的需求在浏览器里要同时满足几组现实约束、代价不小——持续持有设备句柄、后台不丢音频、本地模型推理。这几点是 firefly-studio day-1 就确定的代价优先级落点,因此初始化即 Electron + C++,没有纯浏览器过渡期。主进程经 node-addon-api 加载 firefly_native.node,由 loadNative() 在首次 IPC 调用时按需加载(带 if (nativeMod) return 缓存)。
口型同步是靠音频能量还是音素驱动的?
当前纯能量驱动——渲染进程 Stage 用 C++ 回传的 audioEnergy(0~1)直接驱动 VRM 口型 blendshape,能分「大声/小声」,分不出具体音素(「啊」与「嗯」)。文中设想的「VAD + ASR 语义驱动口型」当前版本未落地,是已知表达上限,而非被某转向解决。
第一版的 VAD 真的没问题吗?后来踩过什么坑?
第一版 IsSpeech 仅简单阈值判定(energy >= threshold_),语音段边界靠上层 mic_capture 状态机(silenceMs/minSpeechMs)。该状态机结束检测确实出过真实 bug(P0-001):采集正常但 ASR 不触发,根因是结束判定逻辑。修复即重构 VAD 状态机;后续补「连续 N 帧确认」与自适应阈值(P1-005)。
C++ 原生模块在主进程加载,安全边界上有什么已知风险?
主进程持有音频设备句柄并加载本地模型(Whisper.cpp / Piper),原生模块因此拥有系统级音频权限。当前未在原生模块外做额外沙箱或权限隔离,若 C++ 侧存在内存漏洞,影响范围是整个 Electron 进程。属已知项,本地优先桌面应用的攻击面主要在本地,多进程沙箱化是后续可补方向。
为什么选本地 Whisper.cpp / Piper 而不是云端 ASR / TTS?
核心是「本地优先」下两条硬约束——隐私(音频/文本不出本机)与离线(断网可用),外加零 API 成本与可预测本地延迟。代价是嘈杂环境准确率略低、需自下载模型(Whisper base ≈140MB、Piper 每音色 ≈60MB)、占本地 CPU/内存。对桌面数字人,隐私与离线优先于「云端更高准确率」。
为什么 LLM 流式输出用 SSE 而不是 WebSocket?
流式输出只需「服务端→客户端」单向推送,SSE 基于 HTTP、实现简单、浏览器原生支持、自带自动重连。代价是单向、仅文本、部分代理会缓冲。对「打字机式输出」单向足够,故未引入 WebSocket 的双向协议复杂度。
项目完整源码见 GitHub:erishen/firefly-studio。
发表回复