ESP32-S3 IDF实时语音开发:Realtime直连与回声消除
上一篇完成了 ESP32-S3 的 I2S 数字麦克风与 MAX98357 录放音,跑通了最基础的音频闭环。这一次继续往前走:不再把声音只留在开发板里,而是把麦克风 PCM 通过 WebSocket 实时发给大模型,再把模型返回的 PCM 流式播放出来。
本次先后直连了 Qwen Omni Realtime 和豆包 Realtime。本文重点记录回声消除、播放缓冲与插话打断的实现,以及调试过程中遇到的声音卡顿、回声自触发和 I2S 杂音问题。

对应工程:
projects/03_qwen_omni_realtime_pttprojects/04_esp_sr_afe_aec_demoprojects/05_doubao_realtime
这里要先明确本次实验的边界:没有搭建中转后端,也没有实现一套服务端 VAD。ESP32-S3 直接通过 TLS WebSocket 连接 Qwen 和豆包的 Realtime API:
ESP32-S3 -> Wi-Fi -> TLS/WebSocket -> Realtime 模型接口
文中提到的 semantic_vad 或轮次检测,都是 Realtime 模型接口本身提供的能力;我们在开发板上实现的是音频采集、AEC、网络收发、播放缓冲和本地快速停播。
一、这次最终做成了什么
最终的数据链路如下:
板载 I2S 数字麦克风(16kHz)
↓
32-bit I2S sample 转 mono PCM16
↓
ESP-SR AFE AEC(麦克风 M + 播放参考 R)
↓
上行音频队列
↓
Base64 + JSON + WebSocket
↓
Qwen Omni Realtime / 豆包 Realtime
↓
模型返回 24kHz mono PCM16
↓
PSRAM 播放抖动缓冲
↓
音量缩放 + stereo I2S
↓
MAX98357 + 喇叭
它已经不再是“录完再发、收完再播”的请求响应程序,而是两条并行的实时流:
上行:麦克风 -> AEC -> WebSocket -> 模型
下行:模型 -> WebSocket -> 抖动缓冲 -> MAX98357
在模型说话时,麦克风仍然工作;用户再次开口后,本地会快速停止当前播放,并向服务端发送取消事件。这才是本次学习的核心目标:全双工实时语音对话。
二、先统一音频格式
两个模型的接入协议并不完全一样,但本次使用的音频格式基本一致:
| 链路 | 采样率 | 声道 | 格式 |
|---|---|---|---|
| 数字麦克风 I2S | 16kHz | stereo slot 中取 left | 32-bit slot |
| 上传模型 | 16kHz | mono | signed PCM16 little-endian |
| 模型返回 | 24kHz | mono | signed PCM16 little-endian |
| MAX98357 | 24kHz | stereo | 左右声道写同一份 PCM16 |
16kHz mono PCM16 每秒的数据量为:
16000 * 1 * 2 = 32000 bytes/s
24kHz mono PCM16 每秒为:
24000 * 1 * 2 = 48000 bytes/s
这个数据量本身不大,真正增加开销的是 Base64 和 JSON。Base64 会把二进制体积放大到约 4/3,再加上 WebSocket、TLS 和 JSON 字段,ESP32-S3 需要同时处理内存、网络和实时音频调度。
三、从 PTT 到真正的 Realtime
最开始做 Qwen Demo 时,我先实现了一个最容易验证的 PTT 版本:按键后录制 3 秒,上传整段 PCM,再等待模型回答。
这种方式的优点是逻辑简单:
录音 -> 上传 -> commit -> response -> 播放
但它不是真正的实时对话。用户必须等录音结束,模型也无法在播放时听见新的讲话。
随后把它改成持续流式上传:
每次读取 512 个 16kHz sample
512 / 16000 = 32ms
每 32ms 得到一帧麦克风 PCM。音频帧先进入 FreeRTOS 队列,再由独立网络任务合并成较大的 WebSocket 事件发送。这样麦克风任务不会因为 TLS 发送阻塞而停止采集。
Qwen 工程的名字仍保留了早期的 ptt,但代码已经逐步演进为 Realtime 全双工版本。
四、接入 Qwen Omni Realtime
本次使用的模型是:
qwen3.5-omni-plus-realtime
WebSocket 地址在 URL 中携带模型名:
wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-plus-realtime
鉴权请求头:
Authorization: Bearer <DashScope API Key>
连接成功后先发送 session.update,配置输入输出格式、音色和 Qwen Realtime 内置的轮次检测:
{
"type": "session.update",
"session": {
"modalities": ["text", "audio"],
"input_audio_format": "pcm",
"output_audio_format": "pcm",
"voice": "Tina",
"turn_detection": {
"type": "semantic_vad",
"threshold": 0.1,
"prefix_padding_ms": 500,
"silence_duration_ms": 900
}
}
}
之后持续发送:
{
"type": "input_audio_buffer.append",
"audio": "<Base64 PCM>"
}
Qwen Realtime 接口通过 semantic_vad 判断当前语音轮次什么时候结束,因此直连模式不需要每一轮手动发送 commit 和 response.create。这里没有我们自己部署或编写的 VAD 服务。
模型的下行音频主要来自:
response.audio.delta
response.audio.done
response.done
其中 response.audio.delta 的 delta 字段是 Base64 编码的 PCM。设备解码后不能立刻“一包一播”,而是要先写入播放环形缓冲。
五、接入豆包 Realtime
豆包工程参考了火山引擎端到端实时语音全双工文档,以及之前服务端项目中的接入实现。
WebSocket 地址:
wss://openspeech.bytedance.com/api/v3/duplex/realtime/dialogue
鉴权头与 Qwen 不同:
X-Api-Key: <API Key>
X-Api-Connect-Id: <每次连接唯一的随机 ID>
连接后发送 session.create,本次模型版本为:
1.2.6.0
核心配置可以简化理解为:
{
"type": "session.create",
"session": {
"model": "1.2.6.0",
"instructions": "你是一位耐心、简洁的中文老师。",
"audio": {
"input": {
"format": { "type": "pcm", "sample_rate": 16000 }
},
"output": {
"format": { "type": "pcm_s16le", "sample_rate": 24000 },
"voice": "zh_female_vv_jupiter_bigtts"
}
}
}
}
上行仍然使用 input_audio_buffer.append,下行事件则变为:
response.output_audio.started
response.output_audio.delta
response.output_audio.done
response.done
豆包还会返回用户语音转写:
conversation.item.input_audio_transcription.started
conversation.item.input_audio_transcription.delta
conversation.item.input_audio_transcription.completed
这些事件既方便在串口观察识别结果,也可以作为插话检测的后备信号。
六、两个 Realtime 协议的异同
| 项目 | Qwen Omni Realtime | 豆包 Realtime |
|---|---|---|
| 会话初始化 | session.update | session.create |
| 鉴权 | Authorization: Bearer | X-Api-Key |
| 上行音频 | input_audio_buffer.append | input_audio_buffer.append |
| 下行音频 | response.audio.delta | response.output_audio.delta |
| 本次输入采样率 | 16kHz | 16kHz |
| 本次输出采样率 | 24kHz | 24kHz |
| 轮次判断 | Realtime 接口内置 semantic_vad | Realtime 接口自动处理 |
| 取消回复 | response.cancel | response.cancel |
从 ESP32-S3 的角度看,两者可以共享绝大部分底层架构:
I2S -> PCM -> AEC -> Queue -> WebSocket
WebSocket -> Base64 -> PCM -> Ring Buffer -> I2S
真正需要单独适配的是鉴权、会话 JSON 和事件名称。因此后续完全可以继续把“音频引擎”和“模型协议”拆成两个模块。
七、第一个大坑:WebSocket 大消息会分片
Qwen 第一个可播放版本曾出现:
websocket text event too large: payload_len=20680
audio delta base64 decode failed: -42
一开始误以为每次 WEBSOCKET_EVENT_DATA 都对应一条完整 JSON。实际上 esp_websocket_client 可能把一个二十多 KB 的文本事件拆成多次回调。
几个字段的含义是:
data_len 当前分片长度
payload_len 完整 WebSocket payload 长度
payload_offset 当前分片在完整 payload 中的偏移
所以不能对每个 data_ptr 直接调用 cJSON。正确处理方式是:
payload_offset == 0时开始一条新消息。- 把每个
data_len字节追加到接收缓冲。 - 当
payload_offset + data_len >= payload_len时再解析完整 JSON。 - 如果总长度超过缓冲区,要丢掉整条消息,不能拿半截 JSON 解码 Base64。
示意代码:
memcpy(s_ws_rx_text + s_ws_rx_text_len,
data->data_ptr,
data->data_len);
s_ws_rx_text_len += data->data_len;
if (data->payload_offset + data->data_len >= data->payload_len) {
s_ws_rx_text[s_ws_rx_text_len] = '\0';
enqueue_ws_rx_message((uint8_t *)s_ws_rx_text,
s_ws_rx_text_len,
false);
s_ws_rx_text_len = 0;
}
这里还有一个内存上的取舍:WebSocket 客户端自己的 I/O buffer 不必直接开到 64KB。可以让客户端使用 4KB 小缓冲,在应用层把分片拼到 PSRAM 中,减少内部 SRAM 压力。
八、音频任务不能等待网络
另一个明显问题是声音会颤抖、卡顿。最初所有事情都在一个任务中完成:
读取 I2S -> AEC -> Base64 -> WebSocket send
只要一次 TLS 写入或网络调度多花几十毫秒,下一帧麦克风就会晚到,AEC 的参考时钟也会偏移。
最终把任务拆成:
| 任务 | 作用 |
|---|---|
| mic task | 固定节奏读取 I2S、转换 PCM、执行 AEC |
| uplink task | 从队列取 PCM,Base64 后发送 WebSocket |
| WebSocket callback | 只做分片拼接和入队,不做重解析 |
| WebSocket RX task | 解析 JSON、解码模型音频 |
| playback task | 按 I2S 时钟从播放环读取 PCM |
核心原则是:
音频时钟必须由 I2S 决定,不能由 Wi-Fi 和 TLS 决定。
网络慢时,上行队列宁可丢最旧的一帧,也不能无限堆积。否则模型听到的声音会越来越“延迟”,几秒后才收到用户刚才说的话。
为了降低 JSON、Base64 和 WebSocket 锁的调用次数,512 sample 的 32ms AEC 帧还可以适度合包。例如三个连续帧合成约 96ms 的上行事件。分包过小会让 ESP32 忙于协议开销,分包过大则会增加插话延迟,需要结合网络情况折中。
九、为什么需要播放抖动缓冲
模型返回的音频不是每 20ms 准时到达。实际日志中,一条 delta 可能包含三四百毫秒 PCM,两条消息之间也会有数百毫秒间隔。
如果每收到一条就立即播放:
收到一包 -> 播放完 -> 等网络 -> 收到下一包 -> 再播放
听起来就是断断续续。
解决方式是在 PSRAM 中建立环形缓冲:
WebSocket RX -> playback ring -> I2S playback task
播放任务先等待缓冲达到阈值,再开始匀速消费。Qwen 实验中使用过约 300ms 预缓冲,豆包根据实测网络抖动调整到约 800ms。
缓冲越小,首字延迟越低,但更容易欠载;缓冲越大,声音更稳,但用户会觉得模型“想很久才开口”。这不是一个越大越好的参数。
工程中还记录了两个很有用的日志:
audio delta gap: 380ms
playback underflow
如果只有 audio delta gap,但播放环中一直有数据,声音理论上仍然连续;真正说明播放器饿死的是 playback underflow。
十、AEC 不是 ESP32-S3 的硬件开关
在排查“模型自己回复自己”时,我一度以为 ESP32-S3 内部有一个硬件 AEC 模块,只需要打开配置。后来确认并不是这样。
本次使用的是 Espressif ESP-SR 中的 AFE AEC 软件算法:
s_afe_aec = afe_aec_create(
"MR",
AEC_FILTER_LENGTH,
AFE_TYPE_FD,
AFE_MODE_LOW_COST
);
AEC 需要两路时间对齐的 16kHz PCM:
M:麦克风采到的“用户声音 + 喇叭回声”
R:设备实际送给喇叭的播放参考
输入排列为:
M0, R0, M1, R1, M2, R2 ...
然后调用:
afe_aec_process(s_afe_aec, s_aec_input_frame, s_aec_out_frame);
10.1 参考音必须来自真正播放的 PCM
不能直接把模型原始 PCM 当参考,因为实际播放前还做了音量缩放。AEC 的参考应该和送往 MAX98357 的 sample 尽量一致:
模型 PCM
-> 播放音量缩放
-> 一份写入 MAX98357
-> 同一份转成 16kHz,写入 AEC 参考环
10.2 24kHz 参考要转成 16kHz
麦克风和 AEC 使用 16kHz,而模型输出是 24kHz。本次使用连续的 3:2 重采样,把每 3 个 24k sample 转成 2 个 16k sample,并跨播放块保存不足 3 个的尾部采样。
如果每个网络包都从头独立重采样,包边界会造成参考波形不连续,AEC 效果会明显变差。
10.3 参考音还要补偿声学延迟
数字 PCM 写入 I2S 后,需要经过 DMA、MAX98357、喇叭和空气传播,最后才重新进入麦克风。
所以当前麦克风帧不能和“刚刚写入 I2S 的参考帧”直接对应。本次通过独立 AEC Demo 反复实测,最终使用约 80ms 的参考延迟窗口:
#define AEC_REF_DELAY_MS 80
这个参数不是通用答案。更换外壳、喇叭、音量或麦克风位置,都可能需要重新测量。
10.4 最终 AEC 参数与初始化代码
下面是 05_doubao_realtime 最终使用的核心参数。麦克风每次读取 512 个 16kHz sample,也就是 32ms;这个长度必须与 ESP-SR AEC 返回的 chunk size 一致。
#define MIC_SAMPLE_RATE_HZ 16000
#define MIC_READ_FRAMES 512
#define ENABLE_AEC 1
#define AEC_FILTER_LENGTH 4
#define AEC_REF_RING_SAMPLES (MIC_SAMPLE_RATE_HZ * 2)
#define AEC_REF_DELAY_MS 80
// 远端播放参考足够大,而 AEC 输出很小时,把它视为残余回声。
#define AEC_FAR_END_GUARD 1
#define AEC_REF_ACTIVE_ABS_LEVEL 180
#define AEC_NEAR_END_KEEP_ABS_LEVEL 1800
AEC 的工作帧访问频繁,并且官方算法对内存对齐有要求,所以把它们分配在 16 字节对齐的内部 RAM;较大的参考环放在 PSRAM:
static esp_err_t init_aec(void)
{
#if ENABLE_AEC
s_aec_ref_ring_mutex = xSemaphoreCreateMutex();
ESP_RETURN_ON_FALSE(s_aec_ref_ring_mutex != NULL,
ESP_ERR_NO_MEM, TAG,
"create AEC reference mutex failed");
s_aec_ref_ring = heap_caps_malloc(
AEC_REF_RING_SAMPLES * sizeof(s_aec_ref_ring[0]),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT
);
ESP_RETURN_ON_FALSE(s_aec_ref_ring != NULL,
ESP_ERR_NO_MEM, TAG,
"alloc AEC reference ring failed");
// MR:麦克风 M + 播放参考 R;FD:全双工模式。
s_afe_aec = afe_aec_create(
"MR",
AEC_FILTER_LENGTH,
AFE_TYPE_FD,
AFE_MODE_LOW_COST
);
ESP_RETURN_ON_FALSE(s_afe_aec != NULL,
ESP_ERR_NO_MEM, TAG,
"create ESP-SR AFE AEC failed");
s_aec_frame_samples = afe_aec_get_chunksize(s_afe_aec);
ESP_RETURN_ON_FALSE(s_aec_frame_samples == MIC_READ_FRAMES,
ESP_ERR_INVALID_SIZE, TAG,
"AEC frame size mismatch: aec=%d mic=%d",
s_aec_frame_samples,
MIC_READ_FRAMES);
s_aec_ref_frame = heap_caps_aligned_calloc(
16, s_aec_frame_samples, sizeof(int16_t),
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT
);
s_aec_input_frame = heap_caps_aligned_calloc(
16, s_aec_frame_samples * 2, sizeof(int16_t),
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT
);
s_aec_out_frame = heap_caps_aligned_calloc(
16, s_aec_frame_samples, sizeof(int16_t),
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT
);
ESP_RETURN_ON_FALSE(
s_aec_ref_frame != NULL &&
s_aec_input_frame != NULL &&
s_aec_out_frame != NULL,
ESP_ERR_NO_MEM, TAG,
"alloc aligned AEC frame buffers failed"
);
aec_ref_ring_clear();
#endif
return ESP_OK;
}
10.5 从真正播放的 PCM 生成参考音
模型返回 24kHz mono PCM。代码先应用播放音量,再把同一个 sample 同时用于 MAX98357 和 AEC 参考,不能一份缩放、一份不缩放:
for (size_t i = 0; i < frames_now; i++) {
int32_t scaled = (int32_t)mono_pcm[offset + i]
* PLAYBACK_VOLUME_LEVEL
/ PLAYBACK_VOLUME_DENOMINATOR;
int16_t sample = clamp_i16(scaled);
// AEC 参考使用音量缩放后的真实播放 sample。
s_amp_mono_reference_24k[i] = sample;
// MAX98357 使用 stereo I2S,左右 slot 写同一份 mono。
s_amp_stereo_samples[i * 2] = sample;
s_amp_stereo_samples[i * 2 + 1] = sample;
}
if (s_afe_aec != NULL) {
aec_ref_write_24k_mono(s_amp_mono_reference_24k,
frames_now);
}
i2s_channel_write(s_amp_tx_chan,
s_amp_stereo_samples,
frames_now * 2 * sizeof(int16_t),
&bytes_written,
pdMS_TO_TICKS(2000));
24kHz 到 16kHz 使用连续 3:2 轻量重采样。s_aec_resample_group_count 是全局状态,所以网络包或 I2S 块结束时未凑满的 sample 会保留到下一块,不会在边界重新开始:
static void aec_ref_write_24k_mono(const int16_t *samples,
size_t sample_count)
{
size_t out_count = 0;
for (size_t i = 0; i < sample_count; i++) {
s_aec_resample_group[s_aec_resample_group_count++] = samples[i];
if (s_aec_resample_group_count == 3) {
s_aec_ref_resampled[out_count++] =
s_aec_resample_group[0];
s_aec_ref_resampled[out_count++] =
(int16_t)(((int32_t)s_aec_resample_group[1]
+ s_aec_resample_group[2]) / 2);
s_aec_resample_group_count = 0;
}
}
aec_ref_ring_write(s_aec_ref_resampled, out_count);
}
10.6 从参考环中取出对齐帧
播放任务和麦克风任务独立运行,参考音还会领先于真正从喇叭传到麦克风的声音,因此不能直接使用最新参考帧。最终代码用环形缓冲保存参考流,并通过 80ms 对齐窗口读取:
static void aec_ref_ring_read_delay_or_zero(int16_t *out,
size_t sample_count)
{
if (s_aec_ref_ring == NULL || sample_count == 0) {
if (sample_count > 0) {
memset(out, 0, sample_count * sizeof(out[0]));
}
return;
}
xSemaphoreTake(s_aec_ref_ring_mutex, portMAX_DELAY);
size_t delay_samples =
MIC_SAMPLE_RATE_HZ * AEC_REF_DELAY_MS / 1000;
size_t readable = s_aec_ref_ring_filled > delay_samples
? s_aec_ref_ring_filled - delay_samples
: 0;
size_t read_count = sample_count < readable
? sample_count
: readable;
size_t read_pos =
(s_aec_ref_ring_tail + delay_samples)
% AEC_REF_RING_SAMPLES;
size_t first = AEC_REF_RING_SAMPLES - read_pos;
if (first > read_count) {
first = read_count;
}
memcpy(out,
s_aec_ref_ring + read_pos,
first * sizeof(out[0]));
size_t second = read_count - first;
if (second > 0) {
memcpy(out + first,
s_aec_ref_ring,
second * sizeof(out[0]));
}
if (read_count > 0) {
s_aec_ref_ring_tail =
(s_aec_ref_ring_tail + read_count)
% AEC_REF_RING_SAMPLES;
s_aec_ref_ring_filled -= read_count;
}
xSemaphoreGive(s_aec_ref_ring_mutex);
if (read_count < sample_count) {
memset(out + read_count,
0,
(sample_count - read_count) * sizeof(out[0]));
}
}
刚开始播放、参考尚未达到对齐窗口时,函数输出零参考;每轮模型重新开口前还会清空上一轮参考环,避免旧声音参与新一轮 AEC。
10.7 麦克风帧怎样送入 AEC
下面是最终 mic_stream_task() 中最核心的一段。先取对齐参考,再构造 M0,R0,M1,R1...,最后使用 AEC 输出替代原始麦克风 PCM 上传:
const int16_t *pcm_to_send = s_mic_pcm16;
uint32_t mic_abs = mean_abs_i16(s_mic_pcm16, frames_read);
uint32_t ref_abs = 0;
uint32_t out_abs = mic_abs;
bool playback_active =
s_response_playing || playback_ring_level() > 0;
if (s_afe_aec != NULL &&
frames_read == (size_t)s_aec_frame_samples) {
aec_ref_ring_read_delay_or_zero(s_aec_ref_frame,
frames_read);
for (size_t i = 0; i < frames_read; i++) {
s_aec_input_frame[i * 2] = s_mic_pcm16[i];
s_aec_input_frame[i * 2 + 1] = s_aec_ref_frame[i];
}
size_t out_bytes = afe_aec_process(
s_afe_aec,
s_aec_input_frame,
s_aec_out_frame
);
if (out_bytes != frames_read * sizeof(int16_t)) {
ESP_LOGW(TAG, "AFE AEC output size mismatch");
continue;
}
mic_abs = mean_abs_i16(s_mic_pcm16, frames_read);
ref_abs = mean_abs_i16(s_aec_ref_frame, frames_read);
out_abs = mean_abs_i16(s_aec_out_frame, frames_read);
// 播放参考明显存在、AEC 输出又很小时,
// 剩下的更可能是远端回声,而不是真人插话。
if (playback_active &&
ref_abs >= AEC_REF_ACTIVE_ABS_LEVEL &&
out_abs < AEC_NEAR_END_KEEP_ABS_LEVEL) {
memset(s_aec_out_frame,
0,
frames_read * sizeof(int16_t));
out_abs = 0;
}
pcm_to_send = s_aec_out_frame;
}
// 无论当前有没有人说话,每个 32ms 帧仍然连续入队上传。
enqueue_uplink_audio(pcm_to_send, frames_read);
排查 AEC 时,最有价值的是同时打印三个平均绝对值:
mic:原始麦克风能量
ref:播放参考能量
out:AEC 输出能量
如果播放期间 mic 和 ref 很大,而 out 明显下降,说明参考方向和对齐基本正确;如果 out 反而更大,优先检查左右 slot、参考音量、采样率和延迟,而不是继续盲目调阈值。
十一、本地能量检测和 Realtime 轮次检测不是一回事
这里踩过一个非常关键的坑。
Qwen 实验中加入过本地能量门控:检测到近端人声才把 AEC 输出放进上行队列,同时使用 Qwen Realtime 内置的 semantic_vad 判断语音轮次。这样可以减少背景噪声和回声残留对模型接口的影响。
但把同样策略原样搬到豆包后,出现了长时间卡顿,串口最终给出真正线索:
AudioServerNoAudioInputTooLongError
原因是本地安静时完全停止发送,而豆包全双工接口依赖连续实时的音频时钟。Realtime API 长时间收不到任何音频,就会等待甚至报错。
最终豆包工程改成:
有人声:连续上传 AEC 输出
无人声:仍按实时节奏连续上传音频/静音
轮次判断:交给豆包 Realtime 接口
这次得到的结论是:
本地能量检测可以帮助做 UI、唤醒和插话判断,但不能想当然地拿它截断所有模型的上行流。是否允许断流必须看具体 Realtime 协议。
十二、怎样实现自然的插话打断
全双工最难调的不是“模型能回答”,而是模型说话时用户能不能自然打断。
本次尝试过几种方案。
12.1 只依赖 Realtime 接口的 started 事件
豆包会发:
conversation.item.input_audio_transcription.started
但弱噪声和残余回声也可能触发这个事件。如果一收到就清空播放,很容易出现模型自己打断自己。
12.2 等待第一段非空转写
这种方式很可靠,但云端转写可能晚几秒,用户说完“停一下”后,喇叭还在继续讲,体验很差。
12.3 本地快速停播,Realtime 模型负责识别
最终采用两层策略:
- 播放期间观察 AEC 输出,连续两帧超过近端人声阈值,约 64ms 内停止本地播放。
- 音频上行仍然连续,Realtime 模型接口继续识别用户说了什么、当前轮次什么时候结束。
- 第一段非空转写仍然作为本地检测漏判时的后备信号。
if (playback_active && out_abs >= BARGE_IN_ABS_LEVEL) {
s_barge_in_voice_frames++;
if (s_barge_in_voice_frames == 2) {
mark_user_turn_and_interrupt("local AEC near-end");
}
} else {
s_barge_in_voice_frames = 0;
}
检测到插话后要同时完成几件事:
停止接受当前回复音频
清空本地播放环
清空旧的 AEC 参考
发送 response.cancel
继续上传麦克风 PCM
丢弃取消生效前仍在路上的旧 audio delta
12.4 最终打断状态机
打断检测复用 AEC 后的 out_abs。当前一帧是 32ms,连续两帧超过阈值才确认,可以过滤单点毛刺,同时把本地停播延迟控制在约 64ms:
#define BARGE_IN_ABS_LEVEL AEC_NEAR_END_KEEP_ABS_LEVEL
#define BARGE_IN_CONFIRM_FRAMES 2
static uint32_t s_barge_in_voice_frames;
static volatile bool s_user_turn_pending;
static volatile bool s_response_playing;
static volatile bool s_accepting_response_audio;
static volatile bool s_cancel_response_requested;
static volatile bool s_drop_response_audio_until_done;
在每个 AEC 帧处理完成后执行本地检测:
if (playback_active && out_abs >= BARGE_IN_ABS_LEVEL) {
s_barge_in_voice_frames++;
if (s_barge_in_voice_frames == BARGE_IN_CONFIRM_FRAMES) {
mark_user_turn_and_interrupt("local AEC near-end");
}
} else {
s_barge_in_voice_frames = 0;
}
真正的停播函数如下:
static void mark_user_turn_and_interrupt(const char *source)
{
// 标记已经有一轮真实用户输入,后续模型回复可以被接收。
s_user_turn_pending = true;
if (!s_response_playing && playback_ring_level() == 0) {
return;
}
// 1. 立即停止本地播放状态。
s_response_playing = false;
s_accepting_response_audio = false;
// 2. response.cancel 到达云端需要时间,期间可能还有旧 delta 在路上。
// 在收到当前 response.done/canceled 之前全部丢弃。
s_drop_response_audio_until_done = true;
// 3. 清除已经缓存、但还没有从喇叭播放的旧回答。
playback_ring_clear();
// 4. 老师声音已被打断,旧参考也不能带入下一轮 AEC。
aec_ref_ring_clear();
// 5. 让独立上行任务发送 response.cancel,
// 不在音频任务里直接等待 WebSocket/TLS。
s_cancel_response_requested = true;
if (s_playback_task_handle != NULL) {
xTaskNotifyGive(s_playback_task_handle);
}
ESP_LOGI(TAG,
"user barge-in, interrupt playback: source=%s",
source);
}
为什么需要 s_drop_response_audio_until_done?因为本地清空播放环只影响已经收到的数据,网络中可能还有旧回复的 audio.delta。如果不做隔离,刚静音的喇叭会被下一条旧 delta 再次唤醒。
所有下行音频进入播放环之前先经过这道门:
static bool mark_response_audio_started(void)
{
// 当前回复已经被用户取消,丢弃仍在路上的旧音频。
if (s_drop_response_audio_until_done) {
return false;
}
if (!s_response_playing) {
// 没有用户输入却出现新回复,视为回声自触发。
if (!s_user_turn_pending) {
s_accepting_response_audio = false;
s_cancel_response_requested = true;
ESP_LOGW(TAG, "drop self-triggered response audio");
return false;
}
s_user_turn_pending = false;
s_accepting_response_audio = true;
s_last_audio_delta_ms = 0;
aec_ref_start_response();
}
s_response_playing = true;
return s_accepting_response_audio;
}
static void handle_audio_delta(cJSON *root)
{
cJSON *delta = cJSON_GetObjectItem(root, "delta");
if (!cJSON_IsString(delta) || delta->valuestring == NULL) {
return;
}
size_t out_len = 0;
int ret = mbedtls_base64_decode(
s_ws_audio_binary,
WS_AUDIO_BINARY_BUFFER_SIZE,
&out_len,
(const unsigned char *)delta->valuestring,
strlen(delta->valuestring)
);
if (ret != 0 || !mark_response_audio_started()) {
return;
}
playback_ring_write(s_ws_audio_binary, out_len);
}
当 Realtime 接口确认旧回复结束或取消后,再解除丢弃状态:
if (strcmp(type->valuestring, "response.output_audio.done") == 0 ||
strcmp(type->valuestring, "response.done") == 0) {
s_response_playing = false;
s_accepting_response_audio = false;
s_drop_response_audio_until_done = false;
}
if (strcmp(type->valuestring, "response.canceled") == 0) {
s_drop_response_audio_until_done = false;
}
12.5 非空转写只作为后备打断
conversation.item.input_audio_transcription.started 可能被弱噪声触发,所以最终代码只用它重置转写缓冲,不立即停播:
if (strcmp(type->valuestring,
"conversation.item.input_audio_transcription.started") == 0) {
s_input_transcript_len = 0;
s_input_transcript[0] = '\0';
}
如果本地 AEC 能量检测漏判,第一段非空转写才作为可靠后备:
if (strcmp(type->valuestring,
"conversation.item.input_audio_transcription.delta") == 0) {
cJSON *delta = cJSON_GetObjectItem(root, "text");
if (!cJSON_IsString(delta)) {
delta = cJSON_GetObjectItem(root, "delta");
}
if (cJSON_IsString(delta) && delta->valuestring != NULL) {
size_t delta_len = strlen(delta->valuestring);
if (delta_len > 0 && !s_user_turn_pending) {
mark_user_turn_and_interrupt("realtime transcript");
}
// 后面继续把 delta 追加到转写字符串,代码略。
}
}
最终职责分工是:
本地 AEC 能量检测:尽快让喇叭停下来
response.cancel:通知 Realtime 接口停止当前回复
Realtime 接口:继续识别用户内容和语音轮次
非空 transcription.delta:本地检测漏判时兜底
十三、打断后的“嘟嘟”声来自哪里
还有一个很有意思的现象:用户打断后,喇叭开始周期性“嘟嘟”响,直到模型下一次开口才恢复。
问题不在模型音频,而在 I2S TX 欠载。
旧代码在没有播放数据时等待 100ms,却只偶尔写入一小块静音。MAX98357 的 I2S DMA 大部分时间没有新 sample,可能重复末尾数据或产生周期性脉冲。
修复后,播放任务在以下状态都会持续写零采样:
空闲
等待首次预缓冲
播放欠载后的重新缓冲
用户打断后
i2s_channel_write() 本身会按硬件播放节奏阻塞,因此持续写静音不会让任务空转。最终的数字静音函数是:
static void write_silence(size_t frame_count)
{
size_t frames_left = frame_count;
while (frames_left > 0) {
size_t frames_now = frames_left > AMP_WRITE_FRAMES
? AMP_WRITE_FRAMES
: frames_left;
memset(s_amp_stereo_samples,
0,
frames_now * 2 * sizeof(int16_t));
size_t bytes_written = 0;
i2s_channel_write(
s_amp_tx_chan,
s_amp_stereo_samples,
frames_now * 2 * sizeof(int16_t),
&bytes_written,
pdMS_TO_TICKS(1000)
);
frames_left -= frames_now;
}
}
播放任务在等待预缓冲和欠载恢复时都持续调用它:
if (!playing) {
size_t threshold = recovering_from_underflow
? PLAYBACK_REBUFFER_BYTES
: PLAYBACK_PREBUFFER_BYTES;
if (level >= threshold ||
(!s_response_playing && level > 0)) {
playing = true;
recovering_from_underflow = false;
} else {
// 没有语音数据时也保持 BCLK、LRCLK 和零 sample 连续输出。
write_silence(AMP_WRITE_FRAMES);
continue;
}
}
size_t read_bytes = playback_ring_read(
(uint8_t *)s_playback_mono_samples,
AMP_WRITE_FRAMES * sizeof(int16_t)
);
if (read_bytes == 0) {
if (s_response_playing) {
recovering_from_underflow = true;
}
playing = false;
write_silence(AMP_WRITE_FRAMES);
continue;
}
这个问题也呼应了上一篇录放音中的经验:播放结束不能只是“不再写数据”,而要明确处理 I2S 的尾部和空闲状态。
十四、PSRAM 和内部 SRAM 怎样分工
ESP32-S3-N16R8 有 8MB PSRAM,但并不意味着所有数据都应该放 PSRAM。
本次大致这样分配:
| 数据 | 位置 | 原因 |
|---|---|---|
| 1MB 播放环形缓冲 | PSRAM | 容量大,适合存模型 PCM |
| 64KB WebSocket JSON | PSRAM | 大消息分片重组 |
| Base64 与解码缓冲 | PSRAM | 短时大块数据 |
| AEC 参考环 | PSRAM | 需要保存延迟窗口 |
| 当前 AEC frame | 对齐的内部 RAM | 算法访问频繁、需要对齐 |
| I2S 小块收发 buffer | 静态内存 | 生命周期固定,避免任务栈溢出 |
大缓冲不要放在任务局部变量里,否则很容易再次遇到 stack overflow。实时算法的小工作集则不能只因为 PSRAM 大就全部搬出去,访问延迟和对齐要求同样重要。
十五、私密配置不要写进源码
两个工程都通过 Kconfig.projbuild 提供配置项:
idf.py menuconfig
Qwen 工程进入:
Qwen Omni Realtime Demo
配置:
- Wi-Fi SSID
- Wi-Fi password
- DashScope API Key
- Qwen response voice
豆包工程进入:
Doubao Realtime Demo
配置:
- Wi-Fi SSID
- Wi-Fi password
- Volcengine Doubao API Key
- Doubao response voice
- System prompt
真实值只进入被 .gitignore 忽略的 sdkconfig。sdkconfig.defaults 只保存可公开的编译选项和空配置,不能把密码、API Key 写进去。
十六、编译与烧录
Qwen:
cd projects/03_qwen_omni_realtime_ptt
idf.py set-target esp32s3
idf.py build
idf.py -p /dev/cu.usbserial-10 flash monitor
豆包:
cd projects/05_doubao_realtime
idf.py set-target esp32s3
idf.py build
idf.py -p /dev/cu.usbserial-10 flash monitor
成功启动后,应依次看到类似日志:
Wi-Fi connected
websocket connected
session.created / session.updated
user transcript: ...
response.output_audio.started
playback start: buffered=...
response.output_audio.done
response.done
排障时优先关注:
websocket text event too large
base64 decode failed
uplink queue full
audio delta gap
playback underflow
AudioServerNoAudioInputTooLongError
这些日志分别对应消息缓冲、分片拼接、上行吞吐、网络抖动、播放器欠载和上行断流,远比一句“听起来有点卡”更容易定位问题。
十七、这次最重要的收获
这次最大的变化,是开始用“实时系统”的视角理解语音对话。
一个能跑通的 Realtime Demo,不只是下面三步:
连接 WebSocket
上传 PCM
播放返回 PCM
它至少还要处理:
- I2S 音频时钟不能被网络阻塞。
- WebSocket 大消息可能分片,不能解析半截 JSON。
- 网络音频必须经过抖动缓冲再匀速播放。
- AEC 需要真实播放参考、重采样和延迟对齐。
- Realtime 接口的轮次检测与本地快速停播要职责分离。
- 插话时既要快速停播,又要避免回声误触发。
- 空闲时仍要维持正确的 I2S 与上行音频时序。
- PSRAM、内部 SRAM 和任务栈要明确分工。
从单次录放音到 Qwen,再到豆包,表面上只是把模型换了一次,实际上把采集、播放、网络、内存和实时调度完整串了起来。
后面还可以继续做:
- 抽象统一的 Realtime provider 接口,在 Qwen 与豆包之间动态切换。
- 用更高质量的重采样器替代当前轻量 3:2 插值。
- 增加 OLED 状态显示,包括联网、聆听、思考、播放和打断。
- 为 AEC 延迟、音量和插话阈值增加运行时调参工具。
- 加入唤醒词、OTA、会话历史和工具调用。
现在这块 ESP32-S3 已经不只是一个会播放声音的开发板,而是具备了实时听、实时说、可以被打断的 AI 语音终端雏形。