Skip to content

当用户先提问,模型开始语音回答;在模型语音还没播完时,用户再次说话,希望“插话”或“打断”;但模型没有马上停下当前语音,而是继续把原来的语音输出完,或者至少继续输出一段。这就导致用户和模型同时说话,交互体验像“抢话”。

这个现象通常叫:

  1. barge-in failure 也就是“插话失败”或“打断失败”

可以,这个能力官方文档里是支持的。你现在要补的是两部分:

  1. 服务端取消当前回复
  2. 前端立即停播并清空音频缓冲

阿里云官方文档对 Qwen-Omni Realtime 的说明很明确:

  • Java SDK 里 OmniRealtimeConversation 提供了 cancelResponse(),作用就是“取消正在进行的响应”;如果当前没有可取消的响应,服务端会返回错误。(Aliyun Help Center)
  • 开启 turn_detection 后,回复阶段如果检测到用户开始说话,服务会“立刻停止这一次的回复并转换到输入状态”。文档还说明,即使开启了 turn_detection,你仍然可以主动调用 response_cancel 来打断。(Aliyun Help Center)
  • 官方 realtime 文档示例里,处理打断的方式就是在 input_audio_buffer.speech_started 事件到来时,如果当前正在回复,则调用 response.cancel。(Aliyun Help Center)
  • 阿里云开发者社区给的播放器参考还特别提到:出现打断事件时,要清空编码队列和音频队列,否则本地还会继续播已经缓存的音频;他们的示例把最大打断延迟控制在约 100ms。(Aliyun Developer Community)

你现在这段 QwenOmniRealtimeBridge 已经开了:

java
.enableTurnDetection(true)

这意味着你已经具备“服务端 VAD 检测用户插话”的基础能力。(Aliyun Help Center) 但你目前在 speech_started 里只是往前端发了一个事件,没有真正执行取消:

java
case "input_audio_buffer.speech_started":
  sendJson(new WsVoiceAgentResponseMessage("speech_started"));
  break;

所以要支持真正的打断,建议这样改。

后端改法 ​

先加一个“当前是否正在回复”的状态位。

java
private final AtomicBoolean responding = new AtomicBoolean(false);

然后在事件里维护它:

java
case "response.created":
  responding.set(true);
  break;

case "response.done":
  responding.set(false);
  sendJson(new WsVoiceAgentResponseMessage("turn_complete"));
  break;

接着在 speech_started 里主动取消当前回复:

java
case "input_audio_buffer.speech_started":
  sendJson(new WsVoiceAgentResponseMessage("speech_started"));

  if (responding.get()) {
    try {
      conversation.cancelResponse(); // 关键:主动打断当前回复
      responding.set(false);
      sendJson(new WsVoiceAgentResponseMessage("response_cancelled"));
    } catch (Exception e) {
      log.error("cancelResponse failed", e);
      sendError("cancel_response_failed", e.getMessage());
    }
  }
  break;

如果你想更稳一点,还可以在收到 speech_started 时顺手通知前端丢弃当前播放队列:

java
sendJson(new WsVoiceAgentResponseMessage("stop_audio_playback"));

为什么只调 cancelResponse() 还不够 ​

因为模型端“停止生成”和浏览器端“停止播放”是两回事。

你的后端已经把一部分 response.audio.delta 转成 PCM 发给前端了:

java
case "response.audio.delta": {
  String b64 = event.has("delta") ? event.get("delta").getAsString() : "";
  if (StrUtil.notBlank(b64)) {
    byte[] pcm = Base64.getDecoder().decode(b64);
    callback.sendBinary(pcm);
  }
  break;
}

这意味着即使服务端已经停止继续生成,前端播放器仍可能在播放已经收到但还没播完的 PCM。这正是你看到“用户插话了,模型还继续说一小段”的常见原因。这个现象和官方播放器说明是一致的:如果不清空本地队列,打断不会立刻体感生效。(Aliyun Developer Community)

前端要配合做的事 ​

当前端收到:

  • speech_started
  • 或你新增的 stop_audio_playback
  • 或 response_cancelled

