wangzhibo
10 天以前 220e4fab27b7f419b3e89e2f2d28ae789aac09d7
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,4 +1,5 @@
#include "app_task.h"
#include "parent_schedule.h"
#include "application.h"
#include "board.h"
@@ -12,21 +13,45 @@
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <dirent.h>
#include <ctype.h>
#include <string>
#include <esp_heap_caps.h>
#include <esp_timer.h>
#define TAG "app_task"
#define APP_TASK_NVS_NS "app_task"
#define APP_TASK_NVS_KEY "pending"
#define APP_TASK_NVS_LAST_REP "last_rep"
#define APP_TASK_NVS_LAST_RPT "last_rpt"
#define APP_TASK_NVS_LAST_SHD "last_shd"
#define APP_TASK_JSON_MAX 768
#define APP_TASK_ID_MAX 32
#define APP_TASK_SOURCE_MAX 24
#define LESSON_CLIP_NAME_MAX 16
#define LESSON_CLIP_MAX 128
static char s_current_app[APP_TASK_ID_MAX] = "chat";
static char s_current_task[APP_TASK_ID_MAX] = "conversation";
static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
static char s_current_params[APP_TASK_JSON_MAX] = "{}";
static int s_current_priority = 0;
static bool s_finish_task_after_mp3 = true;
static float s_shadowing_listen_ratio = 1.2f;
static bool s_wait_start_phrase = false;
static bool s_start_phrase_tts_begun = false;
static bool s_start_detect_sent = false;
static bool s_listen_after_phrase = false;
static int64_t s_wait_start_us = 0;
static std::string s_start_detect_text;
void Board_TrySendLessonStartDetect(void);
bool Board_IsWaitingStartPhrase(void)
{
    return s_wait_start_phrase;
}
typedef struct {
    const char *app_id;
@@ -63,6 +88,20 @@
int Board_AppTask_CurrentPriority(void)
{
    return s_current_priority;
}
static bool is_safe_task_id(const char *id)
{
    if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
        return false;
    }
    for (const char *p = id; *p; ++p) {
        unsigned char ch = static_cast<unsigned char>(*p);
        if (!isalnum(ch) && ch != '_' && ch != '-') {
            return false;
        }
    }
    return true;
}
static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
@@ -376,6 +415,328 @@
    return name[0] != '\0';
}
static const char *last_lesson_nvs_key(const char *task_id)
{
    if (task_id && strcmp(task_id, "shadowing") == 0) {
        return APP_TASK_NVS_LAST_SHD;
    }
    if (task_id && strcmp(task_id, "repeater") == 0) {
        return APP_TASK_NVS_LAST_RPT;
    }
    return NULL;
}
static const char *const k_last_lesson_keys[] = {
    "lesson_id",
    "lesson",
    "lessonId",
    "en_passes",
    "enPasses",
    "cn_repeat",
    "cn_speed",
    "language",
    "lang",
    "speed",
    "on_complete",
    "listen_ratio",
    "sentence_count",
    "org_id",
    "orgId",
    NULL,
};
static bool json_has_lesson_id(cJSON *root)
{
    if (!root) {
        return false;
    }
    const cJSON *id = cJSON_GetObjectItem(root, "lesson_id");
    if (!id) {
        id = cJSON_GetObjectItem(root, "lessonId");
    }
    if (!id) {
        id = cJSON_GetObjectItem(root, "lesson");
    }
    return cJSON_IsString(id) && id->valuestring && id->valuestring[0];
}
static bool compact_last_lesson_json(const char *params_json, char *out, size_t out_size)
{
    if (!out || out_size == 0) {
        return false;
    }
    out[0] = '\0';
    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
        return false;
    }
    cJSON *root = cJSON_Parse(params_json);
    if (!root) {
        return false;
    }
    cJSON *compact = cJSON_CreateObject();
    if (!compact) {
        cJSON_Delete(root);
        return false;
    }
    for (int i = 0; k_last_lesson_keys[i]; ++i) {
        cJSON *item = cJSON_GetObjectItem(root, k_last_lesson_keys[i]);
        if (item) {
            cJSON_AddItemToObject(compact, k_last_lesson_keys[i], cJSON_Duplicate(item, 1));
        }
    }
    bool ok = json_has_lesson_id(compact);
    if (ok) {
        const cJSON *id = cJSON_GetObjectItem(compact, "lesson_id");
        if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
            id = cJSON_GetObjectItem(compact, "lessonId");
            if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
                id = cJSON_GetObjectItem(compact, "lesson");
            }
            if (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) {
                cJSON_AddStringToObject(compact, "lesson_id", id->valuestring);
            }
        }
        char *printed = cJSON_PrintUnformatted(compact);
        if (printed) {
            strlcpy(out, printed, out_size);
            ok = out[0] != '\0';
            cJSON_free(printed);
        } else {
            ok = false;
        }
    }
    cJSON_Delete(compact);
    cJSON_Delete(root);
    return ok;
}
static bool params_has_lesson_id(const char *params_json)
{
    if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
        return false;
    }
    cJSON *root = cJSON_Parse(params_json);
    if (!root) {
        return false;
    }
    bool ok = json_has_lesson_id(root);
    cJSON_Delete(root);
    return ok;
}
static bool sd_business_file_ready(const char *business_path)
{
    char sd_path[SDCARD_PATH_MAX_LEN];
    if (!business_path || business_path[0] == '\0') {
        return false;
    }
    if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
        return false;
    }
    struct stat st;
    if (stat(sd_path, &st) != 0) {
        return false;
    }
    return S_ISREG(st.st_mode) && st.st_size > 0;
}
static const char *json_clip_name(cJSON *obj)
{
    if (!cJSON_IsObject(obj)) {
        return NULL;
    }
    const char *keys[] = {"name", "path", "url"};
    for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) {
        cJSON *item = cJSON_GetObjectItem(obj, keys[i]);
        if (cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
            return item->valuestring;
        }
    }
    return NULL;
}
static int mp3_name_order(const void *a, const void *b)
{
    int left = atoi(static_cast<const char *>(a));
    int right = atoi(static_cast<const char *>(b));
    if (left != right) {
        return left - right;
    }
    return strcmp(static_cast<const char *>(a), static_cast<const char *>(b));
}
static int list_lesson_mp3s(const char *business_dir, char names[][LESSON_CLIP_NAME_MAX], int cap)
{
    char sd_path[SDCARD_PATH_MAX_LEN];
    if (!business_dir || business_dir[0] == '\0' || !names || cap < 1) {
        return 0;
    }
    if (SDCard_Map_Business_Path(business_dir, sd_path, sizeof(sd_path)) != ESP_OK) {
        return 0;
    }
    DIR *dir = opendir(sd_path);
    if (!dir) {
        return 0;
    }
    int count = 0;
    struct dirent *ent;
    while ((ent = readdir(dir)) != NULL && count < cap) {
        const char *name = ent->d_name;
        size_t len = name ? strlen(name) : 0;
        if (len < 5 || len >= LESSON_CLIP_NAME_MAX || strcasecmp(name + len - 4, ".mp3") != 0) {
            continue;
        }
        strlcpy(names[count], name, LESSON_CLIP_NAME_MAX);
        count++;
    }
    closedir(dir);
    if (count > 1) {
        qsort(names, static_cast<size_t>(count), LESSON_CLIP_NAME_MAX, mp3_name_order);
    }
    return count;
}
static const char *shadowing_lang_folder(const char *language)
{
    return (language && strcasecmp(language, "cn") == 0) ? "cn" : "en";
}
static bool probe_lesson_id_on_sd(const char *task_id, const char *lesson_id, const char *language)
{
    int level = 0;
    int story = 0;
    if (!lesson_id || sscanf(lesson_id, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
        return true;
    }
    char path[SDCARD_PATH_MAX_LEN];
    if (task_id && strcmp(task_id, "shadowing") == 0) {
        const char *lang = shadowing_lang_folder(language);
        snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s",
                 level, story, lang);
        char names[1][LESSON_CLIP_NAME_MAX];
        return list_lesson_mp3s(path, names, 1) >= 1;
    }
    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
    char names[1][LESSON_CLIP_NAME_MAX];
    if (list_lesson_mp3s(path, names, 1) < 1) {
        return false;
    }
    snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
    return list_lesson_mp3s(path, names, 1) >= 1;
}
static bool lesson_files_ready(const char *task_id, const char *params_json)
{
    if (!params_json || params_json[0] == '\0') {
        return true;
    }
    cJSON *params = cJSON_Parse(params_json);
    if (!params) {
        return true;
    }
    cJSON *items = cJSON_GetObjectItem(params, "items");
    bool checked = false;
    if (cJSON_IsArray(items)) {
        cJSON *it = NULL;
        cJSON_ArrayForEach(it, items) {
            const char *name = json_clip_name(it);
            if (!name) {
                continue;
            }
            checked = true;
            if (!sd_business_file_ready(name)) {
                cJSON_Delete(params);
                return false;
            }
        }
        if (checked) {
            cJSON_Delete(params);
            return true;
        }
    }
    const char *single = json_clip_name(params);
    if (single) {
        bool ok = sd_business_file_ready(single);
        cJSON_Delete(params);
        return ok;
    }
    cJSON *lesson = cJSON_GetObjectItem(params, "lesson_id");
    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
        lesson = cJSON_GetObjectItem(params, "lesson");
    }
    if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
        lesson = cJSON_GetObjectItem(params, "lessonId");
    }
    cJSON *lang = cJSON_GetObjectItem(params, "language");
    if (!cJSON_IsString(lang) || !lang->valuestring) {
        lang = cJSON_GetObjectItem(params, "lang");
    }
    bool ok = true;
    if (cJSON_IsString(lesson) && lesson->valuestring && lesson->valuestring[0] != '\0') {
        ok = probe_lesson_id_on_sd(task_id, lesson->valuestring,
                                  cJSON_IsString(lang) ? lang->valuestring : NULL);
    }
    cJSON_Delete(params);
    return ok;
}
static void show_lesson_sync_alert(void)
{
    Board_Show_Alert("正在同步", "正在同步课文,请稍候", "cloud_arrow_down");
}
bool Board_LessonFilesReady(const char *task_id, const char *params_json)
{
    return lesson_files_ready(task_id, params_json);
}
bool Board_IsLessonPlaybackActive(void)
{
    return s_wait_start_phrase || SDCard_Is_Playing();
}
void Board_RememberLastLessonTask(const char *task_id, const char *params_json)
{
    const char *key = last_lesson_nvs_key(task_id);
    char compact[APP_TASK_JSON_MAX];
    if (!key || !compact_last_lesson_json(params_json, compact, sizeof(compact))) {
        return;
    }
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) != ESP_OK) {
        return;
    }
    if (nvs_set_str(handle, key, compact) == ESP_OK) {
        nvs_commit(handle);
        ESP_LOGI(TAG, "remember last %s lesson", task_id);
    }
    nvs_close(handle);
}
bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size)
{
    const char *key = last_lesson_nvs_key(task_id);
    if (!key || !buf || buf_size == 0) {
        return false;
    }
    buf[0] = '\0';
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
        return false;
    }
    size_t length = buf_size;
    esp_err_t err = nvs_get_str(handle, key, buf, &length);
    nvs_close(handle);
    if (err != ESP_OK || buf[0] == '\0' || !params_has_lesson_id(buf)) {
        buf[0] = '\0';
        return false;
    }
    return true;
}
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
                                const char *source, const char *alarm_id)
{
@@ -417,66 +778,391 @@
    return err;
}
static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size,
                                char *source, size_t source_size, char *params_json, size_t params_size)
{
    nvs_handle_t handle;
    if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
        return false;
    }
    char buf[APP_TASK_JSON_MAX];
    size_t length = sizeof(buf);
    esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length);
    nvs_close(handle);
    if (err != ESP_OK) {
        return false;
    }
    cJSON *root = cJSON_Parse(buf);
    if (!root) {
        return false;
    }
    const cJSON *app = cJSON_GetObjectItem(root, "app_id");
    const cJSON *task = cJSON_GetObjectItem(root, "task_id");
    const cJSON *src = cJSON_GetObjectItem(root, "source");
    const cJSON *params = cJSON_GetObjectItem(root, "params");
    copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat");
    copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation");
    copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat");
    if (params_json && params_size > 0) {
        params_json[0] = '\0';
        if (params) {
            char *printed = cJSON_PrintUnformatted(params);
            if (printed) {
                strlcpy(params_json, printed, params_size);
                free(printed);
            }
        }
    }
    cJSON_Delete(root);
    return app_id[0] != '\0';
}
static void preempt_current_skills(void)
{
    SDCard_Stop_Play();
    BoardMqtt_StopPlaylistPlayback();
    Application::GetInstance().Schedule([]() {
        auto state = Application::GetInstance().GetDeviceState();
        if (state == kDeviceStateSpeaking) {
            Application::GetInstance().AbortSpeaking(kAbortReasonNone);
}
static bool is_schedule_talk_task(const char *task_id)
{
    return task_id && (strcmp(task_id, "conversation") == 0 ||
                       strcmp(task_id, "live_translate") == 0 ||
                       strcmp(task_id, "bilingual_chat") == 0);
}
static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle,
                          bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false)
{
    std::string params = s_current_params[0] ? s_current_params : "{}";
    if (sd_ready && params.size() >= 2 && params.back() == '}') {
        params.pop_back();
        if (params.size() > 1) {
            params += ',';
        }
        params += "\"sd_ready\":true}";
    }
    std::string detect = "[app_task]";
    detect += (app_id && app_id[0]) ? app_id : "chat";
    detect += "|";
    detect += (task_id && task_id[0]) ? task_id : "conversation";
    detect += "|";
    detect += (source && source[0]) ? source : "default_chat";
    detect += "|";
    detect += params;
    const bool hold_for_phrase = sd_ready || wait_phrase;
    if (hold_for_phrase) {
        s_start_detect_text = detect;
        s_start_detect_sent = false;
    }
    Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() {
        auto& app = Application::GetInstance();
        app.HoldIdleForServerPrompt();
        if (hold_for_phrase) {
            // 关机拉起时通道可能还没打开。连上后再上报,等「开始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,
@@ -516,6 +1202,16 @@
                cJSON_Delete(params);
            }
        }
        const bool server_first = Board::GetInstance().IsNetworkReady() && source &&
            (strcmp(source, "voice") == 0 || strcmp(source, "mqtt_immediate") == 0 ||
             strcmp(source, "schedule") == 0);
        if (server_first) {
            if (name[0]) {
                Board_RememberRepeaterPlayback(name, speed, repeat < 1 ? 1 : repeat, power_off_after);
            }
            notify_server(app_id, task_id, source, true);
            return;
        }
        if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
            ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
            notify_server(app_id, task_id, source, true);
@@ -526,12 +1222,6 @@
        }
        speed = SDCard_Clamp_MP3_Speed(speed);
        Board_SetFinishTaskAfterMp3(true);
        // 有网的语音入口:先让 Server 播报进场词,音频由 MCP 在 TTS 结束后再播。
        if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) {
            Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
            notify_server(app_id, task_id, source, true);
            return;
        }
        esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
        if (err != ESP_OK) {
            ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
@@ -555,19 +1245,40 @@
        return ESP_ERR_INVALID_ARG;
    }
    const char *src = (source && source[0]) ? source : "mqtt_immediate";
    const char *tid = (task_id && task_id[0]) ? task_id : NULL;
    const app_task_skill_map_t *spec = find_task_spec(app_id, tid);
    if (!spec) {
        ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : "");
        return ESP_ERR_NOT_FOUND;
    char resolved_tid[APP_TASK_ID_MAX];
    copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation");
    const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid);
    if (spec) {
        copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid);
    } else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) {
        ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid);
        return ESP_ERR_INVALID_ARG;
    } else {
        ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid);
    }
    tid = spec->task_id;
    const char *tid = resolved_tid;
    int new_priority = Board_AppTask_SourcePriority(src);
    if (new_priority < s_current_priority) {
    if (strcmp(src, "mqtt_immediate") != 0 && new_priority < s_current_priority) {
        ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
                 app_id, tid, src, new_priority, s_current_priority);
        return ESP_ERR_INVALID_STATE;
    }
    if (s_wait_start_phrase && strcmp(src, "schedule") == 0 &&
        strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) {
        ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid);
        return ESP_OK;
    }
    if (strcmp(src, "schedule") == 0 &&
        (strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) {
        const char *raw_params = (params_json && params_json[0]) ? params_json : "{}";
        if (!lesson_files_ready(tid, raw_params)) {
            ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid);
            show_lesson_sync_alert();
            return ESP_ERR_NOT_FOUND;
        }
    }
    if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
