修复 Pendant 衍生项目无痛移植问题

实机验证通过后,按 Kapi 无屏底座路线补齐 Pendant RTC 吊坠项目的迁移修复。

1. BLE 配网与资源隔离
- sdkconfig.defaults 开启 BT 优先 PSRAM 分配,并将 LWIP socket 上限提升到 20
- sdkconfig.defaults.esp32s3 允许 BSS/NOINIT 放入 PSRAM,释放内部 SRAM 给 BLE/WiFi/RTC
- 配网模式 codec 使用 StartOutputOnly(),跳过麦克风 RX DMA 和 ES7210 输入链路
- ResetWifiConfiguration() 改为独立 wifi_reset task,避免在 iot_button/esp_timer 回调中阻塞延时
- WifiBoard 增加 IsWifiConfigMode(),供启动阶段判断是否走配网资源隔离路径

2. 音频底噪与 DMA 残留修复
- AudioCodec 增加 StartOutputOnly(),支持仅启动扬声器输出
- RTC 音频通道打开后灌入 200ms silence PCM,覆盖 I2S DMA 残留数据
- 软退出进入待命前重启 codec output 并再次灌静音,减少待命音/欢迎语前杂音
- box_audio_codec 在无硬件回采时使用 channel_mask=0,避免 I2S slot mask 被错误污染

3. 软件 loopback AEC
- 引入 esp_aec 底层同步 API,使用 DAC 输出复制构建 ref ring
- 上行 mic PCM 与延迟 ref 做同步消回声,适配无屏无硬件回采的 Pendant 形态
- AEC 采用 lazy init,减少启动阶段对 WiFi/BLE 内部 SRAM 的压力
- ref 静音时直接 passthrough,避免 AI 静音后误压制用户语音
- 在 player_pipeline_write 和 codec->OutputData 两条下行路径都追加 ref hook

4. RTC 连接稳定性与软退出
- VolcRtcProtocol 增加 LeaveRoom(bool notify_closed),支持 stop 房间但保留 rtc_handle
- hibernate 路径使用 LeaveRoom(false),避免关闭回调顺手关掉 codec output 导致待命音无声
- LeaveRoom/ForceRebuildEngine 重置 downlink_is_pcm_ 和首包标志,避免本地 Opus 音效被当 PCM 播成杂音
- OpenAudioChannel 连续失败 3 次后 ForceRebuildEngine,清理 RTC SDK 内部异常状态
- 加入 DIAG-RTC socket/heap/PSRAM/RSSI 日志,便于定位 ICE socket 和内存问题

5. Dialog watchdog 与 BOOT 唤醒
- Dialog watchdog 到期不再写 reboot_dlg_idle 后 esp_restart
- 新增 EnterIdleHibernate():软退房、清空残留音频队列、关闭麦克风、播放待命音后静默
- 新增 WakeFromHibernate():BOOT 唤醒后复用 RTC engine 并通过 ToggleChatState() 重连 RTC
- BOOT 单击优先判断 IsHibernating(),异步唤醒,避免走普通按键状态机
- hibernate 期间禁止 PowerSaveTimer 进入 Light Sleep,保护 I2C/codec 总线

6. 文档与衍生项目沉淀
- 更新石头光源属性检测方案文档
- 将 Pendant 实测通过的软退出、AEC、BLE 配网隔离经验同步到衍生项目移植规则
This commit is contained in:
Rdzleo 2026-05-29 13:33:54 +08:00
parent 93f0e19d1d
commit 8111515277
14 changed files with 650 additions and 50 deletions

View File

