From 220e4fab27b7f419b3e89e2f2d28ae789aac09d7 Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期四, 08 十月 2026 16:29:55 +0800
Subject: [PATCH] 5种场景模式家长端ok

---
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc |  977 +++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 873 insertions(+), 104 deletions(-)

diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
index df9e80e..bc248d6 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,4 +1,5 @@
 #include "app_task.h"
+#include "parent_schedule.h"
 
 #include "application.h"
 #include "board.h"
@@ -12,21 +13,45 @@
 #include <string.h>
 #include <stdio.h>
 #include <stdlib.h>
+#include <sys/stat.h>
+#include <dirent.h>
+#include <ctype.h>
+#include <string>
+#include <esp_heap_caps.h>
+#include <esp_timer.h>
 
 #define TAG "app_task"
 #define APP_TASK_NVS_NS "app_task"
 #define APP_TASK_NVS_KEY "pending"
 #define APP_TASK_NVS_LAST_REP "last_rep"
+#define APP_TASK_NVS_LAST_RPT "last_rpt"
+#define APP_TASK_NVS_LAST_SHD "last_shd"
 #define APP_TASK_JSON_MAX 768
 #define APP_TASK_ID_MAX 32
 #define APP_TASK_SOURCE_MAX 24
+#define LESSON_CLIP_NAME_MAX 16
+#define LESSON_CLIP_MAX 128
 
 static char s_current_app[APP_TASK_ID_MAX] = "chat";
 static char s_current_task[APP_TASK_ID_MAX] = "conversation";
 static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
+static char s_current_params[APP_TASK_JSON_MAX] = "{}";
 static int s_current_priority = 0;
 static bool s_finish_task_after_mp3 = true;
 static float s_shadowing_listen_ratio = 1.2f;
+static bool s_wait_start_phrase = false;
+static bool s_start_phrase_tts_begun = false;
+static bool s_start_detect_sent = false;
+static bool s_listen_after_phrase = false;
+static int64_t s_wait_start_us = 0;
+static std::string s_start_detect_text;
+
+void Board_TrySendLessonStartDetect(void);
+
+bool Board_IsWaitingStartPhrase(void)
+{
+    return s_wait_start_phrase;
+}
 
 typedef struct {
     const char *app_id;
@@ -63,6 +88,20 @@
 int Board_AppTask_CurrentPriority(void)
 {
     return s_current_priority;
+}
+
+static bool is_safe_task_id(const char *id)
+{
+    if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
+        return false;
+    }
+    for (const char *p = id; *p; ++p) {
+        unsigned char ch = static_cast<unsigned char>(*p);
+        if (!isalnum(ch) && ch != '_' && ch != '-') {
+            return false;
+        }
+    }
+    return true;
 }
 
 static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
@@ -376,6 +415,328 @@
     return name[0] != '\0';
 }
 
