ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建本地优先的语音转写工具:Electron与whisper.cpp实战

从零构建本地优先的语音转写工具:Electron与whisper.cpp实战

1. 为什么我决定自己动手做一个 VoiceStudio

1.1 我遇到的真实场景

先说说我为什么会被逼到这个份上。过去两年里,我的手机里塞满了各种录音文件:有录的播客素材、每周例会上的头脑风暴、采访对象的音频、还有临时用语音备忘录记下来的碎片想法。我算了一下,零零散散加起来有几百条,乱到我自己都懒得去翻。每次想找某次会议上说的某个观点,我得一条条点开听,听到一半发现不是,退出去再点下一条。最崩溃的一次,我在出差路上找一段关于产品定价的讨论,花了快四十分钟,最后还是没找到,只能打电话问同事当时到底说了什么。

当时我尝试过市面上几乎所有主流工具。有的工具云端转写做得不错,但录音文件必须上传到别人服务器,很多采访内容我根本不敢传;有的本地工具界面好看,但只支持单一格式,而且录音过程中切后台就断;还有一些号称"AI整理笔记"的应用,实际上就是把你录的内容丢给云端大模型,隐私上完全没有保障。用了一圈下来,我得出一个结论:我需要的是一个真正本地运行、录音稳定、能自动转写、还能把语音变成可检索内容的工作台。于是就有了这个我断断续续折腾了三个多月的项目——VoiceStudio。

1.2 VoiceStudio 最终形态简述

VoiceStudio 实质上是一个本地优先的语音录制与整理工具。它跑在桌面上,用起来和一般录音软件差不多,但核心差异在三点:第一,录音、降噪、转写全部在本地完成,不传任何音频数据出去;第二,它有完整的波形时间轴,支持标记、裁剪、拼接,做播客粗剪也够用;第三,转写结果带时间戳,能配合波形视图直接点选定位,还能全文检索。简单说,它把"录、转、剪、找"这四个环节放到了一个界面里。

这个工具最重要的使用场景是那些对隐私有要求的对话记录:记者采访、律师访谈、医疗沟通、产品评审。在这些场景下,你不可能把录音传到网上让别人转写,但完全不用转写又对不起这几十分钟的录音。VoiceStudio 的定位就是解决这个矛盾:本地AI转写,数据不出门。我后面会详细讲技术选型和踩坑过程,先说结论:这个方向完全走得通,whisper.cpp 结合现代 CPU,转写一小时的音频大概需要三到八分钟,完全在可以接受的范围内。

2. 架构选型:本地优先的 Electron 应用

2.1 技术栈与目录结构

VoiceStudio 的技术栈很简单:Electron 作为桌面容器,React 加 TypeScript 写界面,SQLite 做元数据存储,ffmpeg 负责格式转换,whisper.cpp 负责语音转写。所有重活都丢给子进程,主进程只做调度和窗口管理。

voice-studio/ ├── app/ # Electron 主进程 │ ├── main.ts # 窗口与生命周期 │ ├── ipc.ts # IPC 通道定义 │ └── scheduler.ts # 录音/转写任务调度 ├── renderer/ # React 渲染进程 │ ├── components/ # 波形、时间轴、录音面板 │ ├── hooks/ # 媒体设备、播放状态管理 │ └── workers/ # Web Worker 峰值计算 ├── core/ # 纯逻辑层,可在 Node 或渲染进程复用 │ ├── pcm.ts # PCM 编码 │ ├── wav.ts # WAV 文件封装 │ └── segments.ts # 时间戳段处理 ├── native/ # 本地二进制目录 │ ├── whisper # whisper.cpp 可执行文件 │ └── ffmpeg # ffmpeg 静态构建 └── data/ # 用户数据目录 ├── recordings/ # 录音文件 ├── transcriptions/ # 转写 JSON └── voice-studio.db # SQLite 数据库

这个目录结构看着简单,却是我重构了两次之后定下来的。最早我把所有逻辑都塞在主进程里,结果录音写入和界面渲染互相抢资源,偶尔会出现爆音。后来我把纯逻辑抽到 core 层,录音相关放主进程,波形绘制相关放渲染进程 worker,两者完全解耦,问题就没再出现过。

