跳到主要内容

第 4 章 音频子系统:main/audio/

这是整个项目最复杂的模块(3338 行)。本章先把整体数据流和任务/队列模型讲清楚,再逐文件展开 audio_service.cc(686 行,逐函数),最后讲三个抽象:AudioCodecAudioProcessorWakeWord 的设计与具体实现(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)OpusCodecTaskApplication 主循环 → protocol
audio_decode_queue_AudioStreamPacket(Opus)40 包protocol on_incoming_audioOpusCodecTask
audio_playback_queue_AudioTask(PCM)2 任务OpusCodecTaskAudioOutputTask
audio_testing_queue_AudioStreamPacket时长上限 10sAudioInputTask(测试模式)EnableAudioTesting(false) 时倒灌进 decode 队列回放

encode/playback 限到 2 是有意为之——大对象不要堆积;send/decode 用 Opus 体积小,可以堆 40 个不怕。

4.1.2 三个常驻 RTOS 任务

AudioService::Start() 起 3 个 task:

Task优先级栈大小绑核职责
audio_input8(高)6 KB(USE_AUDIO_PROCESSOR)/ 4 KB(无)core 0(USE_AUDIO_PROCESSOR)/ 不绑从 codec 读 PCM,喂给唤醒词或处理器或测试队列
audio_output44 KB / 2 KB不绑audio_playback_queue_ 取 PCM,写 codec
opus_codec2(低)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 用;
  • AudioDebuggerCONFIG_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++;
}

解码侧

  1. 取出 decode 任务、释放锁、解码(可能耗 5-20ms,绝不持锁);
  2. 如果解码器采样率跟 codec 输出不一致就重采样;
  3. 重新拿锁,推到 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();
}
}
}

编码侧

  1. 取出 encode 任务、释放锁、Opus 编码;
  2. 根据 task 类型:发送队列 / 测试队列;
  3. 推送送队列后调 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.cc359无外置 codec 芯片,用 MCU 直驱 PDM 麦克风 + I²S 喇叭i2s_pdm_rx_* + i2s_std_tx_*
es8311_audio_codec.cc196最常见的 ES8311(单声道),如面包板、bread-compact-wifi 等I²C 配置寄存器 + I²S
es8374_audio_codec.cc197ES8374同上
es8388_audio_codec.cc221ES8388(带耳机功放)同上
es8389_audio_codec.cc203ES8389(双声道)同上
box_audio_codec.cc244乐鑫 ESP-BOX 用的组合方案双芯片协同
dummy_audio_codec.cc20占位——板子无音频时编译过全空实现

实现细节几百行重复——基本都是"调 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_PERFVoIP 场景高性能 AEC
vad_mode = VAD_MODE_0VAD 灵敏度(共 0/1/2/3 档)
vad_min_noise_ms = 100100ms 内没声音就判定为静音
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_tadd_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_viewPlaySound 接受嵌入式资源不拷贝
C++17 init capture in lambdawake_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 两套通信协议。