+static const char *last_lesson_nvs_key(const char *task_id)
+{
+    if (task_id && strcmp(task_id, "shadowing") == 0) {
+        return APP_TASK_NVS_LAST_SHD;
+    }
+    if (task_id && strcmp(task_id, "repeater") == 0) {
+        return APP_TASK_NVS_LAST_RPT;
+    }
+    return NULL;
+}
+
+static const char *const k_last_lesson_keys[] = {
+    "lesson_id",
+    "lesson",
+    "lessonId",
+    "en_passes",
+    "enPasses",
+    "cn_repeat",
+    "cn_speed",
+    "language",
+    "lang",
+    "speed",
+    "on_complete",
+    "listen_ratio",
+    "sentence_count",
+    "org_id",
+    "orgId",
+    NULL,
+};
+
+static bool json_has_lesson_id(cJSON *root)
+{
+    if (!root) {
+        return false;
+    }
+    const cJSON *id = cJSON_GetObjectItem(root, "lesson_id");
+    if (!id) {
+        id = cJSON_GetObjectItem(root, "lessonId");
+    }
+    if (!id) {
+        id = cJSON_GetObjectItem(root, "lesson");
+    }
+    return cJSON_IsString(id) && id->valuestring && id->valuestring[0];
+}
+
+static bool compact_last_lesson_json(const char *params_json, char *out, size_t out_size)
+{
+    if (!out || out_size == 0) {
+        return false;
+    }
+    out[0] = '\0';
+    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
+        return false;
+    }
+    cJSON *root = cJSON_Parse(params_json);
+    if (!root) {
+        return false;
+    }
+    cJSON *compact = cJSON_CreateObject();
+    if (!compact) {
+        cJSON_Delete(root);
+        return false;
+    }
+    for (int i = 0; k_last_lesson_keys[i]; ++i) {
+        cJSON *item = cJSON_GetObjectItem(root, k_last_lesson_keys[i]);
+        if (item) {
+            cJSON_AddItemToObject(compact, k_last_lesson_keys[i], cJSON_Duplicate(item, 1));
+        }
+    }
+    bool ok = json_has_lesson_id(compact);
+    if (ok) {
+        const cJSON *id = cJSON_GetObjectItem(compact, "lesson_id");
+        if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+            id = cJSON_GetObjectItem(compact, "lessonId");
+            if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+                id = cJSON_GetObjectItem(compact, "lesson");
+            }
+            if (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) {
+                cJSON_AddStringToObject(compact, "lesson_id", id->valuestring);
+            }
+        }
+        char *printed = cJSON_PrintUnformatted(compact);
+        if (printed) {
+            strlcpy(out, printed, out_size);
+            ok = out[0] != '\0';
+            cJSON_free(printed);
+        } else {
+            ok = false;
+        }
+    }
+    cJSON_Delete(compact);
+    cJSON_Delete(root);
+    return ok;
+}
+
+static bool params_has_lesson_id(const char *params_json)
+{
+    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
+        return false;
+    }
+    cJSON *root = cJSON_Parse(params_json);
+    if (!root) {
+        return false;
+    }
+    bool ok = json_has_lesson_id(root);
+    cJSON_Delete(root);
+    return ok;
+}
+
+static bool sd_business_file_ready(const char *business_path)
+{
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    if (!business_path || business_path[0] == '\0') {
+        return false;
+    }
+    if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+        return false;
+    }
+    struct stat st;
+    if (stat(sd_path, &st) != 0) {
+        return false;
+    }
+    return S_ISREG(st.st_mode) && st.st_size > 0;
+}
+
+static const char *json_clip_name(cJSON *obj)
+{
+    if (!cJSON_IsObject(obj)) {
+        return NULL;
+    }
+    const char *keys[] = {"name", "path", "url"};
+    for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) {
+        cJSON *item = cJSON_GetObjectItem(obj, keys[i]);
+        if (cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
+            return item->valuestring;
+        }
+    }
+    return NULL;
+}
+
+static int mp3_name_order(const void *a, const void *b)
+{
+    int left = atoi(static_cast<const char *>(a));
+    int right = atoi(static_cast<const char *>(b));
+    if (left != right) {
+        return left - right;
+    }
+    return strcmp(static_cast<const char *>(a), static_cast<const char *>(b));
+}
+
+static int list_lesson_mp3s(const char *business_dir, char names[][LESSON_CLIP_NAME_MAX], int cap)
+{
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    if (!business_dir || business_dir[0] == '\0' || !names || cap < 1) {
+        return 0;
+    }
+    if (SDCard_Map_Business_Path(business_dir, sd_path, sizeof(sd_path)) != ESP_OK) {
+        return 0;
+    }
+    DIR *dir = opendir(sd_path);
+    if (!dir) {
+        return 0;
+    }
+    int count = 0;
+    struct dirent *ent;
+    while ((ent = readdir(dir)) != NULL && count < cap) {
+        const char *name = ent->d_name;
+        size_t len = name ? strlen(name) : 0;
+        if (len < 5 || len >= LESSON_CLIP_NAME_MAX || strcasecmp(name + len - 4, ".mp3") != 0) {
+            continue;
+        }
+        strlcpy(names[count], name, LESSON_CLIP_NAME_MAX);
+        count++;
+    }
+    closedir(dir);
+    if (count > 1) {
+        qsort(names, static_cast<size_t>(count), LESSON_CLIP_NAME_MAX, mp3_name_order);
+    }
+    return count;
+}
+
+static const char *shadowing_lang_folder(const char *language)
+{
+    return (language && strcasecmp(language, "cn") == 0) ? "cn" : "en";
+}
+
+static bool probe_lesson_id_on_sd(const char *task_id, const char *lesson_id, const char *language)
+{
+    int level = 0;
+    int story = 0;
+    if (!lesson_id || sscanf(lesson_id, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+        return true;
+    }
+    char path[SDCARD_PATH_MAX_LEN];
+    if (task_id && strcmp(task_id, "shadowing") == 0) {
+        const char *lang = shadowing_lang_folder(language);
+        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s",
+                 level, story, lang);
+        char names[1][LESSON_CLIP_NAME_MAX];
+        return list_lesson_mp3s(path, names, 1) >= 1;
+    }
+    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
+    char names[1][LESSON_CLIP_NAME_MAX];
+    if (list_lesson_mp3s(path, names, 1) < 1) {
+        return false;
+    }
+    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
+    return list_lesson_mp3s(path, names, 1) >= 1;
+}
+
+static bool lesson_files_ready(const char *task_id, const char *params_json)
+{
+    if (!params_json || params_json[0] == '\0') {
+        return true;
+    }
+    cJSON *params = cJSON_Parse(params_json);
+    if (!params) {
+        return true;
+    }
+
+    cJSON *items = cJSON_GetObjectItem(params, "items");
+    bool checked = false;
+    if (cJSON_IsArray(items)) {
+        cJSON *it = NULL;
+        cJSON_ArrayForEach(it, items) {
+            const char *name = json_clip_name(it);
+            if (!name) {
+                continue;
+            }
+            checked = true;
+            if (!sd_business_file_ready(name)) {
+                cJSON_Delete(params);
+                return false;
+            }
+        }
+        if (checked) {
+            cJSON_Delete(params);
+            return true;
+        }
+    }
+
+    const char *single = json_clip_name(params);
+    if (single) {
+        bool ok = sd_business_file_ready(single);
+        cJSON_Delete(params);
+        return ok;
+    }
+
+    cJSON *lesson = cJSON_GetObjectItem(params, "lesson_id");
+    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
+        lesson = cJSON_GetObjectItem(params, "lesson");
+    }
+    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
+        lesson = cJSON_GetObjectItem(params, "lessonId");
+    }
+    cJSON *lang = cJSON_GetObjectItem(params, "language");
+    if (!cJSON_IsString(lang) || !lang->valuestring) {
+        lang = cJSON_GetObjectItem(params, "lang");
+    }
+    bool ok = true;
+    if (cJSON_IsString(lesson) && lesson->valuestring && lesson->valuestring[0] != '\0') {
+        ok = probe_lesson_id_on_sd(task_id, lesson->valuestring,
+                                  cJSON_IsString(lang) ? lang->valuestring : NULL);
+    }
+    cJSON_Delete(params);
+    return ok;
+}
+
+static void show_lesson_sync_alert(void)
+{
+    Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+}
+
+bool Board_LessonFilesReady(const char *task_id, const char *params_json)
+{
+    return lesson_files_ready(task_id, params_json);
+}
+
+bool Board_IsLessonPlaybackActive(void)
+{
+    return s_wait_start_phrase || SDCard_Is_Playing();
+}
+
+void Board_RememberLastLessonTask(const char *task_id, const char *params_json)
+{
+    const char *key = last_lesson_nvs_key(task_id);
+    char compact[APP_TASK_JSON_MAX];
+    if (!key || !compact_last_lesson_json(params_json, compact, sizeof(compact))) {
+        return;
+    }
+    nvs_handle_t handle;
+    if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) != ESP_OK) {
+        return;
+    }
+    if (nvs_set_str(handle, key, compact) == ESP_OK) {
+        nvs_commit(handle);
+        ESP_LOGI(TAG, "remember last %s lesson", task_id);
+    }
+    nvs_close(handle);
+}
+
+bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size)
+{
+    const char *key = last_lesson_nvs_key(task_id);
+    if (!key || !buf || buf_size == 0) {
+        return false;
+    }
+    buf[0] = '\0';
+    nvs_handle_t handle;
+    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
+        return false;
+    }
+    size_t length = buf_size;
+    esp_err_t err = nvs_get_str(handle, key, buf, &length);
+    nvs_close(handle);
+    if (err != ESP_OK || buf[0] == '\0' || !params_has_lesson_id(buf)) {
+        buf[0] = '\0';
+        return false;
+    }
+    return true;
+}
+
 esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
                                 const char *source, const char *alarm_id)
 {
@@ -417,66 +778,391 @@
     return err;
 }
 
