fix: 同步 Kapi 软 RTC 退出五连修到数字人项目(待命音 + 欢迎语杂音)

从 Kapi commit b1577d8 / a3a476f 完整移植 5 个修复,覆盖三类问题:
1. 开机/唤醒后按 BOOT 进 RTC 房间,欢迎语前 1-3 秒杂音
2. 软 RTC 退出(41s 无对话触发 Dialog watchdog)后待命音"卡卡正在待命"无声/杂音/被截
3. 软退出后按 BOOT 唤醒,欢迎语前杂音

【修复 1】OnAudioChannelOpened EnableOutput(true) 后立刻灌 200ms silence
  - 防止 I2S DMA 启动后到 RTC 真实 PCM 到达 1-3s 空窗的杂音

【修复 2】LeaveRoom 加 notify_closed 参数(默认 true 不变老路径)
  - hibernate 路径传 false 跳过 on_audio_channel_closed_ 回调
  - 避免回调链 player_pipeline_close → EnableOutput(false) 误关 codec
    导致待命音无声

【修复 3】LeaveRoom 不再 volc_rtc_destroy, 保留 rtc_handle_
  - 唤醒时 OpenAudioChannel 直接 volc_rtc_start 复用 handle, 不死循环
  - 服务端 AI 任务无需 destroy 也会按 180s 兜底机制清理

【修复 4 - 最隐蔽】LeaveRoom 末尾重置 downlink_is_pcm_ = false
  - 火山 RTC 下行是 PCM, DataCallback 设 downlink_is_pcm_=true
  - 不重置 → PlaySound 的 Opus 包被 OnAudioOutput 当成 raw PCM 字节流
    直接写 codec → 杂音而非待命音
  - 唤醒重连后 DataCallback 收下一包会自动重置, 不影响欢迎语

【修复 5】OnAudioInput 入口加 hibernating_ guard
  - hibernate 期间禁用输入侧, 防止访问关闭的 codec → std::bad_alloc abort
  - 不冻结 OnAudioOutput, 让待命音队列正常被消费

【EnterIdleHibernate 重写】套用 Kapi 新顺序:
  Step 0: hibernating_=true + 50ms (让 OnAudioInput guard 生效)
  Step 1: LeaveRoom(false) (codec output 保留)
  Step 2: background_task->WaitForCompletion
  Step 3: 清空 audio_decode_queue_
  Step 4: EnableInput(false) + close recorder_pipeline
  Step 5: 强制 esp_pm 禁用 Light Sleep
  Step 5.5: EnableOutput(false→true) + 200ms silence (清 LeaveRoom 副作用)
  Step 6: SetDeviceState(idle) → PlaySound 待命音
  Step 7: WaitForAudioPlayback (队列消费完毕)
  Step 7.5: background_task->WaitForCompletion + vTaskDelay(1000)
            (DMA + ES8311 FIFO + 功放尾音衰减, 防尾音截断)
  Step 8: player_pipeline_close
  Step 9: NVS idle_cycles_++
  Step 10: 显示字幕"已自动退出RTC对话..."(数字人特有, 保留)

【WakeFromHibernate】调整 hibernating_=false 顺序
  - 先放下 hibernating_, 让 ToggleChatState 期间 OnAudioInput guard 通过
  - 否则 ToggleChatState 期间音频上行迟迟不开

编译: kapi.bin 0x41c000 (4.21MB), 分区 25% 空闲。
实测三项全通: 欢迎语干净 + 待命音清晰完整 + 唤醒欢迎语干净。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rdzleo 2026-05-18 10:11:36 +08:00
parent eceadda807
commit 22b7a70d7d
4 changed files with 130 additions and 59 deletions

View File

