# 语音上传时延与录音上限：问题分析与待办

- 记录日期：2026-10-09（Asia/Shanghai）
- 状态：**仅分析，尚未改动任何代码**；本机不刷写，未启动或停止任何服务。
- 上游背景：[voice-weak-network-fix.md](voice-weak-network-fix.md)、
  [voice-weak-network-round2-acceptance.md](voice-weak-network-round2-acceptance.md)
- 关联提交（分析时的代码基线）：`0391464 fix: move microphone drain buffer off capture task stack`
- 触发来源：2026-10-09 实体验收失败的复盘（`dist/physical-acceptance-20261009/`）之外，
  用户对**交互时延**与**录音时长上限**提出的两个独立疑问。

---

## 0. 结论摘要

1. **"先传完再转文字"是真实的结构性时延**，但瓶颈不是带宽，而是**块数 × RTT**：
   停等式上传把 40 ms 音频块一个一个发、一块一等 ACK，20 秒录音 = **500 次 RTT**。
2. **改动难度分三档**，其中"边录边传"会推翻刚验收的"传输卡住绝不污染麦克风"设计前提，
   建议排除；**窗口化发送**与**降采样 + 放大块**才是可行方向。
3. **录音上限是硬编码的 20 秒**（`static_assert` 钉死），其本质是 **PSRAM 预算 ÷ 码率**，
   不是语音学选择；44.1 kHz 对 STT 是过采样，其中一大半上限被"过采样税"吃掉。
4. **超过 20 秒会被静默截断**：设备侧停止采集但**无任何提示**，是最需要修的交互缺陷。
5. "什么时候超过就很难处理"的准确临界点不是 20 秒，而是 **RTT ≈ 40 ms** ——
   此时上传耗时开始等于录音耗时，用户体验从"慢"变成"像卡住"。

---

## 1. 当前流水线：严格串行的三段

```
录音结束 → ① 全量上传（逐块停等 ACK）→ ② 一次性 STT → ③ 转写结果等用户确认 → ④ 发送给 Agent
```

### 1.1 上传阶段是真正的瓶颈

设备侧 `firmware/main/cloud_client.cpp`（`upload_until_ack`）：

```cpp
while (wearable::voice_chunk_ready(sequence, recorded, kChunkBytes, ended)) {
    send_json(chunk);                       // 发一块
    // 阻塞等待这一块的 ACK，超时 VOICE_SEND_TIMEOUT_MS = 2000 ms
    ...
    sequence = acked;                       // 收到 ACK 才允许发下一块
}
```

**一次只有一个块在途**，因此总耗时 ≈ 块数 × RTT。

### 1.2 实测参数（决定块数）

| 项 | 值 | 来源 |
| --- | --- | --- |
| 采样率 / 位深 / 声道 | 44100 Hz / 16-bit / 单声道 | `boards/m5stack_stopwatch/audio_backend.cpp:33` |
| 每块 PCM | 3528 B = **40 ms** 音频 | `AUDIO_CAPTURE_BYTES`，`boards/m5stack_stopwatch/audio_backend.h:22` |
| base64 后每块 | 4704 B（+33% 膨胀） | `firmware/main/cloud_client.cpp:474` |
| 20 s 录音总量 | 1764000 B ≈ 1.68 MiB | `VOICE_MAX_RECORDING_BYTES` |
| 20 s 录音块数 | **恰好 500**（无余数） | 1764000 ÷ 3528 |
| 网关单块上限 | 4410 B | `CHUNK_BYTES`，`server/device_gateway/voice.py:25` |

**示例**：500 块 × 50 ms RTT ≈ **25 秒上传换 20 秒语音**，STT 还没开始算。
带宽（1.68 MiB）本身很小，不是问题；问题在块数。

### 1.3 STT 与后续阶段

- STT 走单个 HTTP 请求，总超时 25 s（`server/device_gateway/voice.py:63`），
  模型为 `@cf/openai/whisper-large-v3-turbo`（`voice.py:32`）。
- STT 完成后进入 `AWAITING_CONFIRMATION`，**必须用户再确认**才发送给 Agent
  （`voice.py:429`）。这是设计上的有意停顿，不是缺陷。

---

