feat(voice): add local VAD speech endpoint detection

This commit is contained in:
chenjintao
2026-10-23 10:59:26 +08:00
parent ee32b4d63f
commit f17ce857ff
17 changed files with 1183 additions and 141 deletions
+30
View File
@@ -0,0 +1,30 @@
# ADR 0006: 本地语音端点检测所有权
## 状态
Accepted(实现与主机/构建验证;声学阈值待实机验收)
## 决策
- `domain` 独占本地语音端点契约、VAD 实例、分帧和连续人声/静音状态,声明于
`domain.h`。不向其他 facade 复制这些契约。
- `platform` 保持通用 PCM 观察者原语,将单订阅扩为固定两个槽位,供本地命令
和端点检测共享同一份 AEC 后 PCM。回调同步执行、不可阻塞,不新增采集任务。
- `control_plane` 在真实网络拾音启动后按 PTT generation 启用检测,查询领域快照,
只结束匹配代次的活动输入;保留 8 秒兜底和现有状态机的唯一收尾路径。
- 端点检测不在回调中停止采集或发送协议。平台停止先与在途采集串行,再由原采集任务补齐
最后一个 Opus 帧、限时排空发送;失败时废弃传输,不宣告正常结束或无限重试。
- 停止端点检测先禁用订阅并等待在途回调,再释放 VAD。超时保留资源供重试,
不安排后台释放。快照按值返回,所有 VAD 状态由互斥锁保护。
## 迁移与资源
1. 先扩展通用 PCM 订阅和明确音频停止语义,维持旧调用兼容。
2. 新增领域检测器和可关闭配置,再接入现有 PTT 推进器。
3. 主机验证实际 C 的分帧、代次、失败释放、订阅隔离和尾包顺序,构建启用/关闭配置。
4. 实机检查慢语速/停顿/噪声/远场/提示音、唤醒和按键、取消/断网/OTA,记录
端点到 StopSpeech/首个下行/播放的分段延迟及内部 RAM 最低余量。
固定检测帧为 320 个 int16 样本(640 B),不新增任务、队列或 DMA 缓冲。
WebRTC VAD 的库内分配及采集任务栈峰值需日志和设备测量,不能用帧大小冒充总开销。
检测初始化失败回退原输入窗口,不影响流式上行。
+31 -38
View File
@@ -1,5 +1,10 @@
# 语音对话上行流程详解
> 2026-10-15 更新:默认单击/本地唤醒输入增加本地 VAD(确认 200 ms 人声后,
> 连续 900 ms VAD 静音提前收尾),保留 8 秒兜底。参数、所有权和验收见
> [本地语音端点检测](../local-speech-endpoint.zh-CN.md)。以下松键流程也适用于
> VAD/超时触发的统一 PTT 收尾。
## 1. 文档目的
本文结合当前工程代码,完整说明设备从用户按下语音键开始,到麦克风音频被编码为 Opus 并通过 WebSocket 发送给服务端为止的上行流程。
@@ -72,9 +77,11 @@ WebSocket Binary 发送
```text
用户松开 PTT
↓
停止产生新的 PCM/Opus 包
停止新增 PCM,等待在途采集迭代完成
↓
限时等待发送队列排空
采集任务为不足一帧的 PCM 补零编码
↓
限时等待发送队列和在途包排空
↓
清除上行发送回调
↓
@@ -458,6 +465,7 @@ while (!audio_get_uplink_task_exit_requested(ctx)) {
```c
if (!audio_get_uplink_recording_active(ctx)) {
audio_flush_uplink_tail(ctx);
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100));
continue;
}
@@ -780,12 +788,15 @@ delete_audio_task_with_caps(NULL);
## 8. `uplink_send_task()` 如何发送 Opus
发送任务循环等待队列:
发送任务先在锁内标记 busy,再零等待出队;空队列时清 busy,并等待任务通知
(最多 20 ms)。正常入队后立即通知发送任务,不额外等待轮询周期:
```c
xQueueReceive(ctx->uplink_send_queue, &packet, pdMS_TO_TICKS(100));
xQueueReceive(ctx->uplink_send_queue, &packet, 0U);
```
停止方先查队列再查 busy,避免包刚出队、尚未标记在途时误认为已经排空。
取到有效包后,快照保存好的回调:
```c
@@ -874,51 +885,33 @@ WebSocket Binary 的 payload 是原始 Opus 包,不使用 RTP 封装。
## 11. 停止上行流程
用户松开 PTT 后,Control Plane 执行:
VAD、8 秒兜底或松键将活动输入送入同一 PTT teardown。只有平台停止成功后
才发送 `StopSpeech`;失败则废弃该轮并关闭传输,不能把未排空当成成功。
```c
control_plane_multimodal_stop_uplink();
control_plane_multimodal_send_speech_stop();
```
### 11.1 停止采集与尾帧补齐
### 11.1 停止产生新包
生命周期锁串行化 start/stop。停止方将 `uplink_recording_active` 设为 false,
再获取 capture 锁,等待当前 I2S read/AEC/编码迭代完成。
不足 Opus 帧长的 PCM 交回原采集任务补零编码,不挪到控制线程的小栈。
采集任务与调用方通过受锁保护的尾帧请求/结果握手,不新增编码 worker。
平台层首先执行:
### 11.2 有界排空
```c
audio_set_uplink_recording_active(&s_audio, false);
```
停止预算覆盖 capture 锁等待、尾帧处理、发送队列和在途包等待。
负超时无效;零超时只尝试立即完成,无法完成返回 `ESP_ERR_TIMEOUT`。
超时保留缓冲、回调和待发送数据供重试,不安排后台资源销毁;旧轮未收尾前
新 start 返回失败,不得复用旧数据。常驻采集/发送任务只在 deinit 回收。
采集任务下次循环时进入通知等待,不再读取 Codec,也不再产生新的 Opus 包。
### 11.3 清除回调与协议结束
### 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。
`StopSpeech sent after uplink drain` 日志可与 VAD endpoint 日志对齐。
如果平台停止失败,PTT 收尾走有界传输废弃路径,不无限重试或继续正常提交输入。
## 12. 其他会触发停止上行的情况
+90
View File
@@ -0,0 +1,90 @@
# 本地 VAD 语音端点检测
更新:2026-10-15。适用于 lucky-cat;实现所有权见 [ADR 0006](adr/0006-local-speech-endpoint.md)。
## 当前行为
音频仍然边采集边上传。服务端保持 `push2talk`,设备用本地 VAD 更早决定何时
发送 `StopSpeech`,不切换云端识别模式或修改语音回复语义。
| 配置 | 默认值 | 含义 |
| --- | --- | --- |
| `CONFIG_TQ_SPEECH_ENDPOINT_ENABLE` | y | 仅单击对话模式自动判停,可关闭回退 |
| `CONFIG_TQ_SPEECH_ENDPOINT_MIN_SPEECH_MS` | 200 | 开始判停前需要连续有效人声 |
| `CONFIG_TQ_SPEECH_ENDPOINT_SILENCE_MS` | 900 | 确认人声后,连续 VAD 静音达到该值结束输入 |
| `CONFIG_TQ_PTT_CLICK_CAPTURE_TIMEOUT_MS` | 8000 | 保留原最长窗口兜底 |
本地唤醒仍先完成联网和本地回应提示音,再开始网络拾音和 VAD。按键入口复用
原窗口起点,可能包含连接等待;本次不改变这一点。持续静音或未达到人声阈值的
短促声音不触发提前结束,继续走原超时。阈值以 20 ms 检测帧向上取整。
900 ms 指 VAD 输出连续静音,不等于从物理声音结束到 StopSpeech 的精确时长;
库内判定延迟、20 ms 分帧、现有 80 ms 控制轮询、调度及尾包发送都会影响时间。
句中停顿超过阈值可能被截断;慢语速、轻声、儿童、远场及噪声需实机调参。
## 所有权、并发与收尾
- `platform` 提供固定两个 PCM 观察者槽位,同一份 AEC 后 16 kHz 单声道 PCM
分别交给已有本地命令观察者与领域端点检测器。无第二条采集链或新音频队列。
- `domain_speech_endpoint.c` 使用 WebRTC `vad_create(VAD_MODE_1)` 和 `vad_process()`,
将输入重分帧为 320 样本。PCM 回调零等待取锁,最多处理 512 样本,不执行
协议、等待或资源销毁;状态查询和生命周期使用有界互斥锁。
- 丢失回调、非法输入或超过 100 ms 的输入间断重置连续人声/静音计时,保持保守
判停。只保留“曾确认有人声”,不能把缺失数据当静音补算。
- 控制层只接受活动 `STREAMING`、提示音门禁关闭且 generation 匹配的结束快照。
旧事件不会结束新一轮。初始化失败记录日志,保留原超时。
- 每轮仅尝试启动一次检测。收尾/取消/正常停机注销订阅并释放 VAD;注销超时
禁用订阅、保留实例和在途计数以便重试,不后台释放。
- 平台停止先暂停并等待在途采集,再由原采集任务补零编码尾帧。编码留在原
32 KB 采集任务栈,控制线程只等待结果。发送队列与在途包全部排空后才正常
发送 `StopSpeech`。停止失败走传输废弃路径,不提交正常结束、不无限重试。
- stop 超时后仍可能完成之前请求的尾帧和发送;资源保留,禁止启动新一轮,必须
重试收尾或走 deinit。deinit 才拥有常驻 worker 的退出/join/强制删除和资源销毁。
## 日志与资源
- `VAD armed`:代次、阈值及启用后的内部 RAM/PSRAM 空闲快照。
- `VAD endpoint`:代次、处理音频时长、连续静音、最大回调处理墙钟时间与间断数。
- `StopSpeech sent after uplink drain`:正常输入结束指令已发出。
- `VAD unavailable; retain capture timeout`:本轮回退原输入窗口。
- `Abort voice turn after incomplete drain`:失败废弃,不能记作成功回复。
固定 PCM 重分帧数组为 640 B,另有领域状态/互斥锁和平台第二个观察者槽、
采集与生命周期锁。不新增 worker、任务栈、DMA 或模型分区资源。
VAD 库内部动态分配、原采集栈高水位和联网后的最小内部 RAM 必须实机测量;
`internal_free` 是并发系统的空闲快照,不能直接当成 VAD 的精确分配量。
`max_process_us` 为墙钟时间,含抢占,不是纯 CPU 时间。PSRAM 不增加固定像素/
音频队列缓冲,但库分配采用的实际堆区域仍以运行日志为准。
## 验证与验收
`python3 tools/test_speech_endpoint.py` 编译并执行生产 C,使用可控 VAD/RTOS 桩:
覆盖分帧、纯静音、短噪声、句中停顿、代次、输入间断、初始化失败、注销超时、
订阅并发替换、队列出队与 busy 竞态、原采集上下文尾帧补零、排空与失败重试。
这些测试验证控制逻辑,不验证真实声学识别率或 ESP32 时序。
实机至少检查:
1. 按键和唤醒各测试短句、长句、未说话、轻声、噪声和不同长度句中停顿。
2. 连续多轮、快速取消/重新触发、服务端提前结束、TTS 播放及唤醒提示音门禁。
3. 拾音期间断网、OTA/待机切换、发送拥塞、内存不足与重复初始化。
4. 记录最后人声 → endpoint → StopSpeech → 首个下行音频 → 实际播放的分段延迟;
同时统计误截断、未判停、队列丢包、回调耗时、栈高水位和内部 RAM 最低余量。
本次构建/主机验证结果见下方记录;没有实机日志前,不承诺提速秒数或声学阈值合适。
### 2026-10-15 本次验证记录
- 主机:`test_speech_endpoint.py` 全部通过,启用/关闭分支均编译并执行;
五个现有 `test_lcd_{status_hud,emote_transport,emote_timeline,listening_gfx,fortune_gfx}.py`
回归脚本通过(脚本自带的 profiling 开/关路径也通过)。
- 构建:ESP-IDF 5.5.4,VAD 开启和关闭两个独立 sdkconfig 均构建成功;
项目默认配置保持开启。关闭配置与构建在临时目录,未覆盖项目 sdkconfig。
- 固件:开启 3,167,888 B,关闭 3,157,248 B,开关差异 +10,640 B;
同源 MAP 的内部静态 data +16 B、BSS +832 B,合计 +848 B,IRAM 代码无增量。
这是同一份修改中 VAD 开关的对比,双方均包含共享 PCM/尾包收尾修正,
不能当成整个变更相对旧提交的净成本。模型分区未变,没有新增固定 PSRAM/DMA 缓冲。
- 架构:原检查脚本在已有 `control_plane_battery_service.c` 的 `portMAX_DELAY`
处失败,已对照 HEAD 确认不是本次引入。仅排除该既有检查后,其余架构检查通过;
本次新增路径无无限等待或跨层内部头依赖。`git diff --check` 通过。
- 设备:未烧录,尚无实机误截断率、延迟收益、VAD 动态堆和栈峰值数据。