From 659c470eba542383987aad227b7f918100f131fa Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期五, 11 九月 2026 15:09:05 +0800
Subject: [PATCH] 小智终端 5种 场景模式 OK

---
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc |  513 +++++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 files changed, 477 insertions(+), 36 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 b9dcc3d..df9e80e 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,6 +1,7 @@
 #include "app_task.h"
 
 #include "application.h"
+#include "board.h"
 #include "mqtt.h"
 #include "system.h"
 
@@ -15,6 +16,7 @@
 #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_JSON_MAX 768
 #define APP_TASK_ID_MAX 32
 #define APP_TASK_SOURCE_MAX 24
@@ -23,6 +25,8 @@
 static char s_current_task[APP_TASK_ID_MAX] = "conversation";
 static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
 static int s_current_priority = 0;
+static bool s_finish_task_after_mp3 = true;
+static float s_shadowing_listen_ratio = 1.2f;
 
 typedef struct {
     const char *app_id;
@@ -34,7 +38,9 @@
 static const app_task_skill_map_t k_task_skills[] = {
     {"chat", "conversation", {NULL, NULL, NULL, NULL}},
     {"english_learning", "repeater", {"self.mp3.play", NULL, NULL, NULL}},
+    {"english_learning", "shadowing", {NULL, NULL, NULL, NULL}},
     {"english_translation", "live_translate", {"translate", NULL, NULL, NULL}},
+    {"english_conversation", "bilingual_chat", {NULL, NULL, NULL, NULL}},
 };
 
 int Board_AppTask_SourcePriority(const char *source)
@@ -82,10 +88,292 @@
            (!task_id || task_id[0] == '\0' || strcmp(task_id, "conversation") == 0);
 }
 