-static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size,
-                                char *source, size_t source_size, char *params_json, size_t params_size)
-{
-    nvs_handle_t handle;
-    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
-        return false;
-    }
-    char buf[APP_TASK_JSON_MAX];
-    size_t length = sizeof(buf);
-    esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length);
-    nvs_close(handle);
-    if (err != ESP_OK) {
-        return false;
-    }
-
-    cJSON *root = cJSON_Parse(buf);
-    if (!root) {
-        return false;
-    }
-    const cJSON *app = cJSON_GetObjectItem(root, "app_id");
-    const cJSON *task = cJSON_GetObjectItem(root, "task_id");
-    const cJSON *src = cJSON_GetObjectItem(root, "source");
-    const cJSON *params = cJSON_GetObjectItem(root, "params");
-    copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat");
-    copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation");
-    copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat");
-    if (params_json && params_size > 0) {
-        params_json[0] = '\0';
-        if (params) {
-            char *printed = cJSON_PrintUnformatted(params);
-            if (printed) {
-                strlcpy(params_json, printed, params_size);
-                free(printed);
-            }
-        }
-    }
-    cJSON_Delete(root);
-    return app_id[0] != '\0';
-}
-
 static void preempt_current_skills(void)
 {
     SDCard_Stop_Play();
     BoardMqtt_StopPlaylistPlayback();
-    Application::GetInstance().Schedule([]() {
-        auto state = Application::GetInstance().GetDeviceState();
-        if (state == kDeviceStateSpeaking) {
-            Application::GetInstance().AbortSpeaking(kAbortReasonNone);
+}
+
+static bool is_schedule_talk_task(const char *task_id)
+{
+    return task_id && (strcmp(task_id, "conversation") == 0 ||
+                       strcmp(task_id, "live_translate") == 0 ||
+                       strcmp(task_id, "bilingual_chat") == 0);
+}
+
+static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle,
+                          bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false)
+{
+    std::string params = s_current_params[0] ? s_current_params : "{}";
+    if (sd_ready && params.size() >= 2 && params.back() == '}') {
+        params.pop_back();
+        if (params.size() > 1) {
+            params += ',';
         }
+        params += "\"sd_ready\":true}";
+    }
+    std::string detect = "[app_task]";
+    detect += (app_id && app_id[0]) ? app_id : "chat";
+    detect += "|";
+    detect += (task_id && task_id[0]) ? task_id : "conversation";
+    detect += "|";
+    detect += (source && source[0]) ? source : "default_chat";
+    detect += "|";
+    detect += params;
+    const bool hold_for_phrase = sd_ready || wait_phrase;
+    if (hold_for_phrase) {
+        s_start_detect_text = detect;
+        s_start_detect_sent = false;
+    }
+    Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() {
+        auto& app = Application::GetInstance();
+        app.HoldIdleForServerPrompt();
+        if (hold_for_phrase) {
+            // 鍏虫満鎷夎捣鏃堕�氶亾鍙兘杩樻病鎵撳紑銆傝繛涓婂悗鍐嶄笂鎶ワ紝绛夈�屽紑濮媥x浠诲姟銆嶈瀹屽啀杩涘叆浠诲姟銆�
+            Board_TrySendLessonStartDetect();
+            return;
+        }
+        if (expect_prompt && app.GetDeviceState() == kDeviceStateSpeaking) {
+            app.AbortSpeaking(kAbortReasonNone);
+        }
+        if (app.SendListenDetectNow(detect)) {
+            if (expect_prompt) {
+                app.ArmPendingDetectRetry(detect, 5);
+            }
+            return;
+        }
+        app.SendListenDetect(detect, open_if_idle);
     });
 }
 
-static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle)
+static const char *json_lesson_id(cJSON *params)
 {
-    char detect[160];
-    snprintf(detect, sizeof(detect), "[app_task]%s|%s|%s",
-             app_id && app_id[0] ? app_id : "chat",
-             task_id && task_id[0] ? task_id : "conversation",
-             source && source[0] ? source : "default_chat");
-    Application::GetInstance().SendListenDetect(detect, open_if_idle);
+    if (!params) {
+        return NULL;
+    }
+    const cJSON *id = cJSON_GetObjectItem(params, "lesson_id");
+    if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+        id = cJSON_GetObjectItem(params, "lessonId");
+    }
+    if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+        id = cJSON_GetObjectItem(params, "lesson");
+    }
+    return (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) ? id->valuestring : NULL;
+}
+
+static bool add_playlist_item(sdcard_mp3_playlist_item_t *items, int *n, int cap,
+                              const char *business, float speed, int repeat)
+{
+    if (!items || !n || *n >= cap || !business || business[0] == '\0') {
+        return false;
+    }
+    sdcard_mp3_playlist_item_t *item = &items[*n];
+    if (!SDCard_Resolve_MP3_Path(business, item->path, sizeof(item->path))) {
+        ESP_LOGW(TAG, "lesson clip missing on sd: %s", business);
+        return false;
+    }
+    item->speed = SDCard_Clamp_MP3_Speed(speed);
+    item->repeat = repeat < 1 ? 1 : repeat;
+    if (item->repeat > 10) {
+        item->repeat = 10;
+    }
+    item->power_off_after = false;
+    (*n)++;
+    return true;
+}
+
+static int build_repeater_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
+{
+    int level = 0;
+    int story = 0;
+    const char *lesson = json_lesson_id(params);
+    if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+        return 0;
+    }
+    int cn_repeat = 1;
+    float cn_speed = 1.0f;
+    const cJSON *cn_r = cJSON_GetObjectItem(params, "cn_repeat");
+    if (cJSON_IsNumber(cn_r)) {
+        cn_repeat = cn_r->valueint;
+    }
+    const cJSON *cn_s = cJSON_GetObjectItem(params, "cn_speed");
+    if (cJSON_IsNumber(cn_s)) {
+        cn_speed = static_cast<float>(cn_s->valuedouble);
+    }
+    char dir[SDCARD_PATH_MAX_LEN];
+    char path[SDCARD_PATH_MAX_LEN];
+    static char cn_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+    static char en_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+    int n = 0;
+    snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
+    int cn_n = list_lesson_mp3s(dir, cn_names, LESSON_CLIP_MAX);
+    snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
+    int en_n = list_lesson_mp3s(dir, en_names, LESSON_CLIP_MAX);
+    if (cn_n < 1 || en_n < 1) {
+        return 0;
+    }
+    for (int i = 0; i < cn_n; ++i) {
+        snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn/%.15s",
+                 level, story, cn_names[i]);
+        if (!add_playlist_item(items, &n, cap, path, cn_speed, cn_repeat)) {
+            return n > 0 ? n : 0;
+        }
+    }
+    const cJSON *passes = cJSON_GetObjectItem(params, "en_passes");
+    int en_added = 0;
+    if (cJSON_IsArray(passes)) {
+        cJSON *row = NULL;
+        cJSON_ArrayForEach(row, passes) {
+            if (en_added >= 8) {
+                break;
+            }
+            float speed = 1.0f;
+            int repeat = 1;
+            const cJSON *sp = cJSON_GetObjectItem(row, "speed");
+            const cJSON *rp = cJSON_GetObjectItem(row, "repeat");
+            if (cJSON_IsNumber(sp)) {
+                speed = static_cast<float>(sp->valuedouble);
+            }
+            if (cJSON_IsNumber(rp)) {
+                repeat = rp->valueint;
+            }
+            for (int i = 0; i < en_n; ++i) {
+                snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
+                         level, story, en_names[i]);
+                if (!add_playlist_item(items, &n, cap, path, speed, repeat)) {
+                    return n;
+                }
+            }
+            en_added++;
+        }
+    }
+    if (en_added == 0) {
+        const float speeds[] = {0.8f, 0.9f, 1.0f};
+        const int repeats[] = {1, 2, 5};
+        for (int pass = 0; pass < 3; ++pass) {
+            for (int i = 0; i < en_n; ++i) {
+                snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
+                         level, story, en_names[i]);
+                if (!add_playlist_item(items, &n, cap, path, speeds[pass], repeats[pass])) {
+                    return n;
+                }
+            }
+        }
+    }
+    return n;
+}
+
+static int build_shadowing_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
+{
+    int level = 0;
+    int story = 0;
+    const char *lesson = json_lesson_id(params);
+    if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+        return 0;
+    }
+    char lang[3] = "en";
+    const cJSON *language = cJSON_GetObjectItem(params, "language");
+    if (!cJSON_IsString(language) || !language->valuestring) {
+        language = cJSON_GetObjectItem(params, "lang");
+    }
+    if (cJSON_IsString(language) && language->valuestring && strcasecmp(language->valuestring, "cn") == 0) {
+        memcpy(lang, "cn", 3);
+    }
+    float speed = 0.8f;
+    const cJSON *speed_item = cJSON_GetObjectItem(params, "speed");
+    if (cJSON_IsNumber(speed_item)) {
+        speed = static_cast<float>(speed_item->valuedouble);
+    }
+    char dir[SDCARD_PATH_MAX_LEN];
+    static char names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+    snprintf(dir, sizeof(dir), "/english_learning/shadowing/lessons/l%d/%03d/%.2s", level, story, lang);
+    int found = list_lesson_mp3s(dir, names, LESSON_CLIP_MAX);
+    int n = 0;
+    for (int i = 0; i < found && n < cap; ++i) {
+        char clip[LESSON_CLIP_NAME_MAX];
+        memcpy(clip, names[i], sizeof(clip));
+        clip[sizeof(clip) - 1] = '\0';
+        char path[SDCARD_PATH_MAX_LEN];
+        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s/%.15s",
+                 level, story, lang, clip);
+        if (!add_playlist_item(items, &n, cap, path, speed, 1)) {
+            break;
+        }
+    }
+    return n;
+}
+
+static bool start_lesson_playback_locally(const char *task_id, const char *params_json)
+{
+    cJSON *params = cJSON_Parse((params_json && params_json[0]) ? params_json : "{}");
+    if (!params) {
+        return false;
+    }
+    const bool shadowing = task_id && strcmp(task_id, "shadowing") == 0;
+    const int cap = LESSON_CLIP_MAX;
+    sdcard_mp3_playlist_item_t *items = static_cast<sdcard_mp3_playlist_item_t *>(
+        heap_caps_calloc(static_cast<size_t>(cap), sizeof(*items), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
+    if (!items) {
+        cJSON_Delete(params);
+        return false;
+    }
+    int n = shadowing ? build_shadowing_playlist(params, items, cap)
+                      : build_repeater_playlist(params, items, cap);
+    cJSON_Delete(params);
+    if (n < 1) {
+        heap_caps_free(items);
+        return false;
+    }
+    SDCard_PreparePlaybackAndWait();
+    Board_SetFinishTaskAfterMp3(false);
+    esp_err_t err = SDCard_Play_MP3_File_Playlist(items, n);
+    if (err == ESP_OK) {
+        ESP_LOGI(TAG, "local lesson playlist started clips=%d task=%s", n, task_id ? task_id : "");
+        Board_RememberRepeaterPlayback(items[0].path, items[0].speed, items[0].repeat, false);
+        SDCard_Show_Matching_JPG_For_MP3_Path(items[0].path);
+    } else {
+        ESP_LOGW(TAG, "local lesson playlist failed: %s", esp_err_to_name(err));
+    }
+    heap_caps_free(items);
+    return err == ESP_OK;
+}
+
+static bool is_lesson_start_phrase(const char *text)
+{
+    if (!text) {
+        return false;
+    }
+    return strstr(text, "寮�濮嬫硾鍚�") != NULL || strstr(text, "寮�濮嬭窡璇�") != NULL ||
+           strstr(text, "寮�濮嬬炕璇�") != NULL || strstr(text, "寮�濮嬩腑鑻�") != NULL ||
+           strstr(text, "寮�濮嬪璇�") != NULL;
+}
+
+bool Board_OnStartPhraseSentence(const char *text)
+{
+    if (!s_wait_start_phrase || !is_lesson_start_phrase(text)) {
+        return false;
+    }
+    s_start_phrase_tts_begun = true;
+    if (SDCard_Is_Playing()) {
+        ESP_LOGW(TAG, "stop early lesson mp3 until start phrase finishes");
+        SDCard_Stop_Play();
+    }
+    ESP_LOGI(TAG, "start phrase heard, wait until it finishes");
+    return true;
+}
+
+static void begin_scheduled_talk_after_phrase(void)
+{
+    s_wait_start_phrase = false;
+    s_start_phrase_tts_begun = false;
+    s_listen_after_phrase = false;
+    auto& app = Application::GetInstance();
+    if (app.GetDeviceState() == kDeviceStateSpeaking) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = true;
+        s_listen_after_phrase = true;
+        return;
+    }
+    ESP_LOGI(TAG, "start phrase done, open listening");
+    app.ReleaseHoldIdle();
+    app.StartListeningAfterPrompt();
+}
+
+void Board_FlushPendingLessonPlayback(void)
+{
+    if (!s_wait_start_phrase || !s_start_phrase_tts_begun) {
+        return;
+    }
+    if (s_listen_after_phrase) {
+        begin_scheduled_talk_after_phrase();
+        return;
+    }
+    s_wait_start_phrase = false;
+    s_start_phrase_tts_begun = false;
+    if (strcmp(s_current_task, "repeater") != 0 && strcmp(s_current_task, "shadowing") != 0) {
+        return;
+    }
+    if (Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = true;
+        return;
+    }
+    ESP_LOGI(TAG, "start phrase done, play lesson");
+    start_lesson_playback_locally(s_current_task, s_current_params);
+}
+
+void Board_NoteLessonStartDetectSent(void)
+{
+    if (!s_wait_start_phrase || s_start_detect_sent) {
+        return;
+    }
+    s_start_detect_sent = true;
+    s_wait_start_us = esp_timer_get_time();
+    ESP_LOGI(TAG, "start detect sent, wait for phrase");
+}
+
+void Board_TrySendLessonStartDetect(void)
+{
+    if (!s_wait_start_phrase || s_start_detect_sent || s_start_detect_text.empty()) {
+        return;
+    }
+    auto& app = Application::GetInstance();
+    auto state = app.GetDeviceState();
+    if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
+        return;
+    }
+    if (app.IsAudioChannelConnected()) {
+        if (app.SendListenDetectNow(s_start_detect_text)) {
+            Board_NoteLessonStartDetectSent();
+        }
+        return;
+    }
+    if (state == kDeviceStateIdle && Board::GetInstance().IsNetworkReady()) {
+        app.SendListenDetect(s_start_detect_text, true);
+    }
+}
+
+void Board_PollPendingLessonPlayback(void)
+{
+    if (!s_wait_start_phrase) {
+        return;
+    }
+    auto state = Application::GetInstance().GetDeviceState();
+    if (!s_start_phrase_tts_begun) {
+        if (!s_start_detect_sent) {
+            Board_TrySendLessonStartDetect();
+            return;
+        }
+        const int64_t elapsed_ms = (esp_timer_get_time() - s_wait_start_us) / 1000;
+        if (elapsed_ms >= 15000 && state != kDeviceStateSpeaking && state != kDeviceStateConnecting) {
+            if (s_listen_after_phrase) {
+                ESP_LOGW(TAG, "start phrase timeout, open listening");
+                s_start_phrase_tts_begun = true;
+                begin_scheduled_talk_after_phrase();
+            } else {
+                ESP_LOGW(TAG, "start phrase timeout, play lesson locally");
+                s_start_phrase_tts_begun = true;
+                Board_FlushPendingLessonPlayback();
+            }
+        }
+        return;
+    }
+    if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
+        return;
+    }
+    Board_FlushPendingLessonPlayback();
+}
+
+static void return_to_chat_locally(const char *reason, const char *detect)
+{
+    ESP_LOGW(TAG, "%s, return to chat locally", reason ? reason : "skill event not sent");
+    copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
+    copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
+    copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
+    s_current_priority = 0;
+    Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
+    Application::GetInstance().ReconnectAfterLesson(detect ? detect : "");
 }
 
 static void dispatch_skill(const char *skill, const char *app_id, const char *task_id,
@@ -516,6 +1202,16 @@
                 cJSON_Delete(params);
             }
         }
