从 PyQt6 到 Electron:给 Edge TTS 做一个“多角色配音机“的踩坑手记

大家好,这篇文章记录了我开发 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}`
}

这期间还踩了两个并发坑:

  1. 平台间竞争同一个 stage 目录 ------Linux 和 Windows 并行构建时,一方构建完顺手清理了共享目录,另一方立刻 ENOENT。解法:stage 按平台隔离成 .ffmpeg-stage-linux/.ffmpeg-stage-win/.ffmpeg-stage-mac。
  2. 产物体积膨胀到 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,所以署名必须按实际分发的二进制来写,不能想当然写源码的许可证。

心得:选依赖就是选许可证。开源不是"随便用",是"带着规则用"。


八、写在最后

回顾这个项目,技术上没有用到什么高深概念,但每一个"简单"的功能背后,都站着一两个曾经让我深夜挠头的坑:

  1. 面对不稳定网络服务 :完整性判据要自己找(offsetCompensation),看门狗、分级退避、随机抖动、段间冷却、磁盘缓存,一个都不能少;
  2. 数据结构选对了,代码量能少一个数量级 :浮点 PCM + Float32Array 让我无需任何音频处理库;
  3. i18n 是架构问题不是翻译问题,第一天就要做;
  4. 跨平台打包要"不信任一切默认值":目录语义、静态配置、平台差异、工具链 bug,全部亲自验证;
  5. 许可证由你最强的那个 copyleft 依赖决定,署名要按实际分发的二进制写;
  6. 以及最重要的一条------"应该没问题"是世界上最危险的四个字,能跑就跑一遍,能截图就截一张。

项目已在 GitHub / Gitee / GitCode 同步开源(AGPL-3.0-only),欢迎 Star、提 Issue、帮我翻译更多语言。语音是微软 Edge 在线服务提供的,本项目只是一个站在巨人肩膀上的独立客户端。

愿每一位和在线服务、打包工具、许可证搏斗过的开发者,都能被这个世界温柔以待。🍻

项目地址:

相关推荐
可乐鸡翅yeah_3 小时前
FFmpeg 生成 HLS 独立分片 independent‑segments 参数到底有什么用
javascript·ffmpeg·音视频·safari·m3u8
福兮说3 小时前
canvas 旋转图片的六个坑:四角被裁、Math.ceil 多出 1px 黑边、JPG 角发黑、翻转方向反了
前端·javascript·图像处理·canvas
Ai-_Man4 小时前
您您这可以把Grok的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。AI导出鸭
javascript·人工智能·ai·小程序·电脑
liangshanbo12155 小时前
JavaScript 异步核心主线
javascript·事件循环·异步操作
传人once6 小时前
悬停旋转放大和位移效果如何写
javascript·css·动画
光影少年7 小时前
Taro 是如何解析入口配置 app.config.ts 和页面配置的?
微信小程序·小程序·typescript·reactjs·百度小程序·taro
默_笙8 小时前
🍔 中间件不只是打日志:四个钩子、一次短路,和自带的工具
前端·javascript
Dovis(誓平步青云)9 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
xieter9 小时前
【技术精选】TypeScript 高级类型体操:条件类型与模板字面量深度剖析 (2026-10-06)
javascript
ss2739 小时前
AI全栈实战 | 3.3-02 Python 并发:有 GIL 为什么还用多线程,asyncio 和 JS 事件循环同源不同味
开发语言·javascript·python