用户按下录音按钮后,最希望看到的是文字马上出现在屏幕上,开发者自然会把注意力放在 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 成功连接,只能说明通道建立了。要判断一场录音能否直接使用实时结果,至少还要回答这些问题:
- 服务端是否已经发出可以发送音频的准备事件?
- 准备完成前产生的音频有没有进入缓冲?
- 缓冲有没有溢出?
- 发送队列中的数据是否都收到了成功回调?
- 录音中途是否发生断线或重连?
- 停止时,末尾尚未发出的音频有没有排空?
我会为每场录音记录"实时音频是否完整发送"。
初始值为完整,一旦确认发生丢包,或者断线后无法恢复,就把它改为不完整。
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 断开而丢失。