+        const bool server_first = Board::GetInstance().IsNetworkReady() && source &&
+            (strcmp(source, "voice") == 0 || strcmp(source, "mqtt_immediate") == 0 ||
+             strcmp(source, "schedule") == 0);
+        if (server_first) {
+            if (name[0]) {
+                Board_RememberRepeaterPlayback(name, speed, repeat < 1 ? 1 : repeat, power_off_after);
+            }
+            notify_server(app_id, task_id, source, true);
+            return;
+        }
         if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
             ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
             notify_server(app_id, task_id, source, true);
@@ -526,12 +1222,6 @@
         }
         speed = SDCard_Clamp_MP3_Speed(speed);
         Board_SetFinishTaskAfterMp3(true);
-        // 鏈夌綉鐨勮闊冲叆鍙o細鍏堣 Server 鎾姤杩涘満璇嶏紝闊抽鐢� MCP 鍦� TTS 缁撴潫鍚庡啀鎾��
-        if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) {
-            Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
-            notify_server(app_id, task_id, source, true);
-            return;
-        }
         esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
         if (err != ESP_OK) {
             ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
@@ -555,19 +1245,40 @@
         return ESP_ERR_INVALID_ARG;
     }
     const char *src = (source && source[0]) ? source : "mqtt_immediate";
-    const char *tid = (task_id && task_id[0]) ? task_id : NULL;
-    const app_task_skill_map_t *spec = find_task_spec(app_id, tid);
-    if (!spec) {
-        ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : "");
-        return ESP_ERR_NOT_FOUND;
+    char resolved_tid[APP_TASK_ID_MAX];
+    copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation");
+    const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid);
+    if (spec) {
+        copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid);
+    } else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) {
+        ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid);
+        return ESP_ERR_INVALID_ARG;
+    } else {
+        ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid);
     }
