| | |
| | | #include "app_task.h" |
| | | #include "parent_schedule.h" |
| | | |
| | | #include "application.h" |
| | | #include "board.h" |
| | |
| | | #include <string.h> |
| | | #include <stdio.h> |
| | | #include <stdlib.h> |
| | | #include <sys/stat.h> |
| | | #include <dirent.h> |
| | | #include <ctype.h> |
| | | #include <string> |
| | | #include <esp_heap_caps.h> |
| | | #include <esp_timer.h> |
| | | |
| | | #define TAG "app_task" |
| | | #define APP_TASK_NVS_NS "app_task" |
| | | #define APP_TASK_NVS_KEY "pending" |
| | | #define APP_TASK_NVS_LAST_REP "last_rep" |
| | | #define APP_TASK_NVS_LAST_RPT "last_rpt" |
| | | #define APP_TASK_NVS_LAST_SHD "last_shd" |
| | | #define APP_TASK_JSON_MAX 768 |
| | | #define APP_TASK_ID_MAX 32 |
| | | #define APP_TASK_SOURCE_MAX 24 |
| | | #define LESSON_CLIP_NAME_MAX 16 |
| | | #define LESSON_CLIP_MAX 128 |
| | | |
| | | static char s_current_app[APP_TASK_ID_MAX] = "chat"; |
| | | static char s_current_task[APP_TASK_ID_MAX] = "conversation"; |
| | | static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat"; |
| | | static char s_current_params[APP_TASK_JSON_MAX] = "{}"; |
| | | static int s_current_priority = 0; |
| | | static bool s_finish_task_after_mp3 = true; |
| | | static float s_shadowing_listen_ratio = 1.2f; |
| | | static bool s_wait_start_phrase = false; |
| | | static bool s_start_phrase_tts_begun = false; |
| | | static bool s_start_detect_sent = false; |
| | | static bool s_listen_after_phrase = false; |
| | | static int64_t s_wait_start_us = 0; |
| | | static std::string s_start_detect_text; |
| | | |
| | | void Board_TrySendLessonStartDetect(void); |
| | | |
| | | bool Board_IsWaitingStartPhrase(void) |
| | | { |
| | | return s_wait_start_phrase; |
| | | } |
| | | |
| | | typedef struct { |
| | | const char *app_id; |
| | |
| | | int Board_AppTask_CurrentPriority(void) |
| | | { |
| | | return s_current_priority; |
| | | } |
| | | |
| | | static bool is_safe_task_id(const char *id) |
| | | { |
| | | if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) { |
| | | return false; |
| | | } |
| | | for (const char *p = id; *p; ++p) { |
| | | unsigned char ch = static_cast<unsigned char>(*p); |
| | | if (!isalnum(ch) && ch != '_' && ch != '-') { |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id) |
| | |
| | | return name[0] != '\0'; |
| | | } |
| | | |
| | | static const char *last_lesson_nvs_key(const char *task_id) |
| | | { |
| | | if (task_id && strcmp(task_id, "shadowing") == 0) { |
| | | return APP_TASK_NVS_LAST_SHD; |
| | | } |
| | | if (task_id && strcmp(task_id, "repeater") == 0) { |
| | | return APP_TASK_NVS_LAST_RPT; |
| | | } |
| | | return NULL; |
| | | } |
| | | |
| | | static const char *const k_last_lesson_keys[] = { |
| | | "lesson_id", |
| | | "lesson", |
| | | "lessonId", |
| | | "en_passes", |
| | | "enPasses", |
| | | "cn_repeat", |
| | | "cn_speed", |
| | | "language", |
| | | "lang", |
| | | "speed", |
| | | "on_complete", |
| | | "listen_ratio", |
| | | "sentence_count", |
| | | "org_id", |
| | | "orgId", |
| | | NULL, |
| | | }; |
| | | |
| | | static bool json_has_lesson_id(cJSON *root) |
| | | { |
| | | if (!root) { |
| | | return false; |
| | | } |
| | | const cJSON *id = cJSON_GetObjectItem(root, "lesson_id"); |
| | | if (!id) { |
| | | id = cJSON_GetObjectItem(root, "lessonId"); |
| | | } |
| | | if (!id) { |
| | | id = cJSON_GetObjectItem(root, "lesson"); |
| | | } |
| | | return cJSON_IsString(id) && id->valuestring && id->valuestring[0]; |
| | | } |
| | | |
| | | static bool compact_last_lesson_json(const char *params_json, char *out, size_t out_size) |
| | | { |
| | | if (!out || out_size == 0) { |
| | | return false; |
| | | } |
| | | out[0] = '\0'; |
| | | if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) { |
| | | return false; |
| | | } |
| | | cJSON *root = cJSON_Parse(params_json); |
| | | if (!root) { |
| | | return false; |
| | | } |
| | | cJSON *compact = cJSON_CreateObject(); |
| | | if (!compact) { |
| | | cJSON_Delete(root); |
| | | return false; |
| | | } |
| | | for (int i = 0; k_last_lesson_keys[i]; ++i) { |
| | | cJSON *item = cJSON_GetObjectItem(root, k_last_lesson_keys[i]); |
| | | if (item) { |
| | | cJSON_AddItemToObject(compact, k_last_lesson_keys[i], cJSON_Duplicate(item, 1)); |
| | | } |
| | | } |
| | | bool ok = json_has_lesson_id(compact); |
| | | if (ok) { |
| | | const cJSON *id = cJSON_GetObjectItem(compact, "lesson_id"); |
| | | if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) { |
| | | id = cJSON_GetObjectItem(compact, "lessonId"); |
| | | if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) { |
| | | id = cJSON_GetObjectItem(compact, "lesson"); |
| | | } |
| | | if (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) { |
| | | cJSON_AddStringToObject(compact, "lesson_id", id->valuestring); |
| | | } |
| | | } |
| | | char *printed = cJSON_PrintUnformatted(compact); |
| | | if (printed) { |
| | | strlcpy(out, printed, out_size); |
| | | ok = out[0] != '\0'; |
| | | cJSON_free(printed); |
| | | } else { |
| | | ok = false; |
| | | } |
| | | } |
| | | cJSON_Delete(compact); |
| | | cJSON_Delete(root); |
| | | return ok; |
| | | } |
| | | |
| | | static bool params_has_lesson_id(const char *params_json) |
| | | { |
| | | if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) { |
| | | return false; |
| | | } |
| | | cJSON *root = cJSON_Parse(params_json); |
| | | if (!root) { |
| | | return false; |
| | | } |
| | | bool ok = json_has_lesson_id(root); |
| | | cJSON_Delete(root); |
| | | return ok; |
| | | } |
| | | |
| | | static bool sd_business_file_ready(const char *business_path) |
| | | { |
| | | char sd_path[SDCARD_PATH_MAX_LEN]; |
| | | if (!business_path || business_path[0] == '\0') { |
| | | return false; |
| | | } |
| | | if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) { |
| | | return false; |
| | | } |
| | | struct stat st; |
| | | if (stat(sd_path, &st) != 0) { |
| | | return false; |
| | | } |
| | | return S_ISREG(st.st_mode) && st.st_size > 0; |
| | | } |
| | | |
| | | static const char *json_clip_name(cJSON *obj) |
| | | { |
| | | if (!cJSON_IsObject(obj)) { |
| | | return NULL; |
| | | } |
| | | const char *keys[] = {"name", "path", "url"}; |
| | | for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) { |
| | | cJSON *item = cJSON_GetObjectItem(obj, keys[i]); |
| | | if (cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') { |
| | | return item->valuestring; |
| | | } |
| | | } |
| | | return NULL; |
| | | } |
| | | |
| | | static int mp3_name_order(const void *a, const void *b) |
| | | { |
| | | int left = atoi(static_cast<const char *>(a)); |
| | | int right = atoi(static_cast<const char *>(b)); |
| | | if (left != right) { |
| | | return left - right; |
| | | } |
| | | return strcmp(static_cast<const char *>(a), static_cast<const char *>(b)); |
| | | } |
| | | |
| | | static int list_lesson_mp3s(const char *business_dir, char names[][LESSON_CLIP_NAME_MAX], int cap) |
| | | { |
| | | char sd_path[SDCARD_PATH_MAX_LEN]; |
| | | if (!business_dir || business_dir[0] == '\0' || !names || cap < 1) { |
| | | return 0; |
| | | } |
| | | if (SDCard_Map_Business_Path(business_dir, sd_path, sizeof(sd_path)) != ESP_OK) { |
| | | return 0; |
| | | } |
| | | DIR *dir = opendir(sd_path); |
| | | if (!dir) { |
| | | return 0; |
| | | } |
| | | int count = 0; |
| | | struct dirent *ent; |
| | | while ((ent = readdir(dir)) != NULL && count < cap) { |
| | | const char *name = ent->d_name; |
| | | size_t len = name ? strlen(name) : 0; |
| | | if (len < 5 || len >= LESSON_CLIP_NAME_MAX || strcasecmp(name + len - 4, ".mp3") != 0) { |
| | | continue; |
| | | } |
| | | strlcpy(names[count], name, LESSON_CLIP_NAME_MAX); |
| | | count++; |
| | | } |
| | | closedir(dir); |
| | | if (count > 1) { |
| | | qsort(names, static_cast<size_t>(count), LESSON_CLIP_NAME_MAX, mp3_name_order); |
| | | } |
| | | return count; |
| | | } |
| | | |
| | | static const char *shadowing_lang_folder(const char *language) |
| | | { |
| | | return (language && strcasecmp(language, "cn") == 0) ? "cn" : "en"; |
| | | } |
| | | |
| | | static bool probe_lesson_id_on_sd(const char *task_id, const char *lesson_id, const char *language) |
| | | { |
| | | int level = 0; |
| | | int story = 0; |
| | | if (!lesson_id || sscanf(lesson_id, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) { |
| | | return true; |
| | | } |
| | | char path[SDCARD_PATH_MAX_LEN]; |
| | | if (task_id && strcmp(task_id, "shadowing") == 0) { |
| | | const char *lang = shadowing_lang_folder(language); |
| | | snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s", |
| | | level, story, lang); |
| | | char names[1][LESSON_CLIP_NAME_MAX]; |
| | | return list_lesson_mp3s(path, names, 1) >= 1; |
| | | } |
| | | snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story); |
| | | char names[1][LESSON_CLIP_NAME_MAX]; |
| | | if (list_lesson_mp3s(path, names, 1) < 1) { |
| | | return false; |
| | | } |
| | | snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en", level, story); |
| | | return list_lesson_mp3s(path, names, 1) >= 1; |
| | | } |
| | | |
| | | static bool lesson_files_ready(const char *task_id, const char *params_json) |
| | | { |
| | | if (!params_json || params_json[0] == '\0') { |
| | | return true; |
| | | } |
| | | cJSON *params = cJSON_Parse(params_json); |
| | | if (!params) { |
| | | return true; |
| | | } |
| | | |
| | | cJSON *items = cJSON_GetObjectItem(params, "items"); |
| | | bool checked = false; |
| | | if (cJSON_IsArray(items)) { |
| | | cJSON *it = NULL; |
| | | cJSON_ArrayForEach(it, items) { |
| | | const char *name = json_clip_name(it); |
| | | if (!name) { |
| | | continue; |
| | | } |
| | | checked = true; |
| | | if (!sd_business_file_ready(name)) { |
| | | cJSON_Delete(params); |
| | | return false; |
| | | } |
| | | } |
| | | if (checked) { |
| | | cJSON_Delete(params); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | const char *single = json_clip_name(params); |
| | | if (single) { |
| | | bool ok = sd_business_file_ready(single); |
| | | cJSON_Delete(params); |
| | | return ok; |
| | | } |
| | | |
| | | cJSON *lesson = cJSON_GetObjectItem(params, "lesson_id"); |
| | | if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') { |
| | | lesson = cJSON_GetObjectItem(params, "lesson"); |
| | | } |
| | | if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') { |
| | | lesson = cJSON_GetObjectItem(params, "lessonId"); |
| | | } |
| | | cJSON *lang = cJSON_GetObjectItem(params, "language"); |
| | | if (!cJSON_IsString(lang) || !lang->valuestring) { |
| | | lang = cJSON_GetObjectItem(params, "lang"); |
| | | } |
| | | bool ok = true; |
| | | if (cJSON_IsString(lesson) && lesson->valuestring && lesson->valuestring[0] != '\0') { |
| | | ok = probe_lesson_id_on_sd(task_id, lesson->valuestring, |
| | | cJSON_IsString(lang) ? lang->valuestring : NULL); |
| | | } |
| | | cJSON_Delete(params); |
| | | return ok; |
| | | } |
| | | |
| | | static void show_lesson_sync_alert(void) |
| | | { |
| | | Board_Show_Alert("正在同步", "正在同步课文,请稍候", "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) |
| | | { |
| | |
| | | return err; |
| | | } |
| | | |
| | | static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size, |
| | | char *source, size_t source_size, char *params_json, size_t params_size) |
| | | { |
| | | nvs_handle_t handle; |
| | | if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) { |
| | | return false; |
| | | } |
| | | char buf[APP_TASK_JSON_MAX]; |
| | | size_t length = sizeof(buf); |
| | | esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length); |
| | | nvs_close(handle); |
| | | if (err != ESP_OK) { |
| | | return false; |
| | | } |
| | | |
| | | cJSON *root = cJSON_Parse(buf); |
| | | if (!root) { |
| | | return false; |
| | | } |
| | | const cJSON *app = cJSON_GetObjectItem(root, "app_id"); |
| | | const cJSON *task = cJSON_GetObjectItem(root, "task_id"); |
| | | const cJSON *src = cJSON_GetObjectItem(root, "source"); |
| | | const cJSON *params = cJSON_GetObjectItem(root, "params"); |
| | | copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat"); |
| | | copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation"); |
| | | copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat"); |
| | | if (params_json && params_size > 0) { |
| | | params_json[0] = '\0'; |
| | | if (params) { |
| | | char *printed = cJSON_PrintUnformatted(params); |
| | | if (printed) { |
| | | strlcpy(params_json, printed, params_size); |
| | | free(printed); |
| | | } |
| | | } |
| | | } |
| | | cJSON_Delete(root); |
| | | return app_id[0] != '\0'; |
| | | } |
| | | |
| | | static void preempt_current_skills(void) |
| | | { |
| | | SDCard_Stop_Play(); |
| | | BoardMqtt_StopPlaylistPlayback(); |
| | | Application::GetInstance().Schedule([]() { |
| | | auto state = Application::GetInstance().GetDeviceState(); |
| | | if (state == kDeviceStateSpeaking) { |
| | | Application::GetInstance().AbortSpeaking(kAbortReasonNone); |
| | | } |
| | | |
| | | static bool is_schedule_talk_task(const char *task_id) |
| | | { |
| | | return task_id && (strcmp(task_id, "conversation") == 0 || |
| | | strcmp(task_id, "live_translate") == 0 || |
| | | strcmp(task_id, "bilingual_chat") == 0); |
| | | } |
| | | |
| | | static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle, |
| | | bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false) |
| | | { |
| | | std::string params = s_current_params[0] ? s_current_params : "{}"; |
| | | if (sd_ready && params.size() >= 2 && params.back() == '}') { |
| | | params.pop_back(); |
| | | if (params.size() > 1) { |
| | | params += ','; |
| | | } |
| | | params += "\"sd_ready\":true}"; |
| | | } |
| | | std::string detect = "[app_task]"; |
| | | detect += (app_id && app_id[0]) ? app_id : "chat"; |
| | | detect += "|"; |
| | | detect += (task_id && task_id[0]) ? task_id : "conversation"; |
| | | detect += "|"; |
| | | detect += (source && source[0]) ? source : "default_chat"; |
| | | detect += "|"; |
| | | detect += params; |
| | | const bool hold_for_phrase = sd_ready || wait_phrase; |
| | | if (hold_for_phrase) { |
| | | s_start_detect_text = detect; |
| | | s_start_detect_sent = false; |
| | | } |
| | | Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() { |
| | | auto& app = Application::GetInstance(); |
| | | app.HoldIdleForServerPrompt(); |
| | | if (hold_for_phrase) { |
| | | // 关机拉起时通道可能还没打开。连上后再上报,等「开始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 void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle) |
| | | static const char *json_lesson_id(cJSON *params) |
| | | { |
| | | char detect[160]; |
| | | snprintf(detect, sizeof(detect), "[app_task]%s|%s|%s", |
| | | app_id && app_id[0] ? app_id : "chat", |
| | | task_id && task_id[0] ? task_id : "conversation", |
| | | source && source[0] ? source : "default_chat"); |
| | | Application::GetInstance().SendListenDetect(detect, open_if_idle); |
| | | if (!params) { |
| | | return NULL; |
| | | } |
| | | const cJSON *id = cJSON_GetObjectItem(params, "lesson_id"); |
| | | if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) { |
| | | id = cJSON_GetObjectItem(params, "lessonId"); |
| | | } |
| | | if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) { |
| | | id = cJSON_GetObjectItem(params, "lesson"); |
| | | } |
| | | return (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) ? id->valuestring : NULL; |
| | | } |
| | | |
| | | static bool add_playlist_item(sdcard_mp3_playlist_item_t *items, int *n, int cap, |
| | | const char *business, float speed, int repeat) |
| | | { |
| | | if (!items || !n || *n >= cap || !business || business[0] == '\0') { |
| | | return false; |
| | | } |
| | | sdcard_mp3_playlist_item_t *item = &items[*n]; |
| | | if (!SDCard_Resolve_MP3_Path(business, item->path, sizeof(item->path))) { |
| | | ESP_LOGW(TAG, "lesson clip missing on sd: %s", business); |
| | | return false; |
| | | } |
| | | item->speed = SDCard_Clamp_MP3_Speed(speed); |
| | | item->repeat = repeat < 1 ? 1 : repeat; |
| | | if (item->repeat > 10) { |
| | | item->repeat = 10; |
| | | } |
| | | item->power_off_after = false; |
| | | (*n)++; |
| | | return true; |
| | | } |
| | | |
| | | static int build_repeater_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap) |
| | | { |
| | | int level = 0; |
| | | int story = 0; |
| | | const char *lesson = json_lesson_id(params); |
| | | if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) { |
| | | return 0; |
| | | } |
| | | int cn_repeat = 1; |
| | | float cn_speed = 1.0f; |
| | | const cJSON *cn_r = cJSON_GetObjectItem(params, "cn_repeat"); |
| | | if (cJSON_IsNumber(cn_r)) { |
| | | cn_repeat = cn_r->valueint; |
| | | } |
| | | const cJSON *cn_s = cJSON_GetObjectItem(params, "cn_speed"); |
| | | if (cJSON_IsNumber(cn_s)) { |
| | | cn_speed = static_cast<float>(cn_s->valuedouble); |
| | | } |
| | | char dir[SDCARD_PATH_MAX_LEN]; |
| | | char path[SDCARD_PATH_MAX_LEN]; |
| | | static char cn_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX]; |
| | | static char en_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX]; |
| | | int n = 0; |
| | | snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story); |
| | | int cn_n = list_lesson_mp3s(dir, cn_names, LESSON_CLIP_MAX); |
| | | snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/en", level, story); |
| | | int en_n = list_lesson_mp3s(dir, en_names, LESSON_CLIP_MAX); |
| | | if (cn_n < 1 || en_n < 1) { |
| | | return 0; |
| | | } |
| | | for (int i = 0; i < cn_n; ++i) { |
| | | snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn/%.15s", |
| | | level, story, cn_names[i]); |
| | | if (!add_playlist_item(items, &n, cap, path, cn_speed, cn_repeat)) { |
| | | return n > 0 ? n : 0; |
| | | } |
| | | } |
| | | const cJSON *passes = cJSON_GetObjectItem(params, "en_passes"); |
| | | int en_added = 0; |
| | | if (cJSON_IsArray(passes)) { |
| | | cJSON *row = NULL; |
| | | cJSON_ArrayForEach(row, passes) { |
| | | if (en_added >= 8) { |
| | | break; |
| | | } |
| | | float speed = 1.0f; |
| | | int repeat = 1; |
| | | const cJSON *sp = cJSON_GetObjectItem(row, "speed"); |
| | | const cJSON *rp = cJSON_GetObjectItem(row, "repeat"); |
| | | if (cJSON_IsNumber(sp)) { |
| | | speed = static_cast<float>(sp->valuedouble); |
| | | } |
| | | if (cJSON_IsNumber(rp)) { |
| | | repeat = rp->valueint; |
| | | } |
| | | for (int i = 0; i < en_n; ++i) { |
| | | snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s", |
| | | level, story, en_names[i]); |
| | | if (!add_playlist_item(items, &n, cap, path, speed, repeat)) { |
| | | return n; |
| | | } |
| | | } |
| | | en_added++; |
| | | } |
| | | } |
| | | if (en_added == 0) { |
| | | const float speeds[] = {0.8f, 0.9f, 1.0f}; |
| | | const int repeats[] = {1, 2, 5}; |
| | | for (int pass = 0; pass < 3; ++pass) { |
| | | for (int i = 0; i < en_n; ++i) { |
| | | snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s", |
| | | level, story, en_names[i]); |
| | | if (!add_playlist_item(items, &n, cap, path, speeds[pass], repeats[pass])) { |
| | | return n; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | return n; |
| | | } |
| | | |
| | | static int build_shadowing_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap) |
| | | { |
| | | int level = 0; |
| | | int story = 0; |
| | | const char *lesson = json_lesson_id(params); |
| | | if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) { |
| | | return 0; |
| | | } |
| | | char lang[3] = "en"; |
| | | const cJSON *language = cJSON_GetObjectItem(params, "language"); |
| | | if (!cJSON_IsString(language) || !language->valuestring) { |
| | | language = cJSON_GetObjectItem(params, "lang"); |
| | | } |
| | | if (cJSON_IsString(language) && language->valuestring && strcasecmp(language->valuestring, "cn") == 0) { |
| | | memcpy(lang, "cn", 3); |
| | | } |
| | | float speed = 0.8f; |
| | | const cJSON *speed_item = cJSON_GetObjectItem(params, "speed"); |
| | | if (cJSON_IsNumber(speed_item)) { |
| | | speed = static_cast<float>(speed_item->valuedouble); |
| | | } |
| | | char dir[SDCARD_PATH_MAX_LEN]; |
| | | static char names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX]; |
| | | snprintf(dir, sizeof(dir), "/english_learning/shadowing/lessons/l%d/%03d/%.2s", level, story, lang); |
| | | int found = list_lesson_mp3s(dir, names, LESSON_CLIP_MAX); |
| | | int n = 0; |
| | | for (int i = 0; i < found && n < cap; ++i) { |
| | | char clip[LESSON_CLIP_NAME_MAX]; |
| | | memcpy(clip, names[i], sizeof(clip)); |
| | | clip[sizeof(clip) - 1] = '\0'; |
| | | char path[SDCARD_PATH_MAX_LEN]; |
| | | snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s/%.15s", |
| | | level, story, lang, clip); |
| | | if (!add_playlist_item(items, &n, cap, path, speed, 1)) { |
| | | break; |
| | | } |
| | | } |
| | | return n; |
| | | } |
| | | |
| | | static bool start_lesson_playback_locally(const char *task_id, const char *params_json) |
| | | { |
| | | cJSON *params = cJSON_Parse((params_json && params_json[0]) ? params_json : "{}"); |
| | | if (!params) { |
| | | return false; |
| | | } |
| | | const bool shadowing = task_id && strcmp(task_id, "shadowing") == 0; |
| | | const int cap = LESSON_CLIP_MAX; |
| | | sdcard_mp3_playlist_item_t *items = static_cast<sdcard_mp3_playlist_item_t *>( |
| | | heap_caps_calloc(static_cast<size_t>(cap), sizeof(*items), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); |
| | | if (!items) { |
| | | cJSON_Delete(params); |
| | | return false; |
| | | } |
| | | int n = shadowing ? build_shadowing_playlist(params, items, cap) |
| | | : build_repeater_playlist(params, items, cap); |
| | | cJSON_Delete(params); |
| | | if (n < 1) { |
| | | heap_caps_free(items); |
| | | return false; |
| | | } |
| | | SDCard_PreparePlaybackAndWait(); |
| | | Board_SetFinishTaskAfterMp3(false); |
| | | esp_err_t err = SDCard_Play_MP3_File_Playlist(items, n); |
| | | if (err == ESP_OK) { |
| | | ESP_LOGI(TAG, "local lesson playlist started clips=%d task=%s", n, task_id ? task_id : ""); |
| | | Board_RememberRepeaterPlayback(items[0].path, items[0].speed, items[0].repeat, false); |
| | | SDCard_Show_Matching_JPG_For_MP3_Path(items[0].path); |
| | | } else { |
| | | ESP_LOGW(TAG, "local lesson playlist failed: %s", esp_err_to_name(err)); |
| | | } |
| | | heap_caps_free(items); |
| | | return err == ESP_OK; |
| | | } |
| | | |
| | | static bool is_lesson_start_phrase(const char *text) |
| | | { |
| | | if (!text) { |
| | | return false; |
| | | } |
| | | return strstr(text, "开始泛听") != NULL || strstr(text, "开始跟读") != NULL || |
| | | strstr(text, "开始翻译") != NULL || strstr(text, "开始中英") != NULL || |
| | | strstr(text, "开始对话") != NULL; |
| | | } |
| | | |
| | | bool Board_OnStartPhraseSentence(const char *text) |
| | | { |
| | | if (!s_wait_start_phrase || !is_lesson_start_phrase(text)) { |
| | | return false; |
| | | } |
| | | s_start_phrase_tts_begun = true; |
| | | if (SDCard_Is_Playing()) { |
| | | ESP_LOGW(TAG, "stop early lesson mp3 until start phrase finishes"); |
| | | SDCard_Stop_Play(); |
| | | } |
| | | ESP_LOGI(TAG, "start phrase heard, wait until it finishes"); |
| | | return true; |
| | | } |
| | | |
| | | static void begin_scheduled_talk_after_phrase(void) |
| | | { |
| | | s_wait_start_phrase = false; |
| | | s_start_phrase_tts_begun = false; |
| | | s_listen_after_phrase = false; |
| | | auto& app = Application::GetInstance(); |
| | | if (app.GetDeviceState() == kDeviceStateSpeaking) { |
| | | s_wait_start_phrase = true; |
| | | s_start_phrase_tts_begun = true; |
| | | s_listen_after_phrase = true; |
| | | return; |
| | | } |
| | | ESP_LOGI(TAG, "start phrase done, open listening"); |
| | | app.ReleaseHoldIdle(); |
| | | app.StartListeningAfterPrompt(); |
| | | } |
| | | |
| | | void Board_FlushPendingLessonPlayback(void) |
| | | { |
| | | if (!s_wait_start_phrase || !s_start_phrase_tts_begun) { |
| | | return; |
| | | } |
| | | if (s_listen_after_phrase) { |
| | | begin_scheduled_talk_after_phrase(); |
| | | return; |
| | | } |
| | | s_wait_start_phrase = false; |
| | | s_start_phrase_tts_begun = false; |
| | | if (strcmp(s_current_task, "repeater") != 0 && strcmp(s_current_task, "shadowing") != 0) { |
| | | return; |
| | | } |
| | | if (Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking) { |
| | | s_wait_start_phrase = true; |
| | | s_start_phrase_tts_begun = true; |
| | | return; |
| | | } |
| | | ESP_LOGI(TAG, "start phrase done, play lesson"); |
| | | start_lesson_playback_locally(s_current_task, s_current_params); |
| | | } |
| | | |
| | | void Board_NoteLessonStartDetectSent(void) |
| | | { |
| | | if (!s_wait_start_phrase || s_start_detect_sent) { |
| | | return; |
| | | } |
| | | s_start_detect_sent = true; |
| | | s_wait_start_us = esp_timer_get_time(); |
| | | ESP_LOGI(TAG, "start detect sent, wait for phrase"); |
| | | } |
| | | |
| | | void Board_TrySendLessonStartDetect(void) |
| | | { |
| | | if (!s_wait_start_phrase || s_start_detect_sent || s_start_detect_text.empty()) { |
| | | return; |
| | | } |
| | | auto& app = Application::GetInstance(); |
| | | auto state = app.GetDeviceState(); |
| | | if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) { |
| | | return; |
| | | } |
| | | if (app.IsAudioChannelConnected()) { |
| | | if (app.SendListenDetectNow(s_start_detect_text)) { |
| | | Board_NoteLessonStartDetectSent(); |
| | | } |
| | | return; |
| | | } |
| | | if (state == kDeviceStateIdle && Board::GetInstance().IsNetworkReady()) { |
| | | app.SendListenDetect(s_start_detect_text, true); |
| | | } |
| | | } |
| | | |
| | | void Board_PollPendingLessonPlayback(void) |
| | | { |
| | | if (!s_wait_start_phrase) { |
| | | return; |
| | | } |
| | | auto state = Application::GetInstance().GetDeviceState(); |
| | | if (!s_start_phrase_tts_begun) { |
| | | if (!s_start_detect_sent) { |
| | | Board_TrySendLessonStartDetect(); |
| | | return; |
| | | } |
| | | const int64_t elapsed_ms = (esp_timer_get_time() - s_wait_start_us) / 1000; |
| | | if (elapsed_ms >= 15000 && state != kDeviceStateSpeaking && state != kDeviceStateConnecting) { |
| | | if (s_listen_after_phrase) { |
| | | ESP_LOGW(TAG, "start phrase timeout, open listening"); |
| | | s_start_phrase_tts_begun = true; |
| | | begin_scheduled_talk_after_phrase(); |
| | | } else { |
| | | ESP_LOGW(TAG, "start phrase timeout, play lesson locally"); |
| | | s_start_phrase_tts_begun = true; |
| | | Board_FlushPendingLessonPlayback(); |
| | | } |
| | | } |
| | | return; |
| | | } |
| | | if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) { |
| | | return; |
| | | } |
| | | Board_FlushPendingLessonPlayback(); |
| | | } |
| | | |
| | | static void return_to_chat_locally(const char *reason, const char *detect) |
| | | { |
| | | ESP_LOGW(TAG, "%s, return to chat locally", reason ? reason : "skill event not sent"); |
| | | copy_id(s_current_app, sizeof(s_current_app), "chat", "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation"); |
| | | copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat"); |
| | | s_current_priority = 0; |
| | | Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL); |
| | | Application::GetInstance().ReconnectAfterLesson(detect ? detect : ""); |
| | | } |
| | | |
| | | static void dispatch_skill(const char *skill, const char *app_id, const char *task_id, |
| | |
| | | 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); |
| | |
| | | } |
| | | 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)); |
| | |
| | | return ESP_ERR_INVALID_ARG; |
| | | } |
| | | const char *src = (source && source[0]) ? source : "mqtt_immediate"; |
| | | const char *tid = (task_id && task_id[0]) ? task_id : NULL; |
| | | const app_task_skill_map_t *spec = find_task_spec(app_id, tid); |
| | | if (!spec) { |
| | | ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : ""); |
| | | return ESP_ERR_NOT_FOUND; |
| | | char resolved_tid[APP_TASK_ID_MAX]; |
| | | copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation"); |
| | | const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid); |
| | | if (spec) { |
| | | copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid); |
| | | } else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) { |
| | | ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid); |
| | | return ESP_ERR_INVALID_ARG; |
| | | } else { |
| | | ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid); |
| | | } |
| | | tid = spec->task_id; |
| | | const char *tid = resolved_tid; |
| | | |
| | | int new_priority = Board_AppTask_SourcePriority(src); |
| | | if (new_priority < s_current_priority) { |
| | | if (strcmp(src, "mqtt_immediate") != 0 && new_priority < s_current_priority) { |
| | | ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d", |
| | | app_id, tid, src, new_priority, s_current_priority); |
| | | return ESP_ERR_INVALID_STATE; |
| | | } |
| | | |
| | | if (s_wait_start_phrase && strcmp(src, "schedule") == 0 && |
| | | strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) { |
| | | ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid); |
| | | return ESP_OK; |
| | | } |
| | | |
| | | if (strcmp(src, "schedule") == 0 && |
| | | (strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) { |
| | | const char *raw_params = (params_json && params_json[0]) ? params_json : "{}"; |
| | | if (!lesson_files_ready(tid, raw_params)) { |
| | | ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid); |
| | | show_lesson_sync_alert(); |
| | | return ESP_ERR_NOT_FOUND; |
| | | } |
| | | } |
| | | |
| | | if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 && |
| | |
| | | copy_id(s_current_app, sizeof(s_current_app), app_id, "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), tid, "conversation"); |
| | | copy_id(s_current_source, sizeof(s_current_source), src, "default_chat"); |
| | | |
| | | char loaded_params[APP_TASK_JSON_MAX]; |
| | | loaded_params[0] = '\0'; |
| | | const char *raw = (params_json && params_json[0]) ? params_json : "{}"; |
| | | const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0; |
| | | if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) { |
| | | if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) { |
| | | raw = loaded_params; |
| | | ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid); |
| | | } |
| | | } |
| | | if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) { |
| | | Board_RememberLastLessonTask(tid, s_current_params); |
| | | } else { |
| | | strlcpy(s_current_params, raw, sizeof(s_current_params)); |
| | | if (lesson_task) { |
| | | Board_RememberLastLessonTask(tid, s_current_params); |
| | | } |
| | | } |
| | | const char *effective = s_current_params; |
| | | s_current_priority = new_priority; |
| | | Board_SavePendingTask(app_id, tid, params_json, src, NULL); |
| | | s_wait_start_phrase = false; |
| | | s_start_phrase_tts_begun = false; |
| | | s_listen_after_phrase = false; |
| | | Board_SavePendingTask(app_id, tid, effective, src, NULL); |
| | | |
| | | ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src); |
| | | if (!spec->skills[0]) { |
| | | dispatch_skill(NULL, app_id, tid, params_json, src); |
| | | if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) { |
| | | s_wait_start_phrase = true; |
| | | s_start_phrase_tts_begun = false; |
| | | s_start_detect_sent = false; |
| | | s_listen_after_phrase = true; |
| | | s_wait_start_us = esp_timer_get_time(); |
| | | Board_Schedule_OnTaskStarted(app_id, tid, src, effective); |
| | | notify_server(app_id, tid, src, true, true, false, true); |
| | | return ESP_OK; |
| | | } |
| | | if (lesson_task && !lesson_files_ready(tid, effective)) { |
| | | ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid); |
| | | show_lesson_sync_alert(); |
| | | } else if (lesson_task && lesson_files_ready(tid, effective)) { |
| | | s_wait_start_phrase = true; |
| | | s_start_phrase_tts_begun = false; |
| | | s_start_detect_sent = false; |
| | | s_wait_start_us = esp_timer_get_time(); |
| | | Board_Schedule_OnTaskStarted(app_id, tid, src, effective); |
| | | notify_server(app_id, tid, src, false, true, true); |
| | | return ESP_OK; |
| | | } |
| | | Board_Schedule_OnTaskStarted(app_id, tid, src, effective); |
| | | if (!spec || !spec->skills[0]) { |
| | | dispatch_skill(NULL, app_id, tid, effective, src); |
| | | return ESP_OK; |
| | | } |
| | | for (int i = 0; i < 4 && spec->skills[i]; ++i) { |
| | | dispatch_skill(spec->skills[i], app_id, tid, params_json, src); |
| | | dispatch_skill(spec->skills[i], app_id, tid, effective, src); |
| | | } |
| | | return ESP_OK; |
| | | } |
| | |
| | | copy_id(task_id, sizeof(task_id), s_current_task, "conversation"); |
| | | const bool shadowing = strcmp(task_id, "shadowing") == 0; |
| | | const bool repeater = strcmp(task_id, "repeater") == 0; |
| | | // 复读清单是否结束由 Server 决定。设备不要在此写 NVS 切回 chat。 |
| | | // WS 在线时清单结束由 Server 切回 chat。WS 已断则下面本地兜底,避免一直停在泛听。 |
| | | const bool end_task = !shadowing && !repeater && |
| | | task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3; |
| | | |
| | |
| | | const bool open_mic = !stopped && shadowing; |
| | | // 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。 |
| | | if (!Application::GetInstance().SendListenDetectNow(detect)) { |
| | | // WS 已断:释放 AFE 后重连,把 completed 补发给 Server 播结束语。 |
| | | if (repeater && !stopped) { |
| | | return_to_chat_locally("repeater completed not sent", detect); |
| | | Board_Schedule_OnNaturalEnd(); |
| | | return; |
| | | } |
| | | Application::GetInstance().SendListenDetect(detect, false); |
| | | } |
| | | if (open_mic) { |
| | | Application::GetInstance().GetAudioService().ResetDecoder(); |
| | | Application::GetInstance().ResumeListeningAfterMp3Clip(); |
| | | } else if (repeater && !stopped) { |
| | | // 清单已播完,马上要播「复读结束」:保持功放,不要再 ResetDecoder。 |
| | | // 与跟读清单结束相同:关 AFE/唤醒,等 Server 播「泛听结束」再回对话。 |
| | | Application::GetInstance().GetAudioService().EnableVoiceProcessing(false); |
| | | Application::GetInstance().GetAudioService().EnableWakeWordDetection(false); |
| | | Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback(); |
| | | Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true); |
| | | Board_Schedule_OnNaturalEnd(); |
| | | } else if (repeater && stopped) { |
| | | Board_Schedule_OnUserExit(); |
| | | } else if (!repeater) { |
| | | Application::GetInstance().GetAudioService().ResetDecoder(); |
| | | } |
| | |
| | | snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s", |
| | | app_id, task_id); |
| | | if (!Application::GetInstance().SendListenDetectNow(detect)) { |
| | | ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally"); |
| | | copy_id(s_current_app, sizeof(s_current_app), "chat", "chat"); |
| | | copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation"); |
| | | copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat"); |
| | | s_current_priority = 0; |
| | | Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL); |
| | | auto &app = Application::GetInstance(); |
| | | app.GetAudioService().SetAudioPowerInhibit(false); |
| | | app.GetAudioService().EnableVoiceProcessing(false); |
| | | app.GetAudioService().EnableWakeWordDetection(true); |
| | | if (app.GetDeviceState() == kDeviceStateListening || |
| | | app.GetDeviceState() == kDeviceStateConnecting) { |
| | | app.SetDeviceState(kDeviceStateIdle); |
| | | } |
| | | return_to_chat_locally("playlist_completed not sent", detect); |
| | | Board_Schedule_OnNaturalEnd(); |
| | | return; |
| | | } |
| | | Application::GetInstance().GetAudioService().EnableVoiceProcessing(false); |
| | | Application::GetInstance().GetAudioService().EnableWakeWordDetection(false); |
| | | Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback(); |
| | | Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true); |
| | | Board_Schedule_OnNaturalEnd(); |
| | | }); |
| | | } |
| | | |
| | | void Board_ResolveBootOrRestore(bool from_alarm_wake) |
| | | { |
| | | char app_id[APP_TASK_ID_MAX] = "chat"; |
| | | char task_id[APP_TASK_ID_MAX] = "conversation"; |
| | | char source[APP_TASK_SOURCE_MAX] = "default_chat"; |
| | | char params[APP_TASK_JSON_MAX] = "{}"; |
| | | bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id), |
| | | source, sizeof(source), params, sizeof(params)); |
| | | if (!has_pending) { |
| | | Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL); |
| | | Board_StartAppTask("chat", "conversation", "{}", "default_chat"); |
| | | return; |
| | | if (!from_alarm_wake) { |
| | | Board_Schedule_Reload(); |
| | | Board_Schedule_Recompute(); |
| | | } |
| | | |
| | | const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0; |
| | | const bool offline = !Board::GetInstance().IsNetworkReady(); |
| | | if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) { |
| | | ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d", |
| | | app_id, task_id, source, offline ? 1 : 0); |
| | | Board_StartAppTask(app_id, task_id, params, source); |
| | | return; |
| | | const int window = 180; |
| | | if (Board_Schedule_IsDueNow(window)) { |
| | | char app_id[APP_TASK_ID_MAX]; |
| | | char task_id[APP_TASK_ID_MAX]; |
| | | char params[APP_TASK_JSON_MAX] = "{}"; |
| | | if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id), |
| | | params, sizeof(params))) { |
| | | ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d", |
| | | app_id, task_id, from_alarm_wake ? 1 : 0); |
| | | Board_StartAppTask(app_id, task_id, params, "schedule"); |
| | | return; |
| | | } |
| | | } |
| | | |
| | | Board_StartAppTask("chat", "conversation", "{}", "default_chat"); |
| | |
| | | return; |
| | | } |
| | | if (!Board::GetInstance().IsNetworkReady()) { |
| | | ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 复读机 to play locally"); |
| | | ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 泛听 to play locally"); |
| | | return; |
| | | } |
| | | Application::GetInstance().SendListenDetect("[voice_entry]", true); |
| | |
| | | } |
| | | 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 (strcmp(command, OFFLINE_REPEATER_WORD) == 0 || |
| | | strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 || |
| | | strstr(command, "开始复读") || strstr(command, "复读机")) { |
| | | strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "打开泛听")) { |
| | | if (s_current_priority >= 2) { |
| | | return true; |
| | | } |