2.2 为什么不用 Tauri,也不用纯 Web

我前期花了不少时间纠结要不要用 Tauri 替代 Electron。Tauri 确实更轻,内存占用小,包体也小很多。但最终我还是选了 Electron,原因很现实:Electron 的 Node 集成非常成熟,better-sqlite3、child_process 这些关键库用起来零摩擦;Tauri 的 Rust 后端虽然性能好,但我要做的核心功能大多是调用现有命令行工具,Rust 侧要重新封装一层,工程量反而更大。而且 Electron 开发调试生态确实完善,这对我这样一个做功能优先于炫技的项目来说更重要。

纯 Web 方案我也考虑过。PWA 能实现录音,MediaRecorder API 在所有主流浏览器里都有,转写也可以接到 WebGPU 推理。但有一个绕不开的问题:文件系统权限。录音完成后你要导出、改名、归档,浏览器给你的沙箱体验会让你想砸电脑。而且后台录音时浏览器标签页一旦被回收,录音就断了。桌面应用在这件事上天然有优势,录音进程独立于 UI,哪怕界面卡死,采集线程还能继续把数据写到磁盘。

2.3 进程划分与数据流

VoiceStudio 的进程关系是典型的 Electron 三进程模型:主进程、渲染进程、工具子进程。主进程持有一切资源的访问权,包括麦克风、文件系统、SQLite;渲染进程只干一件事,就是显示状态和接收用户操作;whisper 和 ffmpeg 则作为独立子进程由主进程调度。

// 主进程录音调度核心伪代码 ipcMain.handle("recording:start", async (event, payload) => { const session = createRecordingSession(payload); // 打开音频输入流 const stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); // 将可写流交给录音管理器 recordingManager.start(session, stream); return session.id; });

这里要说明一下,getUserMedia在 Electron 的主进程里和浏览器里一样可用,但它属于 Web API,并不真正访问底层硬件。它走的是 Chromium 的音频采集管线,经过去噪和自动增益处理后,以 Float32 PCM 的格式把样本交给我们。也就是说,我们拿到的已经是经过前处理的干净的 PCM 数据,这一点对后面降噪和转写的效果影响非常大。

整体数据流是这样的:麦克风 → Chromium 音频管线 → AudioWorklet 采集 → PCM 编码 → WAV 文件 → SQLite 记录元数据。转写时:WAV 文件 → whisper.cpp 子进程 → JSON 转写结果 → SQLite 全文索引。每个环节都是单向数据流,出了问题可以单独排查,这比把逻辑搅在一起好处理得多。

3. 录音采集链路:从 AudioWorklet 到 WAV 文件

3.1 MediaRecorder 的坑

最早我用的是 MediaRecorder API。它简单,几行代码就能把麦克风流录下来。但深入用下去,问题一个接一个出现。首先是格式问题,MediaRecorder 在 Electron 里录出来的默认格式是 webm/opus,很多老牌剪辑软件根本打不开,你总得再转一道;其次它不支持直接控制采样率和位深,它给什么你接什么;最要命的是它不适合做精确的音频处理,你想在录音过程中做"静音段跳过"或者"自动分段",从 MediaRecorder 拿到的数据已经经过编码器压缩,做这些操作你得先解码,绕了一大圈。

我的诉求很简单:录出来的东西必须是一份干净的 WAV 格式,位深16bit、采样率48kHz,这样才能直接喂给 ffmpeg 和 whisper,不用反复转码。MediaRecorder 给不了我这种控制力,所以我决定绕开它,直接用 AudioWorklet 从源头拿 Float32 样本。

3.2 AudioWorklet 采集与降噪

AudioWorklet 是 Chromium 提供给 JS 的高性能音频处理节点。它运行在独立的音频渲染线程上,不受主线程 JS 阻塞影响,可以在每一个音频回调里拿到原始的 PCM 样本块。我在 worklet 里做了三件事:格式整理、静音检测、简单降噪。