-    tid = spec->task_id;
+    const char *tid = resolved_tid;
 
     int new_priority = Board_AppTask_SourcePriority(src);
-    if (new_priority < s_current_priority) {
+    if (strcmp(src, "mqtt_immediate") != 0 && new_priority < s_current_priority) {
         ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
                  app_id, tid, src, new_priority, s_current_priority);
         return ESP_ERR_INVALID_STATE;
+    }
+
+    if (s_wait_start_phrase && strcmp(src, "schedule") == 0 &&
+        strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) {
+        ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid);
+        return ESP_OK;
+    }
+
+    if (strcmp(src, "schedule") == 0 &&
+        (strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) {
+        const char *raw_params = (params_json && params_json[0]) ? params_json : "{}";
+        if (!lesson_files_ready(tid, raw_params)) {
+            ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid);
+            show_lesson_sync_alert();
+            return ESP_ERR_NOT_FOUND;
+        }
     }
 
     if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
@@ -586,16 +1297,62 @@
     copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
     copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
     copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
+
+    char loaded_params[APP_TASK_JSON_MAX];
+    loaded_params[0] = '\0';
+    const char *raw = (params_json && params_json[0]) ? params_json : "{}";
+    const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0;
+    if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) {
+        if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) {
+            raw = loaded_params;
+            ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid);
+        }
+    }
+    if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) {
+        Board_RememberLastLessonTask(tid, s_current_params);
+    } else {
+        strlcpy(s_current_params, raw, sizeof(s_current_params));
+        if (lesson_task) {
+            Board_RememberLastLessonTask(tid, s_current_params);
+        }
+    }
+    const char *effective = s_current_params;
     s_current_priority = new_priority;