时,立刻做这几件事:

  • 停止当前正在播放的音频节点
  • 清空待播放 PCM 队列
  • 丢弃旧 response 残留的音频包
  • 新一轮用户说话开始后,再接收新的模型回复音频

如果前端是“边收边播 + 队列播放”的实现,队列 chunk 越大,打断越慢。官方示例建议音频按约 100ms 一包处理,这样打断延迟更小。(Aliyun Developer Community)

结合你这份代码,最小可行修改 ​

你可以直接把 handleEvent 里的关键部分改成这样:

java
private final AtomicBoolean responding = new AtomicBoolean(false);

private void handleEvent(JsonObject event) {
  try {
    String type = event.has("type") ? event.get("type").getAsString() : "";

    switch (type) {
      case "session.created":
        sendJson(new WsVoiceAgentResponseMessage("setup_complete"));
        break;

      case "response.created":
        responding.set(true);
        break;

      case "input_audio_buffer.speech_started":
        sendJson(new WsVoiceAgentResponseMessage("speech_started"));

        if (responding.get()) {
          try {
            conversation.cancelResponse();
            responding.set(false);

            sendJson(new WsVoiceAgentResponseMessage("response_cancelled"));
            sendJson(new WsVoiceAgentResponseMessage("stop_audio_playback"));
          } catch (Exception e) {
            log.error("cancelResponse failed", e);
            sendError("cancel_response_failed", e.getMessage());
          }
        }
        break;

      case "input_audio_buffer.speech_stopped":
        sendJson(new WsVoiceAgentResponseMessage("speech_stopped"));
        break;

      case "response.audio.delta": {
        String b64 = event.has("delta") ? event.get("delta").getAsString() : "";
        if (StrUtil.notBlank(b64)) {
          byte[] pcm = Base64.getDecoder().decode(b64);
          callback.sendBinary(pcm);
        }
        break;
      }

      case "response.done":
        responding.set(false);
        sendJson(new WsVoiceAgentResponseMessage("turn_complete"));
        break;

      case "error":
        sendError("remote_error", event.toString());
        break;
    }
  } catch (Exception e) {
    log.error("handleEvent error", e);
    sendError("handle_event_error", e.getMessage());
  }
}

结论 ​

你这个场景要支持“用户说话时立刻打断模型播报”,正确做法是:

  • 保留 enableTurnDetection(true),让服务端能检测插话。(Aliyun Help Center)
  • 在 input_audio_buffer.speech_started 时调用 conversation.cancelResponse(),主动取消当前回复。(Aliyun Help Center)
  • 前端收到打断事件后,立即停止播放并清空本地音频缓冲,否则还会继续播旧 PCM。(Aliyun Developer Community)

如果你愿意,我下一条可以直接给你一版可粘贴的完整 Java 修改版 QwenOmniRealtimeBridge。

Google 的 Gemini Realtime (Live API) 和 Qwen 的机制不太一样。 Gemini 没有 cancelResponse() 这种显式接口,它的打断机制主要依赖 Activity Detection + ActivityHandling,也就是:

当检测到新的用户语音活动时自动打断当前响应。

你现在的代码其实已经接近正确配置,但需要理解它的工作方式和补充一点逻辑。


一、Gemini Realtime 的打断机制 ​

Gemini Live 的打断依赖三个关键配置:

1. VAD(语音活动检测) ​

你已经配置了:

java
AutomaticActivityDetection vad = AutomaticActivityDetection.builder()
    .disabled(false)
    .startOfSpeechSensitivity(StartSensitivity.Known.START_SENSITIVITY_HIGH)
    .endOfSpeechSensitivity(EndSensitivity.Known.END_SENSITIVITY_LOW)
    .prefixPaddingMs(100)
    .silenceDurationMs(500)
    .build();

作用:

  • 检测用户何时开始说话
  • 检测用户何时结束说话

2. Turn coverage ​

java
.turnCoverage(TurnCoverage.Known.TURN_INCLUDES_ONLY_ACTIVITY)

含义:

只把 用户语音活动 当作一个 turn。


3. Activity handling(关键) ​

你设置的是:

java
.activityHandling(ActivityHandling.Known.START_OF_ACTIVITY_INTERRUPTS)

