实时字幕负责快,本地录音负责不丢:一个 iOS 录音功能为什么需要两条链路

用户按下录音按钮后,最希望看到的是文字马上出现在屏幕上,开发者自然会把注意力放在 WebSocket、音频分包和字幕刷新上。

但真正决定这个功能是否可靠的,并不是字幕出现得有多快,而是遇到网络中断、App 被系统终止或者服务端处理失败时,这段录音还能不能找回来。

我做过一个会议记录类 iOS app 的功能。它需要一边录音,一边显示实时字幕,结束后再生成完整转写和摘要。

最初看起来,整个处理过程并不复杂:

text 复制代码
麦克风 -> WebSocket -> 实时字幕 -> 完整结果

但投入真实使用后,仅靠这条链路很快暴露出问题。WebSocket 可能断开,字幕可能遗漏,结束事件可能收不到,用户也可能在云端处理期间离开页面。 最终的设计是让同一份音频同时进入两条互相独立的处理链路。

text 复制代码
                         ┌-> WebSocket -> 实时字幕 -> 服务端处理
麦克风 -> PCM 音频帧 --┤
                         └-> 本地 WAV 文件 -> 必要时上传恢复

两条链路承担的任务不同:实时链路让用户尽快看到字幕,本地文件则保存完整录音,避免一次网络故障造成音频丢失。

先把"录下来"和"传上去"分开

音频从 AVAudioEngine 或 Audio Unit 出来以后,可以同时交给本地写入器和实时客户端。关键是两边不能互相阻塞。

下面是一个经过简化的示例:

swift 复制代码
audioRecorder.onPCMData = { [weak self] pcm in
    guard let self else { return }

    // 本地文件负责保留完整录音,优先写入。
    self.localWriter.append(pcm)

    // 实时发送失败只影响字幕,不应打断本地录音。
    if self.liveCaptionsEnabled {
        self.realtimeClient.enqueue(pcm)
    }
}

代码虽然不长,却明确划分了两条链路各自负责的事情:

能力 实时链路 本地文件
录音过程中显示字幕 负责 不负责
弱网时保存完整声音 无法保证 负责
App 重启后恢复处理 只能依赖服务端状态 可以重新上传
录音结束后快速拿结果 通常更快 通常更慢
故障后恢复完整录音 不适合单独承担 适合

如果把"实时字幕正常"误当成"录音已经安全保存",系统就会在最糟糕的时刻暴露问题:用户录了几十分钟,屏幕上也出现过文字,结束后却拿不到完整结果。

PCM 和 WAV 是同一份声音的两种用途

实时服务通常希望收到固定格式的裸 PCM,例如 16 kHz、16 bit、单声道。每 200 毫秒发送一次时,一包大小可以这样计算:

text 复制代码
16000 个采样/秒 x 2 字节 x 1 声道 x 0.2 秒 = 6400 字节

实时发送的是连续 PCM 包:

swift 复制代码
private let packetSize = 6_400

func append(_ data: Data) {
    accumulator.append(data)

    while accumulator.count >= packetSize {
        let packet = accumulator.prefix(packetSize)
        accumulator.removeFirst(packetSize)
        sendQueue.append(Data(packet))
    }
}

本地则更适合保存为 WAV。WAV 只是给 PCM 加上文件头,让播放器、分享工具和后续上传接口能把它当成一个完整文件。

这里容易产生一个误区:既然两边都是同一份声音,是不是保留一边就够了?不够。

PCM 包发往服务端以后,客户端很难确认对方是否完整收到;本地 WAV 则明确保存了设备实际采集到的内容。两者解决的是不同的故障。

"已连接"不等于"音频已完整发送"

WebSocket 成功连接,只能说明通道建立了。要判断一场录音能否直接使用实时结果,至少还要回答这些问题:

  1. 服务端是否已经发出可以发送音频的准备事件?
  2. 准备完成前产生的音频有没有进入缓冲?
  3. 缓冲有没有溢出?
  4. 发送队列中的数据是否都收到了成功回调?
  5. 录音中途是否发生断线或重连?
  6. 停止时,末尾尚未发出的音频有没有排空?

我会为每场录音记录"实时音频是否完整发送"。

初始值为完整,一旦确认发生丢包,或者断线后无法恢复,就把它改为不完整。

swift 复制代码
struct RecordingTask: Codable {
    var realtimeSessionID: String?
    var realtimeCoverageComplete: Bool = true
    var localAudioPath: String
    var uploadState: UploadState = .notStarted
}

func didDropAudioPacket() {
    task.realtimeCoverageComplete = false
    persist(task)
}

这个状态不能只保存在内存里。用户可能在停止录音后立即返回首页,甚至直接退出 App,因此任务 ID、音频路径和后续处理方式都要写入本地存储。

停止录音不是一个按钮事件

停止流程最容易被低估,用户只点了一次按钮,程序内部却需要按顺序完成多件事:

text 复制代码
停止接收新音频
    -> 写完本地文件并修正 WAV 文件头
    -> 把不足一包的尾部数据处理完
    -> 等待发送队列排空
    -> 发送停止指令
    -> 等待服务端完成或超时
    -> 选择实时结果或本地上传

实际代码中的结束逻辑可以写成:

swift 复制代码
func finishRecording() async {
    let localFile = await recorder.stopAndFinalize()
    task.localAudioPath = localFile.path
    persist(task)

    if task.realtimeCoverageComplete,
       let sessionID = task.realtimeSessionID {
        do {
            try await realtimeClient.flushAndStop()
            await pollRealtimeResult(sessionID: sessionID)
            return
        } catch {
            // 只有确认实时结果不可恢复时,才考虑本地文件。
        }
    }

    await uploadLocalAudio(localFile)
}