-    Board_SavePendingTask(app_id, tid, params_json, src, NULL);
+    s_wait_start_phrase = false;
+    s_start_phrase_tts_begun = false;
+    s_listen_after_phrase = false;
+    Board_SavePendingTask(app_id, tid, effective, src, NULL);
 
     ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
-    if (!spec->skills[0]) {
-        dispatch_skill(NULL, app_id, tid, params_json, src);
+    if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = false;
+        s_start_detect_sent = false;
+        s_listen_after_phrase = true;
+        s_wait_start_us = esp_timer_get_time();
+        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+        notify_server(app_id, tid, src, true, true, false, true);
+        return ESP_OK;
+    }
+    if (lesson_task && !lesson_files_ready(tid, effective)) {
+        ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid);
+        show_lesson_sync_alert();
+    } else if (lesson_task && lesson_files_ready(tid, effective)) {
+        s_wait_start_phrase = true;
+        s_start_phrase_tts_begun = false;
+        s_start_detect_sent = false;
+        s_wait_start_us = esp_timer_get_time();
+        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+        notify_server(app_id, tid, src, false, true, true);
+        return ESP_OK;
+    }
+    Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+    if (!spec || !spec->skills[0]) {
+        dispatch_skill(NULL, app_id, tid, effective, src);
         return ESP_OK;
     }
     for (int i = 0; i < 4 && spec->skills[i]; ++i) {
-        dispatch_skill(spec->skills[i], app_id, tid, params_json, src);
+        dispatch_skill(spec->skills[i], app_id, tid, effective, src);
     }
     return ESP_OK;
 }
