diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index c49e8ec..005c3aa 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -12,6 +12,12 @@ config DEVICE_STATUS_REPORT_URL help URL for reporting device status to server +config STORY_API_URL + string "Story API URL" + default "http://192.168.124.8:8000/api/v1/devices/stories/" + help + 故事播放API接口地址,设备会附加 ?mac_address=XX:XX:XX:XX:XX:XX 参数请求 + choice prompt "语言选择" default LANGUAGE_ZH_CN diff --git a/main/application.cc b/main/application.cc index 31b11cd..9d522d6 100644 --- a/main/application.cc +++ b/main/application.cc @@ -28,6 +28,8 @@ #include #include #include +#include +#include #include #define TAG "Application" @@ -737,12 +739,23 @@ void Application::Start() { Alert(Lang::Strings::ERROR, message.c_str(), "sad", Lang::Sounds::P3_EXCLAMATION); } }); + // 收到非字幕的Bot下行消息(ctrl/conv/tool/info等)时中止HTTPS播放 + // subv字幕消息在协议层跳过此回调,由subtitle handler处理(可区分USER/AI) + protocol_->OnBotMessage([this]() { + if (https_playback_active_.load() && !https_playback_abort_.load()) { + AbortHttpsPlayback("收到Bot响应消息"); + } + }); protocol_->OnIncomingAudio([this](std::vector&& data) { + // HTTPS播放中(含HTTP请求阶段)静默丢弃RTC PCM包 + if (https_playback_active_.load() || opus_playback_active_.load()) { + return; + } + if (websocket_protocol_ && websocket_protocol_->IsAudioChannelOpened()) { aborted_ = true; { std::lock_guard lock(mutex_);// 🔒 保护音频队列操作 - // 如果音频队列不为空 if (!audio_decode_queue_.empty()) { ESP_LOGI(TAG, "清空音频队列,大小=%zu", audio_decode_queue_.size()); audio_decode_queue_.clear();// 清空音频队列 @@ -750,7 +763,7 @@ void Application::Start() { } ResetDecoder(); ws_downlink_enabled_.store(false); - ws_playback_active_.store(false); + opus_playback_active_.store(false); websocket_protocol_->CloseAudioChannel();// 关闭WebSocket通道 Schedule([this]() { vTaskDelay(pdMS_TO_TICKS(120)); @@ -1250,16 +1263,18 @@ void Application::Start() { if (!text || !cJSON_IsString(text) || !text->valuestring[0]) continue; bool is_final = definite && cJSON_IsTrue(definite); - bool is_user = false; + // userId 以 "bot_" 开头为AI,其余为用户 + bool is_user = true; if (user_id && cJSON_IsString(user_id)) { - is_user = (strcmp(user_id->valuestring, CONFIG_VOLC_DEVICE_NAME) == 0); + if (strncmp(user_id->valuestring, "bot_", 4) == 0) { + is_user = false; + } } std::string msg = text->valuestring; std::string emotion_str; // 提取并剥离字幕开头的情绪标签(如 "(平静)今天...") - // UTF-8 中文全角括号:(= E2 80 98? 不对,(= \xef\xbc\x88,)= \xef\xbc\x89 // 实际 UTF-8:(= 0xEF 0xBC 0x88,)= 0xEF 0xBC 0x89 if (!is_user && msg.size() >= 6) { const char* p = msg.c_str(); @@ -1313,8 +1328,14 @@ void Application::Start() { } } - const char* role = is_user ? "user" : "assistant"; - ESP_LOGI(TAG, "%s %s: %s", is_final ? ">>" : "..", role, msg.c_str()); + const char* role = is_user ? "USER" : "AI"; + ESP_LOGI(TAG, "%s %s: %s", is_final ? "📝" : "..", role, msg.c_str()); + + // 用户说话时立即中止HTTPS音频播放 + // subv字幕消息在协议层跳过了on_bot_message_,由此处直接处理 + if (is_user && https_playback_active_.load() && !https_playback_abort_.load()) { + AbortHttpsPlayback("检测到用户说话(字幕)"); + } Schedule([this, display, msg, role_str = std::string(role)]() { display->SetChatMessage(role_str.c_str(), msg.c_str()); }); @@ -1999,16 +2020,23 @@ void Application::OnAudioOutput() { auto opus = std::move(audio_decode_queue_.front()); audio_decode_queue_.pop_front(); + // 在出队时捕获opus解码标志,避免background_task异步执行时标志已变化 + // 导致残留的Opus帧被当作PCM播放(产生杂音) + bool is_opus_frame = opus_playback_active_.load(); lock.unlock(); - background_task_->Schedule([this, codec, opus = std::move(opus)]() mutable { + background_task_->Schedule([this, codec, opus = std::move(opus), is_opus_frame]() mutable { if (aborted_) { return; } + // 跳过已中止的HTTPS opus残留帧:出队时is_opus_frame=true,但中止后opus_playback_active_=false + if (is_opus_frame && !opus_playback_active_.load()) { + return; + } std::vector pcm; bool decoded = false; - bool treat_as_pcm = (protocol_ && protocol_->downlink_is_pcm() && !ws_playback_active_.load()); + bool treat_as_pcm = (protocol_ && protocol_->downlink_is_pcm() && !is_opus_frame); if (!treat_as_pcm) { decoded = opus_decoder_->Decode(std::move(opus), pcm); } @@ -2331,6 +2359,12 @@ void Application::AbortSpeaking(AbortReason reason) { ESP_LOGI(TAG, "🔴 Abort speaking - immediate stop"); aborted_ = true; + // 中止HTTPS音频播放(如果正在进行) + if (https_playback_active_.load()) { + https_playback_abort_.store(true); + ESP_LOGI(TAG, "🔴 HTTPS音频播放中止信号已发送"); + } + // 🔧 更新安全操作时间戳 last_safe_operation_.store(std::chrono::steady_clock::now()); @@ -2352,6 +2386,20 @@ void Application::AbortSpeaking(AbortReason reason) { // ⚠️ 移除WaitForCompletion避免死锁,让后台任务通过aborted_标志自然结束 ESP_LOGI(TAG, "🔴 Audio queue cleared, background tasks will stop on next iteration"); + // 重启codec输出以清空I2S DMA缓冲区中残留音频,确保扬声器立即静音 + if (background_task_) { + background_task_->Schedule([this]() { + auto codec = Board::GetInstance().GetAudioCodec(); + if (codec) { + ESP_LOGI(TAG, "DMA flush: output_enabled=%d", codec->output_enabled()); + codec->EnableOutput(false); + vTaskDelay(pdMS_TO_TICKS(10)); + codec->EnableOutput(true); + ESP_LOGI(TAG, "🔇 音频输出已重置,DMA缓冲区已清空"); + } + }); + } + // 🔧 修复:始终尝试发送中止消息以打断RTC下行(不受IsSafeToOperate限制) if (protocol_) { try { @@ -2382,24 +2430,681 @@ void Application::AbortSpeaking(AbortReason reason) { is_aborting_.store(false); } -// 发送讲故事请求 webscoket协议 -void Application::SendStoryRequest() { - if (!websocket_protocol_) { - InitializeWebsocketProtocol();// 初始化WebSocket协议 - if (!websocket_protocol_) { - ESP_LOGW(TAG, "WebSocket协议初始化失败"); - return; +// 中止HTTPS音频播放:清空队列、重置解码器、清除标志、DMA flush +void Application::AbortHttpsPlayback(const char* reason) { + ESP_LOGI(TAG, "🔴 %s,中止HTTPS音频播放", reason); + https_playback_abort_.store(true); + { + std::lock_guard lock(mutex_); + if (!audio_decode_queue_.empty()) { + ESP_LOGI(TAG, "清空HTTPS音频队列,大小=%zu", audio_decode_queue_.size()); + audio_decode_queue_.clear(); } } - Schedule([this]() { - ws_downlink_enabled_.store(true); - // 确保音频通道已打开 - if (!websocket_protocol_->IsAudioChannelOpened()) { - websocket_protocol_->OpenAudioChannel();// 打开音频通道 + ResetDecoder(); + opus_playback_active_.store(false); + https_playback_active_.store(false); + ESP_LOGI(TAG, "🔴 HTTPS播放标志已清除,RTC音频通道已打开"); + // DMA flush:用独立任务立即清空I2S DMA缓冲区 + // 不能用background_task_,RTC音频lambda会持续占用它导致延迟数秒 + xTaskCreate([](void* arg) { + auto codec = Board::GetInstance().GetAudioCodec(); + if (codec) { + ESP_LOGI(TAG, "DMA flush: output_enabled=%d", codec->output_enabled()); + codec->EnableOutput(false); + vTaskDelay(pdMS_TO_TICKS(10)); + codec->EnableOutput(true); + ESP_LOGI(TAG, "🔇 音频输出已重置,DMA缓冲区已清空"); } - websocket_protocol_->SendStoryRequest();// 发送故事请求 - ESP_LOGI(TAG, "通过WebSocket发送的故事请求!"); - }); + vTaskDelete(NULL); + }, "dma_flush", 4096, NULL, 10, NULL); +} + +// 通过HTTPS下载JSON并播放音频(故事/歌曲等) +void Application::HttpsPlaybackFromUrl(const std::string& url) { + // 防止重复启动 + if (https_playback_active_.load() || https_playback_abort_.load() || opus_playback_active_.load()) { + ESP_LOGW(TAG, "[HTTPS播放] 已有音频正在播放或退出中,忽略本次请求"); + return; + } + + std::string url_copy = url; + xTaskCreate([](void* arg) { + std::string* url_ptr = static_cast(arg); + std::string playback_url = std::move(*url_ptr); + delete url_ptr; + + auto& app = Application::GetInstance(); + // 先设置opus标志(覆盖HTTP请求阶段,阻断RTC PCM) + app.opus_playback_active_.store(true); + app.https_playback_abort_.store(false); + + ESP_LOGI(TAG, "[HTTPS播放] 开始下载: %s", playback_url.c_str()); + + // 配置HTTP客户端 + esp_http_client_config_t config = {}; + config.url = playback_url.c_str(); + config.method = HTTP_METHOD_GET; + config.transport_type = HTTP_TRANSPORT_OVER_SSL; + config.timeout_ms = 15000; + config.buffer_size = 2048; + config.buffer_size_tx = 512; +#ifdef CONFIG_MBEDTLS_CERTIFICATE_BUNDLE + config.crt_bundle_attach = esp_crt_bundle_attach; +#endif + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + ESP_LOGE(TAG, "[HTTPS播放] HTTP客户端初始化失败"); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "[HTTPS播放] HTTP连接失败: %s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + int64_t content_length = esp_http_client_fetch_headers(client); + int status_code = esp_http_client_get_status_code(client); + ESP_LOGI(TAG, "[HTTPS播放] HTTP状态码: %d, 内容长度: %lld", status_code, (long long)content_length); + + if (status_code != 200) { + ESP_LOGE(TAG, "[HTTPS播放] HTTP请求失败,状态码: %d", status_code); + esp_http_client_close(client); + esp_http_client_cleanup(client); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // 流式读取整个JSON + std::string json_data; + if (content_length > 0) { + json_data.reserve(content_length); + } + char read_buf[2048]; + int read_len; + int total_read = 0; + while ((read_len = esp_http_client_read(client, read_buf, sizeof(read_buf))) > 0) { + if (app.https_playback_abort_.load()) { + ESP_LOGI(TAG, "[HTTPS播放] 下载被中止"); + break; + } + json_data.append(read_buf, read_len); + total_read += read_len; + } + + esp_http_client_close(client); + esp_http_client_cleanup(client); + ESP_LOGI(TAG, "[HTTPS播放] 下载完成: %d 字节, 堆剩余: %lu", + total_read, (unsigned long)esp_get_free_heap_size()); + + if (app.https_playback_abort_.load()) { + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // 解析JSON + cJSON* root = cJSON_Parse(json_data.c_str()); + json_data.clear(); + json_data.shrink_to_fit(); + + if (!root) { + ESP_LOGE(TAG, "[HTTPS播放] JSON解析失败"); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // 读取音频参数 + cJSON* sample_rate_item = cJSON_GetObjectItem(root, "sample_rate"); + cJSON* frame_duration_item = cJSON_GetObjectItem(root, "frame_duration_ms"); + cJSON* frames_array = cJSON_GetObjectItem(root, "frames"); + + if (!frames_array || !cJSON_IsArray(frames_array)) { + ESP_LOGE(TAG, "[HTTPS播放] JSON中缺少frames数组"); + cJSON_Delete(root); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + int sample_rate = (sample_rate_item && cJSON_IsNumber(sample_rate_item)) ? sample_rate_item->valueint : 16000; + int frame_duration = (frame_duration_item && cJSON_IsNumber(frame_duration_item)) ? frame_duration_item->valueint : 60; + int frame_count = cJSON_GetArraySize(frames_array); + + ESP_LOGI(TAG, "[HTTPS播放] 音频参数: 采样率=%d, 帧时长=%dms, 总帧数=%d", + sample_rate, frame_duration, frame_count); + + app.SetDecodeSampleRate(sample_rate, frame_duration); + app.https_playback_active_.store(true); + + // base64 解码查找表 + static uint8_t b64_table[256] = {0}; + static bool b64_inited = false; + if (!b64_inited) { + const char* chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (int c = 0; chars[c]; c++) { + b64_table[(uint8_t)chars[c]] = (uint8_t)c; + } + b64_inited = true; + } + + // 逐帧base64解码并入队播放 + int enqueued = 0; + for (int i = 0; i < frame_count; i++) { + if (app.https_playback_abort_.load()) { + ESP_LOGI(TAG, "[HTTPS播放] 播放中止,已入队 %d/%d 帧", enqueued, frame_count); + break; + } + + cJSON* frame_item = cJSON_GetArrayItem(frames_array, i); + if (!frame_item || !cJSON_IsString(frame_item) || !frame_item->valuestring) continue; + + const char* b64 = frame_item->valuestring; + size_t b64_len = strlen(b64); + if (b64_len == 0) continue; + + // base64 解码 + size_t out_len = (b64_len * 3) / 4; + if (b64_len >= 1 && b64[b64_len - 1] == '=') out_len--; + if (b64_len >= 2 && b64[b64_len - 2] == '=') out_len--; + + std::vector opus_frame(out_len); + size_t j = 0, k = 0; + while (j < b64_len) { + uint32_t sextet_a = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t sextet_b = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t sextet_c = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t sextet_d = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t triple = (sextet_a << 18) | (sextet_b << 12) | (sextet_c << 6) | sextet_d; + if (k < out_len) opus_frame[k++] = (triple >> 16) & 0xFF; + if (k < out_len) opus_frame[k++] = (triple >> 8) & 0xFF; + if (k < out_len) opus_frame[k++] = triple & 0xFF; + } + + { + std::lock_guard lock(app.mutex_); + app.audio_decode_queue_.emplace_back(std::move(opus_frame)); + } + enqueued++; + + // 控制入队速度:队列过大时等待消费 + while (!app.https_playback_abort_.load()) { + size_t queue_size; + { + std::lock_guard lock(app.mutex_); + queue_size = app.audio_decode_queue_.size(); + } + if (queue_size < 50) break; + vTaskDelay(pdMS_TO_TICKS(30)); + } + + if (enqueued % 100 == 0) { + ESP_LOGI(TAG, "[HTTPS播放] 进度: %d/%d 帧 (%.0f%%)", + enqueued, frame_count, enqueued * 100.0f / frame_count); + } + } + + cJSON_Delete(root); + + // 等待队列播放完毕(或被中止) + while (!app.https_playback_abort_.load()) { + size_t queue_size; + { + std::lock_guard lock(app.mutex_); + queue_size = app.audio_decode_queue_.size(); + } + if (queue_size == 0) break; + vTaskDelay(pdMS_TO_TICKS(100)); + } + + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + ESP_LOGI(TAG, "[HTTPS播放] 播放结束, 堆剩余: %lu", + (unsigned long)esp_get_free_heap_size()); + vTaskDelete(NULL); + }, "https_play", 8192, new std::string(url_copy), 5, NULL); +} + +// 通过故事API请求并播放故事(intro标题 + body正文无缝衔接) +void Application::SendStoryRequest() { + // 防止重复启动 + if (https_playback_active_.load() || https_playback_abort_.load() || opus_playback_active_.load()) { + ESP_LOGW(TAG, "[故事API] 已有音频正在播放或退出中,忽略本次请求"); + return; + } + + xTaskCreate([](void* arg) { + auto& app = Application::GetInstance(); + // 先设置opus和abort标志(覆盖HTTP请求阶段,阻断RTC PCM) + app.opus_playback_active_.store(true); + app.https_playback_abort_.store(false); + + // base64 解码查找表 + static uint8_t b64_table[256] = {0}; + static bool b64_inited = false; + if (!b64_inited) { + const char* chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (int c = 0; chars[c]; c++) { + b64_table[(uint8_t)chars[c]] = (uint8_t)c; + } + b64_inited = true; + } + + // ========== 步骤1: 请求故事API ========== + std::string mac = SystemInfo::GetBleMacAddress(); + // 转大写 + for (auto& c : mac) { + if (c >= 'a' && c <= 'f') c -= 32; + } + + char api_url[256]; + snprintf(api_url, sizeof(api_url), "%s?mac_address=%s", + CONFIG_STORY_API_URL, mac.c_str()); + + ESP_LOGI(TAG, "[故事API] 请求: %s", api_url); + ESP_LOGI(TAG, "[故事API] 空闲堆: %lu", (unsigned long)esp_get_free_heap_size()); + + esp_http_client_config_t api_config = {}; + api_config.url = api_url; + api_config.method = HTTP_METHOD_GET; + api_config.timeout_ms = 10000; + api_config.buffer_size = 2048; + api_config.buffer_size_tx = 512; + + esp_http_client_handle_t api_client = esp_http_client_init(&api_config); + if (!api_client) { + ESP_LOGE(TAG, "[故事API] HTTP客户端初始化失败"); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + esp_err_t err = esp_http_client_open(api_client, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "[故事API] 连接失败: %s", esp_err_to_name(err)); + esp_http_client_cleanup(api_client); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + esp_http_client_fetch_headers(api_client); + int api_status = esp_http_client_get_status_code(api_client); + if (api_status != 200) { + ESP_LOGE(TAG, "[故事API] 请求失败,状态码: %d", api_status); + esp_http_client_close(api_client); + esp_http_client_cleanup(api_client); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // 读取API响应 + std::string api_response; + char buf[1024]; + int rlen; + while ((rlen = esp_http_client_read(api_client, buf, sizeof(buf))) > 0) { + api_response.append(buf, rlen); + } + esp_http_client_close(api_client); + esp_http_client_cleanup(api_client); + + ESP_LOGI(TAG, "[故事API] 响应: %d 字节", (int)api_response.size()); + + if (app.https_playback_abort_.load()) { + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // 解析外层JSON + cJSON* root = cJSON_Parse(api_response.c_str()); + api_response.clear(); + api_response.shrink_to_fit(); + + if (!root) { + ESP_LOGE(TAG, "[故事API] JSON解析失败"); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + cJSON* code_item = cJSON_GetObjectItem(root, "code"); + if (!code_item || code_item->valueint != 0) { + cJSON* msg_item = cJSON_GetObjectItem(root, "message"); + ESP_LOGE(TAG, "[故事API] 服务端错误: %s", + (msg_item && msg_item->valuestring) ? msg_item->valuestring : "unknown"); + cJSON_Delete(root); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + cJSON* data = cJSON_GetObjectItem(root, "data"); + cJSON* title_item = data ? cJSON_GetObjectItem(data, "title") : nullptr; + cJSON* intro_str = data ? cJSON_GetObjectItem(data, "intro_opus_data") : nullptr; + cJSON* opus_url_item = data ? cJSON_GetObjectItem(data, "opus_url") : nullptr; + + if (!intro_str || !cJSON_IsString(intro_str) || !intro_str->valuestring || + !opus_url_item || !cJSON_IsString(opus_url_item) || !opus_url_item->valuestring) { + ESP_LOGE(TAG, "[故事API] 缺少intro_opus_data或opus_url字段"); + cJSON_Delete(root); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "[故事API] 标题: %s", + (title_item && title_item->valuestring) ? title_item->valuestring : "未知"); + + // 提取字符串后释放外层JSON + std::string intro_json_str = intro_str->valuestring; + std::string opus_url = opus_url_item->valuestring; + cJSON_Delete(root); + + // ========== 步骤2: 解析 intro_opus_data ========== + cJSON* intro_root = cJSON_Parse(intro_json_str.c_str()); + intro_json_str.clear(); + intro_json_str.shrink_to_fit(); + + if (!intro_root) { + ESP_LOGE(TAG, "[故事API] intro JSON解析失败"); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + cJSON* intro_sr = cJSON_GetObjectItem(intro_root, "sample_rate"); + cJSON* intro_fd = cJSON_GetObjectItem(intro_root, "frame_duration_ms"); + cJSON* intro_frames = cJSON_GetObjectItem(intro_root, "frames"); + + if (!intro_frames || !cJSON_IsArray(intro_frames)) { + ESP_LOGE(TAG, "[故事API] intro缺少frames数组"); + cJSON_Delete(intro_root); + app.https_playback_active_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + int sample_rate = (intro_sr && cJSON_IsNumber(intro_sr)) ? intro_sr->valueint : 16000; + int frame_duration = (intro_fd && cJSON_IsNumber(intro_fd)) ? intro_fd->valueint : 60; + int intro_count = cJSON_GetArraySize(intro_frames); + + ESP_LOGI(TAG, "[故事API] intro: 采样率=%d, 帧时长=%dms, 帧数=%d (%.1f秒)", + sample_rate, frame_duration, intro_count, + intro_count * frame_duration / 1000.0f); + + app.SetDecodeSampleRate(sample_rate, frame_duration); + app.https_playback_active_.store(true); + + // ========== 步骤3: 入队 intro frames ========== + int enqueued = 0; + int errors = 0; + + for (int i = 0; i < intro_count; i++) { + if (app.https_playback_abort_.load()) break; + + cJSON* fi = cJSON_GetArrayItem(intro_frames, i); + if (!fi || !cJSON_IsString(fi) || !fi->valuestring) { errors++; continue; } + + const char* b64 = fi->valuestring; + size_t b64_len = strlen(b64); + if (b64_len == 0) { errors++; continue; } + + size_t out_len = (b64_len * 3) / 4; + if (b64_len >= 1 && b64[b64_len - 1] == '=') out_len--; + if (b64_len >= 2 && b64[b64_len - 2] == '=') out_len--; + + std::vector frame(out_len); + size_t j = 0, k = 0; + while (j < b64_len) { + uint32_t a = b64_table[(uint8_t)b64[j++]]; + uint32_t b = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t c = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t d = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t triple = (a << 18) | (b << 12) | (c << 6) | d; + if (k < out_len) frame[k++] = (triple >> 16) & 0xFF; + if (k < out_len) frame[k++] = (triple >> 8) & 0xFF; + if (k < out_len) frame[k++] = triple & 0xFF; + } + + { + std::lock_guard lock(app.mutex_); + app.audio_decode_queue_.emplace_back(std::move(frame)); + } + enqueued++; + + // 队列节流 + while (!app.https_playback_abort_.load()) { + size_t qs; + { std::lock_guard lock(app.mutex_); qs = app.audio_decode_queue_.size(); } + if (qs < 50) break; + vTaskDelay(pdMS_TO_TICKS(30)); + } + } + + cJSON_Delete(intro_root); + ESP_LOGI(TAG, "[故事API] intro入队完成: %d帧, 错误: %d", enqueued, errors); + + if (app.https_playback_abort_.load()) { + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // ========== 步骤4: 下载 opus_url 正文 ========== + ESP_LOGI(TAG, "[故事API] 开始下载正文: %s", opus_url.c_str()); + + esp_http_client_config_t opus_config = {}; + opus_config.url = opus_url.c_str(); + opus_config.method = HTTP_METHOD_GET; + opus_config.transport_type = HTTP_TRANSPORT_OVER_SSL; + opus_config.timeout_ms = 15000; + opus_config.buffer_size = 2048; + opus_config.buffer_size_tx = 512; +#ifdef CONFIG_MBEDTLS_CERTIFICATE_BUNDLE + opus_config.crt_bundle_attach = esp_crt_bundle_attach; +#endif + + esp_http_client_handle_t opus_client = esp_http_client_init(&opus_config); + if (!opus_client) { + ESP_LOGE(TAG, "[故事API] opus HTTP初始化失败"); + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + err = esp_http_client_open(opus_client, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "[故事API] opus连接失败: %s", esp_err_to_name(err)); + esp_http_client_cleanup(opus_client); + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + int64_t opus_content_len = esp_http_client_fetch_headers(opus_client); + int opus_status = esp_http_client_get_status_code(opus_client); + ESP_LOGI(TAG, "[故事API] opus状态码: %d, 长度: %lld", opus_status, (long long)opus_content_len); + + if (opus_status != 200) { + ESP_LOGE(TAG, "[故事API] opus请求失败,状态码: %d", opus_status); + esp_http_client_close(opus_client); + esp_http_client_cleanup(opus_client); + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + std::string opus_json; + if (opus_content_len > 0) opus_json.reserve(opus_content_len); + int total_read = 0; + while ((rlen = esp_http_client_read(opus_client, buf, sizeof(buf))) > 0) { + if (app.https_playback_abort_.load()) break; + opus_json.append(buf, rlen); + total_read += rlen; + } + esp_http_client_close(opus_client); + esp_http_client_cleanup(opus_client); + + ESP_LOGI(TAG, "[故事API] opus下载完成: %d 字节, 堆: %lu", + total_read, (unsigned long)esp_get_free_heap_size()); + + if (app.https_playback_abort_.load()) { + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + // ========== 步骤5: 解析并入队 body frames ========== + cJSON* opus_root = cJSON_Parse(opus_json.c_str()); + opus_json.clear(); + opus_json.shrink_to_fit(); + + if (!opus_root) { + ESP_LOGE(TAG, "[故事API] opus JSON解析失败"); + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + cJSON* body_frames = cJSON_GetObjectItem(opus_root, "frames"); + if (!body_frames || !cJSON_IsArray(body_frames)) { + ESP_LOGE(TAG, "[故事API] opus缺少frames数组"); + cJSON_Delete(opus_root); + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + vTaskDelete(NULL); + return; + } + + cJSON* body_sr = cJSON_GetObjectItem(opus_root, "sample_rate"); + cJSON* body_fd = cJSON_GetObjectItem(opus_root, "frame_duration_ms"); + int body_sample_rate = (body_sr && cJSON_IsNumber(body_sr)) ? body_sr->valueint : sample_rate; + int body_frame_duration = (body_fd && cJSON_IsNumber(body_fd)) ? body_fd->valueint : frame_duration; + int body_count = cJSON_GetArraySize(body_frames); + + ESP_LOGI(TAG, "[故事API] body: 采样率=%d, 帧时长=%dms, 帧数=%d (%.1f秒)", + body_sample_rate, body_frame_duration, body_count, + body_count * body_frame_duration / 1000.0f); + + if (body_sample_rate != sample_rate || body_frame_duration != frame_duration) { + app.SetDecodeSampleRate(body_sample_rate, body_frame_duration); + } + + int body_enqueued = 0; + for (int i = 0; i < body_count; i++) { + if (app.https_playback_abort_.load()) { + ESP_LOGI(TAG, "[故事API] body入队中止: %d/%d", body_enqueued, body_count); + break; + } + + cJSON* fi = cJSON_GetArrayItem(body_frames, i); + if (!fi || !cJSON_IsString(fi) || !fi->valuestring) continue; + + const char* b64 = fi->valuestring; + size_t b64_len = strlen(b64); + if (b64_len == 0) continue; + + size_t out_len = (b64_len * 3) / 4; + if (b64_len >= 1 && b64[b64_len - 1] == '=') out_len--; + if (b64_len >= 2 && b64[b64_len - 2] == '=') out_len--; + + std::vector frame(out_len); + size_t j = 0, k = 0; + while (j < b64_len) { + uint32_t a = b64_table[(uint8_t)b64[j++]]; + uint32_t b = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t c = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t d = (j < b64_len) ? b64_table[(uint8_t)b64[j++]] : 0; + uint32_t triple = (a << 18) | (b << 12) | (c << 6) | d; + if (k < out_len) frame[k++] = (triple >> 16) & 0xFF; + if (k < out_len) frame[k++] = (triple >> 8) & 0xFF; + if (k < out_len) frame[k++] = triple & 0xFF; + } + + { + std::lock_guard lock(app.mutex_); + app.audio_decode_queue_.emplace_back(std::move(frame)); + } + body_enqueued++; + + // 队列节流 + while (!app.https_playback_abort_.load()) { + size_t qs; + { std::lock_guard lock(app.mutex_); qs = app.audio_decode_queue_.size(); } + if (qs < 50) break; + vTaskDelay(pdMS_TO_TICKS(30)); + } + + if (body_enqueued % 100 == 0) { + ESP_LOGI(TAG, "[故事API] body进度: %d/%d (%.0f%%)", + body_enqueued, body_count, + body_enqueued * 100.0f / body_count); + } + } + + cJSON_Delete(opus_root); + ESP_LOGI(TAG, "[故事API] body入队完成: %d帧", body_enqueued); + + // ========== 步骤6: 等待播放完毕 ========== + if (!app.https_playback_abort_.load()) { + while (!app.https_playback_abort_.load()) { + size_t qs; + { std::lock_guard lock(app.mutex_); qs = app.audio_decode_queue_.size(); } + if (qs == 0) break; + vTaskDelay(pdMS_TO_TICKS(100)); + } + } + + app.https_playback_active_.store(false); + app.https_playback_abort_.store(false); + app.opus_playback_active_.store(false); + ESP_LOGI(TAG, "[故事API] 播放结束, 堆: %lu", + (unsigned long)esp_get_free_heap_size()); + vTaskDelete(NULL); + + }, "story_play", 10240, NULL, 5, NULL); } // 设置监听模式 diff --git a/main/application.h b/main/application.h index a488755..cd2c79c 100644 --- a/main/application.h +++ b/main/application.h @@ -70,6 +70,8 @@ public: void Alert(const char* status, const char* message, const char* emotion = "", const std::string_view& sound = "");// 警报管理 状态、消息、情感、声音 void DismissAlert();// 关闭警报 void AbortSpeaking(AbortReason reason);// 打断语音播报 + void AbortHttpsPlayback(const char* reason);// 中止HTTPS音频播放并清空DMA + void HttpsPlaybackFromUrl(const std::string& url); // 通过HTTPS下载JSON并播放音频(故事/歌曲等) void SendStoryRequest(); // 发送讲故事 请求 void ToggleChatState();// 切换聊天状态 void ToggleListeningState();// 切换监听状态 @@ -146,6 +148,9 @@ private: #endif std::atomic ws_downlink_enabled_{true};// 🌐 WebSocket下行通道是否启用 std::atomic ws_playback_active_{false};// 🌐 WebSocket下行播放活跃标志 + std::atomic opus_playback_active_{false};// Opus解码播放活跃标志(WS/HTTPS共用) + std::atomic https_playback_active_{false};// HTTPS音频播放进行中标志 + std::atomic https_playback_abort_{false};// HTTPS音频播放中止标志 bool aborted_ = false; bool voice_detected_ = false; bool audio_paused_ = false; // 音频暂停状态标志 diff --git a/main/protocols/protocol.cc b/main/protocols/protocol.cc index 283de46..6a3a419 100644 --- a/main/protocols/protocol.cc +++ b/main/protocols/protocol.cc @@ -24,6 +24,10 @@ void Protocol::OnNetworkError(std::function ca on_network_error_ = callback; } +void Protocol::OnBotMessage(std::function callback) { + on_bot_message_ = callback; +} + void Protocol::SetError(const std::string& message) { error_occurred_ = true; if (on_network_error_ != nullptr) { diff --git a/main/protocols/protocol.h b/main/protocols/protocol.h index 81747ea..7e9c635 100644 --- a/main/protocols/protocol.h +++ b/main/protocols/protocol.h @@ -49,6 +49,7 @@ public: void OnAudioChannelOpened(std::function callback); void OnAudioChannelClosed(std::function callback); void OnNetworkError(std::function callback); + void OnBotMessage(std::function callback); virtual void Start() = 0; virtual bool OpenAudioChannel() = 0; @@ -76,6 +77,7 @@ protected: std::function on_audio_channel_opened_; std::function on_audio_channel_closed_; std::function on_network_error_; + std::function on_bot_message_; int server_sample_rate_ = 24000; int server_frame_duration_ = 60; diff --git a/main/protocols/volc_rtc_protocol.cc b/main/protocols/volc_rtc_protocol.cc index 82f77e4..0219619 100644 --- a/main/protocols/volc_rtc_protocol.cc +++ b/main/protocols/volc_rtc_protocol.cc @@ -111,14 +111,14 @@ void VolcRtcProtocol::Start() { iot_info_.device_name = (char*)CONFIG_VOLC_DEVICE_NAME; ESP_LOGI(TAG, "使用配置文件中的设备名称: %s", iot_info_.device_name); } else { - // 配置文件中的设备名称为空,使用MAC地址作为设备名称 - std::string mac_address = SystemInfo::GetMacAddress(); + // 配置文件中的设备名称为空,使用蓝牙MAC地址作为设备名称 + std::string mac_address = SystemInfo::GetBleMacAddress(); // MAC地址中替换冒号为下划线,避免文件名中包含冒号 std::replace(mac_address.begin(), mac_address.end(), ':', '_'); char* mac_buffer = (char*)malloc(mac_address.length() + 1); strcpy(mac_buffer, mac_address.c_str()); iot_info_.device_name = mac_buffer; - ESP_LOGI(TAG, "使用Wi-Fi MAC地址作为设备名称(已替换冒号为下划线): %s", iot_info_.device_name); + ESP_LOGI(TAG, "使用蓝牙MAC地址作为设备名称(已替换冒号为下划线): %s", iot_info_.device_name); } Settings s("volc"); @@ -583,37 +583,45 @@ void VolcRtcProtocol::DataCallback(void* context, const void* data, size_t len, } } protocol->ProcessAudioData(data, len);// 处理音频数据 - } else if (info->type == VOLC_DATA_TYPE_MESSAGE) { - if (data && len > 0) { - const uint8_t* buf = static_cast(data); - std::string json_text; - if (info->info.message.is_binary && len >= 8) { - bool is_ctrl = (memcmp(buf, "ctrl", 4) == 0); - bool is_conv = (memcmp(buf, "conv", 4) == 0); - bool is_tool = (memcmp(buf, "tool", 4) == 0); - bool is_subv = (memcmp(buf, "subv", 4) == 0); - if (is_ctrl || is_conv || is_tool || is_subv) { - uint32_t json_len = (uint32_t)((buf[4] << 24) | (buf[5] << 16) | (buf[6] << 8) | (buf[7])); - if (json_len > 0 && (size_t)(8 + json_len) <= len) { - json_text.assign(reinterpret_cast(buf + 8), json_len); - if (!protocol->suppress_incoming_message_log_) { - if (is_subv) { - ESP_LOGI(TAG, "接收下行二进制消息(字幕)"); - } else { - const char* prefix = is_ctrl ? "ctrl" : (is_conv ? "conv" : "tool"); - ESP_LOGI(TAG, "接收下行二进制消息(%s): %.*s", prefix, (int)json_text.size(), json_text.c_str()); - } - } - } + } else if (info->type == VOLC_DATA_TYPE_MESSAGE) { + if (data && len > 0) { + const uint8_t* buf = static_cast(data); + std::string json_text; + bool is_subv = false; + + // 不依赖 is_binary 字段(SDK始终返回false),直接检测前缀 + if (len >= 8) { + bool is_ctrl = (memcmp(buf, "ctrl", 4) == 0); + bool is_conv = (memcmp(buf, "conv", 4) == 0); + bool is_tool = (memcmp(buf, "tool", 4) == 0); + is_subv = (memcmp(buf, "subv", 4) == 0); + bool is_info = (memcmp(buf, "info", 4) == 0); + if (is_ctrl || is_conv || is_tool || is_subv || is_info) { + uint32_t json_len = (uint32_t)((buf[4] << 24) | (buf[5] << 16) | (buf[6] << 8) | (buf[7])); + if (json_len > 0 && (size_t)(8 + json_len) <= len) { + json_text.assign(reinterpret_cast(buf + 8), json_len); + if (!is_subv && !protocol->suppress_incoming_message_log_) { + const char* prefix = is_ctrl ? "ctrl" : (is_conv ? "conv" : (is_tool ? "tool" : "info")); + ESP_LOGI(TAG, "接收下行消息(%s): %.*s", prefix, (int)json_text.size(), json_text.c_str()); } } - if (json_text.empty()) { - json_text.assign(reinterpret_cast(data), len); - if (!protocol->suppress_incoming_message_log_) { - ESP_LOGI(TAG, "接收下行消息: %.*s", (int)json_text.size(), json_text.c_str()); - } - } - cJSON* root = cJSON_Parse(json_text.c_str()); + } + } + + if (json_text.empty()) { + json_text.assign(reinterpret_cast(data), len); + if (!protocol->suppress_incoming_message_log_) { + ESP_LOGI(TAG, "接收下行消息: %.*s", (int)json_text.size(), json_text.c_str()); + } + } + + // 非subv消息立即通知应用层中止HTTPS播放 + // subv字幕消息由subtitle handler处理(可区分USER/AI) + if (!is_subv && protocol->on_bot_message_) { + protocol->on_bot_message_(); + } + + cJSON* root = cJSON_Parse(json_text.c_str()); if (root) { const char* sid_keys[] = {"sessionId", "session_id", "sid"}; cJSON* sid = nullptr; diff --git a/sdkconfig b/sdkconfig index bf13569..056906b 100644 --- a/sdkconfig +++ b/sdkconfig @@ -14,7 +14,6 @@ CONFIG_SOC_GDMA_SUPPORTED=y CONFIG_SOC_AHB_GDMA_SUPPORTED=y CONFIG_SOC_GPTIMER_SUPPORTED=y CONFIG_SOC_LCDCAM_SUPPORTED=y -CONFIG_SOC_LCDCAM_CAM_SUPPORTED=y CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED=y CONFIG_SOC_LCDCAM_RGB_LCD_SUPPORTED=y CONFIG_SOC_MCPWM_SUPPORTED=y @@ -102,7 +101,7 @@ CONFIG_SOC_CPU_HAS_FPU=y CONFIG_SOC_HP_CPU_HAS_MULTIPLE_CORES=y CONFIG_SOC_CPU_BREAKPOINTS_NUM=2 CONFIG_SOC_CPU_WATCHPOINTS_NUM=2 -CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE=0x40 +CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE=64 CONFIG_SOC_SIMD_PREFERRED_DATA_ALIGNMENT=16 CONFIG_SOC_DS_SIGNATURE_MAX_BIT_LEN=4096 CONFIG_SOC_DS_KEY_PARAM_MD_IV_LENGTH=16 @@ -209,7 +208,7 @@ CONFIG_SOC_RTCIO_INPUT_OUTPUT_SUPPORTED=y CONFIG_SOC_RTCIO_HOLD_SUPPORTED=y CONFIG_SOC_RTCIO_WAKE_SUPPORTED=y CONFIG_SOC_LP_IO_CLOCK_IS_INDEPENDENT=y -CONFIG_SOC_SDM_GROUPS=1 +CONFIG_SOC_SDM_GROUPS=y CONFIG_SOC_SDM_CHANNELS_PER_GROUP=8 CONFIG_SOC_SDM_CLK_SUPPORT_APB=y CONFIG_SOC_SPI_PERIPH_NUM=3 @@ -370,9 +369,6 @@ CONFIG_SOC_BLE_DEVICE_PRIVACY_SUPPORTED=y CONFIG_SOC_BLUFI_SUPPORTED=y CONFIG_SOC_ULP_HAS_ADC=y CONFIG_SOC_PHY_COMBO_MODULE=y -CONFIG_SOC_LCDCAM_CAM_SUPPORT_RGB_YUV_CONV=y -CONFIG_SOC_LCDCAM_CAM_PERIPH_NUM=1 -CONFIG_SOC_LCDCAM_CAM_DATA_WIDTH_MAX=16 CONFIG_IDF_CMAKE=y CONFIG_IDF_TOOLCHAIN="gcc" CONFIG_IDF_TOOLCHAIN_GCC=y @@ -569,6 +565,7 @@ CONFIG_PARTITION_TABLE_MD5=y # CONFIG_OTA_VERSION_URL="https://xiaozhi-dev-web.goods.fun/xiaozhi/ota/" CONFIG_DEVICE_STATUS_REPORT_URL="http://192.168.124.8:8000/api/v1/devices/report-status" +CONFIG_STORY_API_URL="http://192.168.124.8:8000/api/v1/devices/stories/" CONFIG_LANGUAGE_ZH_CN=y # CONFIG_LANGUAGE_ZH_TW is not set # CONFIG_LANGUAGE_EN_US is not set @@ -588,7 +585,7 @@ CONFIG_VOLC_INSTANCE_ID="68f0bc7611a5cf890711f2d0" CONFIG_VOLC_PRODUCT_KEY="69080ba98219e1f34702d133" CONFIG_VOLC_PRODUCT_SECRET="205b5c3d43f0d3e877908399" CONFIG_VOLC_BOT_ID="botCL63FJgWe" -CONFIG_VOLC_DEVICE_NAME="" +CONFIG_VOLC_DEVICE_NAME="d0_cf_13_03_bb_f0" # CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI is not set # CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI_LCD is not set # CONFIG_BOARD_TYPE_BREAD_COMPACT_ML307 is not set @@ -1035,7 +1032,6 @@ CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y CONFIG_BT_BLE_42_DTM_TEST_EN=y CONFIG_BT_BLE_42_ADV_EN=y CONFIG_BT_BLE_42_SCAN_EN=y -CONFIG_BT_BLE_VENDOR_HCI_EN=y # CONFIG_BT_BLE_HIGH_DUTY_ADV_INTERVAL is not set # CONFIG_BT_ABORT_WHEN_ALLOCATION_FAILS is not set # end of Bluedroid Options @@ -1257,7 +1253,6 @@ CONFIG_ESP_TLS_USE_DS_PERIPHERAL=y # CONFIG_ESP_TLS_SERVER_MIN_AUTH_MODE_OPTIONAL is not set # CONFIG_ESP_TLS_PSK_VERIFICATION is not set # CONFIG_ESP_TLS_INSECURE is not set -CONFIG_ESP_TLS_DYN_BUF_STRATEGY_SUPPORTED=y # end of ESP-TLS # @@ -1285,12 +1280,6 @@ CONFIG_ESP_ERR_TO_NAME_LOOKUP=y CONFIG_ESP_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y # end of Common ESP-related -# -# ESP-Driver:Camera Controller Configurations -# -# CONFIG_CAM_CTLR_DVP_CAM_ISR_CACHE_SAFE is not set -# end of ESP-Driver:Camera Controller Configurations - # # ESP-Driver:GPIO Configurations # @@ -1608,11 +1597,8 @@ CONFIG_ESP_PHY_RF_CAL_PARTIAL=y # CONFIG_ESP_PHY_RF_CAL_NONE is not set # CONFIG_ESP_PHY_RF_CAL_FULL is not set CONFIG_ESP_PHY_CALIBRATION_MODE=0 -CONFIG_ESP_PHY_PLL_TRACK_PERIOD_MS=1000 # CONFIG_ESP_PHY_PLL_TRACK_DEBUG is not set # CONFIG_ESP_PHY_RECORD_USED_TIME is not set -CONFIG_ESP_PHY_IRAM_OPT=y -# CONFIG_ESP_PHY_DEBUG is not set # end of PHY # @@ -2283,7 +2269,6 @@ CONFIG_MBEDTLS_DYNAMIC_BUFFER=y # CONFIG_MBEDTLS_X509_TRUSTED_CERT_CALLBACK is not set # CONFIG_MBEDTLS_SSL_CONTEXT_SERIALIZATION is not set # CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE is not set -# CONFIG_MBEDTLS_SSL_KEYING_MATERIAL_EXPORT is not set CONFIG_MBEDTLS_PKCS7_C=y # end of mbedTLS v3.x related @@ -3314,7 +3299,6 @@ CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_DIS=y CONFIG_SW_COEXIST_ENABLE=y CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y -# CONFIG_CAM_CTLR_DVP_CAM_ISR_IRAM_SAFE is not set # CONFIG_MCPWM_ISR_IN_IRAM is not set # CONFIG_EVENT_LOOP_PROFILING is not set CONFIG_POST_EVENTS_FROM_ISR=y