+bool Board_IsShadowingTask(void)
+{
+    return strcmp(s_current_task, "shadowing") == 0;
+}
+
+bool Board_IsRepeaterTask(void)
+{
+    return strcmp(s_current_task, "repeater") == 0;
+}
+
+void Board_SetShadowingListenRatio(float ratio)
+{
+    if (ratio < 1.2f) {
+        ratio = 1.2f;
+    }
+    if (ratio > 1.5f) {
+        ratio = 1.5f;
+    }
+    s_shadowing_listen_ratio = ratio;
+}
+
+unsigned Board_ShadowingListenWindowMs(unsigned duration_ms)
+{
+    if (duration_ms == 0) {
+        return 800;
+    }
+    unsigned wait_ms = static_cast<unsigned>(static_cast<float>(duration_ms) * s_shadowing_listen_ratio);
+    if (wait_ms < 800) {
+        wait_ms = 800;
+    }
+    return wait_ms;
+}
+
 static void copy_id(char *dest, size_t dest_size, const char *src, const char *fallback)
 {
     const char *value = (src && src[0]) ? src : fallback;
     strlcpy(dest, value, dest_size);
+}
+
+void Board_SetActiveTask(const char *app_id, const char *task_id)
+{
+    if (!app_id || app_id[0] == '\0' || !task_id || task_id[0] == '\0') {
+        return;
+    }
+    if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, task_id) == 0) {
+        return;
+    }
+    copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
+    copy_id(s_current_task, sizeof(s_current_task), task_id, "conversation");
+    ESP_LOGI(TAG, "set active task %s/%s", s_current_app, s_current_task);
+}
+
+static void normalize_media_path(char *path)
+{
+    if (!path) {
+        return;
+    }
+    for (char *p = path; *p; ++p) {
+        if (*p == '\\') {
+            *p = '/';
+        }
+    }
+}
+
+static const char *path_without_leading_slash(const char *path)
+{
+    if (!path) {
+        return "";
+    }
+    return path[0] == '/' ? path + 1 : path;
+}
+
+static bool path_has_folder_prefix(const char *path, const char *prefix)
+{
+    if (!path || !prefix || prefix[0] == '\0') {
+        return false;
+    }
+    char normalized[SDCARD_PATH_MAX_LEN];
+    char needle[96];
+    strlcpy(normalized, path, sizeof(normalized));
+    strlcpy(needle, prefix, sizeof(needle));
+    normalize_media_path(normalized);
+    normalize_media_path(needle);
+    const char *hay = path_without_leading_slash(normalized);
+    const char *pre = path_without_leading_slash(needle);
+    size_t n = strlen(pre);
+    if (n == 0 || strncmp(hay, pre, n) != 0) {
+        return false;
+    }
+    return hay[n] == '\0' || hay[n] == '/';
+}
+
+static bool path_belongs_to_current_task(const char *path)
+{
+    if (is_chat_task(s_current_app, s_current_task)) {
+        return true;
+    }
+    char prefix[96];
+    snprintf(prefix, sizeof(prefix), "/%s/%s/", s_current_app, s_current_task);
+    return path_has_folder_prefix(path, prefix);
+}
+
+static bool basename_is_full_mp3(const char *name)
+{
+    if (!name || name[0] == '\0') {
+        return false;
+    }
+    const char *base = strrchr(name, '/');
+    base = base ? base + 1 : name;
+    if (strcasecmp(base, "full.mp3") == 0) {
+        return true;
+    }
+    size_t n = strlen(base);
+    return n > 9 && strcasecmp(base + n - 9, "_full.mp3") == 0;
+}
+
+void Board_BindTaskFromMediaPath(const char *path)
+{
+    // 璇炬枃鏂囦欢鍙兘閮藉湪 /english_learning/shadowing/ 涓嬨�備换鍔″彧鐢� MCP app_id/task_id 鍐冲畾銆�
+    (void)path;
+}
+
+static bool task_owns_play_audio(const char *app_id, const char *task_id)
+{
+    const app_task_skill_map_t *spec = find_task_spec(app_id, task_id);
+    if (!spec) {
+        return false;
+    }
+    for (int i = 0; i < 4 && spec->skills[i]; ++i) {
+        if (strcmp(spec->skills[i], "self.mp3.play") == 0) {
+            return true;
+        }
+    }
+    return false;
+}
+
+static bool first_sd_mp3_name(char *name, size_t name_size, const char *prefix, bool prefer_full)
+{
+    if (!name || name_size == 0) {
+        return false;
+    }
+    name[0] = '\0';
+    const char *list = SDCard_Get_MP3_List();
+    if (!list || list[0] == '\0') {
+        return false;
+    }
+    cJSON *root = cJSON_Parse(list);
+    if (!root) {
+        return false;
+    }
+    const cJSON *files = cJSON_GetObjectItem(root, "files");
+    if (!cJSON_IsArray(files)) {
+        cJSON_Delete(root);
+        return false;
+    }
+    const cJSON *best = NULL;
+    bool best_full = false;
+    const cJSON *file = NULL;
+    cJSON_ArrayForEach(file, files) {
+        const cJSON *item = cJSON_GetObjectItem(file, "name");
+        if (!cJSON_IsString(item) || !item->valuestring || !item->valuestring[0]) {
+            continue;
+        }
+        if (prefix && prefix[0] && !path_has_folder_prefix(item->valuestring, prefix)) {
+            continue;
+        }
+        bool is_full = basename_is_full_mp3(item->valuestring);
+        if (!best) {
+            best = item;
+            best_full = is_full;
+            continue;
+        }
+        if (prefer_full && is_full && !best_full) {
+            best = item;
+            best_full = true;
+            continue;
+        }
+        if ((!prefer_full || is_full == best_full) &&
+            strcmp(item->valuestring, best->valuestring) < 0) {
+            best = item;
+            best_full = is_full;
+        }
+    }
+    bool ok = best != NULL;
+    if (ok) {
+        strlcpy(name, best->valuestring, name_size);
+    }
+    cJSON_Delete(root);
+    return ok;
+}
+
+void Board_SetFinishTaskAfterMp3(bool finish)
+{
+    s_finish_task_after_mp3 = finish;
+}
+
+void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after)
+{
+    if (!name || name[0] == '\0') {
+        return;
+    }
+    if (strcmp(s_current_task, "repeater") != 0) {
+        return;
+    }
+    cJSON *root = cJSON_CreateObject();
+    if (!root) {
+        return;
+    }
+    cJSON_AddStringToObject(root, "name", name);
+    cJSON_AddNumberToObject(root, "speed", speed);
+    cJSON_AddNumberToObject(root, "repeat", repeat < 1 ? 1 : repeat);
+    cJSON_AddBoolToObject(root, "power_off_after", power_off_after);
+    char *printed = cJSON_PrintUnformatted(root);
+    cJSON_Delete(root);
+    if (!printed) {
+        return;
+    }
+    nvs_handle_t handle;
+    if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) == ESP_OK) {
+        if (nvs_set_str(handle, APP_TASK_NVS_LAST_REP, printed) == ESP_OK) {
+            nvs_commit(handle);
+        }
+        nvs_close(handle);
+    }
+    free(printed);
+}
+
+bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after)
+{
+    if (!name || name_size == 0) {
+        return false;
+    }
+    if (name[0] != '\0') {
+        if (speed) {
+            *speed = SDCard_Clamp_MP3_Speed(*speed);
+        }
+        return true;
+    }
+
+    nvs_handle_t handle;
+    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) == ESP_OK) {
+        char buf[APP_TASK_JSON_MAX];
+        size_t length = sizeof(buf);
+        if (nvs_get_str(handle, APP_TASK_NVS_LAST_REP, buf, &length) == ESP_OK) {
+            cJSON *root = cJSON_Parse(buf);
+            if (root) {
+                const cJSON *name_item = cJSON_GetObjectItem(root, "name");
+                const cJSON *repeat_item = cJSON_GetObjectItem(root, "repeat");
+                const cJSON *power_item = cJSON_GetObjectItem(root, "power_off_after");
+                if (cJSON_IsString(name_item) && name_item->valuestring && name_item->valuestring[0]) {
+                    strlcpy(name, name_item->valuestring, name_size);
+                    if (repeat && cJSON_IsNumber(repeat_item)) {
+                        *repeat = repeat_item->valueint;
+                    }
+                    if (power_off_after) {
+                        if (cJSON_IsBool(power_item)) {
+                            *power_off_after = cJSON_IsTrue(power_item);
+                        } else if (cJSON_IsNumber(power_item)) {
+                            *power_off_after = power_item->valueint != 0;
+                        }
+                    }
+                }
+                cJSON_Delete(root);
+            }
+        }
+        nvs_close(handle);
+    }
+    if (name[0] != '\0' && !path_belongs_to_current_task(name)) {
+        ESP_LOGW(TAG, "discard last_rep from other task: %s", name);
+        name[0] = '\0';
+    }
+
+    if (name[0] == '\0') {
+        char prefix[96];
+        snprintf(prefix, sizeof(prefix), "/%s/%s/lessons", s_current_app, s_current_task);
+        bool prefer_full = strcmp(s_current_task, "repeater") == 0;
+        if (!first_sd_mp3_name(name, name_size, prefix, prefer_full)) {
+            if (is_chat_task(s_current_app, s_current_task)) {
+                first_sd_mp3_name(name, name_size, NULL, false);
+            }
+        }
+    }
+    if (speed) {
+        *speed = SDCard_Clamp_MP3_Speed(*speed);
+    }
+    return name[0] != '\0';
 }
 
 esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
