#include "app_task.h" #include "application.h" #include "board.h" #include "mqtt.h" #include "system.h" #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_JSON_MAX 768 #define APP_TASK_ID_MAX 32 #define APP_TASK_SOURCE_MAX 24 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 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; 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 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'; } 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 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 void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle) { 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); } 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); } } 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); // 有网的语音入口:先让 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, 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"; 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; } 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(); 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"); s_current_priority = new_priority; Board_SavePendingTask(app_id, tid, params_json, 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); return ESP_OK; } for (int i = 0; i < 4 && spec->skills[i]; ++i) { dispatch_skill(spec->skills[i], app_id, tid, params_json, 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; // 复读清单是否结束由 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) { 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; } 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; } 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; 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, "开始复读") || 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); }