@ -848,6 +848,18 @@ void Application::Start() {
ESP_LOGI(TAG, "🔊 启用音频编解码器输出");
codec->EnableOutput(true);// 启用音频编解码器输出
// 🆕 移植自 Kapi commit a3a476f: 灌 200ms 真实静音 PCM 覆盖 I2S DMA 残留数据
// 原因EnableOutput 启动 I2S 后, RTC 真实 PCM 需 1-3 秒才到达
// 这段空窗期 I2S DMA 输出垃圾数据 → PA 放大 → 杂音
// 灌真实 0x0000 静音后, I2S DMA 输出真实 0V, PA 放大 = 完全无声
// 后续 RTC PCM 自然衔接, 无杂音
{
const int silence_samples = codec->output_sample_rate() / 5; // 200ms
std::vector<int16_t> silence(silence_samples, 0);
codec->OutputData(silence);
ESP_LOGI(TAG, "🔇 已灌 200ms 静音 PCM 覆盖 DMA 残留");
}
if (!player_pipeline_) {
player_pipeline_ = player_pipeline_open();
player_pipeline_run(player_pipeline_);
@ -2354,6 +2366,14 @@ void Application::OnAudioOutput() {
}
void Application::OnAudioInput() {
// 🆕 移植自 Kapi commit b1577d8: hibernate 期间禁用输入侧, 允许 OnAudioOutput 跑(消费待命音队列)
// 防止 EnterIdleHibernate / WakeFromHibernate 过渡期间访问到关闭的 codec → std::bad_alloc abort
auto codec_for_guard = Board::GetInstance().GetAudioCodec();
if (hibernating_.load() || !codec_for_guard || !codec_for_guard->input_enabled() || !recorder_pipeline_) {
vTaskDelay(pdMS_TO_TICKS(20));
return;
}
std::vector<int16_t> data;
#if CONFIG_USE_WAKE_WORD_DETECT || CONFIG_USE_CUSTOM_WAKE_WORD
@ -4451,37 +4471,28 @@ void Application::EnterIdleHibernate() {
ESP_LOGW(TAG, "已处于休眠状态,跳过重复进入");
return;
}
ESP_LOGI(TAG, "🌙 进入空闲休眠:真退房 → 字幕提示(不熄屏)");
ESP_LOGI(TAG, "🌙 进入空闲休眠方案C+ 移植自 Kapi b1577d8stop RTC保留 handle→ 播待命音 → 字幕提示");
auto display = Board::GetInstance().GetDisplay();
// [废弃方案] 静音填充曾尝试在此处用 codec->OutputData 填 200ms 静音覆盖 DMA 残留
// 但实测会让 ES7210 codec 进入卡死状态(连续 10 次重启 ES7210 I2C Open fail
// 移除该方案,杂音问题需要用其他方式解决(如降低唤醒后初始音量)
// 1. 真退出 RTC 房间(释放 License
// Protocol 基类的虚函数 LeaveRoom 默认回退到 CloseAudioChannel
// VolcRtcProtocol 覆写为 volc_rtc_stop + volc_rtc_destroy
// 注意LeaveRoom 内部会触发 on_audio_channel_closed_ 回调 → codec EnableOutput(false)
if (protocol_) {
protocol_->LeaveRoom();
}
auto codec = Board::GetInstance().GetAudioCodec();
// 3. 字幕显示推迟到最后做(此时 LVGL 锁竞争最少)— 见步骤 9
// 0. 🔴 最先置 hibernating_=true阻止 AudioLoop 的 OnAudioInput 继续访问 codec
// OnAudioInput 入口有 guard 检测 hibernating_但 OnAudioOutput 继续工作(消费待命音队列)
hibernating_.store(true);
vTaskDelay(pdMS_TO_TICKS(50)); // 等 OnAudioInput 看到标志并跳过当前迭代
// 4. 显式关闭 codec input/output 让状态机重置(回调可能已关 output这里幂等 + 关 input
// 修复 bug若不关闭唤醒后 EnableInput(true) 会进入 "已 open" 异常路径
// → esp_codec_dev_set_in_channel_gain ES_ERROR_CHECK 失败 abort
if (codec) {
ESP_LOGI(TAG, "EnterIdleHibernate: 关闭 codec input/output 重置状态机");
codec->EnableInput(false);
codec->EnableOutput(false);
// 1. 退出 RTC 房间(仅 stop保留 rtc_handle_notify_closed=false 避免回调关 codec
// LeaveRoom 内部同时重置 downlink_is_pcm_=false关键否则 PlaySound 的 Opus 会被当 PCM 写出 → 杂音)
if (protocol_) {
protocol_->LeaveRoom(/*notify_closed=*/false);
}
// 3.5. 清空音频解码队列:阻止 hibernate 之前残留的 standby_sound / AI 半句 PCM
// 在唤醒后的 OnAudioOutput 中被错误"首帧"识别,从而把软静音过早解开。
// 2. 等待 background_task 完成所有待解码任务,避免与本流程竞争 codec
if (background_task_) {
background_task_->WaitForCompletion();
}
// 3. 清空音频解码队列:阻止 hibernate 之前残留的半句 PCM 数据干扰待命音
{
std::lock_guard<std::mutex> lock(mutex_);
if (!audio_decode_queue_.empty()) {
@ -4491,19 +4502,18 @@ void Application::EnterIdleHibernate() {
}
}
// 4. 关闭录音管道(避免唤醒后重新打开时冲突)
// 4. 关闭麦克风codec output 保留,后面要播待命音)
// recorder_pipeline 也关掉避免下一帧 OnAudioInput 误读
if (codec) {
ESP_LOGI(TAG, "EnterIdleHibernate: 关闭 codec 麦克风output 保留播待命音)");
codec->EnableInput(false);
}
if (recorder_pipeline_) {
recorder_pipeline_close(recorder_pipeline_);
recorder_pipeline_ = nullptr;
}
// 5. 关键时序:先设置 hibernating_=true阻止 PowerSaveTimer 进入 Light Sleep
// 再 SetDeviceState(kDeviceStateIdle)(之后 CanEnterSleepMode 会因为 hibernating_=true 而返回 false
hibernating_.store(true);
// 6. 双保险:强制 esp_pm 禁用 Light Sleep保护 I2C/codec 总线不进入睡眠)
// 防止 PowerSaveTimer 已经触发或紧接着触发 esp_pm_configure(light_sleep=true)
// 导致 ES7210/ES8311 唤醒后 I2C 通信失败 → ESP_ERROR_CHECK abort 重启
// 5. 强制 esp_pm 禁用 Light Sleep保护 I2C/codec 总线不睡)
esp_pm_config_t pm_config = {
.max_freq_mhz = 240,
.min_freq_mhz = 240,
@ -4512,26 +4522,67 @@ void Application::EnterIdleHibernate() {
esp_pm_configure(&pm_config);
ESP_LOGI(TAG, "EnterIdleHibernate: 已强制禁用 Light Sleep保护 I2C 总线)");
// 7. 设备状态切回 idle屏幕保持亮起仅字幕提示
// 5.5 🆕 强制重启 codec output 通道,清掉 LeaveRoom 副作用
// volc_rtc_stop 内部会关闭 ES8311 I2S 通道ESP-IDF i2s_channel_disable
// 但 codec class 的 output_enabled_ 标志仍是 true → 后续 PlaySound 写入
// 到的是 disabled 的 I2S → DMA 输出残留垃圾数据 → 杂音而非待命音。
// 这里通过 disable→delay→enable 强制走 i2s_channel_enable 重新激活通道,
// 然后灌 200ms 静音 PCM 覆盖 DMA 残留。
if (codec) {
ESP_LOGI(TAG, "EnterIdleHibernate: 强制重启 codec output 通道(清 LeaveRoom 副作用)");
codec->EnableOutput(false);
vTaskDelay(pdMS_TO_TICKS(20)); // 等 i2s_channel_disable 完成 + DMA 释放
codec->EnableOutput(true);
const int silence_samples = codec->output_sample_rate() / 5; // 200ms
std::vector<int16_t> silence(silence_samples, 0);
codec->OutputData(silence);
ESP_LOGI(TAG, "EnterIdleHibernate: 🔇 已灌 200ms 静音 PCM 覆盖 DMA 残留");
}
// 6. 设备状态切回 idleSetDeviceState 内部触发 PlaySound 入队待命音 Opus
SetDeviceState(kDeviceStateIdle);
// 8. 累计休眠次数NVS 持久化)
// 7. 等待待命音播放完成codec output 仍 enabledAudioLoop OnAudioOutput 正常出队)
ESP_LOGI(TAG, "EnterIdleHibernate: 等待待命音播放完成...");
WaitForAudioPlayback();
// 7.5 🆕 等待背景任务清空 + DMA 尾音播完
// WaitForAudioPlayback 只确认 audio_decode_queue_ 出队完毕,但 OnAudioOutput
// 是 Schedule 到 background_task 异步执行 codec write 的,队列空 ≠ codec 写完。
// codec.Write 返回 ≠ I2S DMA / ES8311 内部 FIFO 输出到喇叭完成。
// 所以这里两步保险:先 WaitForCompletion 等所有解码任务跑完,再 vTaskDelay
// 1000ms 让 DMA + 功放尾音自然衰减。否则 player_pipeline_close 立即停 I2S
// 最后约 1 秒待命音会被截掉。
if (background_task_) {
background_task_->WaitForCompletion();
}
vTaskDelay(pdMS_TO_TICKS(1000));
ESP_LOGI(TAG, "EnterIdleHibernate: DMA 尾音衰减完成");
// 8. 待命音播完后关 player_pipeline内部会关 EnableOutputWakeFromHibernate 重开
if (player_pipeline_) {
player_pipeline_close(player_pipeline_);
player_pipeline_ = nullptr;
ESP_LOGI(TAG, "EnterIdleHibernate: player_pipeline 已关闭");
} else if (codec) {
codec->EnableOutput(false);
ESP_LOGI(TAG, "EnterIdleHibernate: codec output 已关闭(无 pipeline 兜底)");
}
// 9. 累计休眠次数NVS 持久化)
idle_cycles_++;
SaveIdleCyclesToNvs();
ESP_LOGI(TAG, "✓ 已进入空闲休眠(累计第 %d 次)", idle_cycles_);
ESP_LOGI(TAG, "✓ 已进入空闲休眠(累计第 %d 次rtc_handle 保留", idle_cycles_);
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGW(TAG, "🛡 累计休眠 %d 次(阈值 %d下次唤醒触发硬重启清理内存碎片",
idle_cycles_, IDLE_CYCLES_REBOOT_THRESHOLD);
}
// 9. 显示退出提示字幕(最后做:此时 LeaveRoom/SetDeviceState 都完成,
// LVGL 锁竞争最少;带重试确保 LVGL 锁超时也能最终显示)
//
// 注意ai_chat_set_chat_message 内部有 last_content 去重缓存,
// 锁超时时不更新缓存,下次重试相同内容时仍会进入 lvgl_port_lock 路径
// 10. 显示退出提示字幕数字人项目特有Kapi 无屏底座没有此步骤)
// 带重试确保 LVGL 锁超时也能最终显示
const char* hibernate_msg = "已自动退出RTC对话按BOOT键重新连接RTC";
for (int attempt = 0; attempt < 5; attempt++) {
vTaskDelay(pdMS_TO_TICKS(200)); // 让出 CPU 给 LVGL 任务完成当前帧渲染
vTaskDelay(pdMS_TO_TICKS(200));
if (display) {
display->SetChatMessage("system", hibernate_msg);
}
@ -4566,16 +4617,24 @@ void Application::WakeFromHibernate() {
display->SetChatMessage("system", "");
}
// 3. 先复位为 idle 状态EnterIdleHibernate 已设为 idle这里幂等
// 3. 🆕 移植自 Kapi commit b1577d8: 先放下 hibernating_让 AudioLoop 的
// OnAudioInput guard 通过, 再 ToggleChatState。否则 ToggleChatState 期间
// OnAudioInput 仍会被 hibernating_=true 拦截,导致音频上行迟迟不开。
hibernating_.store(false);
// 4. 复位为 idleEnterIdleHibernate 已设为 idle此处幂等
if (device_state_ != kDeviceStateIdle) {
SetDeviceState(kDeviceStateIdle);
}
// 4. 触发 RTC 重连(复用 ToggleChatState → OpenAudioChannel → 自动重建 rtc_handle_
ESP_LOGI(TAG, "WakeFromHibernate: 调用 ToggleChatState() 触发 RTC 重连...");
// 5. 触发 RTC 重连
// - LeaveRoom 只 stop 没 destroyrtc_handle_ 仍然有效
// - OpenAudioChannel 检查 rtc_handle_ 通过 → volc_rtc_start → 成功
// - on_audio_channel_opened_ 回调里会 EnableOutput(true) + 200ms silence + 开 player_pipeline
// - 进入 kDeviceStateDialog 后 recorder_pipeline 也会重开 + EnableInput(true)
ESP_LOGI(TAG, "WakeFromHibernate: 调用 ToggleChatState() 触发 RTC 重连rtc_handle 复用)...");
ToggleChatState();
hibernating_.store(false);
ESP_LOGI(TAG, "✓ 唤醒完成,已触发 RTC 重连(注意:实际重连进度由 ToggleChatState 异步处理)");
ESP_LOGI(TAG, "✓ 唤醒完成,已触发 RTC 重连");
}

View File

@ -55,8 +55,10 @@ public:
virtual bool OpenAudioChannel() = 0;
virtual void CloseAudioChannel() = 0;
// Phase 6: 真退出 RTC 房间(释放 License默认回退到 CloseAudioChannel
// VolcRtcProtocol 覆写:调用 volc_rtc_stop + volc_rtc_destroy
virtual void LeaveRoom() { CloseAudioChannel(); }
// notify_closed=true: 触发 on_audio_channel_closed_ 回调(默认,兼容老路径)
// notify_closed=false: 不触发回调,供 EnterIdleHibernate 使用——避免回调里的
// player_pipeline_close → EnableOutput(false) 误关 codec output 导致待命音无声
virtual void LeaveRoom(bool notify_closed = true) { (void)notify_closed; CloseAudioChannel(); }
virtual bool IsAudioChannelOpened() const = 0;
virtual void SendAudio(const std::vector<uint8_t>& data) = 0;
virtual void SendPcm(const std::vector<uint8_t>& data) {}

View File

@ -423,22 +423,31 @@ void VolcRtcProtocol::CloseAudioChannel() {
}
}
// Phase 6: 真退出 RTC 房间(释放 License
// = volc_rtc_stop + volc_rtc_destroy
// 火山官方文档StopVoiceChat 说明): 真人退房必须 leaveRoom + destroyRTCEngine
// 客户端调用 volc_rtc_destroy 后,服务端 AI 任务会在 180s 内自动停止
void VolcRtcProtocol::LeaveRoom() {
// Phase 6+ 移植自 Kapi commit b1577d8: 退出 RTC 房间
// 仅 volc_rtc_stop保留 rtc_handle_供唤醒后 OpenAudioChannel 复用(再次 volc_rtc_start
// 如果走 destroy 路径,唤醒时 rtc_handle_=nullptr → OpenAudioChannel 直接失败 → 死循环
// 服务端 AI 任务在客户端 stop 后无需 destroy 也会按 180s 兜底机制清理
// notify_closed=false 时跳过 on_audio_channel_closed_避免回调里 player_pipeline_close
// → EnableOutput(false) 把 codec output 关掉导致 hibernate 期间待命音无声
void VolcRtcProtocol::LeaveRoom(bool notify_closed) {
if (rtc_handle_) {
if (is_connected_) {
volc_rtc_stop(rtc_handle_);
is_connected_ = false;
}
volc_rtc_destroy(rtc_handle_);
rtc_handle_ = nullptr;
ESP_LOGI(TAG, "✓ 已真退出 RTC 房间leaveRoom + destroyRTCEngine");
ESP_LOGI(TAG, "✓ 已 stop RTC 房间(保留 handle 供唤醒复用, notify_closed=%d", (int)notify_closed);
}
is_audio_channel_opened_ = false;
if (on_audio_channel_closed_) {
// 🔴 关键修复:重置下行音频格式标志位
// 原因RTC 下行是 PCMDataCallback 把 downlink_is_pcm_=true。退房后若不重置
// 后续 hibernate 中 PlaySound 入队的 Opus 包会被 OnAudioOutput 当成 raw PCM 写出去,
// → 杂音而非待命音。
// 唤醒重连后 DataCallback 收到第一包又会立即重新置位(每包都更新),不影响欢迎语播放。
downlink_is_pcm_ = false;
first_downlink_logged_ = false;
if (notify_closed && on_audio_channel_closed_) {
on_audio_channel_closed_();
}
}

View File

@ -21,9 +21,10 @@ public:
bool OpenAudioChannel() override;// 🔊 打开音频通道
void CloseAudioChannel() override;// 🔊 关闭音频通道(仅 stop 媒体流,不退出房间)
// Phase 6: 真退出 RTC 房间 = volc_rtc_stop + volc_rtc_destroy释放 License
// Phase 6+:退出 RTC 房间(仅 stop保留 rtc_handle_ 供唤醒复用)
// notify_closed=false 时跳过 on_audio_channel_closed_hibernate 路径用,避免 codec 被回调链关
// 与 CloseAudioChannel 区别CloseAudioChannel 只停媒体流,房间仍占用
void LeaveRoom() override;
void LeaveRoom(bool notify_closed = true) override;
bool IsAudioChannelOpened() const override;// 🔊 检查音频通道是否已打开
void SendAbortSpeaking(AbortReason reason) override;// 🔊 发送中止通话请求