feat: Phase 6 软退出方案 C+ — 修复待命音无声 + 唤醒重连失败

问题(上次提交 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) <noreply@anthropic.com>
This commit is contained in:
Rdzleo 2026-05-15 17:43:29 +08:00
parent d5239cf471
commit a3a476f857
6 changed files with 239 additions and 21 deletions

View File

@ -30,6 +30,7 @@
#include <nvs.h>
#include <esp_http_client.h>
#include <esp_crt_bundle.h>
#include <esp_pm.h> // 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<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_);
@ -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<int16_t> 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<std::mutex> 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. 设备状态切回 idleSetDeviceState 内部触发 PlaySound 入队待命音 Opus
SetDeviceState(kDeviceStateIdle);
// 7. 等待待命音播放完成codec output 仍 enabledAudioLoop OnAudioOutput 正常出队)
ESP_LOGI(TAG, "EnterIdleHibernate: 等待待命音播放完成...");
WaitForAudioPlayback();
// 8. 待命音播完后关 player_pipeline内部会关 EnableOutputWakeFromHibernate 重开
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. 复位为 idleEnterIdleHibernate 已设为 idle此处幂等
if (device_state_ != kDeviceStateIdle) {
SetDeviceState(kDeviceStateIdle);
}
// 3. 触发 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();
ESP_LOGI(TAG, "✓ 唤醒完成,已触发 RTC 重连");
}

View File

@ -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<std::chrono::steady_clock::time_point> last_safe_operation_; // 🔧 最后安全操作时间戳
std::atomic<bool> is_switching_to_listening_{false}; // 🔵 标志:正在主动切换到聆听状态
std::atomic<bool> is_low_battery_transition_{false};
// Phase 6 移植:空闲休眠状态
std::atomic<bool> 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;

View File

@ -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<WifiBoard*>(this);
if (wifi_board && wifi_board->IsBleProvisioningActive() && !production_test_mode_) {

View File

@ -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<uint8_t>& data) = 0;
virtual void SendPcm(const std::vector<uint8_t>& data) {}

View File

@ -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_;

View File

@ -20,6 +20,7 @@ public:
void SendG711A(const std::vector<uint8_t>& 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;// 🔊 发送开始监听请求