diff --git a/main/application.cc b/main/application.cc index c2e1d3d..ad2a1b8 100644 --- a/main/application.cc +++ b/main/application.cc @@ -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 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 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 b1577d8):stop 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 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 silence(silence_samples, 0); + codec->OutputData(silence); + ESP_LOGI(TAG, "EnterIdleHibernate: 🔇 已灌 200ms 静音 PCM 覆盖 DMA 残留"); + } + + // 6. 设备状态切回 idle(SetDeviceState 内部触发 PlaySound 入队待命音 Opus) SetDeviceState(kDeviceStateIdle); - // 8. 累计休眠次数(NVS 持久化) + // 7. 等待待命音播放完成(codec output 仍 enabled,AudioLoop 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(内部会关 EnableOutput),WakeFromHibernate 重开 + 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. 复位为 idle(EnterIdleHibernate 已设为 idle,此处幂等) if (device_state_ != kDeviceStateIdle) { SetDeviceState(kDeviceStateIdle); } - // 4. 触发 RTC 重连(复用 ToggleChatState → OpenAudioChannel → 自动重建 rtc_handle_) - ESP_LOGI(TAG, "WakeFromHibernate: 调用 ToggleChatState() 触发 RTC 重连..."); + // 5. 触发 RTC 重连 + // - LeaveRoom 只 stop 没 destroy,rtc_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 重连"); } diff --git a/main/protocols/protocol.h b/main/protocols/protocol.h index 75c2b5e..49f3124 100644 --- a/main/protocols/protocol.h +++ b/main/protocols/protocol.h @@ -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& data) = 0; virtual void SendPcm(const std::vector& data) {} diff --git a/main/protocols/volc_rtc_protocol.cc b/main/protocols/volc_rtc_protocol.cc index 8af80fd..39544ff 100644 --- a/main/protocols/volc_rtc_protocol.cc +++ b/main/protocols/volc_rtc_protocol.cc @@ -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 下行是 PCM,DataCallback 把 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_(); } } diff --git a/main/protocols/volc_rtc_protocol.h b/main/protocols/volc_rtc_protocol.h index b7286d5..98e4512 100644 --- a/main/protocols/volc_rtc_protocol.h +++ b/main/protocols/volc_rtc_protocol.h @@ -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;// 🔊 发送中止通话请求