@@ -586,16 +1297,62 @@
    copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
    copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
    copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
    char loaded_params[APP_TASK_JSON_MAX];
    loaded_params[0] = '\0';
    const char *raw = (params_json && params_json[0]) ? params_json : "{}";
    const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0;
    if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) {
        if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) {
            raw = loaded_params;
            ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid);
        }
    }
    if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) {
        Board_RememberLastLessonTask(tid, s_current_params);
    } else {
        strlcpy(s_current_params, raw, sizeof(s_current_params));
        if (lesson_task) {
            Board_RememberLastLessonTask(tid, s_current_params);
        }
    }
    const char *effective = s_current_params;
    s_current_priority = new_priority;
    Board_SavePendingTask(app_id, tid, params_json, src, NULL);
    s_wait_start_phrase = false;
    s_start_phrase_tts_begun = false;
    s_listen_after_phrase = false;
    Board_SavePendingTask(app_id, tid, effective, src, NULL);
    ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
    if (!spec->skills[0]) {
        dispatch_skill(NULL, app_id, tid, params_json, src);
    if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) {
        s_wait_start_phrase = true;
        s_start_phrase_tts_begun = false;
        s_start_detect_sent = false;
        s_listen_after_phrase = true;
        s_wait_start_us = esp_timer_get_time();
        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
        notify_server(app_id, tid, src, true, true, false, true);
        return ESP_OK;
    }
    if (lesson_task && !lesson_files_ready(tid, effective)) {
        ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid);
        show_lesson_sync_alert();
    } else if (lesson_task && lesson_files_ready(tid, effective)) {
        s_wait_start_phrase = true;
        s_start_phrase_tts_begun = false;
        s_start_detect_sent = false;
        s_wait_start_us = esp_timer_get_time();
        Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
        notify_server(app_id, tid, src, false, true, true);
        return ESP_OK;
    }
    Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
    if (!spec || !spec->skills[0]) {
        dispatch_skill(NULL, app_id, tid, effective, src);
        return ESP_OK;
    }
    for (int i = 0; i < 4 && spec->skills[i]; ++i) {
        dispatch_skill(spec->skills[i], app_id, tid, params_json, src);
        dispatch_skill(spec->skills[i], app_id, tid, effective, src);
    }
    return ESP_OK;
}
@@ -614,7 +1371,7 @@
        copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
        const bool shadowing = strcmp(task_id, "shadowing") == 0;
        const bool repeater = strcmp(task_id, "repeater") == 0;
        // 复读清单是否结束由 Server 决定。设备不要在此写 NVS 切回 chat。
        // WS 在线时清单结束由 Server 切回 chat。WS 已断则下面本地兜底,避免一直停在泛听。
        const bool end_task = !shadowing && !repeater &&
            task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
