第 4 章 音频子系统:main/audio/
这是整个项目最复杂的模块(3338 行)。本章先把整体数据流和任务/队列模型讲清楚,再逐文件展开
audio_service.cc(686 行,逐函数),最后讲三个抽象:AudioCodec、AudioProcessor、WakeWord的设计与具体实现(AFE / ESP-SR / Custom)。
4.1 音频子系统鸟瞰
main/audio/ 的文件树(已在第 1 章列过,这里只列核心):
audio/
├── audio_codec.{cc,h} 硬件音频外设抽象基类(I²S + 音量 + 增益)
├── audio_service.{cc,h} ★ 音频任务总调度(本章主战场,686+160 行)
├── audio_processor.h 前端处理器抽象(AFE/无)
├── wake_word.h 唤醒词检测器抽象
├── codecs/ I²S codec 芯片驱动(ES8311/ES8388/dummy/no_audio…)
├── processors/ AFE 实现 + 调试
│ ├── afe_audio_processor.cc ESP-SR AFE:降噪 + AEC + VAD
│ ├── no_audio_processor.cc 无处理直通
│ └── audio_debugger.cc 把原始 PCM 转发到 UDP 服务给电脑听
├── wake_words/
│ ├── afe_wake_word.cc 基于 ESP-SR AFE 的唤醒(S3/P4)
│ ├── custom_wake_word.cc 基于 ESP-SR multinet(S3/P4 支持任意词)
│ └── esp_wake_word.cc 轻量唤醒(C3 等小芯片)
└── README.md
4.1.1 整体数据流
┌─────────┐ I²S ┌──────────────┐
│ MIC │ ───────► │ AudioCodec │
└─────────┘ 16 kHz │ (input) │
└──────┬───────┘
│ vector<int16_t>
▼
┌──────────────────┐
│ AudioInputTask │ ← 决定喂给谁
└──────┬───────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
WakeWord AudioProcessor AudioTestingQueue
(AFE) (AFE/no) (回放测试录音)
↓ ↓
发出回调 OnOutput PCM
│
▼
PushTaskToEncodeQueue
│
▼
audio_encode_queue_ ◄─────────┐
│ │
▼ │
┌──────────────┐ │
│ OpusCodecTask│ │
└──────┬───────┘ │
│ Opus 编码 ↑ wait
▼ │ condvar
audio_send_queue_ │
│ │
▼ │
on_send_queue_available ──── 主循环 MAIN_EVENT_SEND_AUDIO
│
▼
protocol_->SendAudio() → 网络
────────────────────────────────────────────────────────────────────
网络 ──→ protocol_ on_incoming_audio ──→ PushPacketToDecodeQueue
│
▼
audio_decode_queue_
│
▼ OpusCodecTask 解码
audio_playback_queue_
│
▼ AudioOutputTask
AudioCodec (output) → I²S → 喇叭
5 个队列(注释见 audio_service.h):
| 队列 | 内容 | 上限 | 生产者 | 消费者 |
|---|---|---|---|---|
audio_encode_queue_ | AudioTask(PCM + 类型 + 时间戳) | 2 任务 | AudioInputTask / AudioProcessor 回调 | OpusCodecTask |
audio_send_queue_ | AudioStreamPacket(Opus payload) | 40 包(2400/60) | OpusCodecTask | Application 主循环 → protocol |
audio_decode_queue_ | AudioStreamPacket(Opus) | 40 包 | protocol on_incoming_audio | OpusCodecTask |
audio_playback_queue_ | AudioTask(PCM) | 2 任务 | OpusCodecTask | AudioOutputTask |
audio_testing_queue_ | AudioStreamPacket | 时长上限 10s | AudioInputTask(测试模式) | EnableAudioTesting(false) 时倒灌进 decode 队列回放 |
encode/playback 限到 2 是有意为之——大对象不要堆积;send/decode 用 Opus 体积小,可以堆 40 个不怕。
4.1.2 三个常驻 RTOS 任务
AudioService::Start() 起 3 个 task:
| Task | 优先级 | 栈大小 | 绑核 | 职责 |
|---|---|---|---|---|
audio_input | 8(高) | 6 KB(USE_AUDIO_PROCESSOR)/ 4 KB(无) | core 0(USE_AUDIO_PROCESSOR)/ 不绑 | 从 codec 读 PCM,喂给唤醒词或处理器或测试队列 |
audio_output | 4 | 4 KB / 2 KB | 不绑 | 从 audio_playback_queue_ 取 PCM,写 codec |
opus_codec | 2(低) | 26 KB | 不绑 | 同时管编码和解码两个方向,按需做 |
为什么 input 高、codec 低?
- input 是实时硬件 IO:DMA 缓冲容量有限,取慢了会丢音频——必须高优先级抢占;
- codec 任务 CPU 重,做编码解码:但 Opus 帧之间没有严格 deadline,慢一点队列变长而已——给它低优先级,让出 CPU 给 input 与协议。
xTaskCreatePinnedToCore(..., 0):把 input 绑到 core 0。S3 双核,core 0 抢实时,core 1 给 Wi-Fi / LVGL。这是嵌入式调度的常见手段。
4.1.3 三个事件位(audio_service.h)
#define AS_EVENT_AUDIO_TESTING_RUNNING (1 << 0)
#define AS_EVENT_WAKE_WORD_RUNNING (1 << 1)
#define AS_EVENT_AUDIO_PROCESSOR_RUNNING (1 << 2)
#define AS_EVENT_PLAYBACK_NOT_EMPTY (1 << 3)
AudioInputTask xEventGroupWaitBits 等这三位中任意一个置位才工作;否则永久睡眠(不耗 CPU)。Application 通过 EnableWakeWordDetection / EnableVoiceProcessing / EnableAudioTesting 切换这些位。
4.1.4 与外界交互的回调
struct AudioServiceCallbacks {
std::function<void(void)> on_send_queue_available; // Opus 包就绪
std::function<void(const std::string&)> on_wake_word_detected;
std::function<void(bool)> on_vad_change; // VAD 改变(说话/沉默)
std::function<void(void)> on_audio_testing_queue_full; // 测试队列满
};
每一个都对应 Application 主循环一个事件位(见第 2 章 2.4 节)。
4.2 audio_service.cc 逐函数讲解
4.2.1 构造 + 析构(21-29 行)
AudioService::AudioService() {
event_group_ = xEventGroupCreate();
}
AudioService::~AudioService() {
if (event_group_ != nullptr) {
vEventGroupDelete(event_group_);
}
}
很简单——只建/删事件组。Opus 编解码器和处理器要等 Initialize 才创建(要先知道 codec 的采样率)。
4.2.2 Initialize(codec) (32-74 行) —— 装配整个音频流水线
void AudioService::Initialize(AudioCodec* codec) {
codec_ = codec;
codec_->Start();
opus_decoder_ = std::make_unique<OpusDecoderWrapper>(codec->output_sample_rate(), 1, OPUS_FRAME_DURATION_MS);
opus_encoder_ = std::make_unique<OpusEncoderWrapper>(16000, 1, OPUS_FRAME_DURATION_MS);
opus_encoder_->SetComplexity(0);
- 启动 codec(开 I²S 通道);
- new Opus 解码器,采样率跟 codec 输出对齐(默认 24 kHz 或 16 kHz,按板子);
- new Opus 编码器,固定 16 kHz(输入采样率统一 16 kHz,AFE 也按 16 kHz 输出);
SetComplexity(0)把复杂度调到最低——ESP32 算力有限,复杂度高了编不过来。Opus 牺牲一点压缩率换实时。
if (codec->input_sample_rate() != 16000) {
input_resampler_.Configure(codec->input_sample_rate(), 16000);
reference_resampler_.Configure(codec->input_sample_rate(), 16000);
}
如果 codec 输入不是 16 kHz(某些芯片只支持 48 kHz 等),就配置重采样器。Reference 通道单独一份——双通道板子的回参信号也得重采样。
#if CONFIG_USE_AUDIO_PROCESSOR
audio_processor_ = std::make_unique<AfeAudioProcessor>();
#else
audio_processor_ = std::make_unique<NoAudioProcessor>();
#endif
audio_processor_->OnOutput([this](std::vector<int16_t>&& data) {
PushTaskToEncodeQueue(kAudioTaskTypeEncodeToSendQueue, std::move(data));
});
audio_processor_->OnVadStateChange([this](bool speaking) {
voice_detected_ = speaking;
if (callbacks_.on_vad_change) {
callbacks_.on_vad_change(speaking);
}
});
装配处理器——按编译选项选 AFE 或无处理。两个回调挂钩:
OnOutput:处理器吐出干净 PCM → 直接推到编码队列;OnVadStateChange:处理器检测到说话/沉默 → 同步给上层。
esp_timer_create_args_t audio_power_timer_args = {
.callback = [](void* arg) {
AudioService* audio_service = (AudioService*)arg;
audio_service->CheckAndUpdateAudioPowerState();
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "audio_power_timer",
.skip_unhandled_events = true,
};
esp_timer_create(&audio_power_timer_args, &audio_power_timer_);
}
音频功耗 timer——周期性检查麦克风和喇叭"最近一次活跃时间",超过 15 秒就自动关闭 I²S 通道省电。CheckAndUpdateAudioPowerState 见 4.2.16 节。
4.2.3 Start() (76-118 行) —— 起三个任务
void AudioService::Start() {
service_stopped_ = false;
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING | AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING);
esp_timer_start_periodic(audio_power_timer_, 1000000);
#if CONFIG_USE_AUDIO_PROCESSOR
xTaskCreatePinnedToCore([](void* arg) {
AudioService* audio_service = (AudioService*)arg;
audio_service->AudioInputTask();
vTaskDelete(NULL);
}, "audio_input", 2048 * 3, this, 8, &audio_input_task_handle_, 0);
xTaskCreate([](void* arg) { ...->AudioOutputTask(); ... },
"audio_output", 2048 * 2, this, 4, &audio_output_task_handle_);
#else
// 类似但栈小一点、不绑核
#endif
xTaskCreate([](void* arg) { ...->OpusCodecTask(); ... },
"opus_codec", 2048 * 13, this, 2, &opus_codec_task_handle_);
}
要点:
- 启动前把三个事件位清零,初始状态什么都不开——等上层显式 enable;
- 启动音频功耗 timer 周期 1s;
- 起三个任务(注意栈大小:input 6 KB / codec 26 KB / output 4 KB——codec 大是因为 Opus 编解码内部需要不少临时缓冲);
- 全部用 lambda 作为
xTaskCreate入口,传this进 arg,任务跑完自己vTaskDelete(NULL)。
4.2.4 Stop() (120-133 行) —— 同时清队列 + 唤醒所有阻塞
void AudioService::Stop() {
esp_timer_stop(audio_power_timer_);
service_stopped_ = true;
xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING |
AS_EVENT_WAKE_WORD_RUNNING |
AS_EVENT_AUDIO_PROCESSOR_RUNNING);
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
audio_encode_queue_.clear();
audio_decode_queue_.clear();
audio_playback_queue_.clear();
audio_testing_queue_.clear();
audio_queue_cv_.notify_all();
}
精妙之处:
service_stopped_ = true先设上;- 然后把三个事件位全 set 一遍——这会让正在
xEventGroupWaitBits的 input task 立刻返回,发现service_stopped_后 break; - 同时
notify_all把卡在 condvar 上的 output/codec 任务也唤醒,它们 wait 谓词里也会查service_stopped_然后退出。
"既要保证睡着的任务能被叫醒,又要让它叫醒后立刻知道该退出" 是 RTOS 程序优雅停机的标准套路。
4.2.5 ReadAudioData() (135-188 行) —— 从 codec 读,按需重采样、拆通道
这是从硬件读音频的统一入口,被 input task 多次调用。
bool AudioService::ReadAudioData(std::vector<int16_t>& data, int sample_rate, int samples) {
if (!codec_->input_enabled()) {
esp_timer_stop(audio_power_timer_);
esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000);
codec_->EnableInput(true);
}
第一段:如果 codec 输入是关的(之前被功耗策略关了),现在重新开起来;同时把功耗 timer 改成 1 秒频率(活跃时更频繁检查)。
if (codec_->input_sample_rate() != sample_rate) {
data.resize(samples * codec_->input_sample_rate() / sample_rate * codec_->input_channels());
if (!codec_->InputData(data)) return false;
如果 codec 采样率跟请求的不一样,先按 codec 原生采样率读够样本数,之后再重采样到 16 kHz。
if (codec_->input_channels() == 2) {
// 拆 MIC 和 reference 两路,分别重采样
auto mic_channel = std::vector<int16_t>(data.size() / 2);
auto reference_channel = std::vector<int16_t>(data.size() / 2);
for (size_t i = 0, j = 0; i < mic_channel.size(); ++i, j += 2) {
mic_channel[i] = data[j];
reference_channel[i] = data[j + 1];
}
auto resampled_mic = std::vector<int16_t>(input_resampler_.GetOutputSamples(mic_channel.size()));
auto resampled_reference = std::vector<int16_t>(reference_resampler_.GetOutputSamples(reference_channel.size()));
input_resampler_.Process(mic_channel.data(), mic_channel.size(), resampled_mic.data());
reference_resampler_.Process(reference_channel.data(), reference_channel.size(), resampled_reference.data());
// 交错回 [mic, ref, mic, ref, ...]
data.resize(resampled_mic.size() + resampled_reference.size());
for (size_t i = 0, j = 0; i < resampled_mic.size(); ++i, j += 2) {
data[j] = resampled_mic[i];
data[j + 1] = resampled_reference[i];
}
} else {
// 单通道:直接重采样
auto resampled = std::vector<int16_t>(input_resampler_.GetOutputSamples(data.size()));
input_resampler_.Process(data.data(), data.size(), resampled.data());
data = std::move(resampled);
}
} else {
data.resize(samples * codec_->input_channels());
if (!codec_->InputData(data)) return false;
}
last_input_time_ = std::chrono::steady_clock::now();
debug_statistics_.input_count++;
#if CONFIG_USE_AUDIO_DEBUGGER
if (audio_debugger_ == nullptr) {
audio_debugger_ = std::make_unique<AudioDebugger>();
}
audio_debugger_->Feed(data);
#endif
return true;
}
关键点:
- 双通道 = 主麦克风 + 回参(refrence)。回参通常接到喇叭功放的输出,AEC 算法需要它知道"刚才喇叭放了什么";
- 单通道板子没有回参(无法做硬件级 AEC);
- 数据布局始终是 interleaved(交错),AFE 自己会去拆;
last_input_time_给功耗 timer 用;AudioDebugger在CONFIG_USE_AUDIO_DEBUGGER=y时把原始 PCM 转 UDP 发出去,电脑跑scripts/audio_debug_server.py接收听效果。
4.2.6 AudioInputTask() (190-257 行) —— 输入任务主循环
void AudioService::AudioInputTask() {
while (true) {
EventBits_t bits = xEventGroupWaitBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING |
AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING,
pdFALSE, pdFALSE, portMAX_DELAY);
clearOnExit=pdFALSE:不清位——任务下次进来还能看到状态(事件位等同于"模式标志",不是"一次性事件");waitForAll=pdFALSE:任意一位即可;- 阻塞等任意位置上。
if (service_stopped_) break;
if (audio_input_need_warmup_) {
audio_input_need_warmup_ = false;
vTaskDelay(pdMS_TO_TICKS(120));
continue;
}
warmup(暖机)逻辑:刚切到 listening 时麦克风刚启动,前 120ms 可能有大爆音(DMA 缓冲带噪),延迟一下让硬件稳定下来再读。
if (bits & AS_EVENT_AUDIO_TESTING_RUNNING) {
if (audio_testing_queue_.size() >= AUDIO_TESTING_MAX_DURATION_MS / OPUS_FRAME_DURATION_MS) {
ESP_LOGW(TAG, "Audio testing queue is full, stopping audio testing");
EnableAudioTesting(false);
continue;
}
std::vector<int16_t> data;
int samples = OPUS_FRAME_DURATION_MS * 16000 / 1000;
if (ReadAudioData(data, 16000, samples)) {
if (codec_->input_channels() == 2) {
auto mono_data = std::vector<int16_t>(data.size() / 2);
for (size_t i = 0, j = 0; i < mono_data.size(); ++i, j += 2) {
mono_data[i] = data[j];
}
data = std::move(mono_data);
}
PushTaskToEncodeQueue(kAudioTaskTypeEncodeToTestingQueue, std::move(data));
continue;
}
}
音频测试模式:录最多 10 秒 PCM,编成 Opus 推到 testing 队列。配网时按 BOOT 键开测试,再按一次关掉,关时把队列倒灌进 decode 队列回放——这样用户能听到"麦克风→喇叭"通了没。
if (bits & AS_EVENT_WAKE_WORD_RUNNING) {
std::vector<int16_t> data;
int samples = wake_word_->GetFeedSize();
if (samples > 0) {
if (ReadAudioData(data, 16000, samples)) {
wake_word_->Feed(data);
continue;
}
}
}
if (bits & AS_EVENT_AUDIO_PROCESSOR_RUNNING) {
std::vector<int16_t> data;
int samples = audio_processor_->GetFeedSize();
if (samples > 0) {
if (ReadAudioData(data, 16000, samples)) {
audio_processor_->Feed(std::move(data));
continue;
}
}
}
这里有个隐含的优先级:
- testing 优先于唤醒词(测试模式下不会触发唤醒);
- 唤醒词和处理器互斥——一次只开一个。空闲时只开唤醒词;进 listening 后关唤醒词、开处理器;speaking 时关处理器、(AFE 时)允许唤醒词检测打断。
GetFeedSize() 由对应组件返回它需要的样本数(chunk size,例如 AFE 一般要 16ms = 256 样本/16 kHz)。Read 多少 feed 多少——不浪费数据也不积压。
ESP_LOGE(TAG, "Should not be here, bits: %lx", bits);
break;
}
ESP_LOGW(TAG, "Audio input task stopped");
}
如果三个位都没成功消费,打错误日志并退出——理论上不会到这里。
4.2.7 AudioOutputTask() (259-293 行) —— 条件变量等播放
void AudioService::AudioOutputTask() {
while (true) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
audio_queue_cv_.wait(lock, [this]() { return !audio_playback_queue_.empty() || service_stopped_; });
if (service_stopped_) break;
auto task = std::move(audio_playback_queue_.front());
audio_playback_queue_.pop_front();
audio_queue_cv_.notify_all();
lock.unlock();
if (!codec_->output_enabled()) {
esp_timer_stop(audio_power_timer_);
esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000);
codec_->EnableOutput(true);
}
codec_->OutputData(task->pcm);
last_output_time_ = std::chrono::steady_clock::now();
debug_statistics_.playback_count++;
#if CONFIG_USE_SERVER_AEC
if (task->timestamp > 0) {
lock.lock();
timestamp_queue_.push_back(task->timestamp);
}
#endif
}
ESP_LOGW(TAG, "Audio output task stopped");
}
- 用条件变量
audio_queue_cv_而不是事件组——更适合"等队列非空"这种谓词; wait的 lambda 谓词是 C++11 condition_variable 的推荐用法(虚假唤醒安全);- 取到 task 立刻释放锁——
codec_->OutputData()是阻塞 I²S 写,可能耗几十毫秒,不能持锁; - I²S 输出按需开启,进入功耗 timer 监控;
#if CONFIG_USE_SERVER_AEC:服务器 AEC 模式下把这一帧的时间戳记录下来,等下次 input 时配对发回——服务器拿这两个时间戳就能在你的输入里减掉自己刚发的 TTS。
4.2.8 OpusCodecTask() (295-372 行) —— 双向编解码
void AudioService::OpusCodecTask() {
while (true) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
audio_queue_cv_.wait(lock, [this]() {
return service_stopped_ ||
(!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) ||
(!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE);
});
if (service_stopped_) break;
条件变量的谓词三选一:
- 服务停了;
- 编码侧:encode 队列非空且 send 队列没满;
- 解码侧:decode 队列非空且 playback 队列没满。
send/playback 没满这一条保证不会无限堆积——背压(back pressure)。
if (!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE) {
auto packet = std::move(audio_decode_queue_.front());
audio_decode_queue_.pop_front();
audio_queue_cv_.notify_all();
lock.unlock();
auto task = std::make_unique<AudioTask>();
task->type = kAudioTaskTypeDecodeToPlaybackQueue;
task->timestamp = packet->timestamp;
SetDecodeSampleRate(packet->sample_rate, packet->frame_duration);
if (opus_decoder_->Decode(std::move(packet->payload), task->pcm)) {
if (opus_decoder_->sample_rate() != codec_->output_sample_rate()) {
int target_size = output_resampler_.GetOutputSamples(task->pcm.size());
std::vector<int16_t> resampled(target_size);
output_resampler_.Process(task->pcm.data(), task->pcm.size(), resampled.data());
task->pcm = std::move(resampled);
}
lock.lock();
audio_playback_queue_.push_back(std::move(task));
audio_queue_cv_.notify_all();
} else {
ESP_LOGE(TAG, "Failed to decode audio");
lock.lock();
}
debug_statistics_.decode_count++;
}
解码侧:
- 取出 decode 任务、释放锁、解码(可能耗 5-20ms,绝不持锁);
- 如果解码器采样率跟 codec 输出不一致就重采样;
- 重新拿锁,推到 playback 队列。
SetDecodeSampleRate():服务器下行 Opus 可能采样率不同(24 kHz、16 kHz、48 kHz),解码器要按帧重建。
if (!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) {
auto task = std::move(audio_encode_queue_.front());
audio_encode_queue_.pop_front();
audio_queue_cv_.notify_all();
lock.unlock();
auto packet = std::make_unique<AudioStreamPacket>();
packet->frame_duration = OPUS_FRAME_DURATION_MS;
packet->sample_rate = 16000;
packet->timestamp = task->timestamp;
if (!opus_encoder_->Encode(std::move(task->pcm), packet->payload)) {
ESP_LOGE(TAG, "Failed to encode audio");
continue;
}
if (task->type == kAudioTaskTypeEncodeToSendQueue) {
{
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
audio_send_queue_.push_back(std::move(packet));
}
if (callbacks_.on_send_queue_available) {
callbacks_.on_send_queue_available(); // ★ 通知主循环
}
} else if (task->type == kAudioTaskTypeEncodeToTestingQueue) {
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
audio_testing_queue_.push_back(std::move(packet));
}
debug_statistics_.encode_count++;
lock.lock();
}
}
}
编码侧:
- 取出 encode 任务、释放锁、Opus 编码;
- 根据 task 类型:发送队列 / 测试队列;
- 推送送队列后调
on_send_queue_available触发MAIN_EVENT_SEND_AUDIO(详见第 2 章 2.5)。
注意 send 队列推完后调回调时不能持锁——回调可能反过来锁主循环资源造成死锁。
4.2.9 SetDecodeSampleRate() (374-387) —— 服务端采样率自适应
void AudioService::SetDecodeSampleRate(int sample_rate, int frame_duration) {
if (opus_decoder_->sample_rate() == sample_rate && opus_decoder_->duration_ms() == frame_duration) {
return;
}
opus_decoder_.reset();
opus_decoder_ = std::make_unique<OpusDecoderWrapper>(sample_rate, 1, frame_duration);
auto codec = Board::GetInstance().GetAudioCodec();
if (opus_decoder_->sample_rate() != codec->output_sample_rate()) {
ESP_LOGI(TAG, "Resampling audio from %d to %d", opus_decoder_->sample_rate(), codec->output_sample_rate());
output_resampler_.Configure(opus_decoder_->sample_rate(), codec->output_sample_rate());
}
}
只有变化时才重建解码器(避免每包都新建消耗 CPU)。重建后顺便重配输出重采样器。
4.2.10 PushTaskToEncodeQueue() (389-410) —— 配对时间戳
void AudioService::PushTaskToEncodeQueue(AudioTaskType type, std::vector<int16_t>&& pcm) {
auto task = std::make_unique<AudioTask>();
task->type = type;
task->pcm = std::move(pcm);
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
if (type == kAudioTaskTypeEncodeToSendQueue && !timestamp_queue_.empty()) {
if (timestamp_queue_.size() <= MAX_TIMESTAMPS_IN_QUEUE) {
task->timestamp = timestamp_queue_.front();
} else {
ESP_LOGW(TAG, "Timestamp queue (%u) is full, dropping timestamp", timestamp_queue_.size());
}
timestamp_queue_.pop_front();
}
audio_queue_cv_.wait(lock, [this]() { return audio_encode_queue_.size() < MAX_ENCODE_TASKS_IN_QUEUE; });
audio_encode_queue_.push_back(std::move(task));
audio_queue_cv_.notify_all();
}
服务器 AEC 时间戳对齐机制:
- TTS 播放(
AudioOutputTask)时记录"这一帧的时间戳"到timestamp_queue_; - 后续麦克风采到的 PCM 通过
PushTaskToEncodeQueue推到编码队列时,从 timestamp 队列前端取一个配对——意味着"这一帧麦克风录到的,对应那一帧 TTS 播出的时间"; - 服务器拿到这个配对的时间戳,就能在你的输入里减掉它刚才发的 TTS。
wait 等队列未满:如果 encode 队列满了,这一帧 PCM 就阻塞等空位——背压传递到上游(处理器),让它别堆积。
4.2.11 PushPacketToDecodeQueue() (412-424) —— 上层入队接口
bool AudioService::PushPacketToDecodeQueue(std::unique_ptr<AudioStreamPacket> packet, bool wait) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
if (audio_decode_queue_.size() >= MAX_DECODE_PACKETS_IN_QUEUE) {
if (wait) {
audio_queue_cv_.wait(lock, [this]() { return audio_decode_queue_.size() < MAX_DECODE_PACKETS_IN_QUEUE; });
} else {
return false;
}
}
audio_decode_queue_.push_back(std::move(packet));
audio_queue_cv_.notify_all();
return true;
}
- 协议层(WebSocket/MQTT)调这个把服务器发来的 Opus 包入队;
wait参数控制满了是阻塞还是直接丢——网络回调一般传 false(要尽快返回,丢一帧也没办法),PlaySound传 true(必须播完)。
4.2.12 PopPacketFromSendQueue() (426-435) —— 主循环出队
std::unique_ptr<AudioStreamPacket> AudioService::PopPacketFromSendQueue() {
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
if (audio_send_queue_.empty()) return nullptr;
auto packet = std::move(audio_send_queue_.front());
audio_send_queue_.pop_front();
audio_queue_cv_.notify_all();
return packet;
}
主循环在 MAIN_EVENT_SEND_AUDIO 事件里不停 Pop,直到 nullptr 为止。
4.2.13 唤醒词三件套(437-475)
void AudioService::EncodeWakeWord() {
if (wake_word_) wake_word_->EncodeWakeWordData();
}
const std::string& AudioService::GetLastWakeWord() const {
return wake_word_->GetLastDetectedWakeWord();
}
std::unique_ptr<AudioStreamPacket> AudioService::PopWakeWordPacket() {
auto packet = std::make_unique<AudioStreamPacket>();
if (wake_word_->GetWakeWordOpus(packet->payload)) {
return packet;
}
return nullptr;
}
void AudioService::EnableWakeWordDetection(bool enable) {
if (!wake_word_) return;
if (enable) {
if (!wake_word_initialized_) {
if (!wake_word_->Initialize(codec_, models_list_)) {
ESP_LOGE(TAG, "Failed to initialize wake word");
return;
}
wake_word_initialized_ = true;
}
wake_word_->Start();
xEventGroupSetBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
} else {
wake_word_->Stop();
xEventGroupClearBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
}
}
唤醒词模型很大(几百 KB 模型权重),首次 Enable 才 Initialize——开机不立刻加载,省启动时间。
4.2.14 EnableVoiceProcessing() (477-494)
void AudioService::EnableVoiceProcessing(bool enable) {
if (enable) {
if (!audio_processor_initialized_) {
audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
audio_processor_initialized_ = true;
}
ResetDecoder(); // ★ 清掉播放队列残留
audio_input_need_warmup_ = true; // ★ 下次输入做 120ms 暖机
audio_processor_->Start();
xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
} else {
audio_processor_->Stop();
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
}
}
进入 listening 状态时:
- AFE 第一次 Initialize(耗时几十 ms,懒加载);
- ResetDecoder() 清掉 playback 队列——避免上一次 TTS 残留还在播;
- 设置暖机标志——下次 input task 进来先 delay 120ms 再读;
- 启动 AFE 任务并置位 PROCESSOR_RUNNING。
4.2.15 EnableAudioTesting() (496-507) —— 测试模式开关
void AudioService::EnableAudioTesting(bool enable) {
if (enable) {
xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING);
} else {
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING);
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
audio_decode_queue_ = std::move(audio_testing_queue_); // ★ 倒灌
audio_queue_cv_.notify_all();
}
}
测试关闭瞬间,把 testing 队列里录的所有 Opus 整体 move 进 decode 队列——codec 任务自然就解码 + 播放出来。一行代码完成回放,move 而不是 copy,零开销。
4.2.16 CheckAndUpdateAudioPowerState() (637-650) —— 自动省电
void AudioService::CheckAndUpdateAudioPowerState() {
auto now = std::chrono::steady_clock::now();
auto input_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_input_time_).count();
auto output_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_output_time_).count();
if (input_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->input_enabled()) {
codec_->EnableInput(false);
}
if (output_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->output_enabled()) {
codec_->EnableOutput(false);
}
if (!codec_->input_enabled() && !codec_->output_enabled()) {
esp_timer_stop(audio_power_timer_); // 两边都关了就连 timer 也停掉
}
}
15 秒(AUDIO_POWER_TIMEOUT_MS=15000)没动静就关 I²S 通道。再来活动时 ReadAudioData / output task 会重新开。再三停 timer 节省更深一档的功耗。
4.2.17 PlaySound() (523-620) —— 在线解析 OGG 容器
这一段独特:项目把多种提示音以 OGG 文件形式打进固件,运行期在 RAM 里直接解析 OGG 容器、抽出 Opus 包喂解码器。
void AudioService::PlaySound(const std::string_view& ogg) {
if (!codec_->output_enabled()) {
codec_->EnableOutput(true);
}
const uint8_t* buf = reinterpret_cast<const uint8_t*>(ogg.data());
size_t size = ogg.size();
size_t offset = 0;
auto find_page = [&](size_t start)->size_t {
for (size_t i = start; i + 4 <= size; ++i) {
if (buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S') return i;
}
return static_cast<size_t>(-1);
};
bool seen_head = false;
bool seen_tags = false;
int sample_rate = 16000;
while (true) {
size_t pos = find_page(offset);
if (pos == static_cast<size_t>(-1)) break;
offset = pos;
if (offset + 27 > size) break;
// OGG page: [27 bytes header] [page_segments] [body]
const uint8_t* page = buf + offset;
uint8_t page_segments = page[26];
size_t seg_table_off = offset + 27;
if (seg_table_off + page_segments > size) break;
size_t body_size = 0;
for (size_t i = 0; i < page_segments; ++i) body_size += page[27 + i];
size_t body_off = seg_table_off + page_segments;
if (body_off + body_size > size) break;
// 按 lacing 表把 body 切成多个包
size_t cur = body_off;
size_t seg_idx = 0;
while (seg_idx < page_segments) {
size_t pkt_len = 0;
size_t pkt_start = cur;
bool continued = false;
do {
uint8_t l = page[27 + seg_idx++];
pkt_len += l;
cur += l;
continued = (l == 255);
} while (continued && seg_idx < page_segments);
if (pkt_len == 0) continue;
const uint8_t* pkt_ptr = buf + pkt_start;
if (!seen_head) {
// 第一个 packet 是 OpusHead
if (pkt_len >= 19 && std::memcmp(pkt_ptr, "OpusHead", 8) == 0) {
seen_head = true;
uint8_t version = pkt_ptr[8];
uint8_t channel_count = pkt_ptr[9];
if (pkt_len >= 16) {
sample_rate = pkt_ptr[12] | (pkt_ptr[13] << 8) |
(pkt_ptr[14] << 16) | (pkt_ptr[15] << 24);
}
}
continue;
}
if (!seen_tags) {
// 第二个 packet 是 OpusTags
if (pkt_len >= 8 && std::memcmp(pkt_ptr, "OpusTags", 8) == 0) {
seen_tags = true;
}
continue;
}
// 后续都是 Opus 音频包
auto packet = std::make_unique<AudioStreamPacket>();
packet->sample_rate = sample_rate;
packet->frame_duration = 60;
packet->payload.resize(pkt_len);
std::memcpy(packet->payload.data(), pkt_ptr, pkt_len);
PushPacketToDecodeQueue(std::move(packet), true);
}
offset = body_off + body_size;
}
}
要点:
- 手写 OGG 容器解析器——OGG 是一种封装格式,里面装 Opus;
- 每个 OGG page 27 字节 header + segment 表 + body;
- 第一个 packet 必须是 OpusHead(带采样率),第二个是 OpusTags(元数据),之后才是音频;
- 把音频包直接推到 decode 队列,复用整个解码 → 播放路径;
wait=true:必须把每一帧都推进去,否则提示音会断。
这种"运行期解析"省去了离线转 RAW 的麻烦,OGG 文件可以直接从 scripts/mp3_to_ogg.sh 转出来用。
4.2.18 ResetDecoder() (627-635) —— 切状态时的清理
void AudioService::ResetDecoder() {
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
opus_decoder_->ResetState();
timestamp_queue_.clear();
audio_decode_queue_.clear();
audio_playback_queue_.clear();
audio_testing_queue_.clear();
audio_queue_cv_.notify_all();
}
进 listening 或 speaking 时调用——把所有下行音频残留清干净。Opus::ResetState 是必要的,Opus 是有状态的(前向参考),不清理会导致下次解码有"杂音"。
4.2.19 SetModelsList() + IsAfeWakeWord() (652-686)
void AudioService::SetModelsList(srmodel_list_t* models_list) {
models_list_ = models_list;
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4
if (esp_srmodel_filter(models_list_, ESP_MN_PREFIX, NULL) != nullptr) {
wake_word_ = std::make_unique<CustomWakeWord>();
} else if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) {
wake_word_ = std::make_unique<AfeWakeWord>();
} else {
wake_word_ = nullptr;
}
#else
if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) {
wake_word_ = std::make_unique<EspWakeWord>();
} else {
wake_word_ = nullptr;
}
#endif
if (wake_word_) {
wake_word_->OnWakeWordDetected([this](const std::string& wake_word) {
if (callbacks_.on_wake_word_detected) {
callbacks_.on_wake_word_detected(wake_word);
}
});
}
}
根据芯片型号 + 已加载的模型类型动态选唤醒词实现:
- S3/P4 上若有 multinet(
ESP_MN_PREFIX)→CustomWakeWord(支持自定义任意词); - S3/P4 上若有 wakenet(
ESP_WN_PREFIX)→AfeWakeWord(标准唤醒); - 其它芯片(C3 等)→
EspWakeWord(轻量); - 都没有 → 不支持唤醒。
这是 Assets::Apply() 在第 7 章会调用的——assets 分区里有什么模型就在这里被用上。
bool AudioService::IsAfeWakeWord() {
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4
return wake_word_ != nullptr && dynamic_cast<AfeWakeWord*>(wake_word_.get()) != nullptr;
#else
return false;
#endif
}
dynamic_cast 检查当前是不是 AFE 唤醒——HandleStateChangedEvent 里 speaking 状态判断"能否打断"用到。
4.3 audio_codec.{h,cc} —— I²S 抽象基类
4.3.1 接口(audio_codec.h)
class AudioCodec {
public:
virtual void SetOutputVolume(int volume);
virtual void SetInputGain(float gain);
virtual void EnableInput(bool enable);
virtual void EnableOutput(bool enable);
virtual void OutputData(std::vector<int16_t>& data);
virtual bool InputData(std::vector<int16_t>& data);
virtual void Start();
inline bool duplex() const;
inline bool input_reference() const;
inline int input_sample_rate() const; // 重要:常被上层查
inline int output_sample_rate() const;
inline int input_channels() const;
inline int output_channels() const;
inline int output_volume() const;
inline float input_gain() const;
inline bool input_enabled() const;
inline bool output_enabled() const;
protected:
i2s_chan_handle_t tx_handle_;
i2s_chan_handle_t rx_handle_;
bool duplex_;
bool input_reference_;
...
virtual int Read(int16_t* dest, int samples) = 0;
virtual int Write(const int16_t* data, int samples) = 0;
};
要点:
Read / Write纯虚——具体 codec 子类实现 I²S 收发;- 公开接口
InputData / OutputData包装一层std::vector友好; - 几个状态字段(
duplex_/input_reference_/input_sample_rate_等)由子类构造时设置。
4.3.2 基类实现要点(audio_codec.cc)
void AudioCodec::Start() {
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;
}
if (tx_handle_ != nullptr) ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
if (rx_handle_ != nullptr) ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
EnableInput(true);
EnableOutput(true);
}
void AudioCodec::SetOutputVolume(int volume) {
output_volume_ = volume;
Settings settings("audio", true);
settings.SetInt("output_volume", output_volume_); // ★ 持久化
}
- 启动时从 NVS 读上次保存的音量;
- 改音量自动写回 NVS(断电下次还原)。
子类(如 Es8311AudioCodec)会在自己的实现里覆盖 SetOutputVolume,先调基类保存到 NVS,再发 I²C 命令改 codec 芯片寄存器实际生效。这种"父类做通用、子类做硬件"是嵌入式常用结构。
4.3.3 具体 codec 子类一览
| 文件 | 行数 | 适用硬件 | 关键技术 |
|---|---|---|---|
no_audio_codec.cc | 359 | 无外置 codec 芯片,用 MCU 直驱 PDM 麦克风 + I²S 喇叭 | i2s_pdm_rx_* + i2s_std_tx_* |
es8311_audio_codec.cc | 196 | 最常见的 ES8311(单声道),如面包板、bread-compact-wifi 等 | I²C 配置寄存器 + I²S |
es8374_audio_codec.cc | 197 | ES8374 | 同上 |
es8388_audio_codec.cc | 221 | ES8388(带耳机功放) | 同上 |
es8389_audio_codec.cc | 203 | ES8389(双声道) | 同上 |
box_audio_codec.cc | 244 | 乐鑫 ESP-BOX 用的组合方案 | 双芯片协同 |
dummy_audio_codec.cc | 20 | 占位——板子无音频时编译过 | 全空实现 |
实现细节几百行重复——基本都是"调 esp-codec-dev 组件配置 I²C 写寄存器初始化 codec,然后 i2s_std_new_channel + i2s_channel_init_std_mode 配置 I²S,再实现 Read/Write 包 i2s_channel_read/write"。学习路径:读懂 es8311_audio_codec.cc(最常见),其它板子的看一眼差异即可。
4.4 audio_processor.h + AFE 实现
4.4.1 抽象基类(audio_processor.h)
class AudioProcessor {
public:
virtual void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) = 0;
virtual void Feed(std::vector<int16_t>&& data) = 0;
virtual void Start() = 0;
virtual void Stop() = 0;
virtual bool IsRunning() = 0;
virtual void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) = 0;
virtual void OnVadStateChange(std::function<void(bool speaking)> callback) = 0;
virtual size_t GetFeedSize() = 0;
virtual void EnableDeviceAec(bool enable) = 0;
};
Feed 喂原始 PCM 进;处理完后通过 OnOutput 吐干净 PCM;VAD 状态变化通过 OnVadStateChange 吐。
4.4.2 afe_audio_processor.cc —— ESP-SR AFE 适配
Initialize 关键段:
int ref_num = codec_->input_reference() ? 1 : 0;
std::string input_format;
for (int i = 0; i < codec_->input_channels() - ref_num; i++) input_format.push_back('M');
for (int i = 0; i < ref_num; i++) input_format.push_back('R');
afe_config_t* afe_config = afe_config_init(input_format.c_str(), NULL, AFE_TYPE_VC, AFE_MODE_HIGH_PERF);
afe_config->aec_mode = AEC_MODE_VOIP_HIGH_PERF;
afe_config->vad_mode = VAD_MODE_0;
afe_config->vad_min_noise_ms = 100;
if (vad_model_name != nullptr) afe_config->vad_model_name = vad_model_name;
if (ns_model_name != nullptr) {
afe_config->ns_init = true;
afe_config->ns_model_name = ns_model_name;
afe_config->afe_ns_mode = AFE_NS_MODE_NET;
} else {
afe_config->ns_init = false;
}
afe_config->agc_init = false;
afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
#ifdef CONFIG_USE_DEVICE_AEC
afe_config->aec_init = true;
afe_config->vad_init = false;
#else
afe_config->aec_init = false;
afe_config->vad_init = true;
#endif
afe_iface_ = esp_afe_handle_from_config(afe_config);
afe_data_ = afe_iface_->create_from_config(afe_config);
重要参数解读:
| 参数 | 含义 |
|---|---|
input_format | 字符串编码通道布局,"M" 麦克、"R" 参考。单麦无 ref = "M",双麦+ref = "MMR" |
AFE_TYPE_VC | 语音通讯类型(区别于 AFE_TYPE_SR 语音识别——见 4.5) |
AFE_MODE_HIGH_PERF | 高性能模式(更多内存换更低延迟) |
aec_mode = AEC_MODE_VOIP_HIGH_PERF | VoIP 场景高性能 AEC |
vad_mode = VAD_MODE_0 | VAD 灵敏度(共 0/1/2/3 档) |
vad_min_noise_ms = 100 | 100ms 内没声音就判定为静音 |
ns_init + ns_model_name | 降噪网络(NSNET),从模型目录里读 |
agc_init = false | 不开自动增益控制(项目自己做) |
memory_alloc_mode = MORE_PSRAM | 内存优先 PSRAM(SRAM 不够) |
AEC 和 VAD 互斥:开了 AEC 就不开 VAD(AEC 会大幅修改信号,VAD 不准);没开 AEC 才用 VAD 判断说话/沉默。
xTaskCreate([](void* arg) {
auto this_ = (AfeAudioProcessor*)arg;
this_->AudioProcessorTask();
vTaskDelete(NULL);
}, "audio_communication", 4096, this, 3, NULL);
AFE 自己有一个工作任务,处理"fetch"——AFE 内部用环形缓冲,feed 进去算完后通过 fetch 拿出来。
AudioProcessorTask:
while (true) {
xEventGroupWaitBits(event_group_, PROCESSOR_RUNNING, pdFALSE, pdTRUE, portMAX_DELAY);
auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
if ((xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING) == 0) continue;
if (res == nullptr || res->ret_value == ESP_FAIL) continue;
// VAD 状态变化通知
if (vad_state_change_callback_) {
if (res->vad_state == VAD_SPEECH && !is_speaking_) {
is_speaking_ = true;
vad_state_change_callback_(true);
} else if (res->vad_state == VAD_SILENCE && is_speaking_) {
is_speaking_ = false;
vad_state_change_callback_(false);
}
}
if (output_callback_) {
size_t samples = res->data_size / sizeof(int16_t);
output_buffer_.insert(output_buffer_.end(), res->data, res->data + samples);
// 攒够一个 Opus 帧再吐出去
while (output_buffer_.size() >= frame_samples_) {
if (output_buffer_.size() == frame_samples_) {
output_callback_(std::move(output_buffer_));
output_buffer_.clear();
output_buffer_.reserve(frame_samples_);
} else {
output_callback_(std::vector<int16_t>(output_buffer_.begin(), output_buffer_.begin() + frame_samples_));
output_buffer_.erase(output_buffer_.begin(), output_buffer_.begin() + frame_samples_);
}
}
}
}
精妙点:
- AFE 内部 chunk 是 16ms(256 样本),但 Opus 一帧是 60ms(960 样本)。在
output_buffer_里攒满 60ms 才吐。 - 边界情况优化:刚好等于一帧时直接
std::move,零拷贝;超过一帧时拷贝出一段。
4.4.3 no_audio_processor.cc(小芯片用)
简化实现:不做 AFE,直接把输入 PCM 透传给 OnOutput,并用简单能量检测做 VAD。代码量 59 行,结构跟 AFE 版相似但内部无 ESP-SR 调用。
4.5 wake_word.h + 三种唤醒词实现
4.5.1 抽象基类(wake_word.h)
class WakeWord {
public:
virtual bool Initialize(AudioCodec* codec, srmodel_list_t* models_list) = 0;
virtual void Feed(const std::vector<int16_t>& data) = 0;
virtual void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) = 0;
virtual void Start() = 0;
virtual void Stop() = 0;
virtual size_t GetFeedSize() = 0;
virtual void EncodeWakeWordData() = 0; // 异步把缓存的 2 秒 PCM 编成 Opus
virtual bool GetWakeWordOpus(std::vector<uint8_t>& opus) = 0; // 取一帧 Opus
virtual const std::string& GetLastDetectedWakeWord() const = 0;
};
EncodeWakeWordData + GetWakeWordOpus 是给"上传唤醒词音频做声纹识别"准备的——AFE 检测到唤醒词后保留了刚才那 2 秒 PCM,上层调 Encode 把它转 Opus 流,再调 GetWakeWordOpus 一帧帧拿去发。
4.5.2 afe_wake_word.cc(S3/P4,~208 行)
跟 AFE 处理器类似,但 AFE 类型选 AFE_TYPE_SR:
afe_config_t* afe_config = afe_config_init(input_format.c_str(), models_, AFE_TYPE_SR, AFE_MODE_HIGH_PERF);
afe_config->aec_init = codec_->input_reference();
afe_config->aec_mode = AEC_MODE_SR_HIGH_PERF;
afe_config->afe_perferred_core = 1;
afe_config->afe_perferred_priority = 1;
afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
afe_iface_ = esp_afe_handle_from_config(afe_config);
afe_data_ = afe_iface_->create_from_config(afe_config);
注意 afe_perferred_core = 1——把 AFE 内部任务绑到 core 1。这跟主循环(core 0)错开,双核并行做语音处理 + UI/网络。
模型扫描:
for (int i = 0; i < models_->num; i++) {
if (strstr(models_->model_name[i], ESP_WN_PREFIX) != NULL) {
wakenet_model_ = models_->model_name[i];
auto words = esp_srmodel_get_wake_words(models_, wakenet_model_);
std::stringstream ss(words);
std::string word;
while (std::getline(ss, word, ';')) {
wake_words_.push_back(word);
}
}
}
ESP-SR 模型一个文件里可以包含多个唤醒词("你好小智"; "Hi Lexie"),用分号分隔。这里全部 push 到 wake_words_ 列表。
检测任务:
void AfeWakeWord::AudioDetectionTask() {
while (true) {
xEventGroupWaitBits(event_group_, DETECTION_RUNNING_EVENT, pdFALSE, pdTRUE, portMAX_DELAY);
auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
if (res == nullptr || res->ret_value == ESP_FAIL) continue;
StoreWakeWordData(res->data, res->data_size / sizeof(int16_t));
if (res->wakeup_state == WAKENET_DETECTED) {
Stop();
last_detected_wake_word_ = wake_words_[res->wakenet_model_index - 1];
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
}
}
}
}
void AfeWakeWord::StoreWakeWordData(const int16_t* data, size_t samples) {
wake_word_pcm_.emplace_back(std::vector<int16_t>(data, data + samples));
while (wake_word_pcm_.size() > 2000 / 30) { // 保留 2 秒
wake_word_pcm_.pop_front();
}
}
滑动窗口缓存 2 秒——AFE 每 30ms 给一个 chunk,最多保留 ~67 个 chunk(约 2 秒)。这是后续要"补发"给服务器的素材。
void AfeWakeWord::EncodeWakeWordData() {
const size_t stack_size = 4096 * 7;
wake_word_opus_.clear();
if (wake_word_encode_task_stack_ == nullptr) {
wake_word_encode_task_stack_ = (StackType_t*)heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM);
}
if (wake_word_encode_task_buffer_ == nullptr) {
wake_word_encode_task_buffer_ = (StaticTask_t*)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL);
}
wake_word_encode_task_ = xTaskCreateStatic([](void* arg) {
auto this_ = (AfeWakeWord*)arg;
auto encoder = std::make_unique<OpusEncoderWrapper>(16000, 1, OPUS_FRAME_DURATION_MS);
encoder->SetComplexity(0);
for (auto& pcm: this_->wake_word_pcm_) {
encoder->Encode(std::move(pcm), [this_](std::vector<uint8_t>&& opus) {
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.emplace_back(std::move(opus));
this_->wake_word_cv_.notify_all();
});
}
this_->wake_word_pcm_.clear();
// 推一个空包做哨兵,告诉消费端"全部完了"
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.push_back(std::vector<uint8_t>());
this_->wake_word_cv_.notify_all();
vTaskDelete(NULL);
}, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
}
异步编码任务:
- 把 28 KB 栈通过
heap_caps_malloc(MALLOC_CAP_SPIRAM)分配在 PSRAM 而不是内部 SRAM(节省内部内存); - 用
xTaskCreateStatic而不是xTaskCreate——栈和 TCB 自己管,避免动态分配; - 编码完所有 PCM 后推一个空包作为哨兵,消费端拿到空包就知道"流结束了"。
这是生产者-消费者模式的标准做法:用哨兵(sentinel)通知流结束,避免另设标志位。
bool AfeWakeWord::GetWakeWordOpus(std::vector<uint8_t>& opus) {
std::unique_lock<std::mutex> lock(wake_word_mutex_);
wake_word_cv_.wait(lock, [this]() { return !wake_word_opus_.empty(); });
opus.swap(wake_word_opus_.front());
wake_word_opus_.pop_front();
return !opus.empty(); // 返回 false 表示哨兵到达
}
消费端在 Application::HandleWakeWordDetectedEvent 里循环调,看到 false 就停。
4.5.3 custom_wake_word.cc(254 行,自定义任意词)
基于 ESP-SR multinet (MN)。和 AFE wakenet 区别:multinet 支持运行期注册"任意短语",不局限于预训练词。Initialize 时调 esp_mn_iface_t 的 add_speech_commands 注册一组词。适合"你给设备起个名"的场景。详细 API 跟 wakenet 类似,篇幅原因不展开。
4.5.4 esp_wake_word.cc(87 行,C3 等小芯片)
不走 AFE(小芯片内存不够开 AFE),直接喂 ESP-SR 的 wakenet:
wakenet_iface_ = esp_wn_iface_init(wakenet_model_);
wakenet_data_ = wakenet_iface_->create(wakenet_model_, DET_MODE_90);
void EspWakeWord::Feed(const std::vector<int16_t>& data) {
auto state = wakenet_iface_->detect(wakenet_data_, const_cast<int16_t*>(data.data()));
if (state == WAKENET_DETECTED) {
if (wake_word_detected_callback_) {
wake_word_detected_callback_(wake_words_[0]);
}
}
}
很直接:每喂一块 PCM 就 detect 一次,返回检测到就回调。没有 2 秒缓存、不支持上传唤醒词音频。
4.6 audio_debugger.cc —— 远程听音质
void AudioDebugger::Feed(std::vector<int16_t>& data) {
if (sock_ < 0) {
// 第一次:建 UDP socket,从 NVS 读目标地址
}
sendto(sock_, data.data(), data.size() * sizeof(int16_t), 0, (struct sockaddr*)&addr_, sizeof(addr_));
}
把 16 kHz mono PCM 直接 UDP 喷到指定地址。电脑跑 scripts/audio_debug_server.py:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", port))
while True:
data, _ = sock.recvfrom(65535)
pcm = np.frombuffer(data, dtype=np.int16)
stream.write(pcm.tobytes())
调试录音质量、AEC 残留时非常顺手。
4.7 第三方库依赖(ESP-SR & Opus)
4.7.1 ESP-SR(espressif/esp-sr)
由 idf_component.yml 声明依赖,编译期下载。提供:
wakenet:低算力唤醒词检测;multinet(MN):命令词识别(运行期可注册短语);AFE:声学前端处理框架(VAD、AEC、降噪、波束形成)。
模型文件在 ESP-SR 组件的 model/ 目录或我们项目 assets 分区里。esp_srmodel_init("model") 扫描模型目录加载所有 .bin。
4.7.2 Opus
通过 78__esp-opus 组件(修改过的 Opus 端口)提供:
OpusEncoderWrapper(sr, channels, frame_ms)/Encode(pcm, payload);OpusDecoderWrapper(...)/Decode(payload, pcm);OpusResampler/Configure(in, out)/Process(in, in_size, out)/GetOutputSamples(in_size)。
Opus 的窄带语音模式 16 kHz mono 60ms/帧,码率默认约 24-32 kbps——非常省带宽。
4.8 本章用到的核心技术汇总
| 技术 | 应用 |
|---|---|
| 多 RTOS 任务 + 条件变量 + 事件组 | input/output/codec 三任务协作 |
绑核 (xTaskCreatePinnedToCore) | input 跑 core 0,AFE 跑 core 1 |
| 生产者-消费者 + 背压 | 5 个有上限队列,满时 wait |
| 哨兵元素通知流结束 | wake_word_opus_ 空包 |
| mutex 解锁后做耗时操作 | 取出 task 后放锁再编解码 |
| 抽象基类 + 子类工厂选择 | AudioCodec / AudioProcessor / WakeWord 三套 |
std::chrono::steady_clock | 功耗 timer 计时差 |
std::move 转移所有权 | unique_ptr<AudioStreamPacket> 全流程零拷贝 |
PSRAM 分配栈 heap_caps_malloc(MALLOC_CAP_SPIRAM) | 唤醒词编码任务的 28 KB 栈 |
xTaskCreateStatic | 静态分配 TCB 和栈避免动态分配 |
| 手写 OGG 容器解析 | PlaySound 实时拆 OGG 取 Opus |
| 采样率重采样 | input/reference/output 三套 OpusResampler |
| ESP-SR AFE 框架 | VAD + AEC + 降噪 |
dynamic_cast 运行期类型识别 | IsAfeWakeWord |
| 预处理器条件编译 | 区分 S3/P4 vs C3、是否启用 SERVER_AEC / DEVICE_AEC / USE_AUDIO_PROCESSOR |
std::string_view | PlaySound 接受嵌入式资源不拷贝 |
| C++17 init capture in lambda | wake_word 编码任务 |
4.9 看完本章你应该掌握的
- 5 个队列 + 3 个任务 + 4 个事件位的数据流模型
- 上行(MIC → Opus → 网络)和下行(网络 → Opus → 喇叭)两条路径每一步在做什么
- AFE 处理器为什么要等 60ms 才输出(攒一个 Opus 帧)
- Server AEC 的时间戳如何在 input/output 队列间配对
- 唤醒词 2 秒滑动缓存 + 异步编码 + 哨兵流结束的设计
- codec / processor / wake_word 三套抽象基类的责任划分
- I²S 双通道(MIC + reference)和单通道的区别
- 自动功耗管理(15 秒无活动关 I²S)
- PlaySound 是如何在线解析 OGG 容器抽 Opus 帧的
下一章进入 protocols/——WebSocket 和 MQTT+UDP 两套通信协议。