之前写了一篇博客,介绍了我是怎么从零到一实现语音转文字输入法的。那篇文章主要讲了技术选型和基本实现,包括ONNX Runtime集成、CMVN文件处理、标点符号支持这些基础功能。
但有个问题我一直没细讲,就是怎么做到「又快又准」的。
你看,语音转文字有个经典矛盾:你想要快,就得用户一停顿就立马转写,但短片段(1~2秒)的识别准确率往往很低;你想要准,就得等用户说完再整段转写,但用户就得等更久。
在我们这种「按住说话」的交互模式下,这个问题尤其明显。用户可能连续说几十秒甚至几分钟,如果只在松键时一次性转写,等待时间长;如果按停顿频繁切小段转写,又容易产生碎片化、错误率上升。
所以我就想,能不能设计一套策略,在不牺牲响应速度的前提下,尽量提升最终转写准确率。
核心思路:双路径并行
我的思路是这样的,采用「快路径 + 准路径」双路径并行:
| 路径 | 目标 | 触发时机 | 输出 |
|---|---|---|---|
| 快路径 | 尽快反馈 | 检测到停顿(约0.8秒) | 单段转写结果,先缓存 |
| 准路径 | 提升准确率 | 累积到多段后 | 多段音频拼接后整段转写 |
两条路径同时进行,互不阻塞。用户松键的时候:
- 如果准路径已经完成了,就用准路径的结果替换对应零碎结果
- 如果没完成,仍然使用快路径的零碎结果,保证不卡顿
这样既保证了「边说边转」的即时感,又能在条件满足时用更准确的结果替换。
整体架构
整个流程大概是这样子的:
用户按住快捷键说话
│
▼
静音检测器(阈值:0.8秒停顿)
│
检测到静音
│
├─> 快路径:提取当前段落 → 立即转写 → 缓存
│ (单段转写,响应快,每段至少1秒)
│
累积 ≥ 3 段
│
▼
准路径:动态规划分批 → 多段音频拼接 → 二次转写
│ (每批至少3段、至少3秒,多批可并行)
│
用户松键
│
▼
合并输出:有准路径用准路径,否则用快路径 → 纠错 → 插入
快路径:尽早转写
快路径的目标是保证用户说话的时候能够尽快看到转写结果。
静音检测
我实现的静音检测是这样的:
- 停顿阈值:默认0.8秒,检测到停顿就触发段落切分
- 检测方式:两种方式结合
- 绝对阈值:RMS低于某个值视为静音
- 相对下降:说话后电平明显下降(比如降到峰值的25%)也视为停顿
- 单段最小时长:约1秒,过短段落不单独转写,避免无意义的短片段
段落提取与转写
从 VoiceInputService 提取当前段落的PCM数据,然后调用 SpeechTranscriber 进行转写(用的是本地的ASR模型)。转写完成后存下来,同时保留音频供准路径使用。
/// 单段结果:同时保存音频和转写文本
struct IncrementalSegmentInfo {
let audio: VoiceRecording // PCM数据,16kHz单声道
let transcript: String // 快路径转写结果
}
这里是关键的一点:必须同时保存音频和文本,因为准路径需要把多段音频拼接起来重新转写。
准路径:动态规划分批
准路径的目标是在快路径已经给了反馈的基础上,用更长、更完整的音频来提升准确率。
为何要「至少3段」
最开始我试过2段拼接,但效果提升不明显。后来发现3段及以上的时候,上下文更完整,准确率提升才会更明显。所以我设定了:至少3段才触发准路径。
为何要「至少3秒」
过短音频(比如1~2秒)对ASR模型不友好,容易出现识别不稳定的情况。所以每批至少要3秒,避免产生新的「短片段」问题。
动态规划分批算法
目标是在满足「每批 ≥ 3段、≥ 3秒」的前提下,尽量让每批更长,以提高准确率。
func partitionSegments(_ segments: [IncrementalSegmentInfo]) -> [Range<Int>] {
let totalCount = segments.count
guard totalCount >= 3 else { return [] }
var result: [Range<Int>] = []
var start = 0
while start < totalCount {
let remaining = totalCount - start
// 如果剩余不足3段,合并到上一批或单独成批
if remaining < 3 {
if start > 0 {
// 合并到上一批
result[result.count - 1] = result[result.count - 1].lowerBound..<totalCount
}
break
}
// 至少取3段
var bestEnd = start + 3
var cumulativeDuration = segments[start..<start + 3]
.reduce(0.0) { $0 + $1.audio.duration }
// 如果不足3秒,继续加段
while cumulativeDuration < 3.0 && bestEnd < totalCount {
cumulativeDuration += segments[bestEnd].audio.duration
bestEnd += 1
}
// 如果剩余不足3段,全部并入当前批
let finalRemaining = totalCount - bestEnd
if finalRemaining > 0 && finalRemaining < 3 {
bestEnd = totalCount
} else {
// 尽量扩展当前批,在保证剩余可成批的前提下
while cumulativeDuration >= 3.0 && (finalRemaining == 0 || finalRemaining >= 3) {
bestEnd += 1
if bestEnd > totalCount { break }
cumulativeDuration += segments[bestEnd - 1].audio.duration
}
}
result.append(start..<bestEnd)
start = bestEnd
}
return result
}
这个算法的效果大概是这样的:
| 总段数 | 分批方案 | 说明 |
|---|---|---|
| 3段 | [3] | 一整批 |
| 6段 | [6] 或 [3,3] | 若每段约1秒,可能[6];若每段很短,可能[3,3] |
| 7段 | [3,4] 或 [4,3] 或 [7] | 尽量让首批更长 |
| 9段 | [6,3] 或 [9] | 优先整批或6+3 |
批次并行转写
每次新增段落时,会按当前段数重新规划分批。然后取消旧任务,按新方案启动新任务。每批单独一个Task,多批并行执行。
// 7段 → 4+3两批,并行转写
for (index, range) in partition.enumerated() {
let task = Task {
let refined = await runBatchRefinementTranscription(
segments: segments[range]
)
incrementalBatchRefinedTexts[index] = refined
}
incrementalBatchRefinementTasks.append(task)
}
松键时的合并逻辑
用户松键的时候,需要把所有结果合并起来。
对每一批:
- 如果准路径结果已经有了,就用准路径的
- 否则用该批内各段的快路径转写结果拼接
还有个「剩余段落」:从「最后一次停顿」到「松键」之间的内容。这部分单独转写后,拼接到最终文本末尾。
关键是:松键时不等待未完成的准路径任务。完成了就用准路径,没完成就用快路径,保证不卡顿。
时序示意
时间轴 ──────────────────────────────────────────────────────►
用户: [说1][停0.8s][说2][停0.8s][说3][停0.8s][说4][停0.8s][说5][松键]
│ │ │ │ │ │ │ │
快路径: │ ├转写1─► │ ├转写2─► │ ├转写3─► │ ├转写4─► │
│ │ │ │ │ │ │ │
准路径: │ │ │ │ 3段→规划 │ │ 4段→规划 │
│ │ │ │ ├批1(1+2+3)转写│ │ │
│ │ │ │ │ │ 取消批1 │ │
│ │ │ │ │ ├批1(1+2+3+4)转写│ │
│ │ │ │ │ │ │ │
松键时: 合并:批1(若完成)+批2(若完成)+剩余段落+纠错 → 插入
配置参数
有几个关键参数是可以调整的:
| 参数 | 默认值 | 说明 |
|---|---|---|
| silenceDetectionDuration | 0.8秒 | 停顿多久触发段落切分 |
| incrementalBatchMinSegments | 3 | 至少几段才触发准路径 |
| incrementalBatchMinDuration | 3.0秒 | 每批至少多少秒 |
| voiceInputReleaseTailBufferSeconds | 0.3秒 | 松键后继续录多少秒,减少尾部丢失 |
这些参数可以根据实际使用场景调整。如果你说话停顿比较短,可以把0.8秒调小一点;如果你更在意准确率,可以把3段、3秒这些阈值调大一点。
效果与取舍
效果
这套策略的效果是:
- 快:0.8秒停顿即开始转写,体验接近即时
- 准:多段拼接后整段转写,长音频准确率更高
- 稳:准路径未完成时仍用快路径,不增加等待时间
取舍
当然也是有代价的:
- 准路径会多算一次转写,有一定算力开销
- 分段越多,规划与任务调度越频繁
- 需要在「准确率提升」与「资源消耗」之间做平衡
从实际使用来看,这个trade-off是值得的。用户说话的时候能够很快看到转写结果,而最终输入的文本质量又比只做短段转写要高不少。
总结
通过「快路径 + 准路径」双路径并行,以及「动态规划分批 + 松键时按批合并」的策略,我们成功在实时语音输入场景下同时兼顾了「快」和「准」:
- 快路径:停顿即转写,保证反馈及时
- 准路径:多段拼接后转写,提升准确率
- 动态规划:每批至少3段、3秒,避免过短片段
- 按批合并:松键时优先用准路径结果,未完成则用快路径,保证不卡顿
这套策略应该也适用于其他「按住说话」类语音输入产品。如果你也在做类似的功能,希望这个分享能给你一些启发。
如果你对具体实现细节感兴趣,或者想在自己的项目中应用这套策略,可以把这篇博客的地址告诉AI,让AI帮你实现类似的逻辑。核心的设计思路和代码细节都在这里了。