@@ -632,15 +1389,26 @@
        const bool open_mic = !stopped && shadowing;
        // 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。
        if (!Application::GetInstance().SendListenDetectNow(detect)) {
            // WS 已断:释放 AFE 后重连,把 completed 补发给 Server 播结束语。
            if (repeater && !stopped) {
                return_to_chat_locally("repeater completed not sent", detect);
                Board_Schedule_OnNaturalEnd();
                return;
            }
            Application::GetInstance().SendListenDetect(detect, false);
        }
        if (open_mic) {
            Application::GetInstance().GetAudioService().ResetDecoder();
            Application::GetInstance().ResumeListeningAfterMp3Clip();
        } else if (repeater && !stopped) {
            // 清单已播完,马上要播「复读结束」:保持功放,不要再 ResetDecoder。
            // 与跟读清单结束相同:关 AFE/唤醒,等 Server 播「泛听结束」再回对话。
            Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
            Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
            Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
            Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
            Board_Schedule_OnNaturalEnd();
        } else if (repeater && stopped) {
            Board_Schedule_OnUserExit();
        } else if (!repeater) {
            Application::GetInstance().GetAudioService().ResetDecoder();
        }
@@ -682,48 +1450,36 @@
        snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
                 app_id, task_id);
        if (!Application::GetInstance().SendListenDetectNow(detect)) {
            ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally");
            copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
            copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
            copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
            s_current_priority = 0;
            Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
            auto &app = Application::GetInstance();
            app.GetAudioService().SetAudioPowerInhibit(false);
            app.GetAudioService().EnableVoiceProcessing(false);
            app.GetAudioService().EnableWakeWordDetection(true);
            if (app.GetDeviceState() == kDeviceStateListening ||
                app.GetDeviceState() == kDeviceStateConnecting) {
                app.SetDeviceState(kDeviceStateIdle);
            }
            return_to_chat_locally("playlist_completed not sent", detect);
            Board_Schedule_OnNaturalEnd();
            return;
        }
        Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
        Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
        Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
        Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
        Board_Schedule_OnNaturalEnd();
    });
}
void Board_ResolveBootOrRestore(bool from_alarm_wake)
{
    char app_id[APP_TASK_ID_MAX] = "chat";
    char task_id[APP_TASK_ID_MAX] = "conversation";
    char source[APP_TASK_SOURCE_MAX] = "default_chat";
    char params[APP_TASK_JSON_MAX] = "{}";
    bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id),
                                           source, sizeof(source), params, sizeof(params));
    if (!has_pending) {
        Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
        Board_StartAppTask("chat", "conversation", "{}", "default_chat");
        return;
    if (!from_alarm_wake) {
        Board_Schedule_Reload();
        Board_Schedule_Recompute();
    }
    const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0;
    const bool offline = !Board::GetInstance().IsNetworkReady();
    if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) {
        ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d",
                 app_id, task_id, source, offline ? 1 : 0);
        Board_StartAppTask(app_id, task_id, params, source);
        return;
    const int window = 180;
    if (Board_Schedule_IsDueNow(window)) {
        char app_id[APP_TASK_ID_MAX];
        char task_id[APP_TASK_ID_MAX];
        char params[APP_TASK_JSON_MAX] = "{}";
        if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
                                       params, sizeof(params))) {
            ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d",
                     app_id, task_id, from_alarm_wake ? 1 : 0);
            Board_StartAppTask(app_id, task_id, params, "schedule");
            return;
        }
    }
    Board_StartAppTask("chat", "conversation", "{}", "default_chat");