// audio-recorder-processor.js class AudioRecorderProcessor extends AudioWorkletProcessor { constructor() { super(); this.buffer = []; this.silenceFrames = 0; this.port.onmessage = (e) => { if (e.data.type === "RESET") { this.buffer = []; this.silenceFrames = 0; } }; } process(inputs) { const input = inputs[0]; if (!input || !input[0] || input[0].length === 0) return true; const channelData = input[0]; // 简单的峰值统计,用于后续的自动分段 let peak = 0; for (let i = 0; i < channelData.length; i++) { const v = Math.abs(channelData[i]); if (v > peak) peak = v; } // 静音帧计数,连续超过阈值就向主线程发事件 if (peak < 0.01) { this.silenceFrames++; if (this.silenceFrames === 100) { this.port.postMessage({ type: "SILENCE_START" }); } } else { this.silenceFrames = 0; this.port.postMessage({ type: "SILENCE_END" }); } // 把 Float32 转成 Int16,放入缓冲区 const int16 = new Int16Array(channelData.length); for (let i = 0; i < channelData.length; i++) { const s = Math.max(-1, Math.min(1, channelData[i])); int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } this.buffer.push(int16); this.port.postMessage({ type: "PCM_BLOCK", data: int16.buffer }, [int16.buffer]); return true; } } registerProcessor("audio-recorder-processor", AudioRecorderProcessor);

注意postMessage传数据时我用了第二个参数transferList,这是结构化克隆的一个优化点:把ArrayBuffer的所有权直接转移给主线程,不复制数据。录音数据量很大,48000采样率乘以2字节就是每秒96KB,如果不转移所有权,对象拷贝带来的内存抖动和GC压力会非常明显。

降噪方面我没有上太复杂的算法,做的是轻量级处理:先用 Chromium 自带的noiseSuppression约束做硬件级降噪,再在 worklet 里做了一次 DC 偏移修正。对于常见的环境底噪已经够用了。如果你想要更强的降噪效果,可以在录音完成后把文件交给 ffmpeg 的afftdn滤镜,或者接 RNNoise,这个看具体需求。

3.3 暂停、分段与自动备份

录音过程中最重要的体验是"随时暂停、随时继续"和"按对话段落自动分段"。暂停功能我一开始觉得很容易,后来发现有个细节必须处理:暂停再恢复时,如果音频上下文和采集节点重新建立连接,会有几十毫秒的接入延迟,导致段与段之间出现一截静音或者尾部丢失。我的处理方式是暂停时只是停写磁盘,但 AudioWorklet 采集不停止,用一个环形缓冲把这几百毫秒的数据先缓存住,恢复时无缝衔接。这样段边界永远很干净。

自动分段用的就是上面 worklet 里的静音检测。连续静音超过2.5秒,我就把它当作一次段落切换,会生成一个新的 WAV 片段,同时在 SQLite 里记录这个段落在原录音中的绝对时间偏移。这个功能对长录音尤其有用:一小时的会议录音,按静音分段以后,你不需要听完整段才知道哪部分在讲什么,直接在时间轴上点段跳转就行。

自动备份是我自己给自己加的需求。以前我用其他录音软件,最怕的就是录到一半软件崩溃,前面内容全没了。VoiceStudio 的录音管理器会每5秒把已经编码的 WAV 数据 flush 到磁盘,形成一个临时文件,正常结束时重命名为正式文件。哪怕录制过程中断电,你也能找到最多5秒前的备份数据。这个设计很土,但真的救了我不止一次。

4. 波形可视化与标记时间轴

4.1 解码与峰值计算的正确姿势

波形是 VoiceStudio 的视觉核心。要做波形,第一步是把录音文件解码成样本数据。Electron 里可以直接用 Web Audio 的AudioContext.decodeAudioData()解码 WAV,出来的是一个 AudioBuffer,里面是未压缩的 Float32 样本。这一步我踩过一个性能坑:如果你直接把整个一小时音频都 decode 到内存里,然后渲染成千上万个峰值柱,UI 会卡到怀疑人生。

