Files
lucky-cat/docs/knowledge vault/voice-uplink-flow-zh.md
T

1051 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 语音对话上行流程详解
## 1. 文档目的
本文结合当前工程代码,完整说明设备从用户按下语音键开始,到麦克风音频被编码为 Opus 并通过 WebSocket 发送给服务端为止的上行流程。
本文重点解释以下内容:
- PTT 按键如何驱动多模态会话状态机;
- 为什么必须先等待服务端进入 `Listening`;
- `SendSpeech`、音频采集和 `StopSpeech` 之间的关系;
- ES8311/I2S PCM 数据如何变成单声道 Opus;
- `uplink_capture_task()` 的每一段代码具体在做什么;
- 采集任务和发送任务为什么必须分开;
- 队列满、编码失败、Codec 读取失败和停止录音时如何处理;
- 一帧音频在当前配置下具体包含多少样本和字节。
主要代码文件如下:
| 职责 | 文件 |
| --- | --- |
| PTT 与会话编排 | `components/control_plane/src/multimodal/control_plane_multimodal_runtime.c` |
| 多模态协议和运行状态 | `components/control_plane/src/multimodal/control_plane_multimodal_session_service.c` |
| PTT 后台状态机 | `components/control_plane/src/runtime/control_plane_runtime_workers.c` |
| Domain 层适配 | `components/domain/src/runtime/domain_runtime_adapters.c` |
| Platform 多模态适配 | `components/platform/src/runtime/platform_multimodal_runtime.c` |
| 音频管线结构与配置 | `components/platform/src/multimodal/audio_pipeline.c` |
| 音频采集、编码、队列和停止逻辑 | `components/platform/src/multimodal/audio_pipeline/audio_pipeline_runtime_impl.c` |
| Codec 和 Opus Buffer 初始化 | `components/platform/src/multimodal/audio_pipeline/audio_pipeline_codec_impl.c` |
| WebSocket 实时发送 | `components/platform/src/multimodal/transport_ws/transport_ws_workers_impl.c` |
## 2. 上行流程总览
一次正常的语音上行链路如下:
```text
用户按下 PTT
↓
确认 Wi-Fi 和 WebSocket 已连接
↓
确认服务端多模态 Task 已创建
↓
等待服务端进入 Listening
↓
发送 SendSpeech 指令
↓
将本地 client_speech_active 置为 true
↓
启动或唤醒 uplink_capture_task
↓
ES8311 ADC / I2S RX 采集 PCM
↓
从 Codec 原始 PCM 中提取麦克风通道
↓
得到 16kHz、16-bit、单声道、60ms PCM
↓
Opus 编码
↓
写入 uplink_send_queue
↓
uplink_send_task 取出 Opus 包
↓
再次检查会话是否允许上行
↓
WebSocket Binary 发送
↓
服务端接收并执行 ASR
```
松开 PTT 时:
```text
用户松开 PTT
↓
停止产生新的 PCM/Opus 包
↓
限时等待发送队列排空
↓
清除上行发送回调
↓
发送 StopSpeech 指令
```
## 3. 工程分层与职责
上行过程跨越四层:
```text
app_composition
↓
control_plane
↓
domain
↓
platform
```
各层职责如下:
### 3.1 Control Plane
Control Plane 不直接操作 I2S,也不直接调用 ESP WebSocket API。它负责:
- 判断是否允许开始说话;
- 维护 task、Listening、Speech、Responding 等协议状态;
- 发送 `SendSpeech`、`StopSpeech` 等文本指令;
- 启动和停止本地音频上行;
- 在每个 Opus 包发送前再次检查协议状态;
- 在断线、服务端开始回答或发生错误时停止上行。
### 3.2 Domain
Domain 层在这条链路中主要承担接口边界适配。其函数通常直接把 Control Plane 的请求转发给 Platform,例如:
```c
esp_err_t domain_runtime_multimodal_audio_start_uplink(
domain_multimodal_audio_uplink_cb_t cb,
void *user_data)
{
return platform_multimodal_audio_start_uplink(cb, user_data);
}
```
### 3.3 Platform
Platform 层负责实际能力:
- ES8311 和 I2S 初始化;
- PCM 采集;
- 通道整理;
- Opus 编码;
- FreeRTOS 音频任务和队列;
- WebSocket 二进制发送。
## 4. PTT 按下后的状态推进
按键按下后,`control_plane_multimodal_handle_ptt_press_step()` 不会无条件启动麦克风,而是根据当前状态逐步推进。
核心检查顺序如下。
### 4.1 WebSocket 必须已经连接
```c
if (!state.connected) {
return ESP_ERR_INVALID_STATE;
}
```
没有可用传输连接时,即使采集了音频也无法发送,因此不能开始录音。
### 4.2 必须存在活动 Task
```c
if (!state.has_active_task) {
return control_plane_multimodal_send_start();
}
```
如果还没有服务端多模态 Task,当前按键步骤只负责发送启动 Task 的指令。任务创建是异步的,代码会在后续轮询中继续推进,而不是在一次函数调用中完成所有步骤。
### 4.3 服务端必须进入 Listening
```c
if (!state.server_listening) {
return ESP_ERR_INVALID_STATE;
}
```
这是协议层的重要门禁。设备必须先收到服务端的 `DialogStateChanged(Listening)`,才可以开始本轮语音输入。
如果在服务端尚未准备好时发送二进制音频,可能产生以下问题:
- 音频被服务端丢弃;
- 音频被错误归属到上一轮会话;
- 服务端返回协议状态错误;
- 设备本地已经录音,但 ASR 没有收到开头部分。
### 4.4 发送 SendSpeech
如果本地还没有激活语音输入:
```c
if (!state.client_speech_active) {
esp_err_t speech_err = control_plane_multimodal_send_speech_start();
if (speech_err != ESP_OK) {
return speech_err;
}
return ESP_ERR_INVALID_STATE;
}
```
`control_plane_multimodal_send_speech_start()` 最终发送:
```c
send_directive("continue-task", "SendSpeech", true, false);
```
发送成功后,本地立即更新:
```c
s_client_speech_active = true;
s_ptt_release_required = false;
s_request_to_speak_pending = false;
```
需要特别注意:当前代码中的 `PTT_STATE_WAIT_SPEECH_STARTED` 名称容易让人误以为设备会等待服务端返回 `SpeechStarted` 后才开始采集。实际逻辑是:
1. `SendSpeech` 发送成功;
2. 本地立即将 `client_speech_active` 设为 `true`;
3. 下一次状态机轮询即可启动上行;
4. 服务端返回的 `SpeechStarted` 主要用于表示 ASR 已经开始以及通知上层。
因此,真正启动本地上行的门禁是本地状态组合,而不是单独等待 `SpeechStarted` 事件。
### 4.5 启动音频上行
当以下条件全部满足时,代码调用 `control_plane_multimodal_start_uplink()`:
- WebSocket 已连接;
- 存在活动 Task;
- 服务端处于 Listening;
- 本地 `client_speech_active` 为 true;
- 不要求用户先松开按键;
- 服务端没有正在 Responding;
- 当前不在下行播放保护时间内。
调用链如下:
```text
control_plane_multimodal_start_uplink()
↓
domain_runtime_multimodal_audio_start_uplink()
↓
platform_multimodal_audio_start_uplink()
↓
multimodal_audio_start_uplink()
```
## 5. `multimodal_audio_start_uplink()` 做了什么
`multimodal_audio_start_uplink()` 并不直接执行一个永久采集循环。它负责配置并激活已经存在或即将创建的工作任务。
主要步骤为:
```c
audio_set_uplink_callback(&s_audio, cb, cb_ctx);
```
保存 Opus 包发送回调。采集任务只把包放入队列,真正的发送任务取到包后通过该回调交给 Control Plane。
```c
s_audio.uplink_mono_fill_bytes = 0U;
memset(s_audio.uplink_mono_buf, 0, s_audio.uplink_mono_buf_capacity_bytes);
reset_uplink_debug_stats();
```
清理上一轮残留状态、PCM Buffer 和调试统计。
```c
esp_err_t err = ensure_uplink_worker_tasks(&s_audio);
```
确保以下资源存在:
- `uplink_send_queue`;
- `uplink_capture_task`;
- `uplink_send_task`。
随后:
```c
reset_uplink_send_queue(&s_audio);
audio_set_uplink_recording_active(&s_audio, true);
xTaskNotifyGive(capture_task);
```
清空旧队列,将录音状态设为活动,并立即唤醒采集任务。
## 6. 为什么有两个上行任务
上行由两个 FreeRTOS 任务组成:
```text
uplink_capture_task
读取 PCM
提取通道
Opus 编码
写入队列
│
▼
uplink_send_queue
│
▼
uplink_send_task
读取 Opus 包
调用发送回调
WebSocket Binary 发送
```
拆分任务的根本原因是采集和网络的实时特性不同。
`esp_codec_dev_read()` 受到 I2S 音频时钟驱动,应当稳定地按音频速率读取。WebSocket 发送则可能因为以下原因出现不确定延迟:
- 等待发送锁;
- Wi-Fi 短时重传;
- TCP 发送缓冲区拥塞;
- TLS 加密处理;
- WebSocket 客户端状态切换。
如果采集和网络发送放在同一任务里,网络阻塞会让设备不能及时读取 I2S 数据,最终可能导致 DMA 溢出、丢帧或明显断音。
因此当前实现采用生产者—消费者模型:
- 采集任务是生产者;
- 发送任务是消费者;
- `uplink_send_queue` 是二者之间的缓冲区。
## 7. `uplink_capture_task()` 函数详解
函数签名如下:
```c
static void uplink_capture_task(void *arg)
```
### 7.1 函数签名
`static` 表示函数只在当前编译单元内部可见。
第一个 `void` 表示函数不返回结果。
`void *arg` 是 FreeRTOS 任务函数的通用参数。当前创建任务时传入的是 `NULL`,所以函数开头写:
```c
(void)arg;
```
这表示参数是接口要求保留的,但当前实现有意不使用它,可以避免编译器产生未使用参数警告。
正确签名是:
```c
static void uplink_capture_task(void *arg)
```
它不是一个返回 `void *` 的函数,也不是接收 `void **arg`。
### 7.2 获取音频运行上下文
```c
multimodal_audio_ctx_t *ctx = &s_audio;
```
`s_audio` 是整个音频管线的单例运行上下文。该结构保存:
- Codec 设备句柄;
- Opus 编码器和解码器;
- PCM/Opus Buffer;
- 音频格式;
- 上行队列;
- 采集和发送任务句柄;
- 录音活动状态;
- 任务退出状态;
- 上行发送回调。
使用局部 `ctx` 指针可以缩短后续表达式,也明确表示函数操作的是同一个共享音频上下文。
### 7.3 检查采集和编码资源
```c
if (!ctx->uplink_codec_buf || !ctx->uplink_mono_buf || !ctx->uplink_opus_buf ||
!ctx->opus_enc || ctx->uplink_mono_bytes == 0U || ctx->uplink_codec_read_bytes == 0U) {
```
这里检查三个数据阶段的 Buffer:
| 字段 | 内容 |
| --- | --- |
| `uplink_codec_buf` | Codec/I2S 读取出的原始 PCM |
| `uplink_mono_buf` | 提取麦克风通道后的单声道 PCM |
| `uplink_opus_buf` | Opus 编码器输出 Buffer |
同时检查:
- `opus_enc`:Opus 编码器是否已经创建;
- `uplink_mono_bytes`:每个 Opus 输入帧需要多少 PCM 字节;
- `uplink_codec_read_bytes`:每次应从 Codec 读取多少字节。
如果资源不完整:
```c
audio_set_uplink_capture_task(ctx, NULL);
delete_audio_task_with_caps(NULL);
return;
```
先把上下文中的任务句柄清空,再删除当前任务。清空句柄非常重要,否则其他代码可能认为采集任务仍然存在,从而不会重新创建它。
### 7.4 检查发送队列
```c
if (!ctx->uplink_send_queue) {
```
采集任务不直接发送 WebSocket。它编码出的 Opus 包只能写入 `uplink_send_queue`。如果队列不存在,继续采集只会产生无法消费的数据,因此任务直接记录错误并退出。
### 7.5 输出启动参数
启动日志包含:
- 任务栈大小;
- Opus 输入 PCM 大小;
- Opus 输出 Buffer 大小;
- Codec 每次读取大小;
- Codec 通道数量;
- 当前选择的麦克风通道。
这条日志可以快速判断音频格式和 Buffer 计算是否符合预期。
### 7.6 任务生命周期循环
```c
while (!audio_get_uplink_task_exit_requested(ctx)) {
```
这里存在两个不同的状态:
| 状态 | 含义 |
| --- | --- |
| `uplink_recording_active` | 当前是否需要采集、编码和入队 |
| `uplink_task_exit_requested` | 整个任务是否应该退出并销毁 |
松开 PTT 时通常只把 `uplink_recording_active` 设为 false,采集任务仍然存在,只是不再读取 Codec。
只有音频模块反初始化时,才会把 `uplink_task_exit_requested` 设为 true,使循环结束并真正删除任务。
因此任务生命周期是:
```text
创建任务
↓
等待开始录音
↓
连续采集和编码
↓
停止录音,重新等待
↓
下一轮再次采集
↓
音频反初始化
↓
任务退出
```
这种设计避免每一轮对话都重新创建任务和分配栈内存。
### 7.7 未录音时等待通知
```c
if (!audio_get_uplink_recording_active(ctx)) {
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100));
continue;
}
```
当录音没有激活时,任务通过 FreeRTOS Task Notification 进入阻塞等待。
`pdTRUE` 表示收到通知后清空通知计数。
`pdMS_TO_TICKS(100)` 表示最多等待 100ms。
开始上行时,`multimodal_audio_start_uplink()` 会执行:
```c
xTaskNotifyGive(capture_task);
```
所以任务通常会被立即唤醒,不需要等满 100ms。
等待仍然设置 100ms 超时,是为了让任务即使没有收到通知,也能定期返回循环顶部检查 `uplink_task_exit_requested`。如果永久阻塞,反初始化阶段可能无法及时结束该任务。
### 7.8 每次采集前检查运行资源
```c
if (!ctx->initialized || !ctx->codec_dev || !ctx->opus_enc) {
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
```
任务启动时资源有效,不代表运行过程中始终有效。音频模块可能正在反初始化、恢复或者切换状态。因此每轮采集前再次检查:
- 音频管线是否初始化;
- Codec 句柄是否有效;
- Opus 编码器是否有效。
暂时无效时等待 20ms 再试。延迟可以避免异常状态下形成高速空循环并占满 CPU。
### 7.9 从 Codec 读取 PCM
```c
int ret = esp_codec_dev_read(
ctx->codec_dev,
ctx->uplink_codec_buf,
(int)ctx->uplink_codec_read_bytes);
```
数据路径为:
```text
麦克风模拟信号
↓
ES8311 ADC
↓
I2S RX / DMA
↓
esp_codec_dev_read()
↓
uplink_codec_buf
```
`esp_codec_dev_read()` 通常是阻塞式读取。它等待底层 I2S/DMA 提供指定长度的数据,因此这个调用本身就按照音频采样时钟控制循环速度。
代码不需要在成功读取后额外等待 60ms。如果再增加固定延迟,采集速度可能低于真实音频产生速度,从而导致底层数据积压或丢失。
读取失败时:
```c
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "Mic read failed: 0x%x", ret);
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
```
当前帧被放弃,不会对无效数据进行编码。任务等待 20ms 后重试,而不是直接退出。
### 7.10 当前配置下一帧有多大
工程对语音格式有编译期约束:
```text
采样率:16000 Hz
位深:16 bit
通道:单声道
Opus 帧长:60 ms
Opus 码率:16 kbps
```
每个 60ms 帧包含:
```text
16000 × 0.060 = 960 个采样点
```
每个采样点为一个 `int16_t`,即 2 字节:
```text
960 × 2 = 1920 字节 PCM
```
所以当前正常值是:
```text
uplink_frames = 960
uplink_mono_bytes = 1920
uplink_codec_read_bytes = 1920(当前为单通道)
```
如果底层 Codec 返回双通道交错 PCM,则同样的 960 个时间采样点需要读取:
```text
960 × 2字节 × 2通道 = 3840 字节
```
但提取后的单声道数据仍然是 1920 字节。
### 7.11 计算 PCM 帧数
```c
size_t uplink_frames = ctx->uplink_mono_bytes / sizeof(int16_t);
```
这里的 `uplink_frames` 表示 PCM 采样时刻数量。当前为:
```text
1920 / 2 = 960
```
需要区分三个不同层次的“帧”:
- PCM frame:一个采样时刻,单声道时包含一个样本;
- Opus frame:当前包含 960 个 PCM 样本,也就是 60ms 音频;
- WebSocket frame:承载一个 Opus 包的二进制消息。
### 7.12 提取麦克风通道
```c
extract_mic_channel_16(
(const int16_t *)ctx->uplink_codec_buf,
ctx->fs.channel,
ctx->mic_capture_channel,
ctx->uplink_mono_buf,
uplink_frames);
```
`uplink_codec_buf` 的类型是 `uint8_t *`,但里面保存的是 16-bit PCM,所以先转换为 `const int16_t *`。
参数含义如下:
| 参数 | 含义 |
| --- | --- |
| `uplink_codec_buf` | 原始交错 PCM |
| `fs.channel` | 原始 PCM 通道数 |
| `mic_capture_channel` | 选择哪个通道作为麦克风 |
| `uplink_mono_buf` | 单声道输出 Buffer |
| `uplink_frames` | 要提取的采样点数量 |
假设原始数据为双通道交错格式:
```text
ch0_0, ch1_0, ch0_1, ch1_1, ch0_2, ch1_2 ...
```
选择通道 0 后输出:
```text
ch0_0, ch0_1, ch0_2 ...
```
选择通道 1 后输出:
```text
ch1_0, ch1_1, ch1_2 ...
```
当前工程强制单声道,因此最终通常选择通道 0。保留通道抽取层可以让 Opus 编码器始终面对统一的连续单声道 PCM,而不关心底层 Codec 数据如何排列。
### 7.13 PCM 调试导出
```c
audio_debug_dump_pcm(ctx->uplink_mono_buf, ctx->uplink_mono_bytes);
```
该函数位于通道提取之后、Opus 编码之前,用于调试最终准备上传的 PCM。
它可以帮助判断:
- 麦克风是否真正采到声音;
- 麦克风通道是否选择正确;
- 输入音量是否过低;
- 是否存在削波、底噪或直流偏置;
- 故障发生在硬件采集阶段,还是 Opus/网络阶段。
调试功能关闭时,该调用近似为空操作,不参与正式业务数据传输。
### 7.14 PCM 编码为 Opus 并入队
```c
esp_err_t err = encode_and_queue_mono_pcm(
ctx,
ctx->uplink_mono_buf,
ctx->uplink_mono_bytes);
```
该函数包含两个连续步骤:
```text
单声道 PCM
↓ esp_opus_enc_process()
Opus 包
↓ xQueueSend()
uplink_send_queue
```
Opus 编码核心为:
```c
esp_opus_enc_process(ctx->opus_enc, &in_frame, &out_frame);
```
输入为固定的 1920 字节 PCM,输出为压缩后的 Opus 包。按照 16kbps、60ms 计算,理论平均大小约为:
```text
16000 bit/s × 0.060s = 960 bit
960 / 8 = 120 字节
```
实际包大小仍可能受到编码参数和音频内容影响。
编码成功后,代码把 Opus 数据复制到独立队列元素:
```c
uplink_packet_t packet = {
.len = (uint16_t)out_frame.encoded_bytes,
};
memcpy(packet.data, ctx->uplink_opus_buf, packet.len);
```
必须复制,不能只把 `uplink_opus_buf` 指针交给队列。因为该 Buffer 会被下一轮编码复用,如果发送任务还没有处理上一包,数据就会被覆盖。
### 7.15 队列满时为什么丢旧包
如果队列无法及时接收最新包,代码会尝试先移除最旧包,再放入最新包。
其策略是:
```text
优先保持低延迟,而不是保证每个历史音频包都送达。
```
对于实时对话,如果网络持续变慢而所有旧包都保留,排队延迟会不断增加,最后出现“用户已经说完很久,服务端仍在接收旧语音”的情况。
丢掉一个旧的 60ms 包可能造成短暂语音损失,但能够阻止延迟无限增长。该取舍更符合实时对话场景。
### 7.16 记录输入电平和队列状态
```c
maybe_log_uplink_levels(...);
```
这一步可以统计:
- Codec 原始通道的平均绝对幅度和峰值;
- 提取后单声道 PCM 的平均绝对幅度和峰值;
- 已入队包数和字节数;
- 队列丢包数。
典型判断方式如下:
| 现象 | 可能问题 |
| --- | --- |
| 原始通道和单声道都接近 0 | 麦克风、Codec 输入、增益或 I2S RX 问题 |
| 原始通道正常,单声道接近 0 | 通道选择或通道抽取问题 |
| PCM 电平正常,但队列持续丢包 | WebSocket 发送速度不足或网络拥塞 |
| PCM 和队列都正常,但服务端无声音 | 会话门禁、WebSocket 二进制发送或服务端协议问题 |
### 7.17 错误分类
```c
if (err != ESP_OK && err != ESP_ERR_INVALID_SIZE && err != ESP_ERR_TIMEOUT) {
```
`ESP_ERR_INVALID_SIZE` 通常表示编码后的 Opus 包超过允许的最大尺寸,内部已经记录了专门日志。
`ESP_ERR_TIMEOUT` 通常表示上行发送队列拥塞,内部也有丢包统计和限频日志。
这两类错误不在外层重复打印,避免实时循环每 60ms 刷一条日志,进一步影响系统性能。
对于真正的编码异常,外层记录警告并等待 10ms,避免持续失败时形成高 CPU 占用的忙循环。
### 7.18 任务退出清理
循环结束后执行:
```c
audio_set_uplink_capture_task(ctx, NULL);
```
清空任务句柄,使其他生命周期代码准确知道任务已经退出。
随后记录:
```c
uxTaskGetStackHighWaterMark(NULL)
```
它表示任务运行历史中栈剩余空间的最低值,可用于判断栈配置:
- 数值过小:任务曾经接近栈溢出;
- 数值很大:任务栈可能配置过多。
最后:
```c
delete_audio_task_with_caps(NULL);
```
删除当前任务并释放创建任务时使用的相关资源。这里传入 `NULL` 表示删除当前正在运行的任务。
## 8. `uplink_send_task()` 如何发送 Opus
发送任务循环等待队列:
```c
xQueueReceive(ctx->uplink_send_queue, &packet, pdMS_TO_TICKS(100));
```
取到有效包后,快照保存好的回调:
```c
audio_snapshot_uplink_callback(ctx, &cb, &cb_ctx);
```
然后调用:
```c
esp_err_t send_err = cb(packet.data, packet.len, cb_ctx);
```
当前回调为:
```c
control_plane_audio_uplink_send_cb()
```
它继续调用:
```c
control_plane_multimodal_send_audio_realtime(data, len);
```
## 9. 每个 Opus 包发送前的协议门禁
Control Plane 不会因为采集任务已经启动,就无条件发送所有队列数据。每个包发送前都会检查:
```c
allow_uplink = s_task_id[0] != '\0' &&
s_server_listening &&
s_client_speech_active &&
!s_ptt_release_required &&
!s_remote_responding &&
!s_drop_downstream_audio;
```
具体含义:
| 条件 | 含义 |
| --- | --- |
| `task_id` 非空 | 存在活动多模态 Task |
| `server_listening` | 服务端允许接收语音 |
| `client_speech_active` | 本地处于 SendSpeech 生命周期内 |
| `!ptt_release_required` | 服务端没有要求用户先完成松键动作 |
| `!remote_responding` | 服务端没有正在输出 TTS |
| `!drop_downstream_audio` | 当前没有处于插话/丢弃旧响应状态 |
任何条件不满足,当前包都会返回 `ESP_ERR_INVALID_STATE`,不会通过 WebSocket 发送。
这种逐包检查非常重要,因为音频包在队列中等待期间,会话状态可能已经发生变化。例如服务端突然进入 Responding,此时队列里尚未发送的麦克风数据不能继续上传。
## 10. WebSocket 二进制发送
调用链如下:
```text
control_plane_multimodal_send_audio_realtime()
↓
domain_runtime_multimodal_transport_send_binary_realtime()
↓
platform_multimodal_transport_send_binary_realtime()
↓
multimodal_ws_send_binary_realtime()
↓
websocket_send_binary_internal()
↓
esp_websocket_client_send_bin()
```
实际发送 API:
```c
esp_websocket_client_send_bin(
client,
(const char *)data,
(int)len,
send_timeout);
```
发送前需要取得 WebSocket 发送锁,确保文本指令和二进制数据不会并发破坏底层客户端状态。
实时二进制发送策略只尝试一次,不在音频线程里进行长时间重试。原因是旧音频包即使稍后重发,也可能已经失去实时意义。
WebSocket Binary 的 payload 是原始 Opus 包,不使用 RTP 封装。
## 11. 停止上行流程
用户松开 PTT 后,Control Plane 执行:
```c
control_plane_multimodal_stop_uplink();
control_plane_multimodal_send_speech_stop();
```
### 11.1 停止产生新包
平台层首先执行:
```c
audio_set_uplink_recording_active(&s_audio, false);
```
采集任务下次循环时进入通知等待,不再读取 Codec,也不再产生新的 Opus 包。
### 11.2 等待队列排空
停止函数会在给定超时内等待:
- `uplink_send_queue` 为空;
- 没有正在发送的包。
这样可以尽量保证用户松键之前已经采集完成的最后几帧不会立即丢失。
如果超时仍未排空,代码记录警告并清空剩余队列,避免停止流程无限阻塞。
### 11.3 清除发送回调
```c
audio_set_uplink_callback(&s_audio, NULL, NULL);
```
这可以阻止旧队列数据在下一轮会话中误用上一轮发送上下文。
### 11.4 发送 StopSpeech
本地停止采集后,Control Plane 向服务端发送:
```c
send_directive("continue-task", "StopSpeech", true, false);
```
服务端据此结束本轮语音输入,并继续执行 ASR、LLM 和 TTS。
## 12. 其他会触发停止上行的情况
除了用户松开 PTT,以下情况也会停止麦克风上行:
- WebSocket 断开;
- WebSocket 发生错误;
- 服务端 Task 失败;
- 服务端退出 Listening;
- 收到 `SpeechEnded`;
- 服务端进入 `RespondingStarted`;
- 服务端返回协议 `Error`;
- 无下行响应看门狗超时;
- 用户取消本轮 Speech。
其中服务端开始 Responding 时停止上行,说明当前默认流程偏向半双工:设备播放服务端 TTS 时不继续上传麦克风音频。
## 13. 一帧上行音频的完整生命周期
以当前默认配置为例:
```text
时间范围:60ms
1. ES8311 以 16kHz 采集
产生 960 个采样点
2. 每个采样点为 int16_t
原始单声道 PCM 大小为 1920 字节
3. esp_codec_dev_read()
将 PCM 读取到 uplink_codec_buf
4. extract_mic_channel_16()
产生 1920 字节 uplink_mono_buf
5. esp_opus_enc_process()
压缩为大约 180 字节的 Opus 包
6. 复制到 uplink_packet_t
写入 uplink_send_queue
7. uplink_send_task 取出包
调用 Control Plane 回调
8. Control Plane 检查本包是否仍允许发送
9. esp_websocket_client_send_bin()
发送一个 WebSocket Binary 消息
```
在录音持续期间,上述过程约每 60ms 重复一次。
## 14. 常见问题定位
### 14.1 按键后完全没有采集日志
重点检查:
- WebSocket 是否已连接;
- Task 是否创建成功;
- 是否收到 Listening;
- `SendSpeech` 是否成功;
- `client_speech_active` 是否为 true;
- `multimodal_audio_start_uplink()` 是否执行;
- 采集任务和队列是否创建成功。
### 14.2 有采集日志,但 PCM 电平为 0
重点检查:
- ES8311 ADC 输入;
- I2S RX 配置;
- 麦克风偏置和硬件连接;
- Codec 输入增益;
- `mic_capture_channel`;
- 原始各通道电平。
### 14.3 PCM 电平正常,但没有 Opus 入队
重点检查:
- `opus_enc` 是否有效;
- `uplink_mono_bytes` 是否匹配编码器输入要求;
- `esp_opus_enc_process()` 返回值;
- Opus 输出是否超过最大包限制;
- 上行队列是否持续满载。
### 14.4 Opus 已入队,但服务端听不到
重点检查:
- `uplink_send_task` 是否运行;
- 每包发送前的 `allow_uplink` 条件;
- WebSocket 发送锁和发送超时;
- `esp_websocket_client_send_bin()` 返回长度;
- 服务端是否仍处于 Listening;
- 是否意外进入 `remote_responding` 或 `ptt_release_required`。
### 14.5 语音延迟越来越大
重点检查:
- `uplink_send_queue` 深度;
- 队列溢出和旧包替换日志;
- Wi-Fi 信号和 TCP 发送拥塞;
- WebSocket 实时发送耗时;
- 是否有其他高优先级任务长时间占用 CPU;
- TLS、LCD 或其他 DMA 用户是否影响音频实时性。
## 15. 核心理解总结
`uplink_capture_task()` 不是一次性的“录音函数”,而是一个长期存在的 FreeRTOS 生产者任务。
它只负责:
```text
读取 PCM → 提取麦克风通道 → Opus 编码 → 放入发送队列
```
它不负责:
- 创建多模态 Task;
- 判断服务端是否 Listening;
- 发送 `SendSpeech` 或 `StopSpeech`;
- 直接操作 WebSocket;
- 处理服务端 ASR 结果。
会话合法性由 Control Plane 管理,硬件采集和编码由 Platform 管理,网络发送由独立发送任务完成。三部分通过运行状态、回调和队列连接,从而在保证协议正确性的同时,降低网络波动对实时音频采集的影响。