#include "app_task.h" #include "parent_schedule.h" #include "application.h" #include "board.h" #include "mqtt.h" #include "system.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #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; const char *task_id; const char *skills[4]; } app_task_skill_map_t; /* Configuration table: channels only pass app_id/task_id; skills are looked up here. */ 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) { if (!source) { return 1; } if (strcmp(source, "mqtt_immediate") == 0) { return 3; } if (strcmp(source, "schedule") == 0) { return 2; } if (strcmp(source, "voice") == 0) { return 1; } return 0; } 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(*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) { if (!app_id) { return NULL; } const char *tid = (task_id && task_id[0]) ? task_id : NULL; for (size_t i = 0; i < sizeof(k_task_skills) / sizeof(k_task_skills[0]); ++i) { if (strcmp(k_task_skills[i].app_id, app_id) != 0) { continue; } if (!tid || strcmp(k_task_skills[i].task_id, tid) == 0) { return &k_task_skills[i]; } } return NULL; } static bool is_chat_task(const char *app_id, const char *task_id) { return app_id && strcmp(app_id, "chat") == 0 && (!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(static_cast(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'; } 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(a)); int right = atoi(static_cast(b)); if (left != right) { return left - right; } return strcmp(static_cast(a), static_cast(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(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("正在同步", "正在同步课文,请稍候", "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) { cJSON *root = cJSON_CreateObject(); if (!root) { return ESP_ERR_NO_MEM; } cJSON_AddStringToObject(root, "app_id", app_id && app_id[0] ? app_id : "chat"); cJSON_AddStringToObject(root, "task_id", task_id && task_id[0] ? task_id : "conversation"); cJSON_AddStringToObject(root, "source", source && source[0] ? source : "default_chat"); cJSON_AddStringToObject(root, "alarm_id", alarm_id ? alarm_id : ""); if (params_json && params_json[0]) { cJSON *params = cJSON_Parse(params_json); if (params) { cJSON_AddItemToObject(root, "params", params); } else { cJSON_AddRawToObject(root, "params", params_json); } } else { cJSON_AddItemToObject(root, "params", cJSON_CreateObject()); } char *printed = cJSON_PrintUnformatted(root); cJSON_Delete(root); if (!printed) { return ESP_ERR_NO_MEM; } nvs_handle_t handle; esp_err_t err = nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle); if (err == ESP_OK) { err = nvs_set_str(handle, APP_TASK_NVS_KEY, printed); if (err == ESP_OK) { err = nvs_commit(handle); } nvs_close(handle); } free(printed); return err; } static void preempt_current_skills(void) { SDCard_Stop_Play(); BoardMqtt_StopPlaylistPlayback(); } 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) { // 关机拉起时通道可能还没打开。连上后再上报,等「开始xx任务」说完再进入任务。 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 const char *json_lesson_id(cJSON *params) { 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(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(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(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( heap_caps_calloc(static_cast(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, const char *params_json, const char *source) { if (!skill || skill[0] == '\0') { // 上报 [app_task]。SendListenDetect 会开通道但保持 idle,不开 listening/AFE。 notify_server(app_id, task_id, source, true); return; } if (strcmp(skill, "self.mp3.play") == 0) { char name[SDCARD_PATH_MAX_LEN] = {0}; float speed = 1.0f; int repeat = 1; bool power_off_after = false; if (params_json && params_json[0]) { cJSON *params = cJSON_Parse(params_json); if (params) { const cJSON *name_item = cJSON_GetObjectItem(params, "name"); const cJSON *speed_item = cJSON_GetObjectItem(params, "speed"); const cJSON *repeat_item = cJSON_GetObjectItem(params, "repeat"); const cJSON *power_item = cJSON_GetObjectItem(params, "power_off_after"); if (cJSON_IsString(name_item) && name_item->valuestring) { strlcpy(name, name_item->valuestring, sizeof(name)); } if (cJSON_IsNumber(speed_item)) { speed = SDCard_Clamp_MP3_Speed(static_cast(speed_item->valuedouble)); } if (cJSON_IsNumber(repeat_item)) { repeat = repeat_item->valueint; } 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(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); return; } if (repeat < 1) { repeat = 1; } speed = SDCard_Clamp_MP3_Speed(speed); Board_SetFinishTaskAfterMp3(true); 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, 0); return; } Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after); notify_server(app_id, task_id, source, false); return; } if (strcmp(skill, "translate") == 0) { notify_server(app_id, task_id, source, true); return; } ESP_LOGW(TAG, "unsupported skill: %s", skill); } esp_err_t Board_StartAppTask(const char *app_id, const char *task_id, const char *params_json, const char *source) { if (!app_id || app_id[0] == '\0') { return ESP_ERR_INVALID_ARG; } const char *src = (source && source[0]) ? source : "mqtt_immediate"; 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); } const char *tid = resolved_tid; int new_priority = Board_AppTask_SourcePriority(src); 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 && 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(); 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; 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 (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, effective, src); } return ESP_OK; } void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms) { (void)power_off_requested; // 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; // WS 在线时清单结束由 Server 切回 chat。WS 已断则下面本地兜底,避免一直停在泛听。 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)) { // 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) { // 与跟读清单结束相同:关 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(); } }); } 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)) { 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) { if (!from_alarm_wake) { Board_Schedule_Reload(); Board_Schedule_Recompute(); } 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"); } void Board_EnterVoiceEntry(void) { if (s_current_priority >= 2) { 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); } bool Board_Handle_Voice_Command(const char *command) { if (!command || command[0] == '\0') { return false; } const bool speaking = Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking; if (Board_Is_Mode_Switch_Command(command)) { Board_EnterVoiceEntry(); return true; } 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; } 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, OFFLINE_START_REPEATER_WORD) || strstr(command, "打开泛听")) { if (s_current_priority >= 2) { return true; } 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); }