正确的做法是分层显示。我的方案是用 Web Worker 在后台做峰值抽取,预先计算多级缩放金字塔:第一级是整段音频的1000个峰值点,用于总览图;第二级是每像素对应0.5秒的峰值,用于滚动视图;第三级才是完全解析度。界面切换到哪个缩放级别,就从对应金字塔层取数据,这样不管多长的录音,渲染永远只用处理屏幕上那几千个点。

// peak-calculator.worker.ts - 峰值计算核心 self.onmessage = (e) => { const { buffer, samplesPerPixel } = e.data; const channelData = buffer.getChannelData(0); const peaks = new Float32Array(Math.ceil(channelData.length / samplesPerPixel)); for (let i = 0; i < peaks.length; i++) { const start = i * samplesPerPixel; const end = Math.min(start + samplesPerPixel, channelData.length); let min = 1.0; let max = -1.0; for (let j = start; j < end; j++) { if (channelData[j] < min) min = channelData[j]; if (channelData[j] > max) max = channelData[j]; } peaks[i] = max; // 右上部分和左下部分分开存储,这里是简化版 } self.postMessage({ peaks: peaks.buffer }, [peaks.buffer]); };

这个 worker 拿到原始 AudioBuffer 后,按采样窗口计算峰值,再把结果传回渲染线程。由于 AudioBuffer 的数据量很大,我在主线程和 worker 之间采用了 SharedArrayBuffer 传递音频样本,这样只复制一份数据,两个线程共享读,计算完成后释放。如果你不想碰 SharedArrayBuffer 的跨域限制,也可以退一步用普通 postMessage 加 transferList,效果也还不错。

4.2 绘制与播放同步

波形绘制我用的是 Canvas 2D。绘制逻辑不复杂:遍历当前可视范围的峰值数组,每列画一条竖直线,顶部到最低点,底部到最高点。颜色上我做了个区分,有标记的段落用高亮色,普通段落用灰色,选中区域加半透明遮罩。点击波形任意位置可以跳转播放,拖动选区可以裁剪或导出段落。

真正难的是波形和播放头的同步。音频播放用的是一个独立的 AudioContext,播放位置用audioContext.currentTime实时计算,而这个值需要在每一帧动画里都去读取并映射到波形坐标。用requestAnimationFrame驱动重绘,正常情况下很丝滑,但如果你同时在做大量转写或其他CPU密集型操作,会出现掉帧,播放头看起来一顿一顿的。

我的办法是把播放位置映射做成一个"拉动式"更新:不依赖定时器,而是在每次 waveform 重绘时,从audioContext.currentTime读取真实位置,然后反推当前帧该显示哪一段样本。也就是说,不管什么原因导致帧率波动,播放头永远指向真实的音频时间点。用户感知上不会出现"声音已经到下一秒了,光标还停在上一秒"的错位。

4.3 标记时间轴与导出

标记功能才是这个页面的重头戏。VoiceStudio 支持三类标记:段落标记(自动分段生成)、手写标记(你按一下M键打一个点)、转写段落(whisper 返回的带时间戳文本段)。这三类标记使用同一个数据结构,都存到 SQLite 里,用四个字段表示:开始时间、结束时间、类型、附加 JSON 元数据。这样时间轴的渲染代码只需要处理一种数据格式。

导出方面我是这样处理的:选择一段区域后可以导出为独立 WAV 或 MP3,底层全部走 ffmpeg。这里我踩过一个大坑,Windows 上直接调用 ffmpeg 二进制,路径里有空格或者中文,子进程启动就会失败。后来我做了统一封装:所有外部二进制调用都通过一个toolRunner模块,它会处理路径转义、超时控制和退出码检查,任何一步异常都会结构化地记录到日志。从那以后,导出失败的情况基本绝迹了。