@@ -614,7 +1371,7 @@
         copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
         const bool shadowing = strcmp(task_id, "shadowing") == 0;
         const bool repeater = strcmp(task_id, "repeater") == 0;
-        // 澶嶈娓呭崟鏄惁缁撴潫鐢� Server 鍐冲畾銆傝澶囦笉瑕佸湪姝ゅ啓 NVS 鍒囧洖 chat銆�
+        // WS 鍦ㄧ嚎鏃舵竻鍗曠粨鏉熺敱 Server 鍒囧洖 chat銆俉S 宸叉柇鍒欎笅闈㈡湰鍦板厹搴曪紝閬垮厤涓�鐩村仠鍦ㄦ硾鍚��
         const bool end_task = !shadowing && !repeater &&
             task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
 
@@ -632,15 +1389,26 @@
         const bool open_mic = !stopped && shadowing;
         // 鍚獥浠庤鏂囦竴鍋滃氨寮�濮嬨�備笉瑕佺瓑寮�楹�/AFE 鍒濆鍖栧悗鍐嶄笂鎶� completed銆�
         if (!Application::GetInstance().SendListenDetectNow(detect)) {
+            // WS 宸叉柇锛氶噴鏀� AFE 鍚庨噸杩烇紝鎶� completed 琛ュ彂缁� Server 鎾粨鏉熻銆�
+            if (repeater && !stopped) {
+                return_to_chat_locally("repeater completed not sent", detect);
+                Board_Schedule_OnNaturalEnd();
+                return;
+            }
             Application::GetInstance().SendListenDetect(detect, false);
         }
         if (open_mic) {
             Application::GetInstance().GetAudioService().ResetDecoder();
             Application::GetInstance().ResumeListeningAfterMp3Clip();
         } else if (repeater && !stopped) {
-            // 娓呭崟宸叉挱瀹岋紝椹笂瑕佹挱銆屽璇荤粨鏉熴�嶏細淇濇寔鍔熸斁锛屼笉瑕佸啀 ResetDecoder銆�
+            // 涓庤窡璇绘竻鍗曠粨鏉熺浉鍚岋細鍏� AFE/鍞ら啋锛岀瓑 Server 鎾�屾硾鍚粨鏉熴�嶅啀鍥炲璇濄��
+            Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+            Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
             Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
             Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+            Board_Schedule_OnNaturalEnd();
+        } else if (repeater && stopped) {
+            Board_Schedule_OnUserExit();
         } else if (!repeater) {
             Application::GetInstance().GetAudioService().ResetDecoder();
         }
