N16R8 模组无法跑硬件 ADC 回采 (32-bit STEREO codec + 火山 RTC + 80MHz PSRAM 三者 不可共存, 详见 commit fb4b607 探索教训)。改走软件 loopback ref 方案: codec 保持 baseline 1ch 16-bit (RTC 链路 100% 稳定), DAC 输出 PCM 软件复制一份作 AEC ref 信号, 用 esp_aec.h 底层同步 API (不启后台任务, 不抢 RTC 调度) 处理。 实测验证有效: - AI 说话: mic=187 ref=8929 clean=30 → 回声消除 84% - 用户说话: mic=456 ref=8 clean=456 → passthrough 100% 保留 - 服务端 ASR 正常识别用户语音, AI 正常响应 (📝 USER: + 📝 AI: 字幕完整) - 无 WiFi pm_coex panic, idle 倒计时稳定 主要变动: 1. main/CMakeLists.txt (4 行) - REQUIRES 加 esp-sr (引入 esp_aec.h 底层同步 API) 2. main/application.h (23 行) - aec_handle_ / aec_chunk_size_ / ref_ring_buf_ / ref_ring_capacity_ / ref_ring_write_idx_ / ref_ring_filled_ / aec_ref_delay_samples_ / ref_ring_mutex_ 成员 - InitAec / DeinitAec / AppendRefSamples / GetDelayedRef / ApplyAEC 函数声明 3. main/application.cc (242 行) - include esp_aec.h + esp_heap_caps.h - InitAec: lazy 初始化 (Application 构造时不调, ReadAudio 首次走 AEC 路径触发), 避免开机占内部 SRAM 影响 WiFi 启动; ref_ring_buf 优先 PSRAM 分配 200ms 容量 - DeinitAec: 析构时清理 aec_handle / ref_ring_buf / ref_ring_mutex - AppendRefSamples: DAC PCM 推入 ref ring buffer (mutex 互斥) - GetDelayedRef: 从 ref ring buffer 取延迟后 ref (mic 同步用) - ApplyAEC: 按 chunk_size 处理, 加 ref 静音检测 (RMS<50 时 passthrough), RMS 诊断日志每 2 秒打印一次 (mic/ref/clean) - OnAudioOutput 两个分支 (player_pipeline_write / codec->OutputData) 都加 AppendRefSamples hook, 复制 PCM 到 ref ring buffer - ReadAudio: recorder_pipeline 路径加 lazy InitAec + ApplyAEC, target_samples 取 max(caller_samples, chunk_size) 保持 baseline 20ms PCM 帧大小 - 析构调 DeinitAec 实施 4 大踩坑 (详见 ~/.claude/projects/.../memory/project_software_aec_implementation.md): a) portMUX (spinlock) 禁中断与 WiFi pm_coex 模块冲突 → IllegalInstruction panic 修复: 用 SemaphoreHandle_t (FreeRTOS mutex, 2ms 超时) 替代, 不禁中断 b) AI 静音后 AEC 滤波器维持 echo 模式错误压制用户语音 → ASR 不识别 修复: ApplyAEC 加 ref 静音检测, ref RMS<50 时 passthrough 不调 aec_process c) chunk_size (256, 16ms) ≠ caller_samples (320, 20ms) 让上行 PCM 帧大小变 → 服务端 ASR 不识别非标准帧 修复: target_samples = max(samples, aec_chunk_size_), 保持 baseline 20ms 帧 d) aec_create 占内部 SRAM (~30-50KB) 影响 WiFi RX buffer 分配 → panic 重启 修复: lazy init, ReadAudio 首次需要时才创建实例 资源占用 (实测): - Flash: +59 KB (esp-sr libaec.a) - Internal SRAM: +35-50 KB (aec_handle_t 工作 buffer) - PSRAM: +10-15 KB (ref_ring_buf 200ms + 临时 buffer) - Core 1 CPU: +6-12% (chunk=256, 每 16ms 一次 aec_process) - 整体评估: 适中, 不影响 RTC/WiFi 等其他功能 自言自语根因辨析 (重要认知更正): - 火山控制台 "AI 降噪 OFF" 是 NS 不是 AEC, 服务端 AEC 默认 ON 不显示在 UI - baseline 不自言自语 = 云端 AEC 在兜底 - 自言自语真因常是上行 PCM 数据异常 (如嘟嘟嘟阶段 channel_mask 错位) 触发服务端 VAD 误判, 不是 echo 太大 - 设备端软件 AEC 是减轻云端负载 + 极端场景兜底, 非必需但工程价值显著 调优指南: aec_ref_delay_samples_ 当前 800 (50ms), 根据 mic 离扬声器距离调 30-80ms, 监听 RMS 中 AI 说话期间 clean 最小为最优 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
12 KiB
C++
246 lines
12 KiB
C++
#ifndef _APPLICATION_H_
|
||
#define _APPLICATION_H_
|
||
|
||
#include <freertos/FreeRTOS.h>
|
||
#include <freertos/event_groups.h>
|
||
#include <freertos/task.h>
|
||
#include <esp_timer.h>
|
||
|
||
#include <string>
|
||
#include <mutex>
|
||
#include <list>
|
||
#include <atomic>
|
||
|
||
#include <opus_encoder.h>
|
||
#include <opus_decoder.h>
|
||
#include <opus_resampler.h>
|
||
|
||
#include "protocol.h"
|
||
#include "websocket_protocol.h"
|
||
#include "ota.h"
|
||
#include "background_task.h"
|
||
#include "audio/simple_pipeline.h"
|
||
// #include "ble_service.h" // BLE JSON Service 暂不使用
|
||
|
||
#if CONFIG_USE_WAKE_WORD_DETECT
|
||
#include "wake_word_detect.h"
|
||
#elif CONFIG_USE_CUSTOM_WAKE_WORD
|
||
#include "custom_wake_word.h"
|
||
#endif
|
||
#if CONFIG_USE_AUDIO_PROCESSOR
|
||
#include "audio_processor.h"
|
||
#endif
|
||
|
||
#define SCHEDULE_EVENT (1 << 0)
|
||
#define AUDIO_INPUT_READY_EVENT (1 << 1)
|
||
#define AUDIO_OUTPUT_READY_EVENT (1 << 2)
|
||
|
||
// 未知状态、启动中、WiFi配网模式、空闲待命、连接服务器、语音监听中、语音播报中、固件升级中、设备激活中、致命错误
|
||
enum DeviceState {
|
||
kDeviceStateUnknown,
|
||
kDeviceStateStarting,
|
||
kDeviceStateWifiConfiguring,
|
||
kDeviceStateIdle,
|
||
kDeviceStateConnecting,
|
||
kDeviceStateListening,
|
||
kDeviceStateSpeaking,
|
||
kDeviceStateDialog,
|
||
kDeviceStateUpgrading,
|
||
kDeviceStateActivating,
|
||
kDeviceStateFatalError
|
||
};
|
||
// OPUS音频帧时长(60ms)
|
||
#define OPUS_FRAME_DURATION_MS 60
|
||
// 应用程序主类(单例模式)
|
||
class Application {
|
||
public:
|
||
static Application& GetInstance() {
|
||
static Application instance;
|
||
return instance;
|
||
}
|
||
// 删除拷贝构造函数和赋值运算符
|
||
Application(const Application&) = delete;
|
||
Application& operator=(const Application&) = delete;
|
||
|
||
void Start(); // 启动应用程序
|
||
DeviceState GetDeviceState() const { return device_state_; } // 获取当前状态
|
||
bool IsVoiceDetected() const { return voice_detected_; } // 语音检测状态
|
||
void Schedule(std::function<void()> callback); // 任务调度
|
||
void SetDeviceState(DeviceState state); // 状态变更
|
||
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 SendStoryRequest(); // 通过HTTPS故事API请求并播放故事
|
||
void SendMusicRequest(); // 通过HTTPS音乐API请求并播放音乐
|
||
void HttpsPlaybackFromUrl(const std::string& url); // 通过HTTPS下载JSON并播放音频(故事/歌曲等)
|
||
void ToggleChatState();// 切换聊天状态
|
||
void ToggleListeningState();// 切换监听状态
|
||
void StartListening();// 开始监听
|
||
void StopListening();// 停止监听
|
||
void SendTextMessage(const std::string& text);// 发送文本消息
|
||
void UpdateIotStates();// 更新IOT设备状态
|
||
void Reboot();// 系统重启
|
||
void WakeWordInvoke(const std::string& wake_word);// 唤醒词回调
|
||
void PlaySound(const std::string_view& sound);// 播放声音
|
||
void WaitForAudioPlayback();// 等待音频播报完成
|
||
bool IsAudioQueueEmpty(); // 检查音频队列是否为空
|
||
void ClearAudioQueue(); // 清空音频播放队列
|
||
bool CanEnterSleepMode();// 检查是否可以进入睡眠模式
|
||
|
||
// Phase 6 移植:RTC 软退出 / 唤醒
|
||
void EnterIdleHibernate(); // 进入空闲休眠(真退房 + 待命音 + 状态保留)
|
||
void WakeFromHibernate(); // 从休眠唤醒(BOOT 触发,重连 RTC)
|
||
bool IsHibernating() const { return hibernating_.load(); }
|
||
void StopAudioProcessor();// 停止音频处理器
|
||
void ResetDecoder();// 重置解码器状态(用于修复音频播放问题)
|
||
bool IsSafeToOperate(); // 🔧 检查当前是否可以安全执行操作
|
||
void AbortSpeakingAndReturnToIdle(); // 🔴 专门处理从说话状态到空闲状态的切换
|
||
void AbortSpeakingAndReturnToListening(); // 🔵 专门处理从说话状态到聆听状态的切换
|
||
void PauseAudioPlayback(); // ⏸️ 暂停音频播放
|
||
void ResumeAudioPlayback(); // ▶️ 恢复音频播放
|
||
void SuppressNextIdleSound(); // 🔇 抑制下一个空闲状态的声音播放
|
||
void SetLowBatteryTransition(bool value);
|
||
bool IsLowBatteryTransition() const;
|
||
void InitializeWebsocketProtocol(); // 🌐 初始化WebSocket协议(RTC连接成功后调用)
|
||
// void SendTextViaWebsocket(const std::string& text);// 🌐 通过WebSocket发送文本消息
|
||
|
||
// 姿态传感器接口
|
||
bool IsImuSensorAvailable(); // 检查IMU传感器是否可用
|
||
bool GetImuData(float* acc_x, float* acc_y, float* acc_z,
|
||
float* gyro_x, float* gyro_y, float* gyro_z,
|
||
float* temperature); // 获取IMU传感器数据
|
||
void OnMotionDetected(); // 运动检测事件处理
|
||
bool IsAudioPaused() const { return audio_paused_; } // 检查音频是否暂停
|
||
bool ShouldSkipDialogIdleSession() const { return skip_dialog_idle_session_; }// 是否跳过对话待机会话
|
||
void ClearDialogIdleSkipSession();// 清除对话待机会话标志位
|
||
bool IsDialogUploadEnabled() const { return dialog_upload_enabled_; }// 是否启用对话上传
|
||
void SetDialogUploadEnabled(bool enabled);// 设置对话上传状态
|
||
|
||
// // BLE JSON 命令处理(暂不使用)
|
||
// void HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON* data, BleJsonService& service);
|
||
|
||
private:
|
||
void HttpsApiPlayback(const char* api_url, const char* tag, const char* task_name);// HTTPS API音频播放通用实现
|
||
Application();// 构造函数
|
||
~Application();// 析构函数
|
||
|
||
// 配置使用唤醒词检测
|
||
#if CONFIG_USE_WAKE_WORD_DETECT
|
||
WakeWordDetect wake_word_detect_;
|
||
#elif CONFIG_USE_CUSTOM_WAKE_WORD
|
||
CustomWakeWord wake_word_detect_;
|
||
#endif
|
||
// 音频处理器
|
||
#if CONFIG_USE_AUDIO_PROCESSOR
|
||
AudioProcessor audio_processor_;
|
||
#endif
|
||
Ota ota_;
|
||
std::mutex mutex_;
|
||
std::list<std::function<void()>> main_tasks_;
|
||
std::unique_ptr<Protocol> protocol_;
|
||
std::unique_ptr<WebsocketProtocol> websocket_protocol_; // 🌐 WebSocket协议实例(RTC连接后初始化)
|
||
EventGroupHandle_t event_group_ = nullptr;
|
||
esp_timer_handle_t clock_timer_handle_ = nullptr;
|
||
volatile DeviceState device_state_ = kDeviceStateUnknown;
|
||
std::atomic<bool> is_aborting_{false}; // 🔧 原子标志:防止重复中止操作
|
||
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};
|
||
|
||
// Phase 6 移植:空闲休眠状态
|
||
std::atomic<bool> hibernating_{false}; // 是否处于空闲休眠状态
|
||
int idle_cycles_ = 0; // 累计休眠次数(NVS 持久化)
|
||
static constexpr int IDLE_CYCLES_REBOOT_THRESHOLD = 50; // 累计 50 次触发硬重启清碎片
|
||
void SaveIdleCyclesToNvs();
|
||
void LoadIdleCyclesFromNvs();
|
||
void ResetIdleCyclesNvs();
|
||
|
||
ListeningMode listening_mode_ = kListeningModeAutoStop;
|
||
#if CONFIG_USE_REALTIME_CHAT
|
||
bool realtime_chat_enabled_ = true;
|
||
#else
|
||
bool realtime_chat_enabled_ = false;
|
||
#endif
|
||
std::atomic<bool> ws_downlink_enabled_{true};// 🌐 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; // 音频暂停状态标志
|
||
float current_speaker_volume_ = 0.0f; // 当前扬声器音量,用于语音打断判断
|
||
bool first_idle_location_checked_ = false;// 是否首次查询城市天气
|
||
bool send_pcm_uplink_ = true; // 是否发送PCM音频数据到服务器,由SDK内部转码为G711A
|
||
bool send_g711a_uplink_ = false;// 是否直接发送G711A音频数据到服务器
|
||
|
||
std::chrono::time_point<std::chrono::steady_clock> last_audio_input_time_;
|
||
std::chrono::time_point<std::chrono::steady_clock> last_audible_output_time_; // 最后一次有声音输出的时间点
|
||
bool skip_dialog_idle_session_; // 是否跳过对话待机会话标志
|
||
bool dialog_upload_enabled_ = true; // 对话上传状态标志
|
||
bool dialog_watchdog_running_; // 对话看门狗运行标志
|
||
int dialog_watchdog_last_logged_; // 对话看门狗上次记录的日志时间
|
||
TaskHandle_t dialog_watchdog_task_handle_; // 对话看门狗任务句柄
|
||
int clock_ticks_;
|
||
TaskHandle_t main_loop_task_handle_;
|
||
TaskHandle_t check_new_version_task_handle_;
|
||
|
||
// Audio encode / decode
|
||
TaskHandle_t audio_loop_task_handle_;
|
||
BackgroundTask* background_task_;
|
||
std::chrono::steady_clock::time_point last_output_time_;
|
||
std::list<std::vector<uint8_t>> audio_decode_queue_;
|
||
|
||
std::unique_ptr<OpusEncoderWrapper> opus_encoder_;
|
||
std::unique_ptr<OpusDecoderWrapper> opus_decoder_;
|
||
|
||
OpusResampler input_resampler_;// 输入音频采样器
|
||
OpusResampler reference_resampler_;// 参考音频采样器
|
||
OpusResampler output_resampler_;// 输出音频采样器
|
||
OpusResampler uplink_resampler_;// 上传音频采样器
|
||
|
||
player_pipeline_handle_t player_pipeline_ = nullptr;
|
||
recorder_pipeline_handle_t recorder_pipeline_ = nullptr;
|
||
|
||
// 路径 D'' AEC: esp_aec.h 底层同步 API + 软件 loopback ref
|
||
// codec 保持 baseline 1ch 16-bit (MIC1|MIC2 ES7210 内部混合 mono)
|
||
// DAC 输出 PCM 同步复制到 ref_ring_buf, ReadAudio 调 aec_process(mic, delayed_ref) → clean
|
||
void *aec_handle_ = nullptr; // aec_handle_t* (避免暴露 esp_aec.h 类型)
|
||
int aec_chunk_size_ = 0; // aec_get_chunksize 返回 (16k 通常 512 samples = 32ms)
|
||
int16_t *ref_ring_buf_ = nullptr; // PSRAM 上分配 ~200ms ref ring buffer
|
||
int ref_ring_capacity_ = 0; // ring buf 容量 (samples)
|
||
int ref_ring_write_idx_ = 0; // 写指针 (OnAudioOutput 推进)
|
||
int ref_ring_filled_ = 0; // 已写入样本累计 (用于判断是否足够延迟补偿)
|
||
int aec_ref_delay_samples_ = 800; // 延迟补偿 samples (默认 50ms @16kHz, 后续调优)
|
||
// ⚠️ portMUX (spinlock) 会禁用本核中断, 与 WiFi 协议栈 pm_coex_set_reconnect_policy 冲突
|
||
// 实测引发 IllegalInstruction panic。改用 FreeRTOS mutex (不禁中断, 仅 task 间互斥)
|
||
SemaphoreHandle_t ref_ring_mutex_ = nullptr;
|
||
void InitAec();
|
||
void DeinitAec();
|
||
// 把 DAC 输出 PCM (16kHz mono 16-bit) 推入 ref ring buffer
|
||
void AppendRefSamples(const int16_t *pcm, int samples);
|
||
// 从 ref ring buffer 取 delayed ref (mic 同步用)
|
||
void GetDelayedRef(int16_t *ref_out, int samples);
|
||
// 对单 chunk_size mic PCM 调 aec_process, 输出 clean PCM
|
||
// 累积不足 chunk_size 时直接 passthrough
|
||
void ApplyAEC(std::vector<int16_t>& mic_inout);
|
||
|
||
void MainLoop();// 主事件循环
|
||
void OnAudioInput();// 音频输入回调
|
||
void OnAudioOutput();// 音频输出回调
|
||
void ReadAudio(std::vector<int16_t>& data, int sample_rate, int samples);// 读取音频数据
|
||
void SetDecodeSampleRate(int sample_rate, int frame_duration);// 设置解码采样率
|
||
void CheckNewVersion();// 检查新固件版本
|
||
void ShowActivationCode();// 显示激活码
|
||
void OnClockTimer();// 时钟定时器回调
|
||
void SetListeningMode(ListeningMode mode);// 设置监听模式
|
||
void AudioLoop();// 音频处理循环
|
||
bool suppress_next_idle_sound_ = false;// 标志:是否抑制下一个空闲状态的声音播放
|
||
void StartDialogWatchdog();// 启动对话看门狗
|
||
void StopDialogWatchdog(); // 停止对话看门狗
|
||
|
||
const char* DeviceStateToString(DeviceState state); // 状态枚举转字符串
|
||
};
|
||
|
||
#endif // _APPLICATION_H_
|