@@ -195,6 +483,7 @@
                            const char *params_json, const char *source)
 {
     if (!skill || skill[0] == '\0') {
+        // 涓婃姤 [app_task]銆係endListenDetect 浼氬紑閫氶亾浣嗕繚鎸� idle锛屼笉寮� listening/AFE銆�
         notify_server(app_id, task_id, source, true);
         return;
     }
@@ -214,7 +503,7 @@
                     strlcpy(name, name_item->valuestring, sizeof(name));
                 }
                 if (cJSON_IsNumber(speed_item)) {
-                    speed = static_cast<float>(speed_item->valuedouble);
+                    speed = SDCard_Clamp_MP3_Speed(static_cast<float>(speed_item->valuedouble));
                 }
                 if (cJSON_IsNumber(repeat_item)) {
                     repeat = repeat_item->valueint;
@@ -227,19 +516,29 @@
                 cJSON_Delete(params);
             }
         }
-        if (name[0] == '\0') {
-            ESP_LOGW(TAG, "self.mp3.play missing name");
+        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);
             return;
         }
         if (repeat < 1) {
             repeat = 1;
         }
+        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));
-            Board_OnSkillPlaybackFinished(true, false);
+            Board_OnSkillPlaybackFinished(true, false, 0);
             return;
         }
+        Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
         notify_server(app_id, task_id, source, false);
         return;
     }
@@ -256,18 +555,31 @@
         return ESP_ERR_INVALID_ARG;
     }
     const char *src = (source && source[0]) ? source : "mqtt_immediate";
-    const char *tid = (task_id && task_id[0]) ? task_id : "conversation";
+    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);
+        ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : "");
         return ESP_ERR_NOT_FOUND;
     }
