大家好,这篇文章记录了我开发 edge-tts-roles(Edge-TTS 多角色音频生成器)Electron 版的完整心路历程。
软件功能说起来很朴素:在纯文本里写
[A]你好[B]哈喽[1000][C]停顿一秒[R],给 A/B/C/D 四个角色各选一个微软 Edge 神经网络发音人,它就能把整段台词逐段合成、拼接停顿和蜂鸣,再混入前奏/尾声/背景音乐,导出 WAV/MP3/OGG/FLAC。功能朴素,坑不朴素。下面是我的"受难记录",希望能博君一笑,也能帮后来者少走几步弯路。
一、项目缘起:为什么放着 Python 不用要重写
这个项目最早是 PyQt6 版的,Python 一把梭,numpy 算音频,ffmpeg 编解码,跑得也挺好。那为什么重写?
因为发版。
Python 桌面应用的发版体验,懂的都懂:PyInstaller 打完包体积大得像"操作系统安装镜像",杀软误报是日常,macOS 上的签名公证更是能把人折磨到怀疑人生。用户每次问"为什么 Defender 又报毒了",我都只能报以一个沧桑的微笑。
于是 v2.0.0 我用 Electron 重写了,技术栈:
- Electron 39 + electron-vite 5
- Vue 3.5(
<script setup>)+ TypeScript 5.9 - 音频编解码:捆绑 ffmpeg 二进制
- TTS:
edge-tts-universal(这玩意后面还决定了整个项目的许可证,先埋个伏笔)
TypeScript 严格模式 noUnusedLocals 全开,一个未使用变量都别想混进仓库,主打一个"编译器当监工"。
二、标记解析:一个正则 split 解决的事,千万别写状态机
脚本格式很简单:[A]~[D] 切角色,[1000] 是停顿毫秒数,[R] 是蜂鸣。最初我甚至想过写个逐字符状态机,后来发现 JavaScript 的 String.split 配合带捕获组的正则,会把命中的分隔符交错插回结果数组------天然的 tokenizer:
typescript
// src/shared/textParser.ts
/** 标记语法:[A]/[B]/[C]/[D] 切换角色,[数字] 停顿毫秒数,[R] 蜂鸣声 */
const MARKER_PATTERN = /(\[[ABCD]\])|(\[\d+\])|(\[R\])/
export function parseText(text: string): Segment[] {
const segments: Segment[] = []
let currentRole: RoleId = 'A'
let currentText: string[] = []
const flushText = (): void => {
const t = currentText.join('')
if (t) {
segments.push({ type: 'text', text: t, role: currentRole })
currentText = []
}
}
for (const part of text.split(MARKER_PATTERN)) {
if (!part) continue
if (part.startsWith('[') && part.endsWith(']')) {
flushText()
if (part === '[R]') {
segments.push({ type: 'beep' })
} else if (part.length === 3 && 'ABCD'.includes(part[1])) {
currentRole = part[1] as RoleId
} else {
const duration = Number.parseInt(part.slice(1, -1), 10)
if (Number.isFinite(duration)) {
segments.push({ type: 'pause', durationMs: duration })
}
}
} else {
currentText.push(part)
}
}
flushText()
return segments
}
心得:标准库的边角知识,往往就是你和加班之间的那道护城河。
三、Edge TTS 在线服务:一个"温柔"的断流陷阱
这是整个项目里最阴的一个 bug,没有之一。
3.1 症状:音频"少了一截尾巴",但程序说它成功了
Edge TTS 通过 WebSocket 流式推 MP3。网络不好时,连接会在服务端说完"结束语"之前悄无声息地断开 。更绝的是,edge-tts-universal 在"至少收到过一包音频"的情况下,流提前结束根本不抛异常,ffmpeg 解一个截断的 MP3 退出码还是 0。
整条链路没有任何一个环节报错,只有用户发现:哎?我最后一句话呢?
我最后是靠读库源码找到判据的------正常流程服务端会先推 turn.end,库会把内部状态 state.offsetCompensation 从初始值 0 改写成一个正数。所以:
typescript
// src/main/ttsService.ts
const state = (communicate as unknown as {
state?: { offsetCompensation?: number }
}).state
// 库升级后若该内部字段消失,退化为信任流正常结束(旧行为),避免误判
if (!state || typeof state.offsetCompensation !== 'number') return true
return state.offsetCompensation > 0
3.2 看门狗:防止连接"假活"
还有一种死法:TCP 连接没断,但服务器就是不说话了(经典僵尸连接)。await iterator.next() 会一直等下去,等到天荒地老。解法是每收到一包音频就重置一个 15 秒的空闲计时器,和 iterator.next() 赛跑:
typescript
// 空闲看门狗:每收到一包音频就重置;超过 idleTimeoutMs 无数据判定连接僵死。
const idle = new Promise<'timeout'>((resolve) => {
const arm = (): void => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => resolve('timeout'), this.idleTimeoutMs) // 15000ms
}
resetIdle = arm
arm()
})
while (true) {
const result = await Promise.race([iterator.next(), idle])
if (result === 'timeout') return false // 判定不完整,上层自动重试
// ...
}
3.3 重试策略:15 次机会,退避表 + 随机抖动 + 段间冷却
长文本可能有上百段,任何一段失败都不能让整本书"扑街"。我的策略是:
- 每段最多重试 15 次;
- 退避不是简单指数,而是手写了一张"先快后慢"的表,前两次秒级重试赌它是瞬时抖动,后面拉长到 30 秒赌网络恢复;
- 加 ±20% 随机抖动,避免大量客户端"步调一致"地重试把服务器再锤一遍;
- 等待按 200ms 分片,保证用户点"停止"最迟 0.2 秒响应;
- 某段重试成功后,后续段落先冷却 0.5~2 秒,连续成功两段再解除------被服务器盯上了就别头铁。
typescript
// src/main/ttsService.ts
private async retryBackoff(attempt: number): Promise<void> {
const table = [1000, 2000, 3000, 5000, 8000, 10000, 12000, 15000,
18000, 20000, 22000, 25000, 28000, 30000]
const base = table[Math.min(attempt - 1, table.length - 1)]
const jitter = base * 0.2 * (Math.random() * 2 - 1)
const total = Math.max(0, Math.round(base + jitter))
// 200ms 分片等待,保证停止指令最迟 0.2s 生效
for (let waited = 0; waited < total; waited += 200) {
if (this.stopRequested) return
await this.delay(Math.min(200, total - waited))
}
}
心得:和不稳定的在线服务打交道,要把对方想象成一个"心情时好时坏、还经常挂电话"的合作方------重试、超时、降级、缓存,四件套一个都不能少。
3.4 断点续传:失败是成功之母,缓存是失败的亲妈
合成一本几十分钟的书,第 87 段失败了,难道要从第 1 段重来?当然不。每个成功片段立刻落盘为 PCM 缓存,键是"语音参数+文本"的 SHA-256:
typescript
private segmentCacheKey(
text: string,
settings: { voice: string; rate: number; volume: number; pitch: number }
): string {
const raw = `${settings.voice}|${settings.rate}|${settings.volume}|${settings.pitch}|${text}`
return createHash('sha256').update(raw, 'utf8').digest('hex')
}
改一个字、换一个发音人,都会得到不同的片段,绝不会张冠李戴。缓存超 300 条按修改时间 LRU 淘汰。于是用户的体验变成了:失败 → 点重试 → "嗖"地一下从断点继续,甚至能产生一种"这软件真快"的错觉(不是)。
四、没有 numpy,我用 Float32Array 手搓了一个"混音台"
Python 版的音频运算是 numpy 的主场,相加就是一行 a + b。换到 Node.js,我没有引入任何音频库,全部用 Float32Array 手工实现,因为逻辑真的简单到不值得拉依赖:
- 所有片段统一解码为 24kHz / 双声道 / 32 位浮点 PCM (ffmpeg 一行参数
-f f32le -ac 2 -ar 24000); - 停顿 = 全零数组;蜂鸣 = 正弦波 + 5% 淡入淡出防爆音;
- 拼接 =
set()拷贝;背景音乐循环铺满 = 一个取模运算; - 混音 = 逐采样相加,然后硬限幅到 ±1.0。
typescript
// src/main/audioProcessor.ts
/** 将音频循环(不足时)或截断(超出时)到指定采样数,用于背景音乐铺满整段语音 */
export function loopToLength(pcm: StereoPcm, samples: number): StereoPcm {
const src = pcm.left.length
// ...
for (let i = 0; i < samples; i++) {
const j = i % src // 就这一行,音乐无限循环
left[i] = pcm.left[j]
right[i] = pcm.right[j]
}
return { left, right, sampleRate: pcm.sampleRate }
}
/** 两路 PCM 叠加混音,结果削波到 ±1 */
export function mixPcm(base: StereoPcm, overlay: StereoPcm): StereoPcm {
const n = Math.max(base.left.length, overlay.left.length)
for (let i = 0; i < n; i++) {
let l = (base.left[i] ?? 0) + (overlay.left[i] ?? 0)
let r = (base.right[i] ?? 0) + (overlay.right[i] ?? 0)
if (l > 1) l = 1
else if (l < -1) l = -1
// 右声道同理...
}
}
前奏/尾声/背景音乐的组装则是"先混音、再拼接":背景音乐循环到语音长度后叠加,前奏前置、尾声后置:
typescript
// src/main/ttsService.ts
if (extras.bgm.path) {
const bgm = await loadTrack(extras.bgm.path, extras.bgm.volume)
body = mixPcm(narration, loopToLength(bgm, narration.left.length))
}
const parts: StereoPcm[] = []
if (extras.intro.path) parts.push(await loadTrack(extras.intro.path, extras.intro.volume))
parts.push(body)
if (extras.outro.path) parts.push(await loadTrack(extras.outro.path, extras.outro.volume))
心得:浮点 PCM 的好处是中途怎么折腾都不会累积量化误差,限幅放到最后一步就行。别一上来就找音频框架,先问问自己的数据结构配不配。
五、i18n:曾经图省事硬编码中文,后来哭着补了七种语言
这个项目要求界面文案跟随系统语言,缺翻译时回退英语。我犯过一个经典错误(在另一个项目里):把音色中文名直接写死在逻辑文件里。结果加第二种语言时,发现要改的地方像地鼠一样到处冒头。
这次学乖了,从第一天就写了一个纯函数、零依赖的 i18n 核心,主进程和渲染进程共用:
typescript
// src/shared/i18n/index.ts
/** 解析语言包:精确匹配 → 动态注册包 → 语言前缀匹配 → en-US 基准包。 */
export function resolvePack(locale = detectLocale()): LocalePack {
const key = normalizeLocale(locale)
return (
LOCALE_PACKS[key] ??
dynamicPacks.get(key) ??
LANGUAGE_PREFIX_PACKS[key.split('-')[0] ?? ''] ?? // it-IT 没有?先找 it
BASE_PACK // 还没有?英语兜底
)
}
连音色显示名("Denise")、地区名("français (France)")、性别词("Femme")都进语言包,排序规则是"系统默认语言优先 → 英语 → 其他语言字母序"。最终内置 7 种语言:英、简中、法、德、西、俄、日。还写脚本校验过语言包 key 完整性------133 个 key,一个都不能少。
心得:i18n 不是翻译问题,是架构问题。第一天偷的懒,都是发布前流的泪。
六、跨平台打包:ffmpeg 二进制引发的"连环劫"
如果说前面都是技术小品,打包环节就是一部长篇连续剧。
6.1 ffmpeg:一台机器,要产出五个平台的安装包
ffmpeg-static 这个 npm 包有个特点:postinstall 只下载当前平台的二进制。但我想在一台 Linux 机器上交叉打包 Windows x64/arm64、macOS x64/arm64、Linux x64/arm64。
于是我写了个下载脚本,直接从它的 release(b6.1.1)把 5 个目标平台的二进制全拉下来,GitHub 拉不动就自动回退 npmmirror 镜像:
javascript
// scripts/download-ffmpeg-binaries.mjs
const RELEASE = 'b6.1.1'
const MIRRORS = [
process.env.FFMPEG_BINARIES_URL,
'https://github.com/eugeneware/ffmpeg-static/releases/download',
'https://registry.npmmirror.com/-/binary/ffmpeg-static'
].filter(Boolean)
// 注意:官方没有 win32-arm64 构建!
const TARGETS = [
{ platform: 'linux', arch: 'x64' }, { platform: 'linux', arch: 'arm64' },
{ platform: 'win32', arch: 'x64' },
{ platform: 'darwin', arch: 'x64' }, { platform: 'darwin', arch: 'arm64' }
]
运行时再按当前平台挑选,Windows ARM64 找不到原生版就回退 x64------靠 Windows 11 on ARM 内置的 x64 模拟续命:
typescript
// src/main/ffmpegResolver.ts
function candidateDirs(): string[] {
const dirs = [`${process.platform}-${process.arch}`]
// Windows on ARM 可模拟运行 x64 程序(ffmpeg-static 不提供 win32-arm64 构建)
if (process.platform === 'win32' && process.arch === 'arm64') {
dirs.push('win32-x64')
}
return dirs
}
6.2 extraResources 的"内容"语义:包内目录嵌套之谜
electron-builder 的 extraResources 有个反直觉的点:它复制的是 from 目录的内容 ,不是目录本身。我第一次配置时,stage 目录里还套了层 ffmpeg/,结果安装包里出现了庄严的 resources/ffmpeg/ffmpeg/<triplet>/ 双层嵌套,程序当场找不到北。
6.3 按架构打包:静态配置逼出一个包装脚本
更麻烦的是,extraResources 是静态配置,没法在同一次 x64+arm64 构建里说"x64 包用 x64 的二进制,arm64 包用 arm64 的"。要是全塞进去,每个包凭空胖 80MB。
最终方案是写个包装脚本 scripts/package-dist.mjs:每个架构先把对应二进制暂存到独立 stage 目录,基于 yml 生成一份临时 JSON 配置,逐架构单独调 electron-builder:
javascript
// Windows ARM64 包内放的是 x64 二进制
function sourceTriplet(arch) {
if (platform === 'win' && arch === 'arm64') return 'win32-x64'
const osTriplet = { win: 'win32', mac: 'darwin', linux: 'linux' }[platform]
return `${osTriplet}-${arch}`
}
这期间还踩了两个并发坑:
- 平台间竞争同一个 stage 目录 ------Linux 和 Windows 并行构建时,一方构建完顺手清理了共享目录,另一方立刻
ENOENT。解法:stage 按平台隔离成.ffmpeg-stage-linux/.ffmpeg-stage-win/.ffmpeg-stage-mac。 - 产物体积膨胀到 613MB ------stage 目录和原始二进制同时被打进
app.asar.unpacked,一份资源收两份钱。解法是在files里显式排除resources/ffmpeg和.ffmpeg-stage-*。
6.4 electron-builder 的 ESM 惊魂记
构建工具链自己也会摆烂。electron-builder 26.15.3 用 require() 去加载纯 ESM 的 @noble/hashes(一个哈希库),Node.js 当场 ERR_REQUIRE_ESM。解决办法是升级到 26.17.0,它的依赖树回退到了带 CJS 入口的版本。
心得 :打包工具链的版本锁和你的业务代码一样重要,package-lock.json 该提交就提交。
6.5 NSIS 协议页:用 Wine 亲自点一遍
用户要求 NSIS 安装器必须展示 AGPL 协议。配置其实就两行:
yaml
# electron-builder.yml
nsis:
oneClick: false
license: LICENSE
但"配置写了"和"真的显示了"之间隔着一个宇宙。我在 Linux 上用 Wine 跑安装器,xdotool 模拟点击,PIL 截图,亲眼确认了"许可证协议"页确实渲染出 GNU AGPL v3 全文,点"我同意"之后才进入安装选项页。
心得:跨平台开发最贵的一个词是"应该"。"应该没问题"最容易出问题,能跑就跑一遍。
七、许可证:一个依赖决定了整个项目的"姓氏"
这也是个有意思的话题。我最初在 MIT 和 Apache-2.0 之间纠结,后来拍板 GPL,最后发现------我根本没得选。
因为核心依赖 edge-tts-universal 是 AGPL-3.0 。AGPL 的 copyleft 是 GPL 家族里最强的(连网络交互都触发源码开放条款),链接了它,整体作品就得按 AGPL 发布。于是项目定为 AGPL-3.0-only,NSIS 安装器必须展示协议全文,"关于"对话框里老老实实列出全部开源署名:
typescript
// src/main/menu.ts
const OPEN_SOURCE_SOFTWARE: string[] = [
'Electron (MIT) - https://www.electronjs.org/',
'Vue.js (MIT) - https://vuejs.org/',
'edge-tts-universal (AGPL-3.0) - https://www.npmjs.com/package/edge-tts-universal',
'FFmpeg / ffmpeg-static (GPL-3.0-or-later) - https://ffmpeg.org/',
'electron-store (MIT) - https://github.com/sindresorhus/electron-store',
'@electron-toolkit/* (MIT) - https://github.com/alex8088/electron-toolkit',
'TypeScript (Apache-2.0) - https://www.typescriptlang.org/',
'Vite / electron-vite (MIT) - https://electron-vite.org/'
]
这里还考证过一个细节:FFmpeg 源码本身是 GPL-2+,但 ffmpeg-static 捆绑的 johnvansickle 静态构建自述为 GPLv3,npm 元数据写的是 GPL-3.0-or-later,所以署名必须按实际分发的二进制来写,不能想当然写源码的许可证。
心得:选依赖就是选许可证。开源不是"随便用",是"带着规则用"。
八、写在最后
回顾这个项目,技术上没有用到什么高深概念,但每一个"简单"的功能背后,都站着一两个曾经让我深夜挠头的坑:
- 面对不稳定网络服务 :完整性判据要自己找(
offsetCompensation),看门狗、分级退避、随机抖动、段间冷却、磁盘缓存,一个都不能少; - 数据结构选对了,代码量能少一个数量级 :浮点 PCM +
Float32Array让我无需任何音频处理库; - i18n 是架构问题不是翻译问题,第一天就要做;
- 跨平台打包要"不信任一切默认值":目录语义、静态配置、平台差异、工具链 bug,全部亲自验证;
- 许可证由你最强的那个 copyleft 依赖决定,署名要按实际分发的二进制写;
- 以及最重要的一条------"应该没问题"是世界上最危险的四个字,能跑就跑一遍,能截图就截一张。
项目已在 GitHub / Gitee / GitCode 同步开源(AGPL-3.0-only),欢迎 Star、提 Issue、帮我翻译更多语言。语音是微软 Edge 在线服务提供的,本项目只是一个站在巨人肩膀上的独立客户端。
愿每一位和在线服务、打包工具、许可证搏斗过的开发者,都能被这个世界温柔以待。🍻
项目地址: