From a3a476f8573dbc9b48cc24f409abb0bfa5e005d4 Mon Sep 17 00:00:00 2001 From: Rdzleo Date: Fri, 15 May 2026 17:43:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206=20=E8=BD=AF=E9=80=80=E5=87=BA?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20C+=20=E2=80=94=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=BE=85=E5=91=BD=E9=9F=B3=E6=97=A0=E5=A3=B0=20+=20=E5=94=A4?= =?UTF-8?q?=E9=86=92=E9=87=8D=E8=BF=9E=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(上次提交 d5239cf 之后实测): 1. EnterIdleHibernate 触发的 LeaveRoom() 顺手关闭了 codec output: LeaveRoom → on_audio_channel_closed_ → player_pipeline_close → EnableOutput(false) 导致后续 PlaySound(P3_KAKA_DAIMING) 入队后 AudioLoop 写不出声音, WaitForAudioPlayback 3 秒超时退出, 用户听不到待命音。 2. LeaveRoom 调用 volc_rtc_destroy 后 rtc_handle_ = nullptr, WakeFromHibernate → ToggleChatState → OpenAudioChannel 直接返回 false, 触发 2 秒重试循环, 同时每次失败回退 idle 都重新 PlaySound, codec 状态震荡产生杂音, 服务端 AI 任务也无法重新加入房间。 方案 C+ 修复: - Protocol::LeaveRoom() 新增 bool notify_closed=true 参数 (默认行为不变)。 - VolcRtcProtocol::LeaveRoom(notify_closed): * 只 volc_rtc_stop, 不 volc_rtc_destroy, 保留 rtc_handle_ 供唤醒复用。 * notify_closed=false 时跳过 on_audio_channel_closed_, 不连带关 codec。 - EnterIdleHibernate: * 调用 LeaveRoom(false) → codec output 保留。 * 手动 background_task_->WaitForCompletion + 清空队列 + 关麦克风。 * SetDeviceState(idle) 后 PlaySound 真正能播出来。 * WaitForAudioPlayback 完才 player_pipeline_close (这里再正常关 output)。 - WakeFromHibernate: * 先放下 hibernating_ 让 AudioLoop guard 通过, 再 ToggleChatState。 * 因 rtc_handle_ 仍有效, OpenAudioChannel 走 volc_rtc_start 重启路径, on_audio_channel_opened_ 回调重开 player_pipeline + 灌 200ms silence。 编译: kapi.bin 0x2e6330 (3.04MB), 分区 42% 空闲。 Co-Authored-By: Claude Opus 4.7 (1M context) --- main/application.cc | 208 ++++++++++++++++-- main/application.h | 14 ++ .../movecall_moji_esp32s3.cc | 12 + main/protocols/protocol.h | 5 + main/protocols/volc_rtc_protocol.cc | 20 ++ main/protocols/volc_rtc_protocol.h | 1 + 6 files changed, 239 insertions(+), 21 deletions(-) diff --git a/main/application.cc b/main/application.cc index 87811f9..3b6fe8e 100644 --- a/main/application.cc +++ b/main/application.cc @@ -30,6 +30,7 @@ #include #include #include +#include // Phase 6 移植:esp_pm_configure (hibernate 时禁用 Light Sleep) #define TAG "Application" #define MAC_TAG "BluetoothMAC" @@ -526,6 +527,9 @@ void Application::Start() { auto& board = Board::GetInstance(); SetDeviceState(kDeviceStateStarting); + // Phase 6 移植:从 NVS 加载累计休眠次数(用于内存碎片兜底) + LoadIdleCyclesFromNvs(); + // 读取NVS中的重启标志 Settings sys("system", true); int32_t reboot_dlg_idle = sys.GetInt("reboot_dlg_idle", 0); @@ -844,6 +848,18 @@ void Application::Start() { ESP_LOGI(TAG, "🔊 启用音频编解码器输出"); codec->EnableOutput(true);// 启用音频编解码器输出 + // 🆕 方案 A: 灌 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_); @@ -1950,6 +1966,11 @@ void Application::MainLoop() { void Application::AudioLoop() { auto codec = Board::GetInstance().GetAudioCodec(); while (true) { + // Phase 6 移植:hibernate 期间跳过 codec 访问,避免与 EnterIdleHibernate 关闭 codec 时竞态 + if (hibernating_.load()) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } OnAudioInput(); if (codec->output_enabled()) { OnAudioOutput(); @@ -1991,28 +2012,16 @@ void Application::StartDialogWatchdog() { // 调试日志 ESP_LOGD(TAG, "Dialog watchdog: elapsed=%d, remaining=%d", (int)elapsed, remaining); - // 如果剩余秒数小于等于0,说明对话空闲倒计时已到,需要重启设备 + // 如果剩余秒数小于等于0,说明对话空闲倒计时已到 → 进入空闲休眠(软退房 + 状态保留) + // Phase 6 移植:原本是 esp_restart 硬重启,改为软退出 RTC 房间 + 等 BOOT 唤醒 if (remaining <= 0) { - ESP_LOGI(TAG, "Dialog watchdog idle reached, elapsed=%d, marking and rebooting", (int)elapsed); - Settings sys("system", true); - ESP_LOGI(TAG, "Dialog watchdog: preparing NVS writes (system)"); - sys.SetInt("reboot_dlg_idle", 1); - sys.SetInt("reboot_origin", 1); - ESP_LOGI(TAG, "Dialog watchdog: committing NVS (system)"); - sys.Commit(); - Settings sysr("system"); - int32_t verify = sysr.GetInt("reboot_dlg_idle", 0); - int32_t origin_read = sysr.GetInt("reboot_origin", 0); - if (verify != 1) { - ESP_LOGW(TAG, "Dialog watchdog: NVS verify failed, cause=%d, origin=%d", (int)verify, (int)origin_read); - ESP_LOGW(TAG, "建议: 检查NVS空间是否不足、确认nvs_flash_init成功、避免并发写入(system)"); - } - ESP_LOGI(TAG, "Dialog watchdog (task) set reboot_cause=1, verify=%d, restart in 2000ms", (int)verify); - // 重启前上报设备离线状态 - Board::GetInstance().OnBeforeRestart(); - vTaskDelay(pdMS_TO_TICKS(2000)); - esp_restart();// 重启设备 - app->dialog_watchdog_running_ = false;// 设置看门狗运行标志为false + ESP_LOGI(TAG, "Dialog watchdog 触发:%ds 无对话活动 → 进入空闲休眠", (int)elapsed); + app->dialog_watchdog_running_ = false; // 停止 watchdog 循环 + // Schedule 在主线程执行 EnterIdleHibernate,避免在 watchdog task 里阻塞 1-2s 待命音播放 + app->Schedule([app]() { + app->EnterIdleHibernate(); + }); + break; // 退出 watchdog 循环 } else { // 简化条件判断,移除冗余检查 // 优化桶计算逻辑,使用1秒一个桶,更精确地显示倒计时 @@ -2219,6 +2228,14 @@ void Application::OnAudioOutput() { } void Application::OnAudioInput() { + // Phase 6 移植:codec input 未启用或 recorder_pipeline 未就绪时跳过 + // 防止 EnterIdleHibernate / WakeFromHibernate 过渡期间访问到关闭的 codec → std::bad_alloc abort + auto codec_for_guard = Board::GetInstance().GetAudioCodec(); + if (!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 @@ -3537,6 +3554,13 @@ bool Application::CanEnterSleepMode() { return false; } + // Phase 6 移植:hibernate 期间禁用 PowerSaveTimer 的 Light Sleep + // 否则 esp_pm_configure(light_sleep_enable=true) 会让 I2C 总线进入低功耗 + // 状态,唤醒后 ES7210/ES8311 通信失败导致 ESP_ERROR_CHECK abort 重启 + if (hibernating_.load()) { + return false; + } + // Now it is safe to enter sleep mode return true; } @@ -4279,3 +4303,145 @@ void Application::HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON service.SendResponse(cmd, msg_id, -99, "unknown cmd"); } #endif + +// ============================================================================ +// Phase 6 移植:RTC 软退出 / 唤醒 + idle 待命音修复 +// 源于 Baji_Rtc_Toy 项目,针对 Kapi(无屏底座)做简化适配: +// - 字幕显示替换为日志 + 待命音 +// - LCD 相关代码全部去掉 +// ============================================================================ + +void Application::SaveIdleCyclesToNvs() { + Settings s("hibernate", true); + s.SetInt("idle_cycles", idle_cycles_); +} + +void Application::LoadIdleCyclesFromNvs() { + Settings s("hibernate", false); + idle_cycles_ = s.GetInt("idle_cycles", 0); +} + +void Application::ResetIdleCyclesNvs() { + Settings s("hibernate", true); + s.SetInt("idle_cycles", 0); + idle_cycles_ = 0; +} + +void Application::EnterIdleHibernate() { + if (hibernating_.load()) { + ESP_LOGW(TAG, "已处于休眠状态,跳过重复进入"); + return; + } + ESP_LOGI(TAG, "🌙 进入空闲休眠(方案C+):stop RTC(保留 handle)→ 播待命音 → 静默"); + + auto codec = Board::GetInstance().GetAudioCodec(); + + // 0. 🔴 最先置 hibernating_=true,阻止 AudioLoop 继续访问 codec + hibernating_.store(true); + vTaskDelay(pdMS_TO_TICKS(50)); // 等 AudioLoop 看到标志并跳过当前迭代 + + // 1. 退出 RTC 房间(仅 stop,保留 rtc_handle_;notify_closed=false 避免回调关 codec) + if (protocol_) { + protocol_->LeaveRoom(/*notify_closed=*/false); + } + + // 2. 等待 background_task 完成所有待解码任务,避免与本流程竞争 codec + if (background_task_) { + background_task_->WaitForCompletion(); + } + + // 3. 清空音频解码队列:阻止 hibernate 之前残留的半句 PCM 数据干扰待命音 + { + std::lock_guard lock(mutex_); + if (!audio_decode_queue_.empty()) { + ESP_LOGI(TAG, "EnterIdleHibernate: 清空残留音频队列 size=%zu", + audio_decode_queue_.size()); + audio_decode_queue_.clear(); + } + } + + // 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. 强制 esp_pm 禁用 Light Sleep(保护 I2C/codec 总线不睡) + esp_pm_config_t pm_config = { + .max_freq_mhz = 240, + .min_freq_mhz = 240, + .light_sleep_enable = false, + }; + esp_pm_configure(&pm_config); + ESP_LOGI(TAG, "EnterIdleHibernate: 已强制禁用 Light Sleep(保护 I2C 总线)"); + + // 6. 设备状态切回 idle(SetDeviceState 内部触发 PlaySound 入队待命音 Opus) + SetDeviceState(kDeviceStateIdle); + + // 7. 等待待命音播放完成(codec output 仍 enabled,AudioLoop OnAudioOutput 正常出队) + ESP_LOGI(TAG, "EnterIdleHibernate: 等待待命音播放完成..."); + WaitForAudioPlayback(); + + // 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) { + // 兜底:无 pipeline 时手动关 output + codec->EnableOutput(false); + ESP_LOGI(TAG, "EnterIdleHibernate: codec output 已关闭(无 pipeline 兜底)"); + } + + // 9. 累计休眠次数(NVS 持久化,用于内存碎片兜底) + idle_cycles_++; + SaveIdleCyclesToNvs(); + 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); + } +} + +void Application::WakeFromHibernate() { + if (!hibernating_.load()) { + return; + } + ESP_LOGI(TAG, "☀ 从空闲休眠唤醒(方案C+)"); + + // 内存兜底:累计达到阈值时硬重启清理碎片 + if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) { + ESP_LOGI(TAG, "🛡 累计休眠 %d 次,硬重启清理内存碎片", idle_cycles_); + ResetIdleCyclesNvs(); + Board::GetInstance().OnBeforeRestart(); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + return; + } + + ESP_LOGI(TAG, "WakeFromHibernate: device_state=%d, idle_cycles=%d", + (int)device_state_, idle_cycles_); + + // 1. 先放下 hibernating_,让 AudioLoop 恢复工作(OnAudioInput/Output 的 guard 会通过) + hibernating_.store(false); + + // 2. 复位为 idle(EnterIdleHibernate 已设为 idle,此处幂等) + if (device_state_ != kDeviceStateIdle) { + SetDeviceState(kDeviceStateIdle); + } + + // 3. 触发 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(); + + ESP_LOGI(TAG, "✓ 唤醒完成,已触发 RTC 重连"); +} diff --git a/main/application.h b/main/application.h index 6b3fb70..3baa455 100644 --- a/main/application.h +++ b/main/application.h @@ -87,6 +87,11 @@ public: bool IsAudioQueueEmpty(); // 检查音频队列是否为空 void ClearAudioQueue(); // 清空音频播放队列 bool CanEnterSleepMode();// 检查是否可以进入睡眠模式 + + // Phase 6 移植:RTC 软退出 / 唤醒 + void EnterIdleHibernate(); // 进入空闲休眠(真退房 + 待命音 + 状态保留) + void WakeFromHibernate(); // 从休眠唤醒(BOOT 触发,重连 RTC) + bool IsHibernating() const { return hibernating_.load(); } void StopAudioProcessor();// 停止音频处理器 void ResetDecoder();// 重置解码器状态(用于修复音频播放问题) bool IsSafeToOperate(); // 🔧 检查当前是否可以安全执行操作 @@ -142,6 +147,15 @@ private: std::atomic last_safe_operation_; // 🔧 最后安全操作时间戳 std::atomic is_switching_to_listening_{false}; // 🔵 标志:正在主动切换到聆听状态 std::atomic is_low_battery_transition_{false}; + + // Phase 6 移植:空闲休眠状态 + std::atomic hibernating_{false}; // 是否处于空闲休眠状态 + int idle_cycles_ = 0; // 累计休眠次数(NVS 持久化) + static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50; // 累计 50 次触发硬重启清碎片 + void SaveIdleCyclesToNvs(); + void LoadIdleCyclesFromNvs(); + void ResetIdleCyclesNvs(); + ListeningMode listening_mode_ = kListeningModeAutoStop; #if CONFIG_USE_REALTIME_CHAT bool realtime_chat_enabled_ = true; diff --git a/main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc b/main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc index a399960..56cd22e 100644 --- a/main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc +++ b/main/boards/movecall-moji-esp32s3/movecall_moji_esp32s3.cc @@ -525,6 +525,18 @@ public: auto &app = Application::GetInstance(); auto current_state = app.GetDeviceState(); + // Phase 6 移植:hibernate 状态优先处理 → 唤醒 + RTC 重连 + if (app.IsHibernating()) { + ESP_LOGI(TAG, "🔵 BOOT in hibernate → 唤醒(重连 RTC)"); + // 异步派发,避免按键回调(esp_timer 任务)阻塞 + xTaskCreate([](void* arg) { + (void)arg; + Application::GetInstance().WakeFromHibernate(); + vTaskDelete(NULL); + }, "wake_hibernate", 4096, NULL, 5, NULL); + return; // 不走原来 BOOT 处理逻辑 + } + // 检查是否处于BLE配网状态,如果是则屏蔽按键响应(生产测试模式下除外) auto* wifi_board = dynamic_cast(this); if (wifi_board && wifi_board->IsBleProvisioningActive() && !production_test_mode_) { diff --git a/main/protocols/protocol.h b/main/protocols/protocol.h index 7e9c635..d2d0939 100644 --- a/main/protocols/protocol.h +++ b/main/protocols/protocol.h @@ -54,6 +54,11 @@ public: virtual void Start() = 0; virtual bool OpenAudioChannel() = 0; virtual void CloseAudioChannel() = 0; + // Phase 6 移植:真退出 RTC 房间(释放 License) + // 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 6a66609..3e258d2 100644 --- a/main/protocols/volc_rtc_protocol.cc +++ b/main/protocols/volc_rtc_protocol.cc @@ -406,6 +406,26 @@ void VolcRtcProtocol::CloseAudioChannel() { } } +// Phase 6 移植:退出 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; + } + ESP_LOGI(TAG, "✓ 已 stop RTC 房间(保留 handle 供唤醒复用, notify_closed=%d)", (int)notify_closed); + } + is_audio_channel_opened_ = false; + if (notify_closed && on_audio_channel_closed_) { + on_audio_channel_closed_(); + } +} + // 🔊 检查音频通道是否已打开 bool VolcRtcProtocol::IsAudioChannelOpened() const { return is_audio_channel_opened_; diff --git a/main/protocols/volc_rtc_protocol.h b/main/protocols/volc_rtc_protocol.h index 070f195..0e49ace 100644 --- a/main/protocols/volc_rtc_protocol.h +++ b/main/protocols/volc_rtc_protocol.h @@ -20,6 +20,7 @@ public: void SendG711A(const std::vector& data) override;// 🔊 发送G711A音频数据到RTC bool OpenAudioChannel() override;// 🔊 打开音频通道 void CloseAudioChannel() override;// 🔊 关闭音频通道 + void LeaveRoom(bool notify_closed = true) override;// Phase 6: 退出 RTC 房间(stop,保留 handle 供唤醒复用) bool IsAudioChannelOpened() const override;// 🔊 检查音频通道是否已打开 void SendAbortSpeaking(AbortReason reason) override;// 🔊 发送中止通话请求 void SendStartListening(ListeningMode mode) override;// 🔊 发送开始监听请求