这个配置的含义是:

当检测到新的用户语音活动开始时,中断当前模型响应。

这就是 Gemini 的 barge-in 设置。


二、为什么你仍然看到“模型继续说” ​

这是 Gemini Realtime 最容易踩的坑。

原因通常是:

模型已经停止生成,但客户端仍然在播放旧音频。

你的代码里:

java
p.inlineData().ifPresent(blob -> {
  byte[] data = blob.data().orElse(null);
  callback.sendBinary(data);
});

音频是 实时 streaming 出来的。

如果前端:

  • 已经缓存了 200-500ms PCM
  • 或者播放器队列没清空

就会出现:

用户插话 → 模型已停止 → 浏览器还在播放缓存音频

看起来就像:

模型没有被打断

但实际上:

模型已经停止,只是播放器还在播旧 buffer。


三、Gemini 正确的打断实现方式 ​

Gemini 官方推荐的方式是:

当检测到 新的用户输入 时:

  1. 立即停止播放当前音频
  2. 清空播放器队列
  3. 丢弃未播放 PCM
  4. 继续发送新的用户音频

四、你现在代码里缺少的一件事 ​

你没有把 用户说话开始事件通知前端。

Gemini 会通过 input transcription 或 realtime activity 表示用户说话。

你可以在这里触发打断:

java
sc.inputTranscription().ifPresent(t -> {
  String text = t.text().orElse(null);

  send(new WsVoiceAgentResponseMessage("transcript_in", text));

  // 新增:通知前端打断当前播放
  send(new WsVoiceAgentResponseMessage("speech_started"));

  appendTurnTranscript("user", text);
});

或者更直接:

当收到新的用户音频 chunk 时就通知前端停播。

例如:

java
public CompletableFuture<Void> sendPcm16k(byte[] pcm16k) {

  // 新增:用户开始说话,通知前端打断
  send(new WsVoiceAgentResponseMessage("speech_started"));

  AsyncSession s = this.session;
  ...
}

五、前端必须做的事情(非常关键) ​

当前端收到:

speech_started

需要立即:

audioPlayer.stop()
queue.clear()

否则:

即使 Gemini 已经停止生成

浏览器仍会继续播放 PCM。


六、Gemini 和 Qwen 的打断机制差异 ​

模型打断方式
Gemini Live自动打断 (VAD + ActivityHandling)
Qwen Omni手动 cancelResponse
OpenAI Realtime自动 + response.cancel

所以:

Gemini 通常不需要手动 cancel。

只需要:

START_OF_ACTIVITY_INTERRUPTS

七、你的 Gemini 配置其实已经正确 ​

这一行就是关键:

java
.activityHandling(ActivityHandling.Known.START_OF_ACTIVITY_INTERRUPTS)

只要:

  • VAD 正常
  • 前端清空播放队列

就能实现 真正的 barge-in。


八、Gemini 推荐的 VAD 配置(更适合语音助手) ​

建议把 silence 调低一点:

java
.prefixPaddingMs(80)
.silenceDurationMs(300)

效果:

  • 用户插话更快
  • 打断延迟更低

九、一个小优化(很多团队都会加) ​

如果用户说话时:

暂停向前端发送模型音频

例如:

java
private volatile boolean userSpeaking = false;

用户开始说话:

userSpeaking = true

模型音频:

java
if (!userSpeaking) {
   callback.sendBinary(data);
}

十、总结 ​

Gemini Realtime 的打断机制是:

自动打断

依赖配置:

AutomaticActivityDetection
START_OF_ACTIVITY_INTERRUPTS

你代码里:

其实已经配置好了。

但要真正生效,还需要:

1️⃣ 前端 收到 speech_started 后停止播放 2️⃣ 清空 PCM 播放队列 3️⃣ 丢弃旧音频 buffer

否则就会出现:

模型被打断了,但浏览器还在继续播。


如果你愿意,我可以给你一份:

Gemini Realtime 语音助手的完整最佳实践架构(含打断、低延迟、不卡顿)

基本是现在所有 AI 语音助手都在用的结构。

MIT Licensed