+    tid = spec->task_id;
 
     int new_priority = Board_AppTask_SourcePriority(src);
     if (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 (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
+        strcmp(app_id, "chat") == 0 && strcmp(src, "voice") == 0) {
+        if (SDCard_Is_Playing()) {
+            ESP_LOGI(TAG, "stop leftover MP3 while already in chat");
+            preempt_current_skills();
+        }
+        // 璺熻鐢� MCP 璧锋挱鏃舵湰鍦颁粛鍙兘鏄� chat銆傘�岄��鍑鸿窡璇汇�嶅繀椤婚�氱煡 Server 鍙栨秷鍚獥銆�
+        // 涓嶈鍦ㄨ繖閲� ResumeListening锛氫細 ResetDecoder銆佹彁鍓嶅紑 AFE锛屾杩庤瘝/缁撴潫鎾姤浼氳娓呮帀銆�
+        notify_server("chat", "conversation", "voice", true);
+        return ESP_OK;
     }
 
     preempt_current_skills();
@@ -288,15 +600,107 @@
     return ESP_OK;
 }
 
-void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested)
+void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms)
 {
     (void)power_off_requested;
-    ESP_LOGI(TAG, "skill playback %s", stopped ? "stopped" : "finished");
-    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);
+    // MP3 浠诲姟鏍堝湪 PSRAM锛屼笉鑳藉湪杩欓噷 nvs_set_str / 鏀圭姸鎬佹満銆�
+    Application::GetInstance().Schedule([stopped, duration_ms]() {
+        ESP_LOGI(TAG, "skill playback %s duration_ms=%u task=%s/%s",
+                 stopped ? "stopped" : "finished", duration_ms, s_current_app, s_current_task);
+
+        char app_id[APP_TASK_ID_MAX];
+        char task_id[APP_TASK_ID_MAX];
+        copy_id(app_id, sizeof(app_id), s_current_app, "chat");
+        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銆�
+        const bool end_task = !shadowing && !repeater &&
+            task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
+
+        if (end_task) {
+            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);
+        }
+
+        char detect[208];
+        snprintf(detect, sizeof(detect), "[skill_event]audio.%s|%s|%s|%u",
+                 stopped ? "stopped" : "completed", app_id, task_id, duration_ms);
+        const bool open_mic = !stopped && shadowing;
+        // 鍚獥浠庤鏂囦竴鍋滃氨寮�濮嬨�備笉瑕佺瓑寮�楹�/AFE 鍒濆鍖栧悗鍐嶄笂鎶� completed銆�
+        if (!Application::GetInstance().SendListenDetectNow(detect)) {
+            Application::GetInstance().SendListenDetect(detect, false);
+        }
+        if (open_mic) {
+            Application::GetInstance().GetAudioService().ResetDecoder();
+            Application::GetInstance().ResumeListeningAfterMp3Clip();
+        } else if (repeater && !stopped) {
+            // 娓呭崟宸叉挱瀹岋紝椹笂瑕佹挱銆屽璇荤粨鏉熴�嶏細淇濇寔鍔熸斁锛屼笉瑕佸啀 ResetDecoder銆�
+            Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
+            Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+        } else if (!repeater) {
+            Application::GetInstance().GetAudioService().ResetDecoder();
+        }
+    });
+}
+
+void Board_OnShadowingClipFinished(unsigned duration_ms)
+{
+    Application::GetInstance().Schedule([duration_ms]() {
+        ESP_LOGI(TAG, "shadowing clip finished duration_ms=%u task=%s/%s",
+                 duration_ms, s_current_app, s_current_task);
+
+        char app_id[APP_TASK_ID_MAX];
+        char task_id[APP_TASK_ID_MAX];
+        copy_id(app_id, sizeof(app_id), s_current_app, "chat");
+        copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
+
+        char detect[208];
+        snprintf(detect, sizeof(detect), "[skill_event]audio.completed|%s|%s|%u",
+                 app_id, task_id, duration_ms);
+        if (!Application::GetInstance().SendListenDetectNow(detect)) {
+            Application::GetInstance().SendListenDetect(detect, false);
+        }
+        Application::GetInstance().ResumeListeningAfterMp3Clip();
+    });
+}
+
+void Board_OnShadowingPlaylistFinished(void)
+{
+    Application::GetInstance().Schedule([]() {
+        ESP_LOGI(TAG, "shadowing playlist finished task=%s/%s", s_current_app, s_current_task);
+
+        char app_id[APP_TASK_ID_MAX];
+        char task_id[APP_TASK_ID_MAX];
+        copy_id(app_id, sizeof(app_id), s_current_app, "chat");
+        copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
+
+        char detect[208];
+        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;
+        }
+        Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
+        Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+    });
 }
 
 void Board_ResolveBootOrRestore(bool from_alarm_wake)
