toy-Kapi_Rtc/main/application.h
Rdzleo a369796eb6 feat(audio): 设备端软件 loopback ref AEC (路径 D'') 完整实施
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>
2026-05-19 15:30:20 +08:00

246 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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_