В естественном вербальном диалоге прерывание собеседника (Barge-in / Interruption) — фундаментальный коммуникативный механизм. Слушатель не ожидает полной паузы в несколько секунд, чтобы высказать коррекцию: заметив ошибку или получив достаточную информацию, он начинает говорить на полуслове ("Подожди...", "Нет, выбери другой вариант"). В этот момент говорящий инстинктивно умолкает в течение 100–200 мс и переключается в режим слушания.
Если программный голосовой агент лишен эффективного механизма прерывания, возникает критическая деградация пользовательского опыта:
ОШИБОЧНЫЙ ПАТТЕРН (РАССИНХРОНИЗАЦИЯ КОНТЕКСТА):
[Модель сгенерировала]: "Ваш заказ оформлен. Доставка завтра в 14:00. Курьер позвонит за час."
[Клиент услышал]: "Ваш заказ оформлен. Доставка..." (Прерывание: "Стой, отмени!")
[Память LLM]: Считает, что все 3 предложения доставлены. Возникает контекстная коллизия.
КОРРЕКТНЫЙ ПАТТЕРН С CONTEXT TRUNCATION:
[Клиент]: Детектирует речь пользователя -> Сбрасывает Web Audio очередь.
[DataChannel]: Отправляет 'response.cancel' + 'conversation.item.truncate' (audio_end_ms: 1450).
[Память LLM]: Хвост ответа отсекается. Контекст точно отражает реально услышанный звук.Для решения этой задачи в архитектуру потокового голосового агента встраиваются три взаимосвязанных контура:
Контур прерывания функционирует на стыке цифровой обработки сигналов (DSP), низкоуровневого Web Audio API и протоколов двунаправленного стриминга.
Barge-in (Прерывание) Voice Activity Detection (VAD) Acoustic Echo Cancellation (AEC) conversation.item.truncate Double-TalkАРХИТЕКТУРА ОБРАБОТКИ ПРЕРЫВАНИЙ (BARGE-IN)
+-----------------------------------------------------------------------------------------+
| АКУСТИЧЕСКАЯ СРЕДА (ПОЛЬЗОВАТЕЛЬ + ДИНАМИКИ) |
| Пользователь говорит: "Подожди..." Динамик играет: "Ваш баланс составляет..." |
+-----------------------------------------------------------------------------------------+
| |
v (Речь + Акустическое эхо) v (Опорный сигнал Reference)
+-----------------------------------------------------------------------------------------+
| BROWSER / OS DSP ENGINE |
| |
| [ Микрофон ] ---> [ Adaptive Filter (AEC) ] <--- [ AudioContext Output ] |
| | (Эхо вычтено) |
| v |
| [ Чистый голос пользователя ] |
+-------------------------------------|---------------------------------------------------+
v
+-----------------------------------------------------------------------------------------+
| VAD ENGINE (CLIENT WASM SILERO / SERVER REALTIME VAD) |
| Расчет вероятности речи: P(Speech) > 0.85 в течение 100 мс |
+-------------------------------------|---------------------------------------------------+
| (ТРИГГЕР: РЕЧЬ ОБНАРУЖЕНА)
+------------------------------+------------------------------+
| |
| (1) Локальное мгновенное действие (< 15 мс) | (2) Сетевое событие
v v
+---------------------------------------------+ +-----------------------------------+
| WebAudioQueue.interrupt(): | | RTCDataChannel.send(): |
| - AudioBufferSourceNode.stop() | | - response.cancel |
| - Очистка очереди ожидающих чанков | | - conversation.item.truncate |
| - Динамик немедленно замолкает | | (audio_end_ms: elapsedMs) |
+---------------------------------------------+ +-----------------+-----------------+
|
v
+-----------------------------------+
| REALTIME API CLOUD ENGINE |
| - Немедленная остановка инференса |
| - Отсечение хвоста аудио в памяти |
| - Переход в режим Listen |
+-----------------------------------++-------------------+-----------------------------------+-----------------------------------+
| Параметр | Клиентский VAD (Silero ONNX/WASM) | Серверный VAD (Realtime API) |
+-------------------+-----------------------------------+-----------------------------------+
| Время реакции | 20–40 мс (мгновенно в браузере) | 150–300 мс (задержка сети RTT) |
| Трафик и биллинг | Отправка аудио только при речи | Непрерывный стриминг фонового шума|
| Нагрузка на CPU | ~3-7% одного ядра CPU клиента | 0% на клиенте |
| Сложность кода | Требует WebAssembly и AudioWorklet| Готовый флаг turn_detection в API |
| Защита от Double | Зависит от аппаратного AEC ОС | Модель учитывает синхронный стрим |
+-------------------+-----------------------------------+-----------------------------------+Для реализации корректного прерывания необходимы два компонента:
interrupt().interrupt() и отправляет в DataChannel события response.cancel и conversation.item.truncate./**
* Очередь потокового воспроизведения аудио через Web Audio API
* с поддержкой атомарного сброса и трекинга времени воспроизведения.
*/
export class WebAudioOutputQueue {
private audioCtx: AudioContext | null = null;
private nextPlayTime: number = 0;
private activeSources: Set<AudioBufferSourceNode> = new Set();
private playbackStartTime: number = 0;
private totalSamplesPlayed: number = 0;
private readonly sampleRate: number;
constructor(sampleRate: number = 24000) {
this.sampleRate = sampleRate;
}
public init(): void {
if (!this.audioCtx) {
const AudioCtxClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
this.audioCtx = new AudioCtxClass({ sampleRate: this.sampleRate });
this.nextPlayTime = this.audioCtx.currentTime;
}
if (this.audioCtx.state === "suspended") {
void this.audioCtx.resume();
}
}
/**
* Добавляет сырой PCM16 чанк в очередь непрерывного воспроизведения
*/
public enqueuePcm16Chunk(pcmData: Int16Array): void {
if (!this.audioCtx) {
this.init();
}
const ctx = this.audioCtx!;
// Конвертируем PCM16 в Float32 [-1.0, 1.0]
const float32Data = new Float32Array(pcmData.length);
for (let i = 0; i < pcmData.length; i++) {
float32Data[i] = pcmData[i] / 32768.0;
}
const audioBuffer = ctx.createBuffer(1, float32Data.length, this.sampleRate);
audioBuffer.getChannelData(0).set(float32Data);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
const currentTime = ctx.currentTime;
// Планируем следующий чанк строго встык с предыдущим
const startTime = Math.max(currentTime, this.nextPlayTime);
source.start(startTime);
if (this.activeSources.size === 0) {
this.playbackStartTime = startTime;
}
this.activeSources.add(source);
this.nextPlayTime = startTime + audioBuffer.duration;
source.onended = () => {
this.activeSources.delete(source);
this.totalSamplesPlayed += pcmData.length;
};
}
/**
* Возвращает длительность реально воспроизведенного аудио в миллисекундах
*/
public getElapsedPlaybackMs(): number {
if (!this.audioCtx || this.activeSources.size === 0) {
return Math.round((this.totalSamplesPlayed / this.sampleRate) * 1000);
}
const elapsedSeconds = Math.max(0, this.audioCtx.currentTime - this.playbackStartTime);
return Math.round(elapsedSeconds * 1000);
}
/**
* Мгновенно обрывает воспроизведение и очищает всю очередь
*/
public interrupt(): number {
const elapsedMs = this.getElapsedPlaybackMs();
// Немедленно останавливаем все звучащие в данный момент ноды
this.activeSources.forEach((source) => {
try {
source.stop(0);
source.disconnect();
} catch (err: unknown) {
// Игнорируем ошибку, если источник уже успел завершиться
}
});
this.activeSources.clear();
if (this.audioCtx) {
this.nextPlayTime = this.audioCtx.currentTime;
}
this.totalSamplesPlayed = 0;
this.playbackStartTime = 0;
return elapsedMs;
}
public close(): void {
this.interrupt();
if (this.audioCtx) {
void this.audioCtx.close();
this.audioCtx = null;
}
}
}"use client";
import { useEffect, useRef, useCallback } from "react";
import { WebAudioOutputQueue } from "@/lib/audio/WebAudioOutputQueue";
interface InterruptionManagerProps {
dataChannel: RTCDataChannel | null;
currentResponseId: string | null;
currentActiveItemId: string | null;
audioQueue: WebAudioOutputQueue;
onInterrupted?: (elapsedMs: number) => void;
}
export function useAudioInterruptionManager({
dataChannel,
currentResponseId,
currentActiveItemId,
audioQueue,
onInterrupted,
}: InterruptionManagerProps) {
const isInteractingRef = useRef<boolean>(false);
/**
* Немедленный сброс аудио и отправка команд отмены на сервер
*/
const handleBargeIn = useCallback(() => {
// 1. Атомарно сбрасываем звук на клиенте
const elapsedMs = audioQueue.interrupt();
console.log(`[Barge-in] Речь прервана. Услышано: ${elapsedMs} мс`);
if (onInterrupted) {
onInterrupted(elapsedMs);
}
if (!dataChannel || dataChannel.readyState !== "open") {
return;
}
// 2. Отменяем активную генерацию ответа на стороне модели
if (currentResponseId) {
dataChannel.send(
JSON.stringify({
type: "response.cancel",
})
);
}
// 3. Отсекаем хвост недосказанного сообщения в истории диалога
if (currentActiveItemId && elapsedMs > 0) {
dataChannel.send(
JSON.stringify({
type: "conversation.item.truncate",
item_id: currentActiveItemId,
content_index: 0,
audio_end_ms: elapsedMs,
})
);
}
}, [dataChannel, currentResponseId, currentActiveItemId, audioQueue, onInterrupted]);
useEffect(() => {
if (!dataChannel) return;
const handleMessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
// Серверный VAD зафиксировал начало речи пользователя
if (msg.type === "input_audio_buffer.speech_started") {
isInteractingRef.current = true;
handleBargeIn();
}
if (msg.type === "input_audio_buffer.speech_stopped") {
isInteractingRef.current = false;
}
} catch (err: unknown) {
console.error("[InterruptionManager] Ошибка парсинга события:", err);
}
};
dataChannel.addEventListener("message", handleMessage);
return () => {
dataChannel.removeEventListener("message", handleMessage);
};
}, [dataChannel, handleBargeIn]);
return {
triggerManualInterruption: handleBargeIn,
};
}Если при прерывании отправить только команду response.cancel, генерация новых аудио-токенов остановится, но в истории контекста сервера останется полный исходный текст ответа. В следующем диалоговом цикле модель будет уверена, что пользователь полностью прослушал сообщение, что приведет к коллизиям в рассуждениях (Context Hallucination).
Вызов audioContext.suspend() замораживает весь звуковой движок браузера. Когда контекст будет возобновлен через resume() для следующей фразы, невычищенные старые буферы начнут проигрываться с момента остановки, создавая какофонию из обрывков старых и новых слов. Всегда вызывайте source.stop(0) и очищайте очередь источников.
При значении порога детекции речи (threshold) ниже 0.3 модель будет прерывать свою речь от каждого вдоха пользователя, клика клавиатуры или фонового шума кондиционера. Слишком высокий порог (> 0.8) заставит пользователя громко кричать первые звуки, создавая задержку реакции прерывания в 400–600 мс. Оптимальный промышленный порог: 0.5 с префиксом буферизации (prefix_padding_ms) 300 мс.
Если переключение аудиовыходов (например, отсоединение наушников и переход на встроенные динамики) не обрабатывается через событие navigator.mediaDevices.ondevicechange, браузер может не перекалибровать фильтры эхоподавления. Это приведет к возникновению акустической петли обратной связи.
Пользователь начинает говорить во время звучания динамика. Адаптивный фильтр AEC браузера вычитает звук динамика из сигнала микрофона.
Серверный или клиентский VAD фиксирует превышение вероятности человеческой речи (P > 0.5) и генерирует событие speech_started.
Web Audio Queue на клиенте за 10 мс вызывает stop() для всех активных источников и вычисляет реально прослушанные миллисекунды (elapsedMs).
Через RTCDataChannel отправляется событие response.cancel, немедленно останавливающее инференс графических процессоров (GPU).
Команда conversation.item.truncate с точным значением audio_end_ms синхронизирует память LLM с реально услышанной частью фразы.