// 调用 ffmpeg 导出 mp3 async function exportSegment(segment, format) { const tempInput = path.join(tmpDir, `${segment.id}.wav`); const output = path.join(userOutputDir, `${segment.title}.${format}`); const args = ["-y", "-i", tempInput]; if (format === "mp3") { args.push("-codec:a", "libmp3lame", "-b:a", "192k"); } else if (format === "ogg") { args.push("-codec:a", "libvorbis", "-qscale:a", "6"); } args.push(output); const result = await toolRunner.run("ffmpeg", args); if (!result.ok) { throw new Error(`ffmpeg exit ${result.code}: ${result.stderr}`); } return output; }

5. 本地语音转写与智能整理

5.1 为什么选择 whisper.cpp

语音转写是整个项目里我最谨慎的部分。最开始我试的是 OpenAI 官方 Whisper 的 Python 实现,效果确实好,但每次转写都要拉起一个 Python 进程,加载模型动辄几个GB内存,而且启动速度慢,不适合我这种频繁转写的场景。后来我把目光投向 whisper.cpp,这是 ggml 生态下的 C++ 实现,性能比 Python 版本高一个量级,启动也快很多,更重要的是它可以直接编译成单个可执行文件,不需要任何运行时依赖,特别适合打包进 Electron 应用。

模型选择上我做了几轮实测。tiny 模型转写速度快,但中文识别率惨不忍睹,大量错字;base 稍微好一点,但是仍有较多多音字错误;small 在绝大多数情况下已经足够准确,中文普通话的常用词基本不出错,一小时音频转写时间在MacBook Pro上大概是5分钟;medium 准确率更高,但推理时间直接翻倍,而且模型文件体积大得多,不适合默认配置。最终的策略是:默认使用 small 模型不区分语言,追求最佳性价比;在设置里开放 base 和 medium 给有特殊需求的用户。

5.2 转写任务调度设计

转写任务不能在录音线程里同步跑,因为 whisper.cpp 推理会吃满CPU,如果和录音同时进行,会导致采集线程出现缓存欠载,录音反而出现裂缝。我的处理方式是把转写做成一个独立任务队列,录音完成后自动把文件丢进队列,空闲时逐条处理。界面顶部有一个小状态栏显示队列进度,用户可以随时暂停、调整优先级、或者取消一个转写任务。

// 转写任务队列核心伪代码 const queue = []; async function enqueueTranscription(filePath, options) { const task = { id: nanoid(), filePath, options, status: "pending", createdAt: Date.now(), }; await db.insertTask(task); queue.push(task); scheduleNext(); } async function scheduleNext() { if (running) return; running = true; while (queue.length > 0) { const task = queue.shift(); await runWhisper(task); await db.updateTaskStatus(task.id, "done"); } running = false; } async function runWhisper(task) { const args = [ "-m", path.join(nativeDir, "models", "ggml-small.bin"), "-f", task.filePath, "-ojf", // 输出 JSON "-t", String(os.cpus().length / 2), // 线程数上限,避免占满 "-l", "zh", ]; const result = await toolRunner.run("whisper", args); // 解析输出的 JSON 分段结果,写入 SQLite const segments = JSON.parse(result.stdout).segments; await db.insertTranscription(task.filePath, segments); }

线程数这里我特意设成了CPU核数的一半。whisper.cpp 默认会吃掉所有可用线程,推理速度确实最快,但会让整个系统其他操作卡成幻灯片。录音时尤其不能这样。主动限制一半线程,转写时间延长20%左右,但系统响应依然流畅,这个取舍很划算。

5.3 检索与自动更名

转写完成后,最核心的价值就体现出来了:录音变成了可以搜索的文本。我在 SQLite 里对转写文本建立了 FTS5 全文索引,搜索一个关键词,秒级返回包含它的所有录音片段,并且每条结果带有精确的时间戳。点击结果,波形视图自动跳转到对应时间点,同时高亮该段落。这个体验说实话,用过一次就回不去了。

自动更名是我额外塞进去的一个小功能:转写完成后,提取第一句话的前几个字,加上录音日期,作为文件的默认标题。以前我的录音文件全是"2024-05-11_20-32-11.m4a"这种格式,现在变成了"讨论Q3产品路线图-0509.wav",一眼就知道里面是什么内容。虽然只是简单的拼接逻辑,但这个细节让整个工具的可用性提升了一大截。