## 2. 改动难度分三档

### 档位 1 —— 窗口化发送（推荐，中等难度，收益最高）

把"停等式"改成"滑动窗口"：允许 N 块在途，ACK 回来推进 `sent_limit`。

设备侧已有现成基础：

- `voice_ack_valid(next, sent, handshake, sent_limit)` 已支持"握手可回到 `sent_limit`"
  的语义（`boards/common/include/voice_upload_policy.h`）。
- `sent_limit` 变量已在 `cloud_client.cpp:477` 维护。
- 网关侧 `req.sequence` 严格校验已存在（`voice.py:383`）。

**收益上界**：500 × RTT → 约 (500/N) × RTT。N = 4~8 即可把 20 秒语音的上传
压进 1~2 个 RTT 量级。

**代价与风险**：乱序与重传语义需要收紧（复用 ACK 校验路径）；
弱网验收的 B/C/D 组必须重跑（丢 ACK、断网恢复、重复 chunk）。

### 档位 2 —— 边录边传（不建议，改动最大）

当前架构**刻意**禁止这样做，且注释写明了原因
（`audio_backend.h`、`boards/common/include/voice_capture_limits.h`）：

> PCM no longer traverses this queue; it carries control frames only, so a
> stalled transport can never overflow it and abort an otherwise valid capture.

即"**传输卡住绝不能污染麦克风**"。边录边传要求上传与录音解耦，
同时保住"卡住不截断录音"—— 这正是本轮弱网修复费力建立的保证。
**动它会推翻刚验收的设计前提**，风险与收益不成比例。

### 档位 3 —— 降采样 / 压缩（推荐与档位 1 配合）

44.1 kHz 单声道对 STT 是过采样（Whisper 内部按 16 kHz 处理）。降到 16 kHz：

| | 现在 (44.1 kHz) | 降到 16 kHz |
| --- | --- | --- |
| 每块 40 ms | 3528 B | 1280 B |
| 20 s 总量 | 1.68 MiB | 640 KB |
| 20 s 块数 | 500 | **500（不变）** |

**重要**：降采样把带宽降到 1/2.75，但**块数不变**，所以对"500 × RTT"这个主导项
几乎没有帮助。只有**同时**把块放大到 100~200 ms，块数才能降到 100~200；
再配合档位 1 才是真正的数量级改善。

**副作用**：`AUDIO_SAMPLE_RATE` 同时被**播放链路**（TTS 回放）与**提示音**使用
（`audio_backend.cpp:109/144/162`）。改采样率必须单独确认播放侧影响，
不能只改采集。

---

## 3. 录音上限：20 秒，硬编码

### 3.1 上限的强制方式

`boards/common/include/voice_capture_limits.h:48`：

```cpp
static_assert(VOICE_MAX_RECORDING_BYTES == 1764000,
              "20 s of 44.1 kHz 16-bit mono must remain the recording cap");
```

1764000 B ÷ 88200 B/s = **恰好 20.000 秒**。三层一致：

| 层 | 值 | 位置 |
| --- | --- | --- |
| 设备容量 | 1764000 B | `VOICE_MAX_RECORDING_BYTES` |
| 网关录音上限 | `MAX_SECONDS = 20` | `server/device_gateway/voice.py:22` |
| 上传窗口 | `MAX_UPLOAD_SECONDS = 600` | `voice.py:29` |

### 3.2 20 秒这个数字的来源

**是 PSRAM 预算除以码率，不是语音学选择。** 那份 1.68 MiB 是为了
"上传卡住不丢录音"而**整段常驻 PSRAM**。因此上限 = 愿意为一段录音占用多少 PSRAM
÷ 码率。这也是"上限问题"和"降码率问题"本质是同一个问题的原因：
**降到 16 kHz 后，同样 1.68 MiB 可存约 55 秒。**

### 3.3 超限时的实际行为（设备与网关不一致）

**设备侧**（`boards/m5stack_stopwatch/audio_backend.cpp:336`）：

```cpp
while (ok && !s_stop && !s_cancel && total + AUDIO_CAPTURE_BYTES <= s_recording_capacity)
```