@@ -313,9 +717,12 @@
         return;
     }
 
-    if (from_alarm_wake && strcmp(source, "schedule") == 0 && !is_chat_task(app_id, task_id)) {
-        ESP_LOGI(TAG, "alarm restore runs pending %s/%s", app_id, task_id);
-        Board_StartAppTask(app_id, task_id, params, "schedule");
+    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;
     }
 
@@ -328,6 +735,10 @@
         ESP_LOGI(TAG, "ignore voice_entry, current priority=%d", s_current_priority);
         return;
     }
+    if (!Board::GetInstance().IsNetworkReady()) {
+        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 澶嶈鏈� to play locally");
+        return;
+    }
     Application::GetInstance().SendListenDetect("[voice_entry]", true);
 }
 
@@ -336,36 +747,66 @@
     if (!command || command[0] == '\0') {
         return false;
     }
-    if (strcmp(command, "鍒囨崲妯″紡") == 0 || strcmp(command, "qie huan mo shi") == 0) {
+    const bool speaking = Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking;
+    if (Board_Is_Mode_Switch_Command(command)) {
         Board_EnterVoiceEntry();
         return true;
     }
-    if (strstr(command, "鎴戜滑鏉ヨ亰澶╁惂") || strstr(command, "鑱婁細澶╁惂")) {
-        if (s_current_priority >= 2) {
-            ESP_LOGI(TAG, "ignore voice chat switch, priority=%d", s_current_priority);
-            return true;
-        }
+    if (Board_Is_Chat_Return_Command(command)) {
+        s_current_priority = 0;
         Board_StartAppTask("chat", "conversation", "{}", "voice");
         return true;
     }
-    if (strstr(command, "鎵撳紑缈昏瘧") || strstr(command, "寮�濮嬬炕璇�")) {
+    if (speaking) {
+        if (Board_Is_App_Launch_Command(command)) {
+            ESP_LOGI(TAG, "ignore app launch while TTS is speaking: %s", command);
+            return true;
+        }
+        return false;
+    }
+    if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
+        strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
+        strstr(command, "寮�濮嬪璇�") || strstr(command, "澶嶈鏈�")) {
         if (s_current_priority >= 2) {
             return true;
         }
-        Board_StartAppTask("english_translation", "live_translate", "{}", "voice");
-        return true;
-    }
-    if (strstr(command, "寮�濮嬪璇�") || strstr(command, "鎵撳紑澶嶈")) {
-        if (s_current_priority >= 2) {
-            return true;
-        }
-        Board_EnterVoiceEntry();
-        return true;
-    }
-    if (strstr(command, "鏁板瓧鑳岃") || strstr(command, "鎴愯鎺ラ緳") || strstr(command, "鍙g畻")) {
-        ESP_LOGI(TAG, "reserved voice slot: %s", command);
-        Board_EnterVoiceEntry();
+        Board_StartAppTask("english_learning", "repeater", "{}", "voice");
         return true;
     }
     return false;
 }
+
+static bool layout_id_is_safe(const char *id)
+{
+    if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
+        return false;
+    }
+    for (const char *p = id; *p; ++p) {
+        if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
+              *p == '_' || *p == '-')) {
+            return false;
+        }
+    }
+    return true;
+}
+
+esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id)
+{
+    if (!layout_id_is_safe(app_id)) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    char path[SDCARD_PATH_MAX_LEN];
+    int written;
+    if (task_id && task_id[0]) {
+        if (!layout_id_is_safe(task_id)) {
+            return ESP_ERR_INVALID_ARG;
+        }
+        written = snprintf(path, sizeof(path), "/%s/%s/lessons", app_id, task_id);
+    } else {
+        written = snprintf(path, sizeof(path), "/%s", app_id);
+    }
+    if (written <= 0 || (size_t)written >= sizeof(path)) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+    return SDCard_Ensure_Business_Dir(path);
+}

--
Gitblit v1.9.1