MiMo TTS Studio 需要批量生成数十甚至上百条语音。每条请求独立,但不能无限制并发——API 有限流,本地内存也有上限。解决方案是手写信号量,把并发数控制在 3 条以内。

四种状态:台词的生命周期

每条台词从创建到完成,经历四种状态:

1
2
3
4
5
6
7
8
// 每条台词的数据结构
const line = {
id: generateId('line'),
text: '你好,世界',
audioPath: '',
status: 'pending', // 'pending' | 'generating' | 'done' | 'error'
duration: 0
}

前端用 computed 实时统计各状态数量:

1
2
3
4
5
6
7
8
9
10
const lineStats = computed(() => {
const lines = getCurrentLines()
return {
total: lines.length,
pending: lines.filter(l => l.status === 'pending').length,
generating: lines.filter(l => l.status === 'generating').length,
done: lines.filter(l => l.status === 'done').length,
error: lines.filter(l => l.status === 'error').length
}
})

信号量:手写并发控制器

没有用 p-limit 等第三方库,而是用一个自实现的信号量。核心原理:维护一个活跃请求数计数器和一个等待队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ttsStore.js — 实际实现
const _activeRequests = ref(0)
const MAX_CONCURRENT = 3
const _pendingQueue = []

function _acquireSlot() {
if (_activeRequests.value < MAX_CONCURRENT) {
_activeRequests.value++
return Promise.resolve() // 立即获得槽位
}
return new Promise(resolve => {
_pendingQueue.push(resolve) // 排队等待
})
}

function _releaseSlot() {
_activeRequests.value--
if (_pendingQueue.length > 0) {
_activeRequests.value++
const next = _pendingQueue.shift()
next() // 唤醒队列中的下一个任务
}
}

为什么手写而不用库?因为需要和 Vue 的响应式系统深度集成——_activeRequests 是 ref(),绑定到 UI 的进度条。第三方库的计数器不是响应式的,还得额外包一层。

批量生成主流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ttsStore.js — generateAllLines 用信号量控制并发
async function generateAllLines(charId) {
const lines = getLinesForCharacter(charId)
const tasks = lines.map(line => async () => {
await _acquireSlot()
try {
line.status = 'generating'
const audioPath = await synthesizeLine(charId, line)
line.audioPath = audioPath
line.status = 'done'
} catch (err) {
line.status = 'error'
line.error = err.message
} finally {
_releaseSlot()
}
})

await Promise.allSettled(tasks.map(t => t()))
}

关键设计:所有任务立即启动,但 _acquireSlot() 会阻塞超出并发限制的任务。这比”先分批再串行执行”更灵活——任务完成时立即释放槽位,不需要等整批完成。

单条失败不影响整体

用 Promise.allSettled 而不是 Promise.all。all 在任何一个 reject 时立即 reject,导致其他正在执行的任务结果丢失。allSettled 等所有任务完成,无论成功失败,然后逐个检查结果。

错误的台词状态设为 error,用户可以单独重试,不需要重新生成全部。

缓存命中:跳过已生成的台词

生成前先检查缓存(以下为简化示意):

1
2
3
4
5
// app/electron/service/tts.js — 简化示意
// 缓存键由 _buildCacheKey 用 djb2 哈希算法生成
// 参数:text, voiceId, speed, styleDesc, format, useVoiceDesign, useVoiceClone, provider
// 同一角色 + 同一文本 + 同一语速 + 同一模式 = 同一个缓存 key
// 批量生成时如果中间断电重来,已生成的台词会直接跳过

并发数的选择

MAX_CONCURRENT = 3 是经验值:

  • API 限流:小米 TTS API 默认 QPS 限制,并发太高容易触发 429
  • 内存压力:每条音频 buffer 约 100KB~1MB,同时处理太多会占用大量内存
  • 用户体验:3 条并发足够快(100 条台词约 30 秒完成),同时不会让系统卡顿

注意:以上代码均为简化示意,实际实现包含完整的错误处理和状态管理逻辑。

并发控制不是越多越好。找到 API 限流、内存约束和用户体验的平衡点,才是正确的并发数。