@ -1,4 +1,61 @@
# 石头同频匹配方案说明
# 石头光源属性检测方案说明
## 0. VEML7700 驱动概述
AI智能吊坠项目
该项目基于RTC底框架进行开发融合了VEML7700的光源检测硬件驱动可以检测水晶或宝石的属性
此设备主要用于AI对话+水晶石识别功能大概业务如下:
1、手机APP端会有此产品的所有类别水晶石的光源信息库可能需要设备端进行光源检测后汇总各类别石头的信息库给到APP端进行存储每种 石头需要不同光照条件和角度的信息收集尽量全面防止设备端检测到石头信息后发送到收集APP端进行匹配的时候无法匹配到
2、用户可以购买不同类别的石头每天使用不同的时候进行检测匹配从而从APP端根据用户选择不同的时候进行运势等信息推送
3、根据不同石头匹配的信息和推送后APP端会有数字人形象展示及互动
4、设备推送信息到手机APP端的接口可以统一一个网址然后通过消息类别来区分是什么类型的业务
### 驱动来源与设计决策
本项目的 VEML7700 驱动为**自主编写**,参考了以下两个社区/官方驱动的设计思路,但未直接移植任何一个:
| 参考项目 | 取舍 |
|---------|------|
| [tedyapo/arduino-VEML7700](https://github.com/tedyapo/arduino-VEML7700) (Arduino) | 参考了 Lux 计算公式、Vishay 非线性校正多项式系数、自动量程算法策略 |
| [esp-idf-lib/veml7700](https://github.com/esp-idf-lib/veml7700) v1.0.7 (ESP-IDF 社区组件) | 参考了寄存器定义和配置结构设计 |
**未直接使用上述驱动的原因**
- Arduino 驱动依赖 `Wire.h`,需要重写整个 I2C 通信层才能移植到 ESP-IDF
- ESP-IDF 社区组件依赖 `i2cdev` 库(旧版 I2C API与本项目使用的 `i2c_master` 新 API **不兼容**,同一 I2C 端口无法共存
- ESP-IDF 社区组件存在 `veml7700_get_config()` 函数 Bug连续 4 次赋值给 `integration_time` 字段)、`veml7700_get_ambient_light()` 缺少 mutex 保护、Lux 计算无非线性校正等问题
最终方案:基于项目已有的 `I2cDevice` 基类(使用 ESP-IDF `i2c_master` 新 API自主实现 VEML7700 驱动,复用 Arduino 驱动中经过验证的 Lux 校正公式和自动量程策略。
### 驱动主要功能
| 功能 | 说明 |
|------|------|
| 双通道读取 | ALS光视函数通道偏绿光 555nm+ White宽谱白光通道 |
| 原始值 + Lux 值 | 同时提供 16-bit 原始计数值和经校正的浮点 Lux 值 |
| Vishay 非线性校正 | 高照度(>1000 lux时的多项式补偿系数来自 Vishay 应用笔记Horner 法) |
| 自动量程 | 自动调节增益x1/x2/÷4/÷8和积分时间25~800ms覆盖 0.007~120000 lux 全量程 |
| 3 次采样中位数 | 过滤偶发异常读数(实测观察到单次异常偏低 50% 的情况) |
| 可配置参数 | 增益、积分时间、持续保护次数、省电模式、中断阈值均可独立配置 |
| I2C 总线共享 | 与 ES8311音频编解码器、ES7210ADC、QMI8658AIMU共用同一 I2C 总线GPIO17/18地址无冲突 |
### 驱动文件
| 文件 | 说明 |
|------|------|
| `main/boards/common/veml7700.h` | 驱动头文件 — 寄存器定义、枚举、API 声明 |
| `main/boards/common/veml7700.cc` | 驱动实现 — I2C 16-bit 读写、Lux 计算、非线性校正、自动量程算法 |
### 与参考驱动的功能对比
| 功能 | Arduino 驱动 | ESP-IDF 社区组件 | 本项目驱动 |
|------|-------------|-----------------|-----------|
| I2C 层 | Wire.h (Arduino) | i2cdev (旧API) | **i2c_master (新API)** |
| Lux 非线性校正 | Vishay 多项式 | 无 | **Vishay 多项式** |
| 自动量程 | 有 | 无 | **有** |
| 原始值接口 | 有 | 无 | **有** |
| 3次采样中位数 | 无 | 无 | **有** |
| 线程安全 | N/A | mutexALS 漏了) | I2C 总线级保护 |
| get_config Bug | 无 | 有 | **无** |
## 1. 业务背景
@ -208,3 +265,88 @@ ratio_B = ALS_B / White_B (对方石)
| 不同石头总是能匹配上 | 两块石头材质/颜色极其相近 | 这属于"有缘",是正常现象 |
| 录入提示传感器未初始化 | VEML7700 传感器硬件连接异常 | 重启设备,检查硬件 |
| 匹配结果显示"光照环境差异过大" | 录入(室内)和匹配(室外)跨度过大 | 在相近光照环境下操作 |
## 10. 匹配准确率实测报告
### 测试条件
- 设备:精灵吊坠 ESP32-S3-WROOM-1-N16R8
- 传感器VEML7700-TRI2C 地址 0x10共享 ES_I2C 总线GPIO17/18
- 操作方式:食指+大拇指捏住石头贴紧传感器检测
- 算法版本:双维度匹配(光谱比值 15% + 亮度等级差 ≤1
### 测试1同石头 + 同环境(基准稳定性)
5 次匹配,石头不动,环境不变。
| 次序 | 比值差异 | 结果 |
|------|---------|------|
| 1 | 0.0% | ✅ |
| 2 | 0.4% | ✅ |
| 3 | 0.3% | ✅ |
| 4 | 0.4% | ✅ |
| 5 | 1.2% | ✅ |
**准确率5/5 = 100%**。比值波动 0.0%~1.2%,极其稳定。
### 测试2同石头 + 手指捏持姿势变化
5 次匹配,每次故意改变捏持角度和松紧度。
| 次序 | 比值差异 | 结果 |
|------|---------|------|
| 1 | 1.6% | ✅ |
| 2 | 2.3% | ✅ |
| 3角度变化 | 6.6% | ✅ |
| 4角度变化大 | 9.6% | ✅ |
| 5松紧变化 | 5.4% | ✅ |
**准确率5/5 = 100%**。最大波动 9.6%,距阈值 15% 有 5.4% 安全余量。
### 测试3同石头 + 不同遮挡程度(模拟不同光照)
| 条件 | 比值差异 | 旧方案 Lux 差异 | 新方案结果 | 旧方案预测 |
|------|---------|---------------|-----------|-----------|
| 轻遮挡 10cm | 9.7% | 41.7% | ✅ | ❌ |
| 轻遮挡 10cm | 3.3% | 19.7% | ✅ | ✅ |
| 中遮挡 ~7cm | 12.9% | 49.5% | ✅ | ❌ |
| 重遮挡 <5cm | 19.2% | 60.8% | | |
| 重遮挡 <5cm | 16.6% | 56.6% | | |
| 重遮挡 <5cm | 19.5% | 58.9% | | |
**新方案准确率3/6 = 50%**(轻/中遮挡全过,重遮挡全拒)
**旧方案准确率1/6 = 17%**(仅最轻遮挡偶尔通过)
重遮挡失败属于**正确行为**(手掌近距离改变了光谱成分,不是单纯亮度变化)。
### 测试4不同石头 + 遮挡
3 次匹配,不同石头在遮挡条件下测试。
| 次序 | 比值差异 | 结果 |
|------|---------|------|
| 1 | 20.6% | ❌(正确拒绝) |
| 2 | 23.1% | ❌(正确拒绝) |
| 3 | 22.8% | ❌(正确拒绝) |
**误匹配率0/3 = 0%**。不同石头被正确拒绝。
### 综合准确率汇总
| 场景 | 测试次数 | 正确判定 | 准确率 |
|------|---------|---------|--------|
| 同石头 + 同环境 | 5 | 5 | **100%** |
| 同石头 + 姿势变化 | 5 | 5 | **100%** |
| 同石头 + 轻/中遮挡 | 3 | 3 | **100%** |
| 同石头 + 重遮挡(正确拒绝) | 3 | 3 | **100%** |
| 不同石头(正确拒绝) | 3 | 3 | **100%** |
| **总计** | **19** | **19** | **100%** |
> 注:所有测试结果均符合预期行为。"重遮挡同石头被拒绝"属于正确判定(手掌改变了光谱),不计为错误。
---
**文档版本**v1.0
**编写日期**2026-04-02
**固件版本**1.7.4
**适用硬件**:精灵吊坠 ESP32-S3-WROOM-1-N16R8 + VEML7700-TR

View File

@ -1,4 +1,6 @@
#include "application.h"
#include "esp_aec.h"
#include "esp_heap_caps.h"
// #include "ble_service_config.h" // BLE JSON Service 暂不使用
#include "board.h"
#include "wifi_board.h"
@ -30,6 +32,7 @@
#include <nvs.h>
#include <esp_http_client.h>
#include <esp_crt_bundle.h>
#include <esp_pm.h>
#define TAG "Application"
#define MAC_TAG "BluetoothMAC"
@ -98,9 +101,170 @@ Application::~Application() {
player_pipeline_close(player_pipeline_);
player_pipeline_ = nullptr;
}
DeinitAec();
vEventGroupDelete(event_group_);
}
void Application::InitAec() {
if (aec_handle_ != nullptr) {
return;
}
aec_handle_t* handle = aec_create(16000, 4, 1, AEC_MODE_VOIP_LOW_COST);
if (handle == nullptr) {
ESP_LOGE(TAG, "❌ AEC 初始化失败");
return;
}
aec_handle_ = handle;
aec_chunk_size_ = aec_get_chunksize(handle);
int min_capacity = aec_ref_delay_samples_ + aec_chunk_size_ * 2 + 320;
int desired_capacity = 16000 / 5;
ref_ring_capacity_ = (min_capacity > desired_capacity) ? min_capacity : desired_capacity;
ref_ring_buf_ = (int16_t *)heap_caps_calloc(ref_ring_capacity_, sizeof(int16_t),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (ref_ring_buf_ == nullptr) {
ESP_LOGE(TAG, "❌ ref_ring_buf 分配失败 capacity=%d", ref_ring_capacity_);
aec_destroy(handle);
aec_handle_ = nullptr;
aec_chunk_size_ = 0;
return;
}
ref_ring_write_idx_ = 0;
ref_ring_filled_ = 0;
if (ref_ring_mutex_ == nullptr) {
ref_ring_mutex_ = xSemaphoreCreateMutex();
}
ESP_LOGI(TAG, "✅ AEC 初始化成功: chunk=%d samples, delay=%d samples, ring=%d samples",
aec_chunk_size_, aec_ref_delay_samples_, ref_ring_capacity_);
}
void Application::DeinitAec() {
if (aec_handle_ != nullptr) {
aec_destroy(static_cast<aec_handle_t*>(aec_handle_));
aec_handle_ = nullptr;
aec_chunk_size_ = 0;
}
if (ref_ring_buf_ != nullptr) {
heap_caps_free(ref_ring_buf_);
ref_ring_buf_ = nullptr;
ref_ring_capacity_ = 0;
ref_ring_write_idx_ = 0;
ref_ring_filled_ = 0;
}
if (ref_ring_mutex_ != nullptr) {
vSemaphoreDelete(ref_ring_mutex_);
ref_ring_mutex_ = nullptr;
}
}
void Application::AppendRefSamples(const int16_t *pcm, int samples) {
if (ref_ring_buf_ == nullptr || pcm == nullptr || samples <= 0 || ref_ring_mutex_ == nullptr) {
return;
}
if (xSemaphoreTake(ref_ring_mutex_, pdMS_TO_TICKS(2)) != pdTRUE) {
return;
}
for (int i = 0; i < samples; i++) {
ref_ring_buf_[ref_ring_write_idx_] = pcm[i];
ref_ring_write_idx_ = (ref_ring_write_idx_ + 1) % ref_ring_capacity_;
}
if (ref_ring_filled_ < ref_ring_capacity_) {
ref_ring_filled_ = std::min(ref_ring_filled_ + samples, ref_ring_capacity_);
}
xSemaphoreGive(ref_ring_mutex_);
}
void Application::GetDelayedRef(int16_t *ref_out, int samples) {
if (ref_ring_buf_ == nullptr || ref_out == nullptr || samples <= 0 || ref_ring_mutex_ == nullptr) {
if (ref_out) {
memset(ref_out, 0, samples * sizeof(int16_t));
}
return;
}
if (xSemaphoreTake(ref_ring_mutex_, pdMS_TO_TICKS(2)) != pdTRUE) {
memset(ref_out, 0, samples * sizeof(int16_t));
return;
}
int total_offset = aec_ref_delay_samples_ + samples;
if (ref_ring_filled_ < total_offset) {
memset(ref_out, 0, samples * sizeof(int16_t));
xSemaphoreGive(ref_ring_mutex_);
return;
}
int read_idx = (ref_ring_write_idx_ - total_offset + ref_ring_capacity_) % ref_ring_capacity_;
for (int i = 0; i < samples; i++) {
ref_out[i] = ref_ring_buf_[read_idx];
read_idx = (read_idx + 1) % ref_ring_capacity_;
}
xSemaphoreGive(ref_ring_mutex_);
}
void Application::ApplyAEC(std::vector<int16_t>& mic_inout) {
if (aec_handle_ == nullptr) {
InitAec();
if (aec_handle_ == nullptr || aec_chunk_size_ <= 0) {
return;
}
}
int n = (int)mic_inout.size();
int chunk = aec_chunk_size_;
if (n < chunk) {
return;
}
int processed = 0;
std::vector<int16_t> ref(chunk);
std::vector<int16_t> clean(chunk);
int64_t mic_sq = 0;
int64_t ref_sq = 0;
int64_t clean_sq = 0;
const int REF_SILENCE_RMS_THRESHOLD = 50;
while (processed + chunk <= n) {
GetDelayedRef(ref.data(), chunk);
int64_t ref_chunk_sq = 0;
for (int i = 0; i < chunk; i++) {
int16_t r = ref[i];
ref_chunk_sq += (int64_t)r * r;
ref_sq += (int64_t)r * r;
}
int ref_chunk_rms = (int)sqrt((double)ref_chunk_sq / chunk);
if (ref_chunk_rms < REF_SILENCE_RMS_THRESHOLD) {
for (int i = 0; i < chunk; i++) {
int16_t m = mic_inout[processed + i];
mic_sq += (int64_t)m * m;
clean_sq += (int64_t)m * m;
}
} else {
aec_process(static_cast<const aec_handle_t*>(aec_handle_),
mic_inout.data() + processed, ref.data(), clean.data());
for (int i = 0; i < chunk; i++) {
int16_t m = mic_inout[processed + i];
int16_t c = clean[i];
mic_sq += (int64_t)m * m;
clean_sq += (int64_t)c * c;
mic_inout[processed + i] = c;
}
}
processed += chunk;
}
static uint64_t last_rms_log_us = 0;
uint64_t now_us = esp_timer_get_time();
if (now_us - last_rms_log_us > 2000000 && processed > 0) {
int mic_rms = (int)sqrt((double)mic_sq / processed);
int ref_rms = (int)sqrt((double)ref_sq / processed);
int clean_rms = (int)sqrt((double)clean_sq / processed);
ESP_LOGI(TAG, "🔬 AEC RMS mic=%d ref=%d clean=%d delay=%d",
mic_rms, ref_rms, clean_rms, aec_ref_delay_samples_);
last_rms_log_us = now_us;
}
}
void Application::CheckNewVersion() {
// ESP_LOGI(TAG, "OTA版本检查已临时禁用");
// return;
@ -337,10 +501,19 @@ void Application::ToggleChatState() {
Board::GetInstance().SetPowerSaveMode(false);// 关闭低功耗模式
if (!protocol_->OpenAudioChannel()) {
auto ac = Board::GetInstance().GetAudioCodec();
ESP_LOGW(TAG, "打开音频通道失败将在2秒后重试");
audio_channel_retry_count_++;
ESP_LOGW(TAG, "打开音频通道失败 (第 %d 次), 将在2秒后重试", audio_channel_retry_count_);
if (ac) {
ESP_LOGW(TAG, "Diag: codec out_channels=%d in_channels=%d out_sr=%d in_sr=%d", ac->output_channels(), ac->input_channels(), ac->output_sample_rate(), ac->input_sample_rate());
}
if (audio_channel_retry_count_ >= 3) {
ESP_LOGW(TAG, "🔄 连续失败 3 次, 触发 RTC engine 重建");
auto* volc_rtc = static_cast<VolcRtcProtocol*>(protocol_.get());
if (volc_rtc) {
volc_rtc->ForceRebuildEngine();
}
audio_channel_retry_count_ = 0;
}
SetDeviceState(kDeviceStateIdle);
Schedule([this]() {
vTaskDelay(pdMS_TO_TICKS(2000));
@ -349,6 +522,7 @@ void Application::ToggleChatState() {
});
return;
}
audio_channel_retry_count_ = 0;
listening_mode_ = kListeningModeRealtime;// 设置监听模式为实时监听
SetDeviceState(kDeviceStateDialog);// 设置设备状态为对话模式
@ -526,6 +700,8 @@ void Application::Start() {
auto& board = Board::GetInstance();
SetDeviceState(kDeviceStateStarting);
LoadIdleCyclesFromNvs();
// 读取NVS中的重启标志
Settings sys("system", true);
int32_t reboot_dlg_idle = sys.GetInt("reboot_dlg_idle", 0);
@ -567,7 +743,14 @@ void Application::Start() {
reference_resampler_.Configure(codec->input_sample_rate(), 16000);
}
uplink_resampler_.Configure(16000, 8000);
codec->Start();
auto wifi_board = static_cast<WifiBoard*>(&board);
bool provisioning_mode = wifi_board && wifi_board->IsWifiConfigMode();
if (provisioning_mode) {
ESP_LOGI(TAG, "BLE配网模式音频 codec 使用 output-only跳过麦克风 RX DMA");
codec->StartOutputOnly();
} else {
codec->Start();
}
{
int battery_level = 0;
bool charging = false;
@ -844,6 +1027,13 @@ void Application::Start() {
ESP_LOGI(TAG, "🔊 启用音频编解码器输出");
codec->EnableOutput(true);// 启用音频编解码器输出
{
const int silence_samples = codec->output_sample_rate() / 5;
std::vector<int16_t> silence(silence_samples, 0);
codec->OutputData(silence);
ESP_LOGI(TAG, "🔇 已灌 200ms 静音 PCM 覆盖 DMA 残留");
}
if (!player_pipeline_) {
player_pipeline_ = player_pipeline_open();
player_pipeline_run(player_pipeline_);
@ -1991,28 +2181,14 @@ void Application::StartDialogWatchdog() {
// 调试日志
ESP_LOGD(TAG, "Dialog watchdog: elapsed=%d, remaining=%d", (int)elapsed, remaining);
// 如果剩余秒数小于等于0说明对话空闲倒计时已到需要重启设备
// 如果剩余秒数小于等于0说明对话空闲倒计时已到进入 RTC 软退出
if (remaining <= 0) {
ESP_LOGI(TAG, "Dialog watchdog idle reached, elapsed=%d, marking and rebooting", (int)elapsed);
Settings sys("system", true);
ESP_LOGI(TAG, "Dialog watchdog: preparing NVS writes (system)");
sys.SetInt("reboot_dlg_idle", 1);
sys.SetInt("reboot_origin", 1);
ESP_LOGI(TAG, "Dialog watchdog: committing NVS (system)");
sys.Commit();
Settings sysr("system");
int32_t verify = sysr.GetInt("reboot_dlg_idle", 0);
int32_t origin_read = sysr.GetInt("reboot_origin", 0);
if (verify != 1) {
ESP_LOGW(TAG, "Dialog watchdog: NVS verify failed, cause=%d, origin=%d", (int)verify, (int)origin_read);
ESP_LOGW(TAG, "建议: 检查NVS空间是否不足、确认nvs_flash_init成功、避免并发写入(system)");
}
ESP_LOGI(TAG, "Dialog watchdog (task) set reboot_cause=1, verify=%d, restart in 2000ms", (int)verify);
// 重启前上报设备离线状态
Board::GetInstance().OnBeforeRestart();
vTaskDelay(pdMS_TO_TICKS(2000));
esp_restart();// 重启设备
app->dialog_watchdog_running_ = false;// 设置看门狗运行标志为false
ESP_LOGI(TAG, "Dialog watchdog 触发:%ds 无对话活动 → 进入 RTC 空闲软退出", (int)elapsed);
app->dialog_watchdog_running_ = false;
app->Schedule([app]() {
app->EnterIdleHibernate();
});
break;
} else {
// 简化条件判断,移除冗余检查
// 优化桶计算逻辑使用1秒一个桶更精确地显示倒计时
@ -2175,6 +2351,7 @@ void Application::OnAudioOutput() {
player_pipeline_set_src_rate(player_pipeline_, src_rate);
int bytes = (int)(pcm.size() * sizeof(int16_t));
ESP_LOGD(TAG, "写入播放管道: 采样率=%d 字节=%d", src_rate, bytes);
AppendRefSamples(pcm.data(), (int)pcm.size());
player_pipeline_write(player_pipeline_, (char*)pcm.data(), bytes);
if (bytes > 0) {
this->last_audible_output_time_ = std::chrono::steady_clock::now();
@ -2185,6 +2362,7 @@ void Application::OnAudioOutput() {
}
} else {
ESP_LOGD(TAG, "直接输出PCM到编解码器: 样本=%zu", pcm.size());
AppendRefSamples(pcm.data(), (int)pcm.size());
codec->OutputData(pcm);// 直接输出PCM数据
if (!pcm.empty()) {
this->last_audible_output_time_ = std::chrono::steady_clock::now();
@ -2219,6 +2397,12 @@ void Application::OnAudioOutput() {
}
void Application::OnAudioInput() {
auto codec_for_guard = Board::GetInstance().GetAudioCodec();
if (hibernating_.load() || !codec_for_guard || !codec_for_guard->input_enabled() || !recorder_pipeline_) {
vTaskDelay(pdMS_TO_TICKS(20));
return;
}
std::vector<int16_t> data;
#if CONFIG_USE_WAKE_WORD_DETECT || CONFIG_USE_CUSTOM_WAKE_WORD
@ -2280,14 +2464,9 @@ void Application::OnAudioInput() {
} else if (send_pcm_uplink_) {
ReadAudio(data, 16000, 20 * 16000 / 1000);
if (!data.empty()) {
int out_samples = (int)data.size() / 2;
std::vector<int16_t> down(out_samples);
for (int i = 0, j = 0; i < out_samples; ++i, j += 2) {
down[i] = data[j];
}
std::vector<int16_t> resampled(uplink_resampler_.GetOutputSamples((int)down.size()));
std::vector<int16_t> resampled(uplink_resampler_.GetOutputSamples((int)data.size()));
if (!resampled.empty()) {
uplink_resampler_.Process(down.data(), (int)down.size(), resampled.data());
uplink_resampler_.Process(data.data(), (int)data.size(), resampled.data());
}
std::vector<uint8_t> bytes(resampled.size() * sizeof(int16_t));
for (size_t i = 0; i < resampled.size(); ++i) {
@ -2384,6 +2563,7 @@ void Application::ReadAudio(std::vector<int16_t>& data, int sample_rate, int sam
}
if (!out.empty()) {
data.assign(out.begin(), out.end());// 将输出向量中的数据赋值给输出参数data
ApplyAEC(data);
return;
}
}
@ -2417,6 +2597,7 @@ void Application::ReadAudio(std::vector<int16_t>& data, int sample_rate, int sam
auto resampled = std::vector<int16_t>(input_resampler_.GetOutputSamples(data.size()));
input_resampler_.Process(data.data(), data.size(), resampled.data());
data = std::move(resampled);
ApplyAEC(data);
}
} else {
data.resize(samples);
@ -2424,6 +2605,7 @@ void Application::ReadAudio(std::vector<int16_t>& data, int sample_rate, int sam
ESP_LOGW(TAG, "🎙️ 麦克风采样失败(直读路径),未收到输入数据");
return;
}
ApplyAEC(data);
}
}
@ -3537,6 +3719,10 @@ bool Application::CanEnterSleepMode() {
return false;
}
if (hibernating_.load()) {
return false;
}
// Now it is safe to enter sleep mode
return true;
}
@ -4110,6 +4296,128 @@ void Application::InitializeWebsocketProtocol() {
// BLE JSON 通讯服务集成
// ============================================================
void Application::SaveIdleCyclesToNvs() {
Settings s("hibernate", true);
s.SetInt("idle_cycles", idle_cycles_);
}
void Application::LoadIdleCyclesFromNvs() {
Settings s("hibernate", false);
idle_cycles_ = s.GetInt("idle_cycles", 0);
}
void Application::ResetIdleCyclesNvs() {
Settings s("hibernate", true);
s.SetInt("idle_cycles", 0);
idle_cycles_ = 0;
}
void Application::EnterIdleHibernate() {
if (hibernating_.load()) {
ESP_LOGW(TAG, "已处于休眠状态,跳过重复进入");
return;
}
ESP_LOGI(TAG, "🌙 进入空闲休眠stop RTC保留 handle→ 播待命音 → 静默");
auto codec = Board::GetInstance().GetAudioCodec();
hibernating_.store(true);
vTaskDelay(pdMS_TO_TICKS(50));
if (protocol_) {
protocol_->LeaveRoom(/*notify_closed=*/false);
}
if (background_task_) {
background_task_->WaitForCompletion();
}
{
std::lock_guard<std::mutex> lock(mutex_);
if (!audio_decode_queue_.empty()) {
ESP_LOGI(TAG, "EnterIdleHibernate: 清空残留音频队列 size=%zu", audio_decode_queue_.size());
audio_decode_queue_.clear();
}
}
if (codec) {
ESP_LOGI(TAG, "EnterIdleHibernate: 关闭 codec 麦克风output 保留播待命音)");
codec->EnableInput(false);
}
if (recorder_pipeline_) {
recorder_pipeline_close(recorder_pipeline_);
recorder_pipeline_ = nullptr;
}
esp_pm_config_t pm_config = {
.max_freq_mhz = 240,
.min_freq_mhz = 240,
.light_sleep_enable = false,
};
esp_pm_configure(&pm_config);
ESP_LOGI(TAG, "EnterIdleHibernate: 已禁用 Light Sleep保护 I2C/codec 总线");
if (codec) {
ESP_LOGI(TAG, "EnterIdleHibernate: 重启 codec output 通道并灌静音");
codec->EnableOutput(false);
vTaskDelay(pdMS_TO_TICKS(20));
codec->EnableOutput(true);
const int silence_samples = codec->output_sample_rate() / 5;
std::vector<int16_t> silence(silence_samples, 0);
codec->OutputData(silence);
}
SetDeviceState(kDeviceStateIdle);
ESP_LOGI(TAG, "EnterIdleHibernate: 等待待命音播放完成...");
WaitForAudioPlayback();
if (background_task_) {
background_task_->WaitForCompletion();
}
vTaskDelay(pdMS_TO_TICKS(1000));
if (player_pipeline_) {
player_pipeline_close(player_pipeline_);
player_pipeline_ = nullptr;
ESP_LOGI(TAG, "EnterIdleHibernate: player_pipeline 已关闭");
} else if (codec) {
codec->EnableOutput(false);
ESP_LOGI(TAG, "EnterIdleHibernate: codec output 已关闭");
}
idle_cycles_++;
SaveIdleCyclesToNvs();
ESP_LOGI(TAG, "✓ 已进入空闲休眠(累计第 %d 次rtc_handle 保留)", idle_cycles_);
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGW(TAG, "累计休眠 %d 次,下次唤醒将硬重启清理内存碎片", idle_cycles_);
}
}
void Application::WakeFromHibernate() {
if (!hibernating_.load()) {
return;
}
ESP_LOGI(TAG, "☀ 从空闲休眠唤醒,准备重连 RTC");
if (idle_cycles_ >= IDLE_CYCLES_REBOOT_THRESHOLD) {
ESP_LOGI(TAG, "累计休眠 %d 次,硬重启清理内存碎片", idle_cycles_);
ResetIdleCyclesNvs();
Board::GetInstance().OnBeforeRestart();
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
return;
}
hibernating_.store(false);
if (device_state_ != kDeviceStateIdle) {
SetDeviceState(kDeviceStateIdle);
}
ESP_LOGI(TAG, "WakeFromHibernate: 调用 ToggleChatState() 触发 RTC 重连");
ToggleChatState();
ESP_LOGI(TAG, "✓ 唤醒完成,已触发 RTC 重连");
}
const char* Application::DeviceStateToString(DeviceState state) {
int idx = static_cast<int>(state);
if (idx >= 0 && idx < static_cast<int>(sizeof(STATE_STRINGS) / sizeof(STATE_STRINGS[0]))) {

View File

@ -87,6 +87,9 @@ public:
bool IsAudioQueueEmpty(); // 检查音频队列是否为空
void ClearAudioQueue(); // 清空音频播放队列
bool CanEnterSleepMode();// 检查是否可以进入睡眠模式
void EnterIdleHibernate(); // RTC 空闲软退出
void WakeFromHibernate(); // 从空闲软退出状态唤醒并重连 RTC
bool IsHibernating() const { return hibernating_.load(); }
void StopAudioProcessor();// 停止音频处理器
void ResetDecoder();// 重置解码器状态(用于修复音频播放问题)
bool IsSafeToOperate(); // 🔧 检查当前是否可以安全执行操作
@ -142,6 +145,12 @@ private:
std::atomic<std::chrono::steady_clock::time_point> last_safe_operation_; // 🔧 最后安全操作时间戳
std::atomic<bool> is_switching_to_listening_{false}; // 🔵 标志:正在主动切换到聆听状态
std::atomic<bool> is_low_battery_transition_{false};
std::atomic<bool> hibernating_{false}; // RTC 空闲软退出状态
int idle_cycles_ = 0; // 累计软退出次数
static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50; // 软退出多次后硬重启清碎片
void SaveIdleCyclesToNvs();
void LoadIdleCyclesFromNvs();
void ResetIdleCyclesNvs();
ListeningMode listening_mode_ = kListeningModeAutoStop;
#if CONFIG_USE_REALTIME_CHAT
bool realtime_chat_enabled_ = true;
@ -153,6 +162,7 @@ private:
std::atomic<bool> https_playback_active_{false};// 🌐 HTTPS音频播放进行中标志
std::atomic<bool> https_playback_abort_{false};// 🌐 HTTPS音频播放中止标志
bool aborted_ = false;
int audio_channel_retry_count_ = 0;
bool voice_detected_ = false;
bool audio_paused_ = false; // 音频暂停状态标志
float current_speaker_volume_ = 0.0f; // 当前扬声器音量,用于语音打断判断
@ -188,6 +198,21 @@ private:
player_pipeline_handle_t player_pipeline_ = nullptr;
recorder_pipeline_handle_t recorder_pipeline_ = nullptr;
// 软件 loopback AEC: DAC 输出复制到 ref ring, 上行 mic 与延迟 ref 做同步消回声。
void *aec_handle_ = nullptr;
int aec_chunk_size_ = 0;
int16_t *ref_ring_buf_ = nullptr;
int ref_ring_capacity_ = 0;
int ref_ring_write_idx_ = 0;
int ref_ring_filled_ = 0;
int aec_ref_delay_samples_ = 800;
SemaphoreHandle_t ref_ring_mutex_ = nullptr;
void InitAec();
void DeinitAec();
void AppendRefSamples(const int16_t *pcm, int samples);
void GetDelayedRef(int16_t *ref_out, int samples);
void ApplyAEC(std::vector<int16_t>& mic_inout);
void MainLoop();// 主事件循环
void OnAudioInput();// 音频输入回调
void OnAudioOutput();// 音频输出回调

View File

@ -42,6 +42,19 @@ void AudioCodec::Start() {
ESP_LOGI(TAG, "Audio codec started");
}
void AudioCodec::StartOutputOnly() {
Settings settings("audio", false);
output_volume_ = settings.GetInt("output_volume", output_volume_);
if (output_volume_ <= 0) {
ESP_LOGW(TAG, "Output volume value (%d) is too small, setting to default (10)", output_volume_);
output_volume_ = 10;
}
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
EnableOutput(true);
ESP_LOGI(TAG, "Audio codec started in output-only mode");
}
void AudioCodec::SetOutputVolume(int volume) {
output_volume_ = volume;
ESP_LOGI(TAG, "Set output volume to %d", output_volume_);

View File

@ -25,6 +25,7 @@ public:
virtual void EnableOutput(bool enable);
void Start();
void StartOutputOnly();
void OutputData(std::vector<int16_t>& data);
bool InputData(std::vector<int16_t>& data);

View File

@ -191,12 +191,13 @@ void BoxAudioCodec::EnableInput(bool enable) {
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = static_cast<uint8_t>(input_channels_),
.channel_mask = ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0),
.channel_mask = 0,
// .sample_rate = (uint32_t)output_sample_rate_,
.sample_rate = (uint32_t)input_sample_rate_,
.mclk_multiple = 0,
};
if (input_reference_) {
fs.channel_mask = ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0);
fs.channel_mask |= ESP_CODEC_DEV_MAKE_CHANNEL_MASK(1);
}
ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs));

View File

@ -36,6 +36,24 @@
static const char *TAG = "WifiBoard"; ///< 日志标签用于标识WiFi板级模块的日志输出
static void wifi_reset_task(void* arg) {
auto* board = static_cast<WifiBoard*>(arg);
ESP_LOGI(TAG, "🔄 重置WiFi配置设备将重启进入配网模式");
{
Settings settings("wifi", true);
settings.SetInt("force_ap", 1);
}
auto display = board->GetDisplay();
if (display) {
display->ShowNotification(Lang::Strings::ENTERING_WIFI_CONFIG_MODE);
}
vTaskDelay(pdMS_TO_TICKS(500));
ESP_LOGI(TAG, "🔄 正在重启设备...");
esp_restart();
vTaskDelete(NULL);
}
/**
* @brief WiFi板级管理构造函数
*
@ -331,23 +349,12 @@ void WifiBoard::SetPowerSaveMode(bool enabled) {
// 重置WiFi配置设备将重启进入配网模式
void WifiBoard::ResetWifiConfiguration() {
ESP_LOGI(TAG, "🔄 重置WiFi配置设备将重启进入配网模式");
// 设置WiFi配网标志位确保重启后能正确进入配网模式
{
Settings settings("wifi", true);// 创建WiFi配置设置对象第二个参数true表示立即保存到NVS存储
settings.SetInt("force_ap", 1);// 设置force_ap标志为1这个标志会在设备重启后被检查如果为1则启动WiFi配网服务启动时强制进入AP配网模式
if (xTaskCreate(wifi_reset_task, "wifi_reset", 4096, this, 5, nullptr) != pdPASS) {
ESP_LOGE(TAG, "创建 wifi_reset 任务失败,直接写入配置并重启");
Settings settings("wifi", true);
settings.SetInt("force_ap", 1);
esp_restart();
}
// 获取显示设备对象并显示配网提示信息
auto display = GetDisplay();
if (display) {
// 在屏幕上显示"进入WiFi配置模式"的多语言提示信息
// 让用户知道设备即将重启并进入配网模式
display->ShowNotification(Lang::Strings::ENTERING_WIFI_CONFIG_MODE);
}
vTaskDelay(pdMS_TO_TICKS(500)); // 等待500ms确保NVS配置保存完成如果有屏幕显示可以增加到1000ms让用户看清提示
ESP_LOGI(TAG, "🔄 正在重启设备...");
esp_restart(); // 重启设备,重启后会进入配网模式
}
// 启动BLE配网服务

View File

@ -132,6 +132,7 @@ public:
virtual const char* GetNetworkStateIcon() override;
virtual void SetPowerSaveMode(bool enabled) override;
virtual void ResetWifiConfiguration();
bool IsWifiConfigMode() const { return wifi_config_mode_; }
/**
* @brief BLE配网是否激活

View File

@ -785,6 +785,17 @@ public:
auto &app = Application::GetInstance();
auto current_state = app.GetDeviceState();
// RTC 空闲软退出状态下BOOT 单击优先唤醒并重连 RTC。
if (app.IsHibernating()) {
ESP_LOGI(TAG, "🔵 BOOT in hibernate → 唤醒(重连 RTC");
xTaskCreate([](void* arg) {
(void)arg;
Application::GetInstance().WakeFromHibernate();
vTaskDelete(NULL);
}, "wake_hibernate", 4096, NULL, 5, NULL);
return;
}
// 检查是否处于BLE配网状态如果是则屏蔽按键响应生产测试模式下除外
auto* wifi_board = dynamic_cast<WifiBoard*>(this);
if (wifi_board && wifi_board->IsBleProvisioningActive() && !production_test_mode_) {

View File

@ -54,6 +54,8 @@ public:
virtual void Start() = 0;
virtual bool OpenAudioChannel() = 0;
virtual void CloseAudioChannel() = 0;
// RTC 软退出默认兼容旧协议RTC 协议可重写为仅退房不销毁 engine。
virtual void LeaveRoom(bool notify_closed = true) { (void)notify_closed; CloseAudioChannel(); }
virtual bool IsAudioChannelOpened() const = 0;
virtual void SendAudio(const std::vector<uint8_t>& data) = 0;
virtual void SendPcm(const std::vector<uint8_t>& data) {}

View File

@ -21,6 +21,25 @@
static const char* TAG = "VolcRtcProtocol";
#ifndef DIAG_RTC_BIND_ENABLE
#define DIAG_RTC_BIND_ENABLE 1
#endif
#if DIAG_RTC_BIND_ENABLE
#include "esp_wifi.h"
#include "lwip/sockets.h"
static int diag_count_used_sockets(void) {
int used = 0;
for (int fd = LWIP_SOCKET_OFFSET; fd < LWIP_SOCKET_OFFSET + CONFIG_LWIP_MAX_SOCKETS; fd++) {
struct stat st;
if (fstat(fd, &st) == 0) {
used++;
}
}
return used;
}
#endif
VolcRtcProtocol::VolcRtcProtocol() {
event_group_handle_ = xEventGroupCreate();
}
@ -335,7 +354,8 @@ void VolcRtcProtocol::LogUplinkStatsMaybe() {
// 🔊 打开音频通道
bool VolcRtcProtocol::OpenAudioChannel() {
if (!rtc_handle_) {
ESP_LOGW(TAG, "无法打开音频通道RTC句柄未准备就绪");// 无法打开音频通道RTC句柄未准备就绪
ESP_LOGW(TAG, "无法打开音频通道RTC句柄未准备就绪触发重建");
Start();
return false;
}
if (!is_connected_) {
@ -347,6 +367,18 @@ bool VolcRtcProtocol::OpenAudioChannel() {
xEventGroupClearBits(event_group_handle_, 0x1 | 0x2);
// 新增extra_params 用于传递额外的AgentConfig配置参数
ESP_LOGI(TAG, "Join RTC: handle=%p bot=%s iot_ready=%d free_heap=%u", rtc_handle_, CONFIG_VOLC_BOT_ID, (int)iot_ready_, (unsigned)heap_caps_get_free_size(MALLOC_CAP_DEFAULT));
#if DIAG_RTC_BIND_ENABLE
{
int sockets_used = diag_count_used_sockets();
wifi_ap_record_t ap_info = {};
int rssi = (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) ? ap_info.rssi : -127;
ESP_LOGW("DIAG-RTC", "Pre-Join: sockets=%d/%d heap=%u psram=%u rssi=%d",
sockets_used, CONFIG_LWIP_MAX_SOCKETS,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_DEFAULT),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM),
rssi);
}
#endif
int ret = volc_rtc_start(rtc_handle_, CONFIG_VOLC_BOT_ID, &iot_info_, extra_params_.empty() ? NULL : extra_params_.c_str());
if (ret != 0) {
ESP_LOGE(TAG, "RTC启动失败%d", ret);// RTC启动失败%d
@ -358,6 +390,13 @@ bool VolcRtcProtocol::OpenAudioChannel() {
if ((bits & 0x1) == 0) {
ESP_LOGE(TAG, "RTC连接超时");// RTC连接超时
ESP_LOGW(TAG, "Diag: check Wi-Fi, SNTP time sync, IoT creds, RTC server availability");// 诊断检查Wi-Fi、SNTP时间同步、IoT凭证、RTC服务器可用性
#if DIAG_RTC_BIND_ENABLE
ESP_LOGW("DIAG-RTC", "Post-Fail: sockets=%d/%d heap=%u psram=%u errno=%d(%s)",
diag_count_used_sockets(), CONFIG_LWIP_MAX_SOCKETS,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_DEFAULT),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM),
errno, strerror(errno));
#endif
return false;
}
// Do not block audio readiness on remote user join; enable subscribe immediately
@ -406,6 +445,51 @@ void VolcRtcProtocol::CloseAudioChannel() {
}
}
void VolcRtcProtocol::LeaveRoom(bool notify_closed) {
if (rtc_handle_) {
if (is_connected_) {
volc_rtc_stop(rtc_handle_);
is_connected_ = false;
}
ESP_LOGI(TAG, "✓ 已 stop RTC 房间(保留 handle 供唤醒复用, notify_closed=%d", (int)notify_closed);
}
is_audio_channel_opened_ = false;
// 退房后本地待命音仍是 Opus必须清掉 RTC PCM 下行标志,避免 Opus 被当 PCM 播成杂音。
downlink_is_pcm_ = false;
first_downlink_logged_ = false;
if (notify_closed && on_audio_channel_closed_) {
on_audio_channel_closed_();
}
}
void VolcRtcProtocol::ForceRebuildEngine() {
ESP_LOGW(TAG, "🔄 ForceRebuildEngine: 销毁 RTC engine 以清理 SDK 状态");
#if DIAG_RTC_BIND_ENABLE
ESP_LOGW("DIAG-RTC", "Pre-Rebuild: sockets=%d/%d heap=%u",
diag_count_used_sockets(), CONFIG_LWIP_MAX_SOCKETS,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_DEFAULT));
#endif
if (rtc_handle_) {
if (is_connected_) {
volc_rtc_stop(rtc_handle_);
is_connected_ = false;
}
volc_rtc_destroy(rtc_handle_);
rtc_handle_ = nullptr;
}
is_audio_channel_opened_ = false;
downlink_is_pcm_ = false;
first_downlink_logged_ = false;
vTaskDelay(pdMS_TO_TICKS(2000));
#if DIAG_RTC_BIND_ENABLE
ESP_LOGW("DIAG-RTC", "Post-Rebuild-Wait: sockets=%d/%d heap=%u",
diag_count_used_sockets(), CONFIG_LWIP_MAX_SOCKETS,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_DEFAULT));
#endif
}
// 🔊 检查音频通道是否已打开
bool VolcRtcProtocol::IsAudioChannelOpened() const {
return is_audio_channel_opened_;

View File

@ -20,6 +20,8 @@ public:
void SendG711A(const std::vector<uint8_t>& data) override;// 🔊 发送G711A音频数据到RTC
bool OpenAudioChannel() override;// 🔊 打开音频通道
void CloseAudioChannel() override;// 🔊 关闭音频通道
void LeaveRoom(bool notify_closed = true) override;// RTC 软退出stop 房间并保留 handle
void ForceRebuildEngine();
bool IsAudioChannelOpened() const override;// 🔊 检查音频通道是否已打开
void SendAbortSpeaking(AbortReason reason) override;// 🔊 发送中止通话请求
void SendStartListening(ListeningMode mode) override;// 🔊 发送开始监听请求

View File

@ -159,9 +159,10 @@ CONFIG_BT_GATTS_ENABLE=y
CONFIG_BT_GATTC_ENABLE=y
CONFIG_BT_BLE_SMP_ENABLE=y
CONFIG_BT_STACK_NO_LOG=n
CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST=n
CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST=y
CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY=n
CONFIG_BT_RESERVE_DRAM=0x10000
CONFIG_LWIP_MAX_SOCKETS=20
# BluFi Configuration
CONFIG_BT_BLUFI_ENABLED=y

View File

@ -11,6 +11,8 @@ CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=8192
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=65536
CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y
CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY=y
CONFIG_SPIRAM_MEMTEST=n
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y