#include "app_task.h"
|
#include "parent_schedule.h"
|
|
#include "application.h"
|
#include "board.h"
|
#include "mqtt.h"
|
#include "system.h"
|
|
#include <cJSON.h>
|
#include <esp_log.h>
|
#include <nvs.h>
|
#include <strings.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;
|
const char *task_id;
|
const char *skills[4];
|
} app_task_skill_map_t;
|
|
/* Configuration table: channels only pass app_id/task_id; skills are looked up here. */
|
static const app_task_skill_map_t k_task_skills[] = {
|
{"chat", "conversation", {NULL, NULL, NULL, NULL}},
|
{"english_learning", "repeater", {"self.mp3.play", NULL, NULL, NULL}},
|
{"english_learning", "shadowing", {NULL, NULL, NULL, NULL}},
|
{"english_translation", "live_translate", {"translate", NULL, NULL, NULL}},
|
{"english_conversation", "bilingual_chat", {NULL, NULL, NULL, NULL}},
|
};
|
|
int Board_AppTask_SourcePriority(const char *source)
|
{
|
if (!source) {
|
return 1;
|
}
|
if (strcmp(source, "mqtt_immediate") == 0) {
|
return 3;
|
}
|
if (strcmp(source, "schedule") == 0) {
|
return 2;
|
}
|
if (strcmp(source, "voice") == 0) {
|
return 1;
|
}
|
return 0;
|
}
|
|
int Board_AppTask_CurrentPriority(void)
|
{
|
return s_current_priority;
|
}
|
|
static bool is_safe_task_id(const char *id)
|
{
|
if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
|
return false;
|
}
|
for (const char *p = id; *p; ++p) {
|
unsigned char ch = static_cast<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)
|
{
|
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<unsigned>(static_cast<float>(duration_ms) * s_shadowing_listen_ratio);
|
if (wait_ms < 800) {
|
wait_ms = 800;
|
}
|
return wait_ms;
|
}
|
|
static void copy_id(char *dest, size_t dest_size, const char *src, const char *fallback)
|
{
|
const char *value = (src && src[0]) ? src : fallback;
|
strlcpy(dest, value, dest_size);
|
}
|
|
void Board_SetActiveTask(const char *app_id, const char *task_id)
|
{
|
if (!app_id || app_id[0] == '\0' || !task_id || task_id[0] == '\0') {
|
return;
|
}
|
if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, task_id) == 0) {
|
return;
|
}
|
copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
|
copy_id(s_current_task, sizeof(s_current_task), task_id, "conversation");
|
ESP_LOGI(TAG, "set active task %s/%s", s_current_app, s_current_task);
|
}
|
|
static void normalize_media_path(char *path)
|
{
|
if (!path) {
|
return;
|
}
|
for (char *p = path; *p; ++p) {
|
if (*p == '\\') {
|
*p = '/';
|
}
|
}
|
}
|
|
static const char *path_without_leading_slash(const char *path)
|
{
|
if (!path) {
|
return "";
|
}
|
return path[0] == '/' ? path + 1 : path;
|
}
|
|
static bool path_has_folder_prefix(const char *path, const char *prefix)
|
{
|
if (!path || !prefix || prefix[0] == '\0') {
|
return false;
|
}
|
char normalized[SDCARD_PATH_MAX_LEN];
|
char needle[96];
|
strlcpy(normalized, path, sizeof(normalized));
|
strlcpy(needle, prefix, sizeof(needle));
|
normalize_media_path(normalized);
|
normalize_media_path(needle);
|
const char *hay = path_without_leading_slash(normalized);
|
const char *pre = path_without_leading_slash(needle);
|
size_t n = strlen(pre);
|
if (n == 0 || strncmp(hay, pre, n) != 0) {
|
return false;
|
}
|
return hay[n] == '\0' || hay[n] == '/';
|
}
|
|
static bool path_belongs_to_current_task(const char *path)
|
{
|
if (is_chat_task(s_current_app, s_current_task)) {
|
return true;
|
}
|
char prefix[96];
|
snprintf(prefix, sizeof(prefix), "/%s/%s/", s_current_app, s_current_task);
|
return path_has_folder_prefix(path, prefix);
|
}
|
|
static bool basename_is_full_mp3(const char *name)
|
{
|
if (!name || name[0] == '\0') {
|
return false;
|
}
|
const char *base = strrchr(name, '/');
|
base = base ? base + 1 : name;
|
if (strcasecmp(base, "full.mp3") == 0) {
|
return true;
|
}
|
size_t n = strlen(base);
|
return n > 9 && strcasecmp(base + n - 9, "_full.mp3") == 0;
|
}
|
|
void Board_BindTaskFromMediaPath(const char *path)
|
{
|
// 课文文件可能都在 /english_learning/shadowing/ 下。任务只由 MCP app_id/task_id 决定。
|
(void)path;
|
}
|
|
static bool task_owns_play_audio(const char *app_id, const char *task_id)
|
{
|
const app_task_skill_map_t *spec = find_task_spec(app_id, task_id);
|
if (!spec) {
|
return false;
|
}
|
for (int i = 0; i < 4 && spec->skills[i]; ++i) {
|
if (strcmp(spec->skills[i], "self.mp3.play") == 0) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
static bool first_sd_mp3_name(char *name, size_t name_size, const char *prefix, bool prefer_full)
|
{
|
if (!name || name_size == 0) {
|
return false;
|
}
|
name[0] = '\0';
|
const char *list = SDCard_Get_MP3_List();
|
if (!list || list[0] == '\0') {
|
return false;
|
}
|
cJSON *root = cJSON_Parse(list);
|
if (!root) {
|
return false;
|
}
|
const cJSON *files = cJSON_GetObjectItem(root, "files");
|
if (!cJSON_IsArray(files)) {
|
cJSON_Delete(root);
|
return false;
|
}
|
const cJSON *best = NULL;
|
bool best_full = false;
|
const cJSON *file = NULL;
|
cJSON_ArrayForEach(file, files) {
|
const cJSON *item = cJSON_GetObjectItem(file, "name");
|
if (!cJSON_IsString(item) || !item->valuestring || !item->valuestring[0]) {
|
continue;
|
}
|
if (prefix && prefix[0] && !path_has_folder_prefix(item->valuestring, prefix)) {
|
continue;
|
}
|
bool is_full = basename_is_full_mp3(item->valuestring);
|
if (!best) {
|
best = item;
|
best_full = is_full;
|
continue;
|
}
|
if (prefer_full && is_full && !best_full) {
|
best = item;
|
best_full = true;
|
continue;
|
}
|
if ((!prefer_full || is_full == best_full) &&
|
strcmp(item->valuestring, best->valuestring) < 0) {
|
best = item;
|
best_full = is_full;
|
}
|
}
|
bool ok = best != NULL;
|
if (ok) {
|
strlcpy(name, best->valuestring, name_size);
|
}
|
cJSON_Delete(root);
|
return ok;
|
}
|
|
void Board_SetFinishTaskAfterMp3(bool finish)
|
{
|
s_finish_task_after_mp3 = finish;
|
}
|
|
void Board_RememberRepeaterPlayback(const char *name, float speed, int repeat, bool power_off_after)
|
{
|
if (!name || name[0] == '\0') {
|
return;
|
}
|
if (strcmp(s_current_task, "repeater") != 0) {
|
return;
|
}
|
cJSON *root = cJSON_CreateObject();
|
if (!root) {
|
return;
|
}
|
cJSON_AddStringToObject(root, "name", name);
|
cJSON_AddNumberToObject(root, "speed", speed);
|
cJSON_AddNumberToObject(root, "repeat", repeat < 1 ? 1 : repeat);
|
cJSON_AddBoolToObject(root, "power_off_after", power_off_after);
|
char *printed = cJSON_PrintUnformatted(root);
|
cJSON_Delete(root);
|
if (!printed) {
|
return;
|
}
|
nvs_handle_t handle;
|
if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) == ESP_OK) {
|
if (nvs_set_str(handle, APP_TASK_NVS_LAST_REP, printed) == ESP_OK) {
|
nvs_commit(handle);
|
}
|
nvs_close(handle);
|
}
|
free(printed);
|
}
|
|
bool Board_ResolveRepeaterPlayback(char *name, size_t name_size, float *speed, int *repeat, bool *power_off_after)
|
{
|
if (!name || name_size == 0) {
|
return false;
|
}
|
if (name[0] != '\0') {
|
if (speed) {
|
*speed = SDCard_Clamp_MP3_Speed(*speed);
|
}
|
return true;
|
}
|
|
nvs_handle_t handle;
|
if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) == ESP_OK) {
|
char buf[APP_TASK_JSON_MAX];
|
size_t length = sizeof(buf);
|
if (nvs_get_str(handle, APP_TASK_NVS_LAST_REP, buf, &length) == ESP_OK) {
|
cJSON *root = cJSON_Parse(buf);
|
if (root) {
|
const cJSON *name_item = cJSON_GetObjectItem(root, "name");
|
const cJSON *repeat_item = cJSON_GetObjectItem(root, "repeat");
|
const cJSON *power_item = cJSON_GetObjectItem(root, "power_off_after");
|
if (cJSON_IsString(name_item) && name_item->valuestring && name_item->valuestring[0]) {
|
strlcpy(name, name_item->valuestring, name_size);
|
if (repeat && cJSON_IsNumber(repeat_item)) {
|
*repeat = repeat_item->valueint;
|
}
|
if (power_off_after) {
|
if (cJSON_IsBool(power_item)) {
|
*power_off_after = cJSON_IsTrue(power_item);
|
} else if (cJSON_IsNumber(power_item)) {
|
*power_off_after = power_item->valueint != 0;
|
}
|
}
|
}
|
cJSON_Delete(root);
|
}
|
}
|
nvs_close(handle);
|
}
|
if (name[0] != '\0' && !path_belongs_to_current_task(name)) {
|
ESP_LOGW(TAG, "discard last_rep from other task: %s", name);
|
name[0] = '\0';
|
}
|
|
if (name[0] == '\0') {
|
char prefix[96];
|
snprintf(prefix, sizeof(prefix), "/%s/%s/lessons", s_current_app, s_current_task);
|
bool prefer_full = strcmp(s_current_task, "repeater") == 0;
|
if (!first_sd_mp3_name(name, name_size, prefix, prefer_full)) {
|
if (is_chat_task(s_current_app, s_current_task)) {
|
first_sd_mp3_name(name, name_size, NULL, false);
|
}
|
}
|
}
|
if (speed) {
|
*speed = SDCard_Clamp_MP3_Speed(*speed);
|
}
|
return name[0] != '\0';
|
}
|
|
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)
|
{
|
cJSON *root = cJSON_CreateObject();
|
if (!root) {
|
return ESP_ERR_NO_MEM;
|
}
|
cJSON_AddStringToObject(root, "app_id", app_id && app_id[0] ? app_id : "chat");
|
cJSON_AddStringToObject(root, "task_id", task_id && task_id[0] ? task_id : "conversation");
|
cJSON_AddStringToObject(root, "source", source && source[0] ? source : "default_chat");
|
cJSON_AddStringToObject(root, "alarm_id", alarm_id ? alarm_id : "");
|
if (params_json && params_json[0]) {
|
cJSON *params = cJSON_Parse(params_json);
|
if (params) {
|
cJSON_AddItemToObject(root, "params", params);
|
} else {
|
cJSON_AddRawToObject(root, "params", params_json);
|
}
|
} else {
|
cJSON_AddItemToObject(root, "params", cJSON_CreateObject());
|
}
|
|
char *printed = cJSON_PrintUnformatted(root);
|
cJSON_Delete(root);
|
if (!printed) {
|
return ESP_ERR_NO_MEM;
|
}
|
|
nvs_handle_t handle;
|
esp_err_t err = nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle);
|
if (err == ESP_OK) {
|
err = nvs_set_str(handle, APP_TASK_NVS_KEY, printed);
|
if (err == ESP_OK) {
|
err = nvs_commit(handle);
|
}
|
nvs_close(handle);
|
}
|
free(printed);
|
return err;
|
}
|
|
static void preempt_current_skills(void)
|
{
|
SDCard_Stop_Play();
|
BoardMqtt_StopPlaylistPlayback();
|
}
|
|
static bool is_schedule_talk_task(const char *task_id)
|
{
|
return task_id && (strcmp(task_id, "conversation") == 0 ||
|
strcmp(task_id, "live_translate") == 0 ||
|
strcmp(task_id, "bilingual_chat") == 0);
|
}
|
|
static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle,
|
bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false)
|
{
|
std::string params = s_current_params[0] ? s_current_params : "{}";
|
if (sd_ready && params.size() >= 2 && params.back() == '}') {
|
params.pop_back();
|
if (params.size() > 1) {
|
params += ',';
|
}
|
params += "\"sd_ready\":true}";
|
}
|
std::string detect = "[app_task]";
|
detect += (app_id && app_id[0]) ? app_id : "chat";
|
detect += "|";
|
detect += (task_id && task_id[0]) ? task_id : "conversation";
|
detect += "|";
|
detect += (source && source[0]) ? source : "default_chat";
|
detect += "|";
|
detect += params;
|
const bool hold_for_phrase = sd_ready || wait_phrase;
|
if (hold_for_phrase) {
|
s_start_detect_text = detect;
|
s_start_detect_sent = false;
|
}
|
Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() {
|
auto& app = Application::GetInstance();
|
app.HoldIdleForServerPrompt();
|
if (hold_for_phrase) {
|
// 关机拉起时通道可能还没打开。连上后再上报,等「开始xx任务」说完再进入任务。
|
Board_TrySendLessonStartDetect();
|
return;
|
}
|
if (expect_prompt && app.GetDeviceState() == kDeviceStateSpeaking) {
|
app.AbortSpeaking(kAbortReasonNone);
|
}
|
if (app.SendListenDetectNow(detect)) {
|
if (expect_prompt) {
|
app.ArmPendingDetectRetry(detect, 5);
|
}
|
return;
|
}
|
app.SendListenDetect(detect, open_if_idle);
|
});
|
}
|
|
static const char *json_lesson_id(cJSON *params)
|
{
|
if (!params) {
|
return NULL;
|
}
|
const cJSON *id = cJSON_GetObjectItem(params, "lesson_id");
|
if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
|
id = cJSON_GetObjectItem(params, "lessonId");
|
}
|
if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
|
id = cJSON_GetObjectItem(params, "lesson");
|
}
|
return (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) ? id->valuestring : NULL;
|
}
|
|
static bool add_playlist_item(sdcard_mp3_playlist_item_t *items, int *n, int cap,
|
const char *business, float speed, int repeat)
|
{
|
if (!items || !n || *n >= cap || !business || business[0] == '\0') {
|
return false;
|
}
|
sdcard_mp3_playlist_item_t *item = &items[*n];
|
if (!SDCard_Resolve_MP3_Path(business, item->path, sizeof(item->path))) {
|
ESP_LOGW(TAG, "lesson clip missing on sd: %s", business);
|
return false;
|
}
|
item->speed = SDCard_Clamp_MP3_Speed(speed);
|
item->repeat = repeat < 1 ? 1 : repeat;
|
if (item->repeat > 10) {
|
item->repeat = 10;
|
}
|
item->power_off_after = false;
|
(*n)++;
|
return true;
|
}
|
|
static int build_repeater_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
|
{
|
int level = 0;
|
int story = 0;
|
const char *lesson = json_lesson_id(params);
|
if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
|
return 0;
|
}
|
int cn_repeat = 1;
|
float cn_speed = 1.0f;
|
const cJSON *cn_r = cJSON_GetObjectItem(params, "cn_repeat");
|
if (cJSON_IsNumber(cn_r)) {
|
cn_repeat = cn_r->valueint;
|
}
|
const cJSON *cn_s = cJSON_GetObjectItem(params, "cn_speed");
|
if (cJSON_IsNumber(cn_s)) {
|
cn_speed = static_cast<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,
|
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<float>(speed_item->valuedouble));
|
}
|
if (cJSON_IsNumber(repeat_item)) {
|
repeat = repeat_item->valueint;
|
}
|
if (cJSON_IsBool(power_item)) {
|
power_off_after = cJSON_IsTrue(power_item);
|
} else if (cJSON_IsNumber(power_item)) {
|
power_off_after = power_item->valueint != 0;
|
}
|
cJSON_Delete(params);
|
}
|
}
|
const bool server_first = Board::GetInstance().IsNetworkReady() && source &&
|
(strcmp(source, "voice") == 0 || strcmp(source, "mqtt_immediate") == 0 ||
|
strcmp(source, "schedule") == 0);
|
if (server_first) {
|
if (name[0]) {
|
Board_RememberRepeaterPlayback(name, speed, repeat < 1 ? 1 : repeat, power_off_after);
|
}
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
|
ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
if (repeat < 1) {
|
repeat = 1;
|
}
|
speed = SDCard_Clamp_MP3_Speed(speed);
|
Board_SetFinishTaskAfterMp3(true);
|
esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
|
if (err != ESP_OK) {
|
ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
|
Board_OnSkillPlaybackFinished(true, false, 0);
|
return;
|
}
|
Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
|
notify_server(app_id, task_id, source, false);
|
return;
|
}
|
if (strcmp(skill, "translate") == 0) {
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
ESP_LOGW(TAG, "unsupported skill: %s", skill);
|
}
|
|
esp_err_t Board_StartAppTask(const char *app_id, const char *task_id, const char *params_json, const char *source)
|
{
|
if (!app_id || app_id[0] == '\0') {
|
return ESP_ERR_INVALID_ARG;
|
}
|
const char *src = (source && source[0]) ? source : "mqtt_immediate";
|
char resolved_tid[APP_TASK_ID_MAX];
|
copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation");
|
const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid);
|
if (spec) {
|
copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid);
|
} else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) {
|
ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid);
|
return ESP_ERR_INVALID_ARG;
|
} else {
|
ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid);
|
}
|
const char *tid = resolved_tid;
|
|
int new_priority = Board_AppTask_SourcePriority(src);
|
if (strcmp(src, "mqtt_immediate") != 0 && new_priority < s_current_priority) {
|
ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
|
app_id, tid, src, new_priority, s_current_priority);
|
return ESP_ERR_INVALID_STATE;
|
}
|
|
if (s_wait_start_phrase && strcmp(src, "schedule") == 0 &&
|
strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) {
|
ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid);
|
return ESP_OK;
|
}
|
|
if (strcmp(src, "schedule") == 0 &&
|
(strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) {
|
const char *raw_params = (params_json && params_json[0]) ? params_json : "{}";
|
if (!lesson_files_ready(tid, raw_params)) {
|
ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid);
|
show_lesson_sync_alert();
|
return ESP_ERR_NOT_FOUND;
|
}
|
}
|
|
if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
|
strcmp(app_id, "chat") == 0 && strcmp(src, "voice") == 0) {
|
if (SDCard_Is_Playing()) {
|
ESP_LOGI(TAG, "stop leftover MP3 while already in chat");
|
preempt_current_skills();
|
}
|
// 跟读由 MCP 起播时本地仍可能是 chat。「退出跟读」必须通知 Server 取消听窗。
|
// 不要在这里 ResumeListening:会 ResetDecoder、提前开 AFE,欢迎词/结束播报会被清掉。
|
notify_server("chat", "conversation", "voice", true);
|
return ESP_OK;
|
}
|
|
preempt_current_skills();
|
copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
|
copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
|
copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
|
|
char loaded_params[APP_TASK_JSON_MAX];
|
loaded_params[0] = '\0';
|
const char *raw = (params_json && params_json[0]) ? params_json : "{}";
|
const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0;
|
if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) {
|
if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) {
|
raw = loaded_params;
|
ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid);
|
}
|
}
|
if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) {
|
Board_RememberLastLessonTask(tid, s_current_params);
|
} else {
|
strlcpy(s_current_params, raw, sizeof(s_current_params));
|
if (lesson_task) {
|
Board_RememberLastLessonTask(tid, s_current_params);
|
}
|
}
|
const char *effective = s_current_params;
|
s_current_priority = new_priority;
|
s_wait_start_phrase = false;
|
s_start_phrase_tts_begun = false;
|
s_listen_after_phrase = false;
|
Board_SavePendingTask(app_id, tid, effective, src, NULL);
|
|
ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
|
if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) {
|
s_wait_start_phrase = true;
|
s_start_phrase_tts_begun = false;
|
s_start_detect_sent = false;
|
s_listen_after_phrase = true;
|
s_wait_start_us = esp_timer_get_time();
|
Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
|
notify_server(app_id, tid, src, true, true, false, true);
|
return ESP_OK;
|
}
|
if (lesson_task && !lesson_files_ready(tid, effective)) {
|
ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid);
|
show_lesson_sync_alert();
|
} else if (lesson_task && lesson_files_ready(tid, effective)) {
|
s_wait_start_phrase = true;
|
s_start_phrase_tts_begun = false;
|
s_start_detect_sent = false;
|
s_wait_start_us = esp_timer_get_time();
|
Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
|
notify_server(app_id, tid, src, false, true, true);
|
return ESP_OK;
|
}
|
Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
|
if (!spec || !spec->skills[0]) {
|
dispatch_skill(NULL, app_id, tid, effective, src);
|
return ESP_OK;
|
}
|
for (int i = 0; i < 4 && spec->skills[i]; ++i) {
|
dispatch_skill(spec->skills[i], app_id, tid, effective, src);
|
}
|
return ESP_OK;
|
}
|
|
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms)
|
{
|
(void)power_off_requested;
|
// MP3 任务栈在 PSRAM,不能在这里 nvs_set_str / 改状态机。
|
Application::GetInstance().Schedule([stopped, duration_ms]() {
|
ESP_LOGI(TAG, "skill playback %s duration_ms=%u task=%s/%s",
|
stopped ? "stopped" : "finished", duration_ms, s_current_app, s_current_task);
|
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
copy_id(app_id, sizeof(app_id), s_current_app, "chat");
|
copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
|
const bool shadowing = strcmp(task_id, "shadowing") == 0;
|
const bool repeater = strcmp(task_id, "repeater") == 0;
|
// WS 在线时清单结束由 Server 切回 chat。WS 已断则下面本地兜底,避免一直停在泛听。
|
const bool end_task = !shadowing && !repeater &&
|
task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
|
|
if (end_task) {
|
copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
|
copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
|
copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
|
s_current_priority = 0;
|
Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
|
}
|
|
char detect[208];
|
snprintf(detect, sizeof(detect), "[skill_event]audio.%s|%s|%s|%u",
|
stopped ? "stopped" : "completed", app_id, task_id, duration_ms);
|
const bool open_mic = !stopped && shadowing;
|
// 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。
|
if (!Application::GetInstance().SendListenDetectNow(detect)) {
|
// WS 已断:释放 AFE 后重连,把 completed 补发给 Server 播结束语。
|
if (repeater && !stopped) {
|
return_to_chat_locally("repeater completed not sent", detect);
|
Board_Schedule_OnNaturalEnd();
|
return;
|
}
|
Application::GetInstance().SendListenDetect(detect, false);
|
}
|
if (open_mic) {
|
Application::GetInstance().GetAudioService().ResetDecoder();
|
Application::GetInstance().ResumeListeningAfterMp3Clip();
|
} else if (repeater && !stopped) {
|
// 与跟读清单结束相同:关 AFE/唤醒,等 Server 播「泛听结束」再回对话。
|
Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
|
Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
|
Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
|
Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
|
Board_Schedule_OnNaturalEnd();
|
} else if (repeater && stopped) {
|
Board_Schedule_OnUserExit();
|
} else if (!repeater) {
|
Application::GetInstance().GetAudioService().ResetDecoder();
|
}
|
});
|
}
|
|
void Board_OnShadowingClipFinished(unsigned duration_ms)
|
{
|
Application::GetInstance().Schedule([duration_ms]() {
|
ESP_LOGI(TAG, "shadowing clip finished duration_ms=%u task=%s/%s",
|
duration_ms, s_current_app, s_current_task);
|
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
copy_id(app_id, sizeof(app_id), s_current_app, "chat");
|
copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
|
|
char detect[208];
|
snprintf(detect, sizeof(detect), "[skill_event]audio.completed|%s|%s|%u",
|
app_id, task_id, duration_ms);
|
if (!Application::GetInstance().SendListenDetectNow(detect)) {
|
Application::GetInstance().SendListenDetect(detect, false);
|
}
|
Application::GetInstance().ResumeListeningAfterMp3Clip();
|
});
|
}
|
|
void Board_OnShadowingPlaylistFinished(void)
|
{
|
Application::GetInstance().Schedule([]() {
|
ESP_LOGI(TAG, "shadowing playlist finished task=%s/%s", s_current_app, s_current_task);
|
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
copy_id(app_id, sizeof(app_id), s_current_app, "chat");
|
copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
|
|
char detect[208];
|
snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
|
app_id, task_id);
|
if (!Application::GetInstance().SendListenDetectNow(detect)) {
|
return_to_chat_locally("playlist_completed not sent", detect);
|
Board_Schedule_OnNaturalEnd();
|
return;
|
}
|
Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
|
Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
|
Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
|
Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
|
Board_Schedule_OnNaturalEnd();
|
});
|
}
|
|
void Board_ResolveBootOrRestore(bool from_alarm_wake)
|
{
|
if (!from_alarm_wake) {
|
Board_Schedule_Reload();
|
Board_Schedule_Recompute();
|
}
|
const int window = 180;
|
if (Board_Schedule_IsDueNow(window)) {
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
char params[APP_TASK_JSON_MAX] = "{}";
|
if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
|
params, sizeof(params))) {
|
ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d",
|
app_id, task_id, from_alarm_wake ? 1 : 0);
|
Board_StartAppTask(app_id, task_id, params, "schedule");
|
return;
|
}
|
}
|
|
Board_StartAppTask("chat", "conversation", "{}", "default_chat");
|
}
|
|
void Board_EnterVoiceEntry(void)
|
{
|
if (s_current_priority >= 2) {
|
ESP_LOGI(TAG, "ignore voice_entry, current priority=%d", s_current_priority);
|
return;
|
}
|
if (!Board::GetInstance().IsNetworkReady()) {
|
ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 泛听 to play locally");
|
return;
|
}
|
Application::GetInstance().SendListenDetect("[voice_entry]", true);
|
}
|
|
bool Board_Handle_Voice_Command(const char *command)
|
{
|
if (!command || command[0] == '\0') {
|
return false;
|
}
|
const bool speaking = Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking;
|
if (Board_Is_Mode_Switch_Command(command)) {
|
Board_EnterVoiceEntry();
|
return true;
|
}
|
if (Board_Is_Chat_Return_Command(command)) {
|
s_current_priority = 0;
|
auto &app = Application::GetInstance();
|
if (is_chat_task(s_current_app, s_current_task) && !app.IsAudioChannelConnected()) {
|
// 课文结束已本地回 chat。通道已断时再去 handshake 会卡「连接中」并关掉唤醒。
|
app.Schedule([]() {
|
auto &inner = Application::GetInstance();
|
inner.AnnounceReturnedToChat();
|
inner.GetAudioService().EnableWakeWordDetection(true);
|
if (inner.GetDeviceState() == kDeviceStateConnecting) {
|
inner.SetDeviceState(kDeviceStateIdle);
|
}
|
});
|
return true;
|
}
|
Board_StartAppTask("chat", "conversation", "{}", "voice");
|
return true;
|
}
|
if (speaking) {
|
if (Board_Is_App_Launch_Command(command)) {
|
ESP_LOGI(TAG, "ignore app launch while TTS is speaking: %s", command);
|
return true;
|
}
|
return false;
|
}
|
if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
|
strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
|
strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "打开泛听")) {
|
if (s_current_priority >= 2) {
|
return true;
|
}
|
Board_StartAppTask("english_learning", "repeater", "{}", "voice");
|
return true;
|
}
|
return false;
|
}
|
|
static bool layout_id_is_safe(const char *id)
|
{
|
if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
|
return false;
|
}
|
for (const char *p = id; *p; ++p) {
|
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
|
*p == '_' || *p == '-')) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id)
|
{
|
if (!layout_id_is_safe(app_id)) {
|
return ESP_ERR_INVALID_ARG;
|
}
|
char path[SDCARD_PATH_MAX_LEN];
|
int written;
|
if (task_id && task_id[0]) {
|
if (!layout_id_is_safe(task_id)) {
|
return ESP_ERR_INVALID_ARG;
|
}
|
written = snprintf(path, sizeof(path), "/%s/%s/lessons", app_id, task_id);
|
} else {
|
written = snprintf(path, sizeof(path), "/%s", app_id);
|
}
|
if (written <= 0 || (size_t)written >= sizeof(path)) {
|
return ESP_ERR_INVALID_SIZE;
|
}
|
return SDCard_Ensure_Business_Dir(path);
|
}
|