ESP32-S3 IDF实时语音开发:Realtime直连与回声消除

上一篇完成了 ESP32-S3 的 I2S 数字麦克风与 MAX98357 录放音,跑通了最基础的音频闭环。这一次继续往前走:不再把声音只留在开发板里,而是把麦克风 PCM 通过 WebSocket 实时发给大模型,再把模型返回的 PCM 流式播放出来。

本次先后直连了 Qwen Omni Realtime 和豆包 Realtime。本文重点记录回声消除、播放缓冲与插话打断的实现,以及调试过程中遇到的声音卡顿、回声自触发和 I2S 杂音问题。

对应工程:

  • projects/03_qwen_omni_realtime_ptt
  • projects/04_esp_sr_afe_aec_demo
  • projects/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

在模型说话时,麦克风仍然工作;用户再次开口后,本地会快速停止当前播放,并向服务端发送取消事件。这才是本次学习的核心目标:全双工实时语音对话

二、先统一音频格式

两个模型的接入协议并不完全一样,但本次使用的音频格式基本一致:

链路采样率声道格式
数字麦克风 I2S16kHzstereo slot 中取 left32-bit slot
上传模型16kHzmonosigned PCM16 little-endian
模型返回24kHzmonosigned PCM16 little-endian
MAX9835724kHzstereo左右声道写同一份 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 判断当前语音轮次什么时候结束,因此直连模式不需要每一轮手动发送 commitresponse.create。这里没有我们自己部署或编写的 VAD 服务。

模型的下行音频主要来自:

response.audio.delta
response.audio.done
response.done

其中 response.audio.deltadelta 字段是 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.updatesession.create
鉴权Authorization: BearerX-Api-Key
上行音频input_audio_buffer.appendinput_audio_buffer.append
下行音频response.audio.deltaresponse.output_audio.delta
本次输入采样率16kHz16kHz
本次输出采样率24kHz24kHz
轮次判断Realtime 接口内置 semantic_vadRealtime 接口自动处理
取消回复response.cancelresponse.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。正确处理方式是:

  1. payload_offset == 0 时开始一条新消息。
  2. 把每个 data_len 字节追加到接收缓冲。
  3. payload_offset + data_len >= payload_len 时再解析完整 JSON。
  4. 如果总长度超过缓冲区,要丢掉整条消息,不能拿半截 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 输出能量

如果播放期间 micref 很大,而 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 模型负责识别

最终采用两层策略:

  1. 播放期间观察 AEC 输出,连续两帧超过近端人声阈值,约 64ms 内停止本地播放。
  2. 音频上行仍然连续,Realtime 模型接口继续识别用户说了什么、当前轮次什么时候结束。
  3. 第一段非空转写仍然作为本地检测漏判时的后备信号。
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 JSONPSRAM大消息分片重组
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 忽略的 sdkconfigsdkconfig.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

它至少还要处理:

  1. I2S 音频时钟不能被网络阻塞。
  2. WebSocket 大消息可能分片,不能解析半截 JSON。
  3. 网络音频必须经过抖动缓冲再匀速播放。
  4. AEC 需要真实播放参考、重采样和延迟对齐。
  5. Realtime 接口的轮次检测与本地快速停播要职责分离。
  6. 插话时既要快速停播,又要避免回声误触发。
  7. 空闲时仍要维持正确的 I2S 与上行音频时序。
  8. PSRAM、内部 SRAM 和任务栈要明确分工。

从单次录放音到 Qwen,再到豆包,表面上只是把模型换了一次,实际上把采集、播放、网络、内存和实时调度完整串了起来。

后面还可以继续做:

  1. 抽象统一的 Realtime provider 接口,在 Qwen 与豆包之间动态切换。
  2. 用更高质量的重采样器替代当前轻量 3:2 插值。
  3. 增加 OLED 状态显示,包括联网、聆听、思考、播放和打断。
  4. 为 AEC 延迟、音量和插话阈值增加运行时调参工具。
  5. 加入唤醒词、OTA、会话历史和工具调用。

现在这块 ESP32-S3 已经不只是一个会播放声音的开发板,而是具备了实时听、实时说、可以被打断的 AI 语音终端雏形。