feat: HTTPS故事播放 + RTC/HTTPS双向音频切换状态机 + 协议层优化
1、新增HTTPS故事播放功能:SendStoryRequest通过蓝牙MAC请求故事API,支持intro+body两段式无缝播放,替换原WebSocket故事请求方式; 2、新增HttpsPlaybackFromUrl通用HTTPS音频下载播放方法,支持JSON格式Opus帧流式解码播放; 3、新增RTC↔HTTPS双向音频切换三标志位状态机(opus_playback_active_/https_playback_active_/https_playback_abort_),HTTPS播放期间静默丢弃RTC PCM包,OnAudioOutput捕获is_opus_frame防止残留Opus帧杂音; 4、新增AbortHttpsPlayback中止方法,使用独立高优先级任务(priority=10)执行DMA flush;AbortSpeaking也新增DMA缓冲区flush确保扬声器立即静音; 5、协议层新增OnBotMessage回调,非字幕Bot下行消息立即中止HTTPS播放;volc_rtc_protocol移除is_binary依赖改为直接前缀检测,新增info前缀识别,subv字幕排除on_bot_message_由subtitle handler单独处理; 6、subtitle字幕USER/AI区分从CONFIG_VOLC_DEVICE_NAME比较改为bot_前缀判断,用户说话时立即中止HTTPS播放; 7、Kconfig新增STORY_API_URL故事播放API地址配置; 8、设备注册RTC服务时,设备名称从Wi-Fi MAC地址改为使用蓝牙MAC地址 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1e7ba0763a
commit
ccea0c681c
@ -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
|
||||
|
||||
@ -28,6 +28,8 @@
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_http_client.h>
|
||||
#include <esp_crt_bundle.h>
|
||||
#include <nvs.h>
|
||||
|
||||
#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<uint8_t>&& 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<std::mutex> 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<int16_t> 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协议初始化失败");
|
||||
// 中止HTTPS音频播放:清空队列、重置解码器、清除标志、DMA flush
|
||||
void Application::AbortHttpsPlayback(const char* reason) {
|
||||
ESP_LOGI(TAG, "🔴 %s,中止HTTPS音频播放", reason);
|
||||
https_playback_abort_.store(true);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!audio_decode_queue_.empty()) {
|
||||
ESP_LOGI(TAG, "清空HTTPS音频队列,大小=%zu", audio_decode_queue_.size());
|
||||
audio_decode_queue_.clear();
|
||||
}
|
||||
}
|
||||
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缓冲区已清空");
|
||||
}
|
||||
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<std::string*>(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;
|
||||
}
|
||||
Schedule([this]() {
|
||||
ws_downlink_enabled_.store(true);
|
||||
// 确保音频通道已打开
|
||||
if (!websocket_protocol_->IsAudioChannelOpened()) {
|
||||
websocket_protocol_->OpenAudioChannel();// 打开音频通道
|
||||
|
||||
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;
|
||||
}
|
||||
websocket_protocol_->SendStoryRequest();// 发送故事请求
|
||||
ESP_LOGI(TAG, "通过WebSocket发送的故事请求!");
|
||||
});
|
||||
|
||||
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<uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<uint8_t> 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<std::mutex> 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<std::mutex> 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<uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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);
|
||||
}
|
||||
|
||||
// 设置监听模式
|
||||
|
||||
@ -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<bool> ws_downlink_enabled_{true};// 🌐 WebSocket下行通道是否启用
|
||||
std::atomic<bool> ws_playback_active_{false};// 🌐 WebSocket下行播放活跃标志
|
||||
std::atomic<bool> opus_playback_active_{false};// Opus解码播放活跃标志(WS/HTTPS共用)
|
||||
std::atomic<bool> https_playback_active_{false};// HTTPS音频播放进行中标志
|
||||
std::atomic<bool> https_playback_abort_{false};// HTTPS音频播放中止标志
|
||||
bool aborted_ = false;
|
||||
bool voice_detected_ = false;
|
||||
bool audio_paused_ = false; // 音频暂停状态标志
|
||||
|
||||
@ -24,6 +24,10 @@ void Protocol::OnNetworkError(std::function<void(const std::string& message)> ca
|
||||
on_network_error_ = callback;
|
||||
}
|
||||
|
||||
void Protocol::OnBotMessage(std::function<void()> callback) {
|
||||
on_bot_message_ = callback;
|
||||
}
|
||||
|
||||
void Protocol::SetError(const std::string& message) {
|
||||
error_occurred_ = true;
|
||||
if (on_network_error_ != nullptr) {
|
||||
|
||||
@ -49,6 +49,7 @@ public:
|
||||
void OnAudioChannelOpened(std::function<void()> callback);
|
||||
void OnAudioChannelClosed(std::function<void()> callback);
|
||||
void OnNetworkError(std::function<void(const std::string& message)> callback);
|
||||
void OnBotMessage(std::function<void()> callback);
|
||||
|
||||
virtual void Start() = 0;
|
||||
virtual bool OpenAudioChannel() = 0;
|
||||
@ -76,6 +77,7 @@ protected:
|
||||
std::function<void()> on_audio_channel_opened_;
|
||||
std::function<void()> on_audio_channel_closed_;
|
||||
std::function<void(const std::string& message)> on_network_error_;
|
||||
std::function<void()> on_bot_message_;
|
||||
|
||||
int server_sample_rate_ = 24000;
|
||||
int server_frame_duration_ = 60;
|
||||
|
||||
@ -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");
|
||||
@ -587,32 +587,40 @@ void VolcRtcProtocol::DataCallback(void* context, const void* data, size_t len,
|
||||
if (data && len > 0) {
|
||||
const uint8_t* buf = static_cast<const uint8_t*>(data);
|
||||
std::string json_text;
|
||||
if (info->info.message.is_binary && len >= 8) {
|
||||
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);
|
||||
bool is_subv = (memcmp(buf, "subv", 4) == 0);
|
||||
if (is_ctrl || is_conv || is_tool || is_subv) {
|
||||
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<const char*>(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());
|
||||
}
|
||||
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<const char*>(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"};
|
||||
|
||||
24
sdkconfig
24
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user