手写信号量:批量并发 TTS 生成的并发控制
MiMo TTS Studio 需要批量生成数十甚至上百条语音。每条请求独立,但不能无限制并发——API 有限流,本地内存也有上限。解决方案是手写信号量,把并发数控制在 3 条以内。
四种状态:台词的生命周期
每条台词从创建到完成,经历四种状态:
stateDiagram-v2
[*] --> pending
pending --> generating: 开始合成
generating --> done: 成功
generating --> error: 失败
error --> pending: 重试
1 | // 每条台词的数据结构 |
前端用 computed 实时统计各状态数量:
1 | const lineStats = computed(() => { |
信号量:手写并发控制器
没有用 p-limit 等第三方库,而是用一个自实现的信号量。核心原理:维护一个活跃请求数计数器和一个等待队列:
1 | // ttsStore.js — 实际实现 |
graph TD
A["任务进入"] --> B{"活跃数 < 3?"}
B -->|"是"| C["立即执行"]
B -->|"否"| D["加入等待队列"]
C --> E["执行完毕"]
E --> F["_releaseSlot"]
F --> G{"队列非空?"}
G -->|"是"| H["唤醒下一个任务"]
G -->|"否"| I["空闲"]
D --> H
为什么手写而不用库?因为需要和 Vue 的响应式系统深度集成——_activeRequests 是 ref(),绑定到 UI 的进度条。第三方库的计数器不是响应式的,还得额外包一层。
批量生成主流程
1 | // ttsStore.js — generateAllLines 用信号量控制并发 |
关键设计:所有任务立即启动,但 _acquireSlot() 会阻塞超出并发限制的任务。这比”先分批再串行执行”更灵活——任务完成时立即释放槽位,不需要等整批完成。
单条失败不影响整体
用 Promise.allSettled 而不是 Promise.all。all 在任何一个 reject 时立即 reject,导致其他正在执行的任务结果丢失。allSettled 等所有任务完成,无论成功失败,然后逐个检查结果。
错误的台词状态设为 error,用户可以单独重试,不需要重新生成全部。
缓存命中:跳过已生成的台词
生成前先检查缓存(以下为简化示意):
1 | // app/electron/service/tts.js — 简化示意 |
并发数的选择
MAX_CONCURRENT = 3 是经验值:
- API 限流:小米 TTS API 默认 QPS 限制,并发太高容易触发 429
- 内存压力:每条音频 buffer 约 100KB~1MB,同时处理太多会占用大量内存
- 用户体验:3 条并发足够快(100 条台词约 30 秒完成),同时不会让系统卡顿
注意:以上代码均为简化示意,实际实现包含完整的错误处理和状态管理逻辑。
并发控制不是越多越好。找到 API 限流、内存约束和用户体验的平衡点,才是正确的并发数。