© 2025 Rocky. All rights reserved.

|浙ICP备2025179428号-3|

魔法施展中...

技术文章

技术实践

语音转写「又快又准」:我的双路径并行策略实现

2025-11-23
5 分钟
...
Swift语音识别ASR算法设计

之前写了一篇博客,介绍了我是怎么从零到一实现语音转文字输入法的。那篇文章主要讲了技术选型和基本实现,包括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是值得的。用户说话的时候能够很快看到转写结果,而最终输入的文本质量又比只做短段转写要高不少。

总结

通过「快路径 + 准路径」双路径并行,以及「动态规划分批 + 松键时按批合并」的策略,我们成功在实时语音输入场景下同时兼顾了「快」和「准」:

  1. 快路径:停顿即转写,保证反馈及时
  2. 准路径:多段拼接后转写,提升准确率
  3. 动态规划:每批至少3段、3秒,避免过短片段
  4. 按批合并:松键时优先用准路径结果,未完成则用快路径,保证不卡顿

这套策略应该也适用于其他「按住说话」类语音输入产品。如果你也在做类似的功能,希望这个分享能给你一些启发。

如果你对具体实现细节感兴趣,或者想在自己的项目中应用这套策略,可以把这篇博客的地址告诉AI,让AI帮你实现类似的逻辑。核心的设计思路和代码细节都在这里了。

💡 关于技术判断: 本文反映了我在真实系统中评估技术风险的思考方式。 我现在专注于为创始人和决策者提供独立的后端与架构风险评估服务。 如果您在重大技术决策前需要第三方意见,了解评估服务或联系咨询。

感谢阅读!如果您觉得这篇文章有帮助,欢迎分享给更多的朋友。

上一篇
技术实践

从零到一:打造一款高效的语音转文字输入法

分享如何开发一款基于本地AI模型的语音转文字输入法,包括ONNX Runtime集成、标点符号支持、FN快捷键实现、CMVN文件处理等核心技术实现。

下一篇
生活随笔

越南街头换钱潜规则:为什么有钱送上门,小贩却不赚?

去越南旅行,很多老驴友都会告诉你一个秘诀:除了金店和银行,街头那些卖地图、卖水果、甚至只是坐在路边抽水烟的小贩,其实都是移动的'微型钱庄'。

📮 订阅更新
每周收到最新文章推送,不错过精彩内容

💡 我们尊重您的隐私,不会将邮箱用于其他用途

加载中...

猜你喜欢

技术实践

从零到一:打造一款高效的语音转文字输入法

分享如何开发一款基于本地AI模型的语音转文字输入法,包括ONNX Runtime集成、标点符号支持、FN快捷键实现、CMVN文件处理等核心技术实现。

2025-11-22
Swift
职业发展

日常随想

- 不要教育人,要学会筛选人。有人的始终是无法教育的,别浪费时光。

2022-05-26
技术分享

Tiktok 初探[2]

> 可以阅读前文[TikTok初探1](https://83d.me/2024/07/25/some-about-tiktok/) 。

2024-08-23
tiktok