当用户先提问,模型开始语音回答;在模型语音还没播完时,用户再次说话,希望“插话”或“打断”;但模型没有马上停下当前语音,而是继续把原来的语音输出完,或者至少继续输出一段。这就导致用户和模型同时说话,交互体验像“抢话”。
这个现象通常叫:
- barge-in failure 也就是“插话失败”或“打断失败”
可以,这个能力官方文档里是支持的。你现在要补的是两部分:
- 服务端取消当前回复
- 前端立即停播并清空音频缓冲
阿里云官方文档对 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 已经开了:
.enableTurnDetection(true)
这意味着你已经具备“服务端 VAD 检测用户插话”的基础能力。(Aliyun Help Center) 但你目前在 speech_started 里只是往前端发了一个事件,没有真正执行取消:
case "input_audio_buffer.speech_started":
sendJson(new WsVoiceAgentResponseMessage("speech_started"));
break;
所以要支持真正的打断,建议这样改。
后端改法
先加一个“当前是否正在回复”的状态位。
private final AtomicBoolean responding = new AtomicBoolean(false);
然后在事件里维护它:
case "response.created":
responding.set(true);
break;
case "response.done":
responding.set(false);
sendJson(new WsVoiceAgentResponseMessage("turn_complete"));
break;
接着在 speech_started 里主动取消当前回复:
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 时顺手通知前端丢弃当前播放队列:
sendJson(new WsVoiceAgentResponseMessage("stop_audio_playback"));
为什么只调 cancelResponse() 还不够
因为模型端“停止生成”和浏览器端“停止播放”是两回事。
你的后端已经把一部分 response.audio.delta 转成 PCM 发给前端了:
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 里的关键部分改成这样:
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(语音活动检测)
你已经配置了:
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
.turnCoverage(TurnCoverage.Known.TURN_INCLUDES_ONLY_ACTIVITY)
含义:
只把 用户语音活动 当作一个 turn。
3. Activity handling(关键)
你设置的是:
.activityHandling(ActivityHandling.Known.START_OF_ACTIVITY_INTERRUPTS)
这个配置的含义是:
当检测到新的用户语音活动开始时,中断当前模型响应。
这就是 Gemini 的 barge-in 设置。
二、为什么你仍然看到“模型继续说”
这是 Gemini Realtime 最容易踩的坑。
原因通常是:
模型已经停止生成,但客户端仍然在播放旧音频。
你的代码里:
p.inlineData().ifPresent(blob -> {
byte[] data = blob.data().orElse(null);
callback.sendBinary(data);
});
音频是 实时 streaming 出来的。
如果前端:
- 已经缓存了 200-500ms PCM
- 或者播放器队列没清空
就会出现:
用户插话 → 模型已停止 → 浏览器还在播放缓存音频
看起来就像:
模型没有被打断
但实际上:
模型已经停止,只是播放器还在播旧 buffer。
三、Gemini 正确的打断实现方式
Gemini 官方推荐的方式是:
当检测到 新的用户输入 时:
- 立即停止播放当前音频
- 清空播放器队列
- 丢弃未播放 PCM
- 继续发送新的用户音频
四、你现在代码里缺少的一件事
你没有把 用户说话开始事件通知前端。
Gemini 会通过 input transcription 或 realtime activity 表示用户说话。
你可以在这里触发打断:
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 时就通知前端停播。
例如:
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 配置其实已经正确
这一行就是关键:
.activityHandling(ActivityHandling.Known.START_OF_ACTIVITY_INTERRUPTS)
只要:
- VAD 正常
- 前端清空播放队列
就能实现 真正的 barge-in。
八、Gemini 推荐的 VAD 配置(更适合语音助手)
建议把 silence 调低一点:
.prefixPaddingMs(80)
.silenceDurationMs(300)
效果:
- 用户插话更快
- 打断延迟更低
九、一个小优化(很多团队都会加)
如果用户说话时:
暂停向前端发送模型音频
例如:
private volatile boolean userSpeaking = false;
用户开始说话:
userSpeaking = true
模型音频:
if (!userSpeaking) {
callback.sendBinary(data);
}
十、总结
Gemini Realtime 的打断机制是:
自动打断
依赖配置:
AutomaticActivityDetection
START_OF_ACTIVITY_INTERRUPTS
你代码里:
其实已经配置好了。
但要真正生效,还需要:
1️⃣ 前端 收到 speech_started 后停止播放 2️⃣ 清空 PCM 播放队列 3️⃣ 丢弃旧音频 buffer
否则就会出现:
模型被打断了,但浏览器还在继续播。
如果你愿意,我可以给你一份:
Gemini Realtime 语音助手的完整最佳实践架构(含打断、低延迟、不卡顿)
基本是现在所有 AI 语音助手都在用的结构。