写满即**停止采集**：不报错、不提示，但仍会把已录的 20 秒完整上传。
用户体感是"我一直在说，但最后几秒没被记下来"—— **无反馈的静默截断**。

**网关侧**（`voice.py:477` 的 `cap_reached`）：20 秒定时器到点**不做转写**，
转入 `SUSPENDED`：

> The cap exists to bound memory, not to declare the recording finished.
> Suspend instead: the PCM and the acknowledged sequence survive so a real
> voice_end can still arrive.

这个设计是对的（避免把截断上传当完整录音转写），但意味着长录音的结局
取决于设备是否还能发出 `voice_end`，最终由 `MAX_UPLOAD_SECONDS = 600` 兜底释放。

---

## 4. 各时长区间的现实判断

| 时长 | 判断 | 原因 |
| --- | --- | --- |
| < 1 s | 直接拒收 | `voice_recording_too_short`（<= 1000 ms）；网关同样校验并 cancel |
| 1~5 s | 最舒服 | 上传 125 块；转写短、准确率高 |
| 5~15 s | 合理 | 演示与日常可用；上传 125~375 块，弱网下可感到等待 |
| 15~20 s | 勉强 | 上传 375~500 块；弱网下上传时间可能**超过**录音本身 |
| > 20 s | **不合理，会被静默截断** | 设备停采且无提示；网关转 SUSPENDED 等 `voice_end` |

### 4.1 真正的临界点：RTT ≈ 40 ms

```
上传耗时 ≈ 500 × RTT × (录音秒数 / 20)
录音耗时 = 录音秒数
→ 交叉点：RTT ≈ 40 ms
```

**RTT 在 40 ms 上下时，上传时间与录音时间等长**；RTT 更差时，
"说完最后一句后的等待"会超过"说话时间"，观感从"慢"变成"像卡住"。
这解释了为什么演示"勉强够用"，也说明改善体验的关键是**压块数**
（RTT 属于不可控变量）。

---

## 5. 建议的优先处理项

### 5.1 先量化，再改协议（必做前置）

现在**没有任何实测分阶段耗时数据**，直接改协议等于猜。现有日志已能给出答案：

| 数据 | 日志来源 |
| --- | --- |
| STT 纯耗时 | `[VOICE_STT] event=complete ... duration_ms=%.1f`（`voice.py:541`） |
| 单块发送卡顿 | `[VOICE] tx-stall ... elapsed_ms=%lld`（`cloud_client.cpp:483`） |
| 上传段整体耗时 | `[VOICE] tx type=voice_end ... max_send_ms=%lld`（`cloud_client.cpp:723`） |

**判定准则**：计算 `上传秒数 / 录音秒数`。

- 比值 < 0.5 → 只需做档位 1。
- 比值 > 1 → 值得同时做档位 3 与静默截断反馈。

### 5.2 静默截断缺反馈（优先，改动小）

设备写满 capacity 时应立即发 `voice_end`，并让 UI 明确提示
"已达 20 秒上限"。纯设备侧改动，**不触碰弱网逻辑**，是性价比最高的一项。

### 5.3 降采样（性价比高，但需评估播放侧）

若降到 16 kHz：免改协议即可把上限提到约 55 秒、单块降到 1/2.75。
**但 `AUDIO_SAMPLE_RATE` 被播放与提示音共用**，必须先单独确认播放链路影响。

### 5.4 窗口化发送（收益最大，需重跑弱网验收）

见档位 1。改动集中在 `upload_until_ack` 循环与 ACK 推进逻辑，
需重跑弱网验收的丢 ACK / 断网恢复 / 重复 chunk 三组。

---

## 6. 演示场景的现实建议

**最省事的不是改协议，而是缩短单次语音**：说 5 秒而非 20 秒，
块数从 500 降到 125，上传时间按比例降到 1/4。零代码风险，立即可用。

---

## 7. 明确不在本文范围内

- 未改动任何代码；未启动/停止任何服务。
- 未做真机测量（本机不刷写；见 [flash-station.md](flash-station.md)）。
- 40 ms 的 RTT 交叉点是从常数推导的**量级判断**，不是实测值；
  需由 §5.1 的实际日志替换。
- 档位 1/3 的具体改动面与回归范围尚未细化，待专门处理时展开。