我自己还做了一个关键词高亮功能。转写文本渲染出来以后,所有命中的关键词都会被高亮,并且段落右侧会有一个小的置信度指示器。whisper.cpp 输出的段对象里带了一个tokens结构,每个 token 都有概率值,我可以据此估算出哪些词是模型不太确定的。这些词在界面上用警示色标出来,看到这种词你就有心理预期,转写可能在此处有误。

6. 我踩过的坑与后续方向

6.1 优先级反转和录音枪占

如果上面这些你都看懂了,但只有一个点要记住,那就记住这个:录音过程中绝对不能启动其他CPU密集型任务。不是"最好不要",是绝对不能。我在开发阶段调试转写功能时,一边录音一边跑 whisper,结果转写线程把CPU占满,录音线程拿到的时间片不够,音频缓冲区溢出,最后录出来的文件中间有一大段是爆音。这个 bug 我排查了整整一个下午,一度以为是麦克风或者驱动的问题,后来用性能分析工具才看到是线程调度导致的长延迟。

解决办法就是前面说的任务队列:录音永远是最高优先级,其他任何操作只能排队等录音结束。哪怕是导出一个音频片段,也要等录音完成之后再执行。这个设计我后来觉得不只是技术层面,更是产品层面的正确决策。用户录完音第一反应是"先别卡",导出他们可以等,录音一旦损坏就是不可逆的损失。

6.2 大文件转写时的内存与线程问题

第二个让我头疼的坑是转写大文件时的内存占用。whisper.cpp 默认把整个音频文件读进内存再开始推理。一个一小时、48kHz、16bit的单声道WAV文件差不多330MB,whisper 再把音频重采样到16kHz也会占一份内存,加上模型本身和中间特征,峰值内存轻松超过2GB。这在开发环境不算什么,但在配置低一些的机器上会直接卡死。

我的处理思路是先用 ffmpeg 做预处理,把 WAV 重采样到16kHz单声道、转换为 F32 格式,这样能用 30MB 左右的内存装下一小时音频;另外在配置上加了"低内存模式"开关,开启后会使用 whisper.cpp 的--no-prints和流式分块处理,推理过程中每处理30秒就释放一部分中间状态。实测开启后峰值内存能压到800MB以下,只损失大约5%的速度。

另一个坑是模型文件损坏问题。whisper.cpp 的 ggml 模型文件对损坏极敏感,有一次我程序异常退出导致模型文件写入了一半,再次启动时推理直接崩溃,而且没有任何错误提示。后来我在启动时加了模型文件的 SHA256 校验,不匹配就提示重新下载。如果你也做类似集成,强烈建议加这一步,否则用户会遭遇无法理解的崩溃。

6.3 下一步计划

VoiceStudio 现在的状态能满足我自己的日常使用,但离一个完备的产品还有距离。我接下来的计划有三件事:加人声活动检测(VAD),在录音时对非语言片段自动标记,进一步降低后续搜索定位成本;做一个快速剪辑模式,在转写文本上直接删除无关段落,同步裁剪音频,方便做播客粗剪;还有一个长期方向是多人会话的发言角色分离,现在 whisper.cpp 只能输出单通道转写,区分不了谁在说话,如果想要这个功能得叠加声纹嵌入模型,工作量不小,但值得做。

另外我在考虑把核心的录音、转写、索引能力封装成一个本地命令行工具集,让不想用 Electron 界面的用户也能在脚本里直接调用。毕竟转写和检索这些能力本质上和界面无关,做成可组合的模块,更容易嵌入到其他工作流里。这个想法目前还在验证阶段,等我实现得比较稳了再单独写一篇分享。

回头看这个项目,我自己最满意的不是某个炫酷的功能,而是它终于把"录音、整理、检索"这条链路跑通了,我以前那些躺在手机里的几百条录音现在都变成了可以随时翻找的文本。如果你也有同样的困扰,建议先别急着买那些云端转写服务,试试本地方案。自己动手做一个工具的过程虽然长,但做出来的东西用起来,和拿别人的工具是完全不同的体验。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进