@@ -682,48 +1450,36 @@
         snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
                  app_id, task_id);
         if (!Application::GetInstance().SendListenDetectNow(detect)) {
-            ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally");
-            copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
-            copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
-            copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
-            s_current_priority = 0;
-            Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
-            auto &app = Application::GetInstance();
-            app.GetAudioService().SetAudioPowerInhibit(false);
-            app.GetAudioService().EnableVoiceProcessing(false);
-            app.GetAudioService().EnableWakeWordDetection(true);
-            if (app.GetDeviceState() == kDeviceStateListening ||
-                app.GetDeviceState() == kDeviceStateConnecting) {
-                app.SetDeviceState(kDeviceStateIdle);
-            }
+            return_to_chat_locally("playlist_completed not sent", detect);
+            Board_Schedule_OnNaturalEnd();
             return;
         }
+        Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+        Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
         Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
         Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+        Board_Schedule_OnNaturalEnd();
     });
 }
 
 void Board_ResolveBootOrRestore(bool from_alarm_wake)
 {
-    char app_id[APP_TASK_ID_MAX] = "chat";
-    char task_id[APP_TASK_ID_MAX] = "conversation";
-    char source[APP_TASK_SOURCE_MAX] = "default_chat";
-    char params[APP_TASK_JSON_MAX] = "{}";
-    bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id),
-                                           source, sizeof(source), params, sizeof(params));
-    if (!has_pending) {
-        Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
-        Board_StartAppTask("chat", "conversation", "{}", "default_chat");
-        return;
+    if (!from_alarm_wake) {
+        Board_Schedule_Reload();
+        Board_Schedule_Recompute();
     }
-
-    const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0;
-    const bool offline = !Board::GetInstance().IsNetworkReady();
-    if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) {
-        ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d",
-                 app_id, task_id, source, offline ? 1 : 0);
-        Board_StartAppTask(app_id, task_id, params, source);
-        return;
+    const int window = 180;
+    if (Board_Schedule_IsDueNow(window)) {
+        char app_id[APP_TASK_ID_MAX];
+        char task_id[APP_TASK_ID_MAX];
+        char params[APP_TASK_JSON_MAX] = "{}";
+        if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
+                                       params, sizeof(params))) {
+            ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d",
+                     app_id, task_id, from_alarm_wake ? 1 : 0);
+            Board_StartAppTask(app_id, task_id, params, "schedule");
+            return;
+        }
     }
 
     Board_StartAppTask("chat", "conversation", "{}", "default_chat");
@@ -736,7 +1492,7 @@
         return;
     }
     if (!Board::GetInstance().IsNetworkReady()) {
-        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 澶嶈鏈� to play locally");
+        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 娉涘惉 to play locally");
         return;
     }
     Application::GetInstance().SendListenDetect("[voice_entry]", true);
@@ -754,6 +1510,19 @@
     }
     if (Board_Is_Chat_Return_Command(command)) {
         s_current_priority = 0;
+        auto &app = Application::GetInstance();
+        if (is_chat_task(s_current_app, s_current_task) && !app.IsAudioChannelConnected()) {
+            // 璇炬枃缁撴潫宸叉湰鍦板洖 chat銆傞�氶亾宸叉柇鏃跺啀鍘� handshake 浼氬崱銆岃繛鎺ヤ腑銆嶅苟鍏虫帀鍞ら啋銆�
+            app.Schedule([]() {
+                auto &inner = Application::GetInstance();
+                inner.AnnounceReturnedToChat();
+                inner.GetAudioService().EnableWakeWordDetection(true);
+                if (inner.GetDeviceState() == kDeviceStateConnecting) {
+                    inner.SetDeviceState(kDeviceStateIdle);
+                }
+            });
+            return true;
+        }
         Board_StartAppTask("chat", "conversation", "{}", "voice");
         return true;
     }
@@ -766,7 +1535,7 @@
     }
     if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
         strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
-        strstr(command, "寮�濮嬪璇�") || strstr(command, "澶嶈鏈�")) {
+        strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "鎵撳紑娉涘惉")) {
         if (s_current_priority >= 2) {
             return true;
         }

--
Gitblit v1.9.1