这里最危险的写法,是只要等待超时就立刻上传本地文件。请求超时并不能证明服务端没有收到停止指令,也不能证明实时任务不存在。如果服务端已经创建任务,客户端再上传一次,就可能生成两份结果。

因此,请求失败后是继续查询还是改为上传,要根据客户端已经掌握的信息决定:

已知情况 合理处理
从未拿到实时会话 ID 上传本地文件
音频缓冲明确发生丢包 上传本地文件
停止响应超时,但已有会话 ID 先按会话 ID 查询结果
上传请求确定没有发出 可以重新上传
上传请求已经发出,但响应未知 先查询或使用幂等键,不能盲目再建任务

不要把任务的生命周期绑在页面上

最初实现这类功能时,很容易把轮询、重试和加载步骤都写在录音页面里。页面消失后,任务也跟着没人管了。

更合适的做法是让页面只订阅任务状态:

swift 复制代码
@MainActor
final class RecordingViewController: UIViewController {
    private let workflow: RecordingWorkflow

    override func viewDidLoad() {
        super.viewDidLoad()

        workflow.observe { [weak self] state in
            self?.render(state)
        }
    }
}

录音、上传、轮询和失败恢复由独立的流程管理器负责。本地任务可以保存这些信息:

json 复制代码
{
  "local_id": "local-demo-001",
  "remote_id": "remote-demo-001",
  "status": "processing",
  "local_audio": "Audio/local-demo-001.wav",
  "realtime_coverage_complete": true,
  "retry_action": "query_remote_result",
  "updated_at": "2026-01-01T12:00:00Z"
}

App 再次启动时,不需要重建当时的页面,只要读取任务并按 retry_action 继续查询即可。

失败也要分清发生在哪一步

把所有错误都显示为"生成失败",对用户和开发者都没有帮助。至少应区分以下几类:

阶段 用户看到的结果 本地处理
麦克风启动失败 无法开始录音 不创建任务
实时字幕失败 字幕暂不可用,录音继续 保留本地音频
本地写入失败 录音必须停止 立即提示,不能假装安全
上传失败 录音已保存在设备,可重试 保存待上传状态
云端处理中 可以离开页面 保存远端任务 ID
结果查询暂时失败 稍后继续获取 保留任务,不重复创建

实时字幕失败时,文案也不应该让用户误以为整场录音已经丢失。例如:

text 复制代码
实时字幕暂时不可用。录音仍在设备上保存,结束后会继续处理。

只有确实写入了本地文件,产品才能向用户作出这个承诺。否则这句提示会掩盖真正的数据丢失风险。

一组能快速判断链路是否健康的日志

我后来为音频处理过程增加了四项计数。下面是重新构造的示例日志:

text 复制代码
[Audio] captured=256000 bytes
[Realtime] queued=249600 bytes
[Realtime] confirmed=249600 bytes
[Local] written=256000 bytes checkpoint=ok

四个数字分别回答:麦克风有没有数据、数据有没有进入网络队列、Socket 是否确认发送、本地文件是否持续写入。

发生问题时,差值本身就是线索:

text 复制代码
captured > 0, queued = 0       -> 尚未满足发送条件,或实时功能没有启用
queued > confirmed            -> 网络发送积压或失败
captured > written            -> 本地写入出现问题,风险最高
confirmed 正常但没有字幕       -> 查服务端识别或下行事件

只打印"WebSocket connected"远远不够。它无法回答最重要的问题:声音到底去了哪里。

实时反馈不能代替完整录音

实时字幕很容易让人产生一种错觉:屏幕上已经出现文字,录音应该也安全保存了。实际上,显示字幕和保存完整录音是两件事。

只有在本地独立保存音频、持久化任务 ID,并能根据任务状态选择恢复方式之后,实时字幕才适合把速度放在首位。网络顺畅时,用户可以很快看到结果;网络出现故障时,几十分钟的录音也不会因为一次 Socket 断开而丢失。

相关推荐
Cc.Y1 小时前
Swift深度链接的极简之道
ios·cocoa·swift
jason.zeng@15022071 小时前
(七)「固化 Rest 接口 + Text-to-SQL 灵活查询」双模式 Agent 架构教程
数据库·python·sql·ai·架构·langchain·ai编程
挖掘狂人2 小时前
AI工具选型误区:别再迷信海外模型,国产工具已完成场景反超
大数据·人工智能·ai编程
网络毒刘2 小时前
Rules 冲突排查:多条规则互相打架时如何用优先级、范围与示例消歧
agent·ai编程·cursor·rules
洞窝技术2 小时前
Jev从入门到实战-读懂System One模型并跑通智能if语句
ai编程
冉冉同学2 小时前
AI Agent 开始操作真实手机:移动端自动化的 3 个新考点
android·ai编程
网络毒刘2 小时前
端到端:用 Cursor Agent 完成「小功能 + 单测 + PR 描述」并附人工验收清单
单元测试·agent·ai编程·cursor·工具实践
hudou_k2 小时前
使用WorkBuddy开发项目的实践经验
ai编程·workbuddy
熊猫钓鱼>_>3 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 Landscapist 实现图片加载适配
开发语言·kotlin·华为云·ai编程·harmonyos·鸿蒙·openharmony