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:
parent
d5239cf471
commit
a3a476f857
@ -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. 设备状态切回 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 重连");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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_) {
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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_;
|
||||
|
||||
@ -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;// 🔊 发送开始监听请求
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user