wangzhibo
10 天以前 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
xiaozhi-esp32-2.2.6/main/application.cc
@@ -16,6 +16,7 @@
#include <cstring>
#include <memory>
#include <esp_log.h>
#include <esp_heap_caps.h>
#include <cJSON.h>
#include <driver/gpio.h>
#include <arpa/inet.h>
@@ -25,7 +26,6 @@
extern "C" void SDCard_Pause_Play(void) __attribute__((weak));
extern "C" void SDCard_Resume_Play(void) __attribute__((weak));
extern "C" void SDCard_Stop_Play(void) __attribute__((weak));
extern "C" bool SDCard_Is_Playing(void) __attribute__((weak));
extern "C" bool SDCard_Is_Paused(void) __attribute__((weak));
@@ -37,6 +37,16 @@
    return SDCard_Is_Paused && SDCard_Is_Paused();
}
static bool ShouldHoldIdleForLessonWork() {
    return Board_IsLessonSyncing() || Board_IsRepeaterTask() || Board_IsShadowingTask() ||
           Board_IsWaitingStartPhrase();
}
static bool IsLessonDetectText(const std::string& text) {
    return text.rfind("[lesson_sync]", 0) == 0 ||
           text.rfind("[app_task]english_learning|", 0) == 0;
}
static void PauseSdCardMp3() {
    if (SDCard_Pause_Play) {
        SDCard_Pause_Play();
@@ -46,12 +56,6 @@
static void ResumeSdCardMp3() {
    if (SDCard_Resume_Play) {
        SDCard_Resume_Play();
    }
}
static void StopSdCardMp3() {
    if (SDCard_Stop_Play) {
        SDCard_Stop_Play();
    }
}
@@ -240,6 +244,7 @@
}
void Application::Run() {
    main_loop_task_ = xTaskGetCurrentTaskHandle();
    // Set the priority of the main task to 10
    vTaskPrioritySet(nullptr, 10);
@@ -331,6 +336,7 @@
            display->UpdateStatusBar();
            auto state = GetDeviceState();
            if (state == kDeviceStateConnecting || state == kDeviceStateSpeaking ||
                state == kDeviceStateActivating ||
                (state == kDeviceStateIdle && suppress_wake_until_listen_)) {
                audio_service_.KeepCodecReadyForPlayback();
            }
@@ -341,10 +347,16 @@
                    if (greeting_listen_wait_ticks_ > 0) {
                        greeting_listen_wait_ticks_--;
                        if (greeting_listen_wait_ticks_ == 0) {
                            ESP_LOGW(TAG, "greeting TTS missing, start listening");
                            SetListeningMode(GetDefaultListeningMode());
                            if (ShouldHoldIdleForLessonWork()) {
                                ESP_LOGI(TAG, "skip greeting listen while lesson task");
                            } else {
                                ESP_LOGW(TAG, "greeting TTS missing, start listening");
                                SetListeningMode(GetDefaultListeningMode());
                            }
                        }
                    }
                } else if (Board_IsLessonSyncing()) {
                    boot_wake_restore_ticks_ = 0;
                } else if (++boot_wake_restore_ticks_ >= 20) {
                    ESP_LOGW(TAG, "boot websocket not opened, restore wake word");
                    suppress_wake_until_listen_ = false;
@@ -358,7 +370,7 @@
                speaking_started_us_ > 0) {
                const int64_t wait_ms = (esp_timer_get_time() - speaking_started_us_) / 1000;
                if (wait_ms >= 1000 && (wait_ms / 1000) <= 8) {
                    ESP_LOGW(TAG, "TTS audio still missing after %lldms", (long long)wait_ms);
                    ESP_LOGW(TAG, "TTS audio still missing after %dms", (int)wait_ms);
                }
            }
            if (protocol_ && IsSdCardMp3Playing() && protocol_->IsAudioChannelConnected()) {
@@ -379,7 +391,33 @@
                    pending_detect_text_.clear();
                }
            }
            if (hold_idle_for_prompt_ && !pending_detect_text_.empty() &&
                GetDeviceState() == kDeviceStateIdle &&
                protocol_ && protocol_->IsAudioChannelConnected()) {
                if (pending_detect_retry_ticks_ > 0) {
                    pending_detect_retry_ticks_--;
                } else if (pending_detect_retries_left_ > 0) {
                    pending_detect_retries_left_--;
                    pending_detect_retry_ticks_ = 2;
                    ESP_LOGW(TAG, "retry app_task detect: %s left=%d",
                             pending_detect_text_.c_str(), pending_detect_retries_left_);
                    SendListenDetectNow(pending_detect_text_);
                } else if (IsLessonDetectText(pending_detect_text_) || ShouldHoldIdleForLessonWork()) {
                    ESP_LOGW(TAG, "lesson detect unanswered, keep idle: %s",
                             pending_detect_text_.c_str());
                    pending_detect_text_.clear();
                    pending_detect_retries_left_ = 0;
                    pending_detect_retry_ticks_ = 0;
                } else {
                    ESP_LOGW(TAG, "app_task detect unanswered, start listening");
                    hold_idle_for_prompt_ = false;
                    suppress_wake_until_listen_ = false;
                    pending_detect_text_.clear();
                    SetListeningMode(GetDefaultListeningMode());
                }
            }
            Board_PollPendingLessonPlayback();
            // Print debug info every 10 seconds
            if (clock_ticks_ % 10 == 0) {
                SystemInfo::PrintHeapStats();
@@ -621,6 +659,10 @@
            if (GetDeviceState() == kDeviceStateIdle) {
                break;
            }
            // 激活请求失败时再播一遍 6 位码,避免只在开机瞬间播一次被听漏。
            if (ota_->HasActivationCode() && i < 9) {
                ShowActivationCode(ota_->GetActivationCode(), ota_->GetActivationMessage());
            }
        }
    }
}
@@ -666,10 +708,13 @@
            }
            if (state == kDeviceStateListening || state == kDeviceStateIdle) {
                tts_audio_expected_ = true;
                if (!ShouldHoldIdleForLessonWork()) {
                    hold_idle_for_prompt_ = false;
                }
            }
            tts_response_started_ = true;
            ESP_LOGI(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
            ESP_LOGD(TAG, "TTS audio packet bytes=%u expected=%d ready=%d state=%d",
                     (unsigned)shared_packet->payload.size(),
                     tts_audio_expected_ ? 1 : 0,
                     tts_speaking_state_ready_ ? 1 : 0,
@@ -695,12 +740,10 @@
            ESP_LOGI(TAG, "Skip channel-closed teardown while opening/reusing websocket");
            return;
        }
        // 跟读/复读还在播时不要切 LOW_POWER:后面补发 playlist_completed 会 handshake timeout。
        if ((Board_IsShadowingTask() || Board_IsRepeaterTask()) && IsSdCardMp3Playing()) {
            board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
        } else {
            board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
        }
        // 通道关掉后会立刻切 idle、加载唤醒词。这时再进 MAX_MODEM,
        // MQTT 重连任务和 phy_track_pll 会把内部 SRAM 打穿,ESP_ERROR_CHECK 直接重启。
        // 保持 PERFORMANCE,等 PowerSaveTimer 超时后再真正断网待命。
        board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
        Schedule([this]() {
            auto display = Board::GetInstance().GetDisplay();
            opening_audio_channel_ = false;
@@ -716,6 +759,7 @@
            tts_stop_pending_ = false;
            tts_stop_waiting_ = false;
            tts_idle_waiting_ = false;
            tts_session_end_ = false;
            tts_speaking_state_ready_ = false;
            tts_activity_generation_++;
            audio_service_.ResetDecoder();
@@ -748,11 +792,18 @@
            if (strcmp(state->valuestring, "start") == 0) {
                Schedule([this]() {
                    aborted_ = false;
                    if (!ShouldHoldIdleForLessonWork()) {
                        hold_idle_for_prompt_ = false;
                    }
                    pending_detect_text_.clear();
                    pending_detect_retries_left_ = 0;
                    pending_detect_retry_ticks_ = 0;
                    tts_audio_expected_ = true;
                    tts_response_started_ = false;
                    tts_audio_received_ = false;
                    tts_stop_pending_ = false;
                    tts_stop_waiting_ = false;
                    tts_session_end_ = false;
                    tts_playback_empty_ticks_ = 0;
                    tts_stop_wait_ticks_ = 0;
                    tts_activity_generation_++;
@@ -766,9 +817,15 @@
                             Board::GetInstance().GetAudioCodec()->output_enabled() ? 1 : 0);
                });
            } else if (strcmp(state->valuestring, "stop") == 0) {
                Schedule([this]() {
                    ESP_LOGI(TAG, "TTS stop started=%d received=%d",
                             tts_response_started_, tts_audio_received_);
                auto session_end_item = cJSON_GetObjectItem(root, "session_end");
                const bool session_end = cJSON_IsTrue(session_end_item) ||
                    (cJSON_IsNumber(session_end_item) && session_end_item->valuedouble != 0);
                Schedule([this, session_end]() {
                    ESP_LOGI(TAG, "TTS stop started=%d received=%d session_end=%d",
                             tts_response_started_, tts_audio_received_, session_end ? 1 : 0);
                    if (session_end) {
                        tts_session_end_ = true;
                    }
                    tts_stop_pending_ = true;
                    FinishTtsResponseAfterPlayback();
                });
@@ -785,13 +842,22 @@
                        return;
                    }
                    Schedule([this, display, message = std::string(text->valuestring)]() {
                        if (!ShouldHoldIdleForLessonWork()) {
                            hold_idle_for_prompt_ = false;
                        }
                        pending_detect_text_.clear();
                        pending_detect_retries_left_ = 0;
                        pending_detect_retry_ticks_ = 0;
                        tts_audio_expected_ = true;
                        tts_response_started_ = true;
                        tts_activity_generation_++;
                        tts_playback_empty_ticks_ = 0;
                        audio_service_.SetAudioPowerInhibit(true);
                        audio_service_.KeepCodecReadyForPlayback();
                        if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                        if (Board_OnStartPhraseSentence(message.c_str())) {
                            resume_sdcard_mp3_after_tts_ = false;
                            resume_sdcard_mp3_after_voice_ = false;
                        } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                            PauseSdCardMp3();
                            resume_sdcard_mp3_after_tts_ = true;
                        }
@@ -890,6 +956,18 @@
    protocol_->Start();
}
static void WaitUntilActivationPlaybackIdle(AudioService& audio) {
    audio.KeepCodecReadyForPlayback();
    for (int i = 0; i < 150; ++i) {
        if (audio.IsPlaybackQueueEmpty()) {
            vTaskDelay(pdMS_TO_TICKS(150));
            return;
        }
        audio.KeepCodecReadyForPlayback();
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}
void Application::ShowActivationCode(const std::string& code, const std::string& message) {
    struct digit_sound {
        char digit;
@@ -908,14 +986,24 @@
        digit_sound{'9', Lang::Sounds::OGG_9}
    }};
    ESP_LOGI(TAG, "Speaking activation code: %s", code.c_str());
    audio_service_.KeepCodecReadyForPlayback();
    // 功放刚打开时前几百毫秒常是静音,先稳住再播,避免只听到提示音听不到数字。
    vTaskDelay(pdMS_TO_TICKS(400));
    // This sentence uses 9KB of SRAM, so we need to wait for it to finish
    Alert(Lang::Strings::ACTIVATION, message.c_str(), "link", Lang::Sounds::OGG_ACTIVATION);
    WaitUntilActivationPlaybackIdle(audio_service_);
    for (const auto& digit : code) {
        auto it = std::find_if(digit_sounds.begin(), digit_sounds.end(),
            [digit](const digit_sound& ds) { return ds.digit == digit; });
        if (it != digit_sounds.end()) {
            ESP_LOGI(TAG, "Playing activation digit: %c", digit);
            audio_service_.KeepCodecReadyForPlayback();
            audio_service_.PlaySound(it->sound);
            WaitUntilActivationPlaybackIdle(audio_service_);
            vTaskDelay(pdMS_TO_TICKS(250));
        }
    }
}
@@ -966,6 +1054,87 @@
    return protocol_->SendWakeWordDetected(text);
}
void Application::HoldIdleForServerPrompt() {
    hold_idle_for_prompt_ = true;
    suppress_wake_until_listen_ = true;
    auto state = GetDeviceState();
    if (state == kDeviceStateListening) {
        SetDeviceState(kDeviceStateIdle);
    }
}
void Application::ReleaseHoldIdle() {
    hold_idle_for_prompt_ = false;
    suppress_wake_until_listen_ = false;
    boot_wake_restore_ticks_ = 0;
}
void Application::StartListeningAfterPrompt() {
    SetListeningMode(GetDefaultListeningMode());
}
void Application::ArmPendingDetectRetry(const std::string& text, int retries) {
    if (text.empty() || retries <= 0) {
        return;
    }
    pending_detect_text_ = text;
    pending_detect_retries_left_ = retries;
    pending_detect_retry_ticks_ = 2;
}
bool Application::IsAudioChannelConnected() const {
    return protocol_ && protocol_->IsAudioChannelConnected();
}
void Application::AnnounceReturnedToChat() {
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    suppress_wake_until_listen_ = false;
    audio_service_.KeepCodecReadyForPlayback();
    auto display = Board::GetInstance().GetDisplay();
    display->SetStatus("对话模式");
    display->SetEmotion("happy");
    display->SetChatMessage("assistant", "我们来聊天吧。");
    audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
    ESP_LOGI(TAG, "announced local return to chat");
}
void Application::ReconnectAfterLesson(const std::string& detect) {
    // 课文结束 WS 已断:先释放 AFE,再握手补发 skill_event。
    // Server 会播「泛听/跟读结束。我们来聊天吧。」说完再 listening。
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    suppress_wake_until_listen_ = true;
    audio_service_.SetAudioPowerInhibit(false);
    audio_service_.KeepCodecReadyForPlayback();
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(false);
    audio_service_.ReleaseAfeRuntime();
    auto state = GetDeviceState();
    if (state == kDeviceStateListening || state == kDeviceStateSpeaking) {
        SetDeviceState(kDeviceStateIdle);
    }
    if (detect.empty() || !protocol_) {
        ESP_LOGW(TAG, "lesson reconnect skipped, stay local chat");
        AnnounceReturnedToChat();
        audio_service_.EnableWakeWordDetection(true);
        return;
    }
    // 泛听时长会把 WiFi 待机关掉(跟读有听窗所以不会)。不要用 IsNetworkReady()
    // 直接放弃:后面 OpenAudioChannel 会 EnsureNetworkReadyForWake,连上再播结束语。
    ESP_LOGI(TAG, "lesson reconnect for %s", detect.c_str());
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    SetDeviceState(kDeviceStateConnecting);
    Schedule([this, detect]() {
        ContinueOpenAudioChannelForDetect(detect);
    });
}
void Application::SendListenDetect(const std::string& text, bool open_if_idle) {
    Schedule([this, text, open_if_idle]() {
        if (!protocol_ || text.empty()) {
@@ -977,6 +1146,7 @@
            Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
            protocol_->MarkIncomingActivity();
            if (protocol_->SendWakeWordDetected(text)) {
                Board_NoteLessonStartDetectSent();
                return;
            }
            ESP_LOGW(TAG, "detect send failed, reopen audio channel");
@@ -1089,6 +1259,18 @@
    // AFE 起来后内部堆只剩几 KB,xTaskCreate(8KB) 会直接失败。
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(false);
    const size_t dma_before = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
    ESP_LOGI(TAG, "OpenAudioChannel DMA largest=%u free=%u",
             (unsigned)dma_before,
             (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
    if (dma_before < 8192) {
        ESP_LOGW(TAG, "low DMA, release AFE before websocket");
        audio_service_.ClearWakeWordBuffers();
        audio_service_.ReleaseAfeRuntime();
        vTaskDelay(pdMS_TO_TICKS(50));
        ESP_LOGI(TAG, "OpenAudioChannel DMA after AFE release=%u",
                 (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA));
    }
    Board::GetInstance().SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
    audio_service_.SetAudioPowerInhibit(true);
@@ -1136,25 +1318,45 @@
    listening_mode_ = GetDefaultListeningMode();
    suppress_wake_until_listen_ = true;
    if (!OpenAudioChannelWithRetry(2)) {
        pending_detect_text_ = detect_text;
        if (pending_detect_retries_left_ <= 0) {
            pending_detect_retries_left_ = 3;
        // 课文结束后通道已死时不要反复 handshake:会卡在「连接中」并关掉唤醒词。
        const bool skip_retry = detect_text.rfind("[skill_event]", 0) == 0;
        if (skip_retry) {
            pending_detect_text_.clear();
            pending_detect_retries_left_ = 0;
            pending_detect_retry_ticks_ = 0;
        } else {
            pending_detect_text_ = detect_text;
            if (pending_detect_retries_left_ <= 0) {
                pending_detect_retries_left_ = 3;
            }
            pending_detect_retry_ticks_ = 3;
        }
        pending_detect_retry_ticks_ = 3;
        suppress_wake_until_listen_ = false;
        audio_service_.EnableWakeWordDetection(true);
        SetDeviceState(kDeviceStateIdle);
        ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
        if (skip_retry) {
            ESP_LOGW(TAG, "websocket failed after lesson, keep idle chat without retry");
            AnnounceReturnedToChat();
        } else {
            ESP_LOGW(TAG, "boot websocket failed, will retry detect in 3s");
        }
        if (IsLessonDetectText(detect_text) || ShouldHoldIdleForLessonWork()) {
            suppress_wake_until_listen_ = true;
            audio_service_.EnableWakeWordDetection(false);
        } else {
            audio_service_.EnableWakeWordDetection(true);
        }
        return;
    }
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    pending_detect_retry_ticks_ = 0;
    greeting_listen_wait_ticks_ = 8;
    greeting_listen_wait_ticks_ = (IsLessonDetectText(detect_text) || ShouldHoldIdleForLessonWork()) ? 0 : 8;
    if (!detect_text.empty()) {
        protocol_->MarkIncomingActivity();
        protocol_->SendWakeWordDetected(detect_text);
        if (protocol_->SendWakeWordDetected(detect_text)) {
            Board_NoteLessonStartDetectSent();
        }
    }
    // 保持待命且不开 AFE:欢迎词从 idle 进 speaking。说完再 listening 开麦。
    SetDeviceState(kDeviceStateIdle);
@@ -1185,9 +1387,20 @@
    if (!IsSdCardMp3Playing() || IsSdCardMp3Paused()) {
        return;
    }
    // 音频在播:不上行录音/闲聊,只留离线口令(退出复读/跟读、我们聊天吧)。
    // 音频在播:不上行录音/闲聊,只留离线口令(退出泛听/跟读、我们聊天吧)。
    // 课文还在读 SD 时禁止拉起 AFE:会把 SDMMC DMA 需要的连续内部 SRAM 吃光,fopen abort。
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    const size_t largest_internal = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL);
    if (!SDCard_Mp3OfflineWakeAllowed() || largest_internal < 32768) {
        audio_service_.EnableWakeWordDetection(false);
        if (!SDCard_Mp3OfflineWakeAllowed()) {
            ESP_LOGD(TAG, "offline wake deferred until MP3 cached");
        } else {
            ESP_LOGW(TAG, "skip offline wake, internal largest=%u", (unsigned)largest_internal);
        }
    } else {
        audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    }
    if (GetDeviceState() != kDeviceStateListening) {
        return;
    }
@@ -1241,7 +1454,9 @@
void Application::CloseShadowingListenWindow()
{
    audio_service_.EnableVoiceProcessing(false);
    audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
    // 下一句还要 fopen/解码,「跟读结束」TTS 还要 mbedtls AES。这里不要立刻拉起
    // AFE 唤醒词,否则内部 SRAM 只剩 1KB 连续块,SD DMA / SSL 都会失败。
    audio_service_.EnableWakeWordDetection(false);
    if (protocol_ && protocol_->IsAudioChannelOpened()) {
        while (auto packet = audio_service_.PopPacketFromSendQueue()) {
            if (!protocol_->SendAudio(std::move(packet))) {
@@ -1326,7 +1541,21 @@
    if (state == kDeviceStateIdle) {
        PauseSdCardMp3ForVoice();
        audio_service_.EncodeWakeWord();
        size_t dma_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
        // 再见后唤醒词 AFE 还占着内部堆,DMA 往往不够编码 opus。
        // 旧逻辑会跳过编码却继续阻塞等 opus,主循环卡死,15s 后 codec 掉电,像软关机。
        if (dma_largest < 12288) {
            ESP_LOGW(TAG, "low DMA before wake handshake, release AFE largest=%u",
                     (unsigned)dma_largest);
            audio_service_.ReleaseAfeRuntime();
            dma_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
        }
        if (dma_largest >= 8192) {
            audio_service_.EncodeWakeWord();
        } else {
            ESP_LOGW(TAG, "skip wake opus encode, DMA largest=%u", (unsigned)dma_largest);
            audio_service_.AbortWakeWordEncode();
        }
        auto wake_word = audio_service_.GetLastWakeWord();
        if (!protocol_->IsAudioChannelOpened()) {
@@ -1429,6 +1658,7 @@
                tts_stop_pending_ = false;
                tts_stop_waiting_ = false;
                tts_idle_waiting_ = false;
                tts_session_end_ = false;
                tts_speaking_state_ready_ = false;
                tts_activity_generation_++;
            }
@@ -1437,16 +1667,18 @@
            display->ClearChatMessages();  // Clear messages first
            display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
            audio_service_.EnableVoiceProcessing(false);
            if (suppress_wake_until_listen_) {
            // 再见断线会 speaking→listening→idle:processor AFE 没释放就把唤醒词 AFE 拉起来,
            // audio_detection 建不起来,feed 刷屏直到内部 SRAM 打穿。
            if (!suppress_wake_until_listen_) {
                audio_service_.ReleaseVoiceProcessorRuntime();
            }
            if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                ApplyMp3CommandOnlyMic();
            } else if (suppress_wake_until_listen_ || Board_IsLessonSyncing() ||
                       Board_IsRepeaterTask() || Board_IsShadowingTask()) {
                audio_service_.EnableWakeWordDetection(false);
                audio_service_.SetAudioPowerInhibit(true);
                audio_service_.KeepCodecReadyForPlayback();
            } else if (Board_IsRepeaterTask()) {
                audio_service_.EnableVoiceProcessing(false);
                audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
                audio_service_.SetAudioPowerInhibit(false);
            } else if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
                ApplyMp3CommandOnlyMic();
            } else {
                audio_service_.EnableWakeWordDetection(true);
                audio_service_.SetAudioPowerInhibit(false);
@@ -1522,6 +1754,10 @@
            // Do nothing
            break;
    }
}
bool Application::IsOnMainLoop() const {
    return main_loop_task_ != nullptr && xTaskGetCurrentTaskHandle() == main_loop_task_;
}
void Application::Schedule(std::function<void()>&& callback) {
@@ -1710,6 +1946,7 @@
        tts_stop_pending_ = false;
        tts_stop_waiting_ = false;
        tts_idle_waiting_ = false;
        tts_session_end_ = false;
        tts_speaking_state_ready_ = false;
        tts_activity_generation_++;
@@ -1770,20 +2007,28 @@
}
void Application::FinishTtsResponse() {
    const bool end_session = tts_session_end_;
    const bool hold_idle = hold_idle_for_prompt_;
    tts_audio_expected_ = false;
    tts_response_started_ = false;
    tts_audio_received_ = false;
    tts_stop_pending_ = false;
    tts_stop_waiting_ = false;
    tts_idle_waiting_ = false;
    tts_session_end_ = false;
    tts_speaking_state_ready_ = false;
    tts_playback_empty_ticks_ = 0;
    tts_stop_wait_ticks_ = 0;
    speaking_started_us_ = 0;
    audio_service_.SetAudioPowerInhibit(false);
    suppress_wake_until_listen_ = false;
    pending_detect_text_.clear();
    pending_detect_retries_left_ = 0;
    if (hold_idle || ShouldHoldIdleForLessonWork()) {
        suppress_wake_until_listen_ = true;
    } else {
        suppress_wake_until_listen_ = false;
        pending_detect_text_.clear();
        pending_detect_retries_left_ = 0;
        pending_detect_retry_ticks_ = 0;
    }
    ResumeSdCardMp3AfterVoice();
    if (resume_sdcard_mp3_after_tts_ && !sdcard_mp3_stop_requested_) {
        resume_sdcard_mp3_after_tts_ = false;
@@ -1792,8 +2037,12 @@
        resume_sdcard_mp3_after_tts_ = false;
    }
    if (GetDeviceState() == kDeviceStateSpeaking) {
        if (listening_mode_ == kListeningModeManualStop ||
        if (end_session || hold_idle || ShouldHoldIdleForLessonWork() ||
            listening_mode_ == kListeningModeManualStop ||
            (IsSdCardMp3Playing() && !IsSdCardMp3Paused())) {
            ESP_LOGI(TAG, "TTS done, return to idle session_end=%d hold=%d lesson=%d",
                     end_session ? 1 : 0, hold_idle ? 1 : 0,
                     ShouldHoldIdleForLessonWork() ? 1 : 0);
            SetDeviceState(kDeviceStateIdle);
        } else {
            listening_mode_ = GetDefaultListeningMode();
@@ -1803,6 +2052,7 @@
    if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
        ApplyMp3CommandOnlyMic();
    }
    Board_FlushPendingLessonPlayback();
    if (power_off_requested_) {
        power_off_requested_ = false;
        Board_Enter_Power_Off();
@@ -1866,8 +2116,9 @@
        tts_stop_wait_ticks_++;
        const int empty_needed = tts_audio_received_ ? 1 : 2;
        if (playback_empty && tts_playback_empty_ticks_ >= empty_needed) {
            ESP_LOGI(TAG, "TTS done, return to listen empty=%d wait=%d received=%d",
                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_);
            ESP_LOGI(TAG, "TTS done empty=%d wait=%d received=%d session_end=%d",
                     tts_playback_empty_ticks_, tts_stop_wait_ticks_, tts_audio_received_,
                     tts_session_end_ ? 1 : 0);
            FinishTtsResponse();
        } else if (!playback_empty && tts_stop_wait_ticks_ >= 30) {
            ESP_LOGW(TAG, "TTS playback stuck after stop, return to listen");
@@ -1878,13 +2129,13 @@
    // 欢迎词/长回答:stop 没到时,只在「一直没收到 opus」或「播完后长时间空队列」才回听。
    // 不要用说话墙钟 15s/队列空 2s:中英对话长句会在句间空隙或 15 秒处被掐断。
    if (!tts_audio_received_ && elapsed_ms >= 5000) {
        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%lldms received=0 empty=%d",
                 (long long)elapsed_ms, tts_playback_empty_ticks_);
    if (!tts_audio_received_ && elapsed_ms >= 10000) {
        ESP_LOGW(TAG, "TTS stop missing, return to listen elapsed=%dms received=0 empty=%d",
                 (int)elapsed_ms, tts_playback_empty_ticks_);
        FinishTtsResponse();
    } else if (playback_empty && tts_audio_received_ && tts_playback_empty_ticks_ >= 8) {
        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%lldms empty=%d",
                 (long long)elapsed_ms, tts_playback_empty_ticks_);
        ESP_LOGW(TAG, "TTS stop missing after idle playback elapsed=%dms empty=%d",
                 (int)elapsed_ms, tts_playback_empty_ticks_);
        FinishTtsResponse();
    }
}
@@ -1901,6 +2152,12 @@
        return false;
    }
    // 泛听是 idle 播 SD MP3,AudioService 队列是空的,会被当成可休眠。
    // 跟读每句有听窗所以不会进 WiFi 待机;泛听会把网关掉,结束语就发不出去。
    if (IsSdCardMp3Playing() || Board_IsRepeaterTask() || Board_IsShadowingTask()) {
        return false;
    }
    if (!audio_service_.IsIdle()) {
        return false;
    }