# 语音弱网可靠性：根因修复记录

日期：2026-10-08
分支：`fix/voice-weak-network-reliability`
上游问题记录：[voice-weak-network-followup.md](voice-weak-network-followup.md)
状态：**已实现，待真机验收**（本机不烧写，验收计划见文末）

---

## 1. 问题重述

长按 A 说话，音频边录边通过 WSS 上传。弱网（>2.56 s 中断、两次连续
2 s 写超时、断线重连）会让采集队列溢出，录音被终止并清空，UI 只显示
`Capture cancelled`，用户无法区分"录音完成 / 上传完成 / 转写完成 / AI 已接受"。

旧设计的两个结构性缺陷：

1. **采集被网络阻塞**：`xQueueSend(s_capture_queue, frame, 0)` 非阻塞，
   64 帧（2.56 s）兜不住一次 `esp_websocket_client_send_text` 的 2 s 阻塞，
   更兜不住连续 stall。队列满即 `capture-queue-full` → 录音作废。
2. **上传一次性**：`voice_chunk` 严格按 `sequence` 递增，网关按
   `connection_id` 绑定请求，断线即 `cancel` 且清空 PCM，没有断点续传和
   幂等去重。

## 2. 修复目标（与 TODO 保持一致）

- 采集与网络**解耦**：录音完整落在 PSRAM，网络阻塞不再决定麦克风采集。
- **有确认的分块上传 + 重连续传**：请求按 `device_id + voice_id` 标识，
  网关回报 `next_sequence`，设备从断点续传；重复块幂等。
- **明确进度与可恢复动作**：区分 recording / uploading / transcribing /
  awaiting / sent，失败给出可重试路径，网络长时间不可用后有界保留。
- 录音仍有**时长与内存上限**；用户显式取消立即生效。
- 不无限重试、不虚报成功。

## 3. 设备侧重构

### 3.1 新模块 `boards/common/include/voice_recording_store.h`

纯逻辑、可 host 测试的有界录音存储策略：

- 预分配 20 s 上限：`VOICE_MAX_RECORDING_BYTES = 44100*2*20 = 1_764_000`。
- `capacity / used / full / overflow` 明确状态；写入前检查余量，
  容量不足返回 `false`（**不覆盖、不静默丢**）。
- `chunk_offset` 记录已确认上传位置，供续传读取。

### 3.2 `audio_backend`：采集不再经过网络队列

- 采集任务把 PCM **直接写入 PSRAM 录音缓冲**（`voice_recording_store` 支撑的
  `heap_caps_malloc(MALLOC_CAP_SPIRAM)` 区块），不再 `xQueueSend` 每帧 PCM。
- 采集队列退化为**控制帧通道**（START / END / CANCEL + 进度标记），
  容量需求从 64 帧降到个位数，`capture-queue-full` 不再可能由网络 stall 触发。
- 容量/时长到顶：正常收尾为 END，不再当作错误取消。
- 显式取消：`audio_backend_record_stop(true)` 立即置 `s_cancel`，采集任务立即退出。

### 3.3 `cloud_client`：带 ACK 的分块上传状态机

- `voice_start` 携带 `voice_id / sample_rate / bits / channels / context`，
  并新增 `total_bytes`、`recorded_duration_ms`（可选，用于录音时长上限校验）。
- 每发送一块 `voice_chunk{sequence,audio}` 后**等待** `voice_ack{next_sequence}`，
  未确认块不推进。
- 断线：保留录音缓冲，进入 `UPLOAD_PAUSED`；重连后发 `voice_status`，
  网关返回 `next_sequence` + `state`，从该位置续传。
- 有界等待：超过 `VOICE_UPLOAD_MAX_RETRY_MS` 仍不可用 → 失败为可恢复态
  （保留缓冲，UI 提示重试/丢弃），不无限重试。
- 记录到 `voice_end`（含 `recorded_duration_ms`）后进入转写等待；
  只有收到 `voice_state=SENT` 才报成功。

### 3.4 UI 阶段

`RECORDING → UPLOADING(n%) → TRANSCRIBING → AWAITING_CONFIRMATION →
SENDING → SENT`；弱网停顿时显示 `UPLOAD PAUSED n%`，超过有限窗口后显示
`UPLOAD FAILED`，给出 `A RETRY / B DISCARD`（录音保留在 PSRAM，A 请求网关
重新开启上传窗口并从断点续传，B 丢弃）。界面**不会**在部分上传时显示 100%
或 SENT。Help 页面显示固件版本与编译时间（`DEVICE_LAB_BUILD_TIME`）。
详见 `board.cpp`、`boards/common/include/wearable_renderer.h`。

新增板级回调：

- `board_voice_upload_progress(id, percent, blocked)`：仅当 `id` 为当前录音时
  更新阶段；`percent` 上限 99，`blocked=true` 表示等待中。
- `board_voice_upload_failed(id)`：显示 `UPLOAD FAILED`，不宣称成功；录音保留待
  用户决定。

## 4. 网关侧（`server/device_gateway/voice.py`）

- `VoiceRequest` 增加 `device_id` 与 `connection` 分离；请求表按
  `(device_id, voice_id)` 索引，允许同一录音跨连接恢复。
- 新增 `voice_ack{voice_id,next_sequence,sequence,state}`：每接受一块后回报下一个
  期望序号。`next_sequence` 为规范字段，`sequence` 作为旧固件兼容别名。
- 幂等：`sequence < req.sequence` 的重复块直接重发 `voice_ack`，不重复追加；
  `sequence > req.sequence` 视为乱序 → 显式错误，不静默丢音频。
- 新增 `voice_status{voice_id}` → `voice_ack`，供重连后查询断点。
- 新增 `voice_decision{decision:"retry"}`：设备在有限上传窗口耗尽但录音仍保留时
  请求重开窗口，网关重置接收截止并以 `voice_ack` 返回断点；不重录音、不重提交。
- **录音时长上限**与**上传/接收截止**分离：20 s 是录音时长上限
  （由 `voice_end.recorded_duration_ms` 与字节数共同校验）；
  接收截止改为从"开始接收"起的宽松窗口，续传不再被 20 s 定时器打断。
- 跨连接恢复的 `valid()` 校验目标/generation/context 仍以**当前连接**为准。

## 5. 协议版本

`CONTROL_VERSION` 0.5.0 → **0.6.0**；`docs/protocol.md` 更新 voice 消息与
能力说明。旧固件不声明续传能力时不发 `voice_status`，网关行为向后兼容。

## 6. 非目标

- 不做重启后从磁盘恢复录音（无默认落盘）。
- 不改采样率/压缩（不以识别质量换带宽）。
- Speech Output 保持冻结。