@@ -736,7 +1492,7 @@
        return;
    }
    if (!Board::GetInstance().IsNetworkReady()) {
        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 复读机 to play locally");
        ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 泛听 to play locally");
        return;
    }
    Application::GetInstance().SendListenDetect("[voice_entry]", true);
@@ -754,6 +1510,19 @@
    }
    if (Board_Is_Chat_Return_Command(command)) {
        s_current_priority = 0;
        auto &app = Application::GetInstance();
        if (is_chat_task(s_current_app, s_current_task) && !app.IsAudioChannelConnected()) {
            // 课文结束已本地回 chat。通道已断时再去 handshake 会卡「连接中」并关掉唤醒。
            app.Schedule([]() {
                auto &inner = Application::GetInstance();
                inner.AnnounceReturnedToChat();
                inner.GetAudioService().EnableWakeWordDetection(true);
                if (inner.GetDeviceState() == kDeviceStateConnecting) {
                    inner.SetDeviceState(kDeviceStateIdle);
                }
            });
            return true;
        }
        Board_StartAppTask("chat", "conversation", "{}", "voice");
        return true;
    }
@@ -766,7 +1535,7 @@
    }
    if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
        strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
        strstr(command, "开始复读") || strstr(command, "复读机")) {
        strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "打开泛听")) {
        if (s_current_priority >= 2) {
            return true;
        }