#include "app_task.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>
|
|
#define TAG "app_task"
|
#define APP_TASK_NVS_NS "app_task"
|
#define APP_TASK_NVS_KEY "pending"
|
#define APP_TASK_NVS_LAST_REP "last_rep"
|
#define APP_TASK_JSON_MAX 768
|
#define APP_TASK_ID_MAX 32
|
#define APP_TASK_SOURCE_MAX 24
|
|
static char s_current_app[APP_TASK_ID_MAX] = "chat";
|
static char s_current_task[APP_TASK_ID_MAX] = "conversation";
|
static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
|
static int s_current_priority = 0;
|
static bool s_finish_task_after_mp3 = true;
|
static float s_shadowing_listen_ratio = 1.2f;
|
|
typedef struct {
|
const char *app_id;
|
const char *task_id;
|
const char *skills[4];
|
} app_task_skill_map_t;
|
|
/* Configuration table: channels only pass app_id/task_id; skills are looked up here. */
|
static const app_task_skill_map_t k_task_skills[] = {
|
{"chat", "conversation", {NULL, NULL, NULL, NULL}},
|
{"english_learning", "repeater", {"self.mp3.play", NULL, NULL, NULL}},
|
{"english_learning", "shadowing", {NULL, NULL, NULL, NULL}},
|
{"english_translation", "live_translate", {"translate", NULL, NULL, NULL}},
|
{"english_conversation", "bilingual_chat", {NULL, NULL, NULL, NULL}},
|
};
|
|
int Board_AppTask_SourcePriority(const char *source)
|
{
|
if (!source) {
|
return 1;
|
}
|
if (strcmp(source, "mqtt_immediate") == 0) {
|
return 3;
|
}
|
if (strcmp(source, "schedule") == 0) {
|
return 2;
|
}
|
if (strcmp(source, "voice") == 0) {
|
return 1;
|
}
|
return 0;
|
}
|
|
int Board_AppTask_CurrentPriority(void)
|
{
|
return s_current_priority;
|
}
|
|
static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
|
{
|
if (!app_id) {
|
return NULL;
|
}
|
const char *tid = (task_id && task_id[0]) ? task_id : NULL;
|
for (size_t i = 0; i < sizeof(k_task_skills) / sizeof(k_task_skills[0]); ++i) {
|
if (strcmp(k_task_skills[i].app_id, app_id) != 0) {
|
continue;
|
}
|
if (!tid || strcmp(k_task_skills[i].task_id, tid) == 0) {
|
return &k_task_skills[i];
|
}
|
}
|
return NULL;
|
}
|
|
static bool is_chat_task(const char *app_id, const char *task_id)
|
{
|
return app_id && strcmp(app_id, "chat") == 0 &&
|
(!task_id || task_id[0] == '\0' || strcmp(task_id, "conversation") == 0);
|
}
|
|
bool Board_IsShadowingTask(void)
|
{
|
return strcmp(s_current_task, "shadowing") == 0;
|
}
|
|
bool Board_IsRepeaterTask(void)
|
{
|
return strcmp(s_current_task, "repeater") == 0;
|
}
|
|
void Board_SetShadowingListenRatio(float ratio)
|
{
|
if (ratio < 1.2f) {
|
ratio = 1.2f;
|
}
|
if (ratio > 1.5f) {
|
ratio = 1.5f;
|
}
|
s_shadowing_listen_ratio = ratio;
|
}
|
|
unsigned Board_ShadowingListenWindowMs(unsigned duration_ms)
|
{
|
if (duration_ms == 0) {
|
return 800;
|
}
|
unsigned wait_ms = static_cast<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';
|
}
|
|
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
|
const char *source, const char *alarm_id)
|
{
|
cJSON *root = cJSON_CreateObject();
|
if (!root) {
|
return ESP_ERR_NO_MEM;
|
}
|
cJSON_AddStringToObject(root, "app_id", app_id && app_id[0] ? app_id : "chat");
|
cJSON_AddStringToObject(root, "task_id", task_id && task_id[0] ? task_id : "conversation");
|
cJSON_AddStringToObject(root, "source", source && source[0] ? source : "default_chat");
|
cJSON_AddStringToObject(root, "alarm_id", alarm_id ? alarm_id : "");
|
if (params_json && params_json[0]) {
|
cJSON *params = cJSON_Parse(params_json);
|
if (params) {
|
cJSON_AddItemToObject(root, "params", params);
|
} else {
|
cJSON_AddRawToObject(root, "params", params_json);
|
}
|
} else {
|
cJSON_AddItemToObject(root, "params", cJSON_CreateObject());
|
}
|
|
char *printed = cJSON_PrintUnformatted(root);
|
cJSON_Delete(root);
|
if (!printed) {
|
return ESP_ERR_NO_MEM;
|
}
|
|
nvs_handle_t handle;
|
esp_err_t err = nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle);
|
if (err == ESP_OK) {
|
err = nvs_set_str(handle, APP_TASK_NVS_KEY, printed);
|
if (err == ESP_OK) {
|
err = nvs_commit(handle);
|
}
|
nvs_close(handle);
|
}
|
free(printed);
|
return err;
|
}
|
|
static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size,
|
char *source, size_t source_size, char *params_json, size_t params_size)
|
{
|
nvs_handle_t handle;
|
if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
|
return false;
|
}
|
char buf[APP_TASK_JSON_MAX];
|
size_t length = sizeof(buf);
|
esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length);
|
nvs_close(handle);
|
if (err != ESP_OK) {
|
return false;
|
}
|
|
cJSON *root = cJSON_Parse(buf);
|
if (!root) {
|
return false;
|
}
|
const cJSON *app = cJSON_GetObjectItem(root, "app_id");
|
const cJSON *task = cJSON_GetObjectItem(root, "task_id");
|
const cJSON *src = cJSON_GetObjectItem(root, "source");
|
const cJSON *params = cJSON_GetObjectItem(root, "params");
|
copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat");
|
copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation");
|
copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat");
|
if (params_json && params_size > 0) {
|
params_json[0] = '\0';
|
if (params) {
|
char *printed = cJSON_PrintUnformatted(params);
|
if (printed) {
|
strlcpy(params_json, printed, params_size);
|
free(printed);
|
}
|
}
|
}
|
cJSON_Delete(root);
|
return app_id[0] != '\0';
|
}
|
|
static void preempt_current_skills(void)
|
{
|
SDCard_Stop_Play();
|
BoardMqtt_StopPlaylistPlayback();
|
Application::GetInstance().Schedule([]() {
|
auto state = Application::GetInstance().GetDeviceState();
|
if (state == kDeviceStateSpeaking) {
|
Application::GetInstance().AbortSpeaking(kAbortReasonNone);
|
}
|
});
|
}
|
|
static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle)
|
{
|
char detect[160];
|
snprintf(detect, sizeof(detect), "[app_task]%s|%s|%s",
|
app_id && app_id[0] ? app_id : "chat",
|
task_id && task_id[0] ? task_id : "conversation",
|
source && source[0] ? source : "default_chat");
|
Application::GetInstance().SendListenDetect(detect, open_if_idle);
|
}
|
|
static void dispatch_skill(const char *skill, const char *app_id, const char *task_id,
|
const char *params_json, const char *source)
|
{
|
if (!skill || skill[0] == '\0') {
|
// 上报 [app_task]。SendListenDetect 会开通道但保持 idle,不开 listening/AFE。
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
if (strcmp(skill, "self.mp3.play") == 0) {
|
char name[SDCARD_PATH_MAX_LEN] = {0};
|
float speed = 1.0f;
|
int repeat = 1;
|
bool power_off_after = false;
|
if (params_json && params_json[0]) {
|
cJSON *params = cJSON_Parse(params_json);
|
if (params) {
|
const cJSON *name_item = cJSON_GetObjectItem(params, "name");
|
const cJSON *speed_item = cJSON_GetObjectItem(params, "speed");
|
const cJSON *repeat_item = cJSON_GetObjectItem(params, "repeat");
|
const cJSON *power_item = cJSON_GetObjectItem(params, "power_off_after");
|
if (cJSON_IsString(name_item) && name_item->valuestring) {
|
strlcpy(name, name_item->valuestring, sizeof(name));
|
}
|
if (cJSON_IsNumber(speed_item)) {
|
speed = SDCard_Clamp_MP3_Speed(static_cast<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);
|
}
|
}
|
if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
|
ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
if (repeat < 1) {
|
repeat = 1;
|
}
|
speed = SDCard_Clamp_MP3_Speed(speed);
|
Board_SetFinishTaskAfterMp3(true);
|
// 有网的语音入口:先让 Server 播报进场词,音频由 MCP 在 TTS 结束后再播。
|
if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) {
|
Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
|
if (err != ESP_OK) {
|
ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
|
Board_OnSkillPlaybackFinished(true, false, 0);
|
return;
|
}
|
Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
|
notify_server(app_id, task_id, source, false);
|
return;
|
}
|
if (strcmp(skill, "translate") == 0) {
|
notify_server(app_id, task_id, source, true);
|
return;
|
}
|
ESP_LOGW(TAG, "unsupported skill: %s", skill);
|
}
|
|
esp_err_t Board_StartAppTask(const char *app_id, const char *task_id, const char *params_json, const char *source)
|
{
|
if (!app_id || app_id[0] == '\0') {
|
return ESP_ERR_INVALID_ARG;
|
}
|
const char *src = (source && source[0]) ? source : "mqtt_immediate";
|
const char *tid = (task_id && task_id[0]) ? task_id : NULL;
|
const app_task_skill_map_t *spec = find_task_spec(app_id, tid);
|
if (!spec) {
|
ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : "");
|
return ESP_ERR_NOT_FOUND;
|
}
|
tid = spec->task_id;
|
|
int new_priority = Board_AppTask_SourcePriority(src);
|
if (new_priority < s_current_priority) {
|
ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
|
app_id, tid, src, new_priority, s_current_priority);
|
return ESP_ERR_INVALID_STATE;
|
}
|
|
if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
|
strcmp(app_id, "chat") == 0 && strcmp(src, "voice") == 0) {
|
if (SDCard_Is_Playing()) {
|
ESP_LOGI(TAG, "stop leftover MP3 while already in chat");
|
preempt_current_skills();
|
}
|
// 跟读由 MCP 起播时本地仍可能是 chat。「退出跟读」必须通知 Server 取消听窗。
|
// 不要在这里 ResumeListening:会 ResetDecoder、提前开 AFE,欢迎词/结束播报会被清掉。
|
notify_server("chat", "conversation", "voice", true);
|
return ESP_OK;
|
}
|
|
preempt_current_skills();
|
copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
|
copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
|
copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
|
s_current_priority = new_priority;
|
Board_SavePendingTask(app_id, tid, params_json, src, NULL);
|
|
ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
|
if (!spec->skills[0]) {
|
dispatch_skill(NULL, app_id, tid, params_json, src);
|
return ESP_OK;
|
}
|
for (int i = 0; i < 4 && spec->skills[i]; ++i) {
|
dispatch_skill(spec->skills[i], app_id, tid, params_json, src);
|
}
|
return ESP_OK;
|
}
|
|
void Board_OnSkillPlaybackFinished(bool stopped, bool power_off_requested, unsigned duration_ms)
|
{
|
(void)power_off_requested;
|
// MP3 任务栈在 PSRAM,不能在这里 nvs_set_str / 改状态机。
|
Application::GetInstance().Schedule([stopped, duration_ms]() {
|
ESP_LOGI(TAG, "skill playback %s duration_ms=%u task=%s/%s",
|
stopped ? "stopped" : "finished", duration_ms, s_current_app, s_current_task);
|
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
copy_id(app_id, sizeof(app_id), s_current_app, "chat");
|
copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
|
const bool shadowing = strcmp(task_id, "shadowing") == 0;
|
const bool repeater = strcmp(task_id, "repeater") == 0;
|
// 复读清单是否结束由 Server 决定。设备不要在此写 NVS 切回 chat。
|
const bool end_task = !shadowing && !repeater &&
|
task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
|
|
if (end_task) {
|
copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
|
copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
|
copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
|
s_current_priority = 0;
|
Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
|
}
|
|
char detect[208];
|
snprintf(detect, sizeof(detect), "[skill_event]audio.%s|%s|%s|%u",
|
stopped ? "stopped" : "completed", app_id, task_id, duration_ms);
|
const bool open_mic = !stopped && shadowing;
|
// 听窗从课文一停就开始。不要等开麦/AFE 初始化后再上报 completed。
|
if (!Application::GetInstance().SendListenDetectNow(detect)) {
|
Application::GetInstance().SendListenDetect(detect, false);
|
}
|
if (open_mic) {
|
Application::GetInstance().GetAudioService().ResetDecoder();
|
Application::GetInstance().ResumeListeningAfterMp3Clip();
|
} else if (repeater && !stopped) {
|
// 清单已播完,马上要播「复读结束」:保持功放,不要再 ResetDecoder。
|
Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
|
Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
|
} else if (!repeater) {
|
Application::GetInstance().GetAudioService().ResetDecoder();
|
}
|
});
|
}
|
|
void Board_OnShadowingClipFinished(unsigned duration_ms)
|
{
|
Application::GetInstance().Schedule([duration_ms]() {
|
ESP_LOGI(TAG, "shadowing clip finished duration_ms=%u task=%s/%s",
|
duration_ms, s_current_app, s_current_task);
|
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
copy_id(app_id, sizeof(app_id), s_current_app, "chat");
|
copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
|
|
char detect[208];
|
snprintf(detect, sizeof(detect), "[skill_event]audio.completed|%s|%s|%u",
|
app_id, task_id, duration_ms);
|
if (!Application::GetInstance().SendListenDetectNow(detect)) {
|
Application::GetInstance().SendListenDetect(detect, false);
|
}
|
Application::GetInstance().ResumeListeningAfterMp3Clip();
|
});
|
}
|
|
void Board_OnShadowingPlaylistFinished(void)
|
{
|
Application::GetInstance().Schedule([]() {
|
ESP_LOGI(TAG, "shadowing playlist finished task=%s/%s", s_current_app, s_current_task);
|
|
char app_id[APP_TASK_ID_MAX];
|
char task_id[APP_TASK_ID_MAX];
|
copy_id(app_id, sizeof(app_id), s_current_app, "chat");
|
copy_id(task_id, sizeof(task_id), s_current_task, "shadowing");
|
|
char detect[208];
|
snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
|
app_id, task_id);
|
if (!Application::GetInstance().SendListenDetectNow(detect)) {
|
ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally");
|
copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
|
copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
|
copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
|
s_current_priority = 0;
|
Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
|
auto &app = Application::GetInstance();
|
app.GetAudioService().SetAudioPowerInhibit(false);
|
app.GetAudioService().EnableVoiceProcessing(false);
|
app.GetAudioService().EnableWakeWordDetection(true);
|
if (app.GetDeviceState() == kDeviceStateListening ||
|
app.GetDeviceState() == kDeviceStateConnecting) {
|
app.SetDeviceState(kDeviceStateIdle);
|
}
|
return;
|
}
|
Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
|
Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
|
});
|
}
|
|
void Board_ResolveBootOrRestore(bool from_alarm_wake)
|
{
|
char app_id[APP_TASK_ID_MAX] = "chat";
|
char task_id[APP_TASK_ID_MAX] = "conversation";
|
char source[APP_TASK_SOURCE_MAX] = "default_chat";
|
char params[APP_TASK_JSON_MAX] = "{}";
|
bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id),
|
source, sizeof(source), params, sizeof(params));
|
if (!has_pending) {
|
Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
|
Board_StartAppTask("chat", "conversation", "{}", "default_chat");
|
return;
|
}
|
|
const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0;
|
const bool offline = !Board::GetInstance().IsNetworkReady();
|
if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) {
|
ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d",
|
app_id, task_id, source, offline ? 1 : 0);
|
Board_StartAppTask(app_id, task_id, params, source);
|
return;
|
}
|
|
Board_StartAppTask("chat", "conversation", "{}", "default_chat");
|
}
|
|
void Board_EnterVoiceEntry(void)
|
{
|
if (s_current_priority >= 2) {
|
ESP_LOGI(TAG, "ignore voice_entry, current priority=%d", s_current_priority);
|
return;
|
}
|
if (!Board::GetInstance().IsNetworkReady()) {
|
ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 复读机 to play locally");
|
return;
|
}
|
Application::GetInstance().SendListenDetect("[voice_entry]", true);
|
}
|
|
bool Board_Handle_Voice_Command(const char *command)
|
{
|
if (!command || command[0] == '\0') {
|
return false;
|
}
|
const bool speaking = Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking;
|
if (Board_Is_Mode_Switch_Command(command)) {
|
Board_EnterVoiceEntry();
|
return true;
|
}
|
if (Board_Is_Chat_Return_Command(command)) {
|
s_current_priority = 0;
|
Board_StartAppTask("chat", "conversation", "{}", "voice");
|
return true;
|
}
|
if (speaking) {
|
if (Board_Is_App_Launch_Command(command)) {
|
ESP_LOGI(TAG, "ignore app launch while TTS is speaking: %s", command);
|
return true;
|
}
|
return false;
|
}
|
if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
|
strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
|
strstr(command, "开始复读") || strstr(command, "复读机")) {
|
if (s_current_priority >= 2) {
|
return true;
|
}
|
Board_StartAppTask("english_learning", "repeater", "{}", "voice");
|
return true;
|
}
|
return false;
|
}
|
|
static bool layout_id_is_safe(const char *id)
|
{
|
if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
|
return false;
|
}
|
for (const char *p = id; *p; ++p) {
|
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
|
*p == '_' || *p == '-')) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
esp_err_t Board_EnsureAppLayout(const char *app_id, const char *task_id)
|
{
|
if (!layout_id_is_safe(app_id)) {
|
return ESP_ERR_INVALID_ARG;
|
}
|
char path[SDCARD_PATH_MAX_LEN];
|
int written;
|
if (task_id && task_id[0]) {
|
if (!layout_id_is_safe(task_id)) {
|
return ESP_ERR_INVALID_ARG;
|
}
|
written = snprintf(path, sizeof(path), "/%s/%s/lessons", app_id, task_id);
|
} else {
|
written = snprintf(path, sizeof(path), "/%s", app_id);
|
}
|
if (written <= 0 || (size_t)written >= sizeof(path)) {
|
return ESP_ERR_INVALID_SIZE;
|
}
|
return SDCard_Ensure_Business_Dir(path);
|
}
|