Классическая архитектура голосовых ассистентов строилась по трехзвенной каскадной схеме: STT (Speech-to-Text) -> LLM -> TTS (Text-to-Speech). В таком конвейере суммарная задержка ответа складывается из транскрипции (200–500 мс), времени генерации первого токена языковой моделью (300–800 мс) и синтеза речи аудиогенератором (300–600 мс). Итоговая задержка (Time-to-First-Audio) составляет 1.5–3.0 секунды, что исключает возможность ведения естественного живого диалога.
Современные мультимодальные модели реального времени (OpenAI Realtime API, Gemini Multimodal Live) оперируют парадигмой Native Audio-to-Audio. Звуковой поток передается непрерывно в сыром представлении (PCM 16-бит, 16 кГц или 24 кГц моно). Модель принимает аудиотокены и генерирует ответные аудиосэмплы напрямую, сокращая задержку диалога до субсекундных значений (менее 300 мс), что приближается к физиологической скорости реакции человека.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT INGEST & VAD PIPELINE │
│ │
│ [ Microphone Stream ] │
│ │ │
│ ▼ (Web Audio API: 48kHz Float32) │
│ [ AudioWorklet Processor (Dedicated Thread) ] │
│ - Downsampling: 48kHz ──► 24kHz / 16kHz │
│ - Conversion: Float32 [-1.0, 1.0] ──► Int16 Linear PCM (Little Endian) │
│ │ │
│ ▼ (PostMessage: 512 samples / 21.3ms chunks) │
│ [ Client-Side VAD (ONNX Runtime Web / Silero VAD) ] │
│ ├── Speech Detected? ──► Stream binary frames via WebSocket/WebRTC │
│ └── [ Barge-In Trigger ] ──► Instant local playback flush! │
└──────────────────────────────────────┬──────────────────────────────────────┘
│ Real-time Bi-directional Stream
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ AI BACKEND (OpenAI Realtime / Gemini Live) │
│ │
│ Native Audio-to-Audio Neural Pipeline (Latency under 300ms) │
│ - Receives client audio packets continuously │
│ - Emits delta audio frames (PCM 24kHz) │
│ - Handles cancellation signal ("response.cancel") │
└──────────────────────────────────────┬──────────────────────────────────────┘
│ Incoming Audio Chunks
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT PLAYBACK & JITTER BUFFER │
│ │
│ [ Jitter Buffer (Ring Buffer) ] ──► [ AudioWorklet Playback Engine ] │
│ - Absorbs packet arrival latency variance │
│ - Zero-latency cancellation upon user interruption (Barge-in) │
│ - Output to Hardware Speakers (AEC Protected) │
└─────────────────────────────────────────────────────────────────────────────┘Для реализации такого интерфейса в React необходимо решить две критические инженерные задачи:
Ментальная модель голосового AI-агента в React 19 базируется на симметричном дуплексном конвейере:
echoCancellation: true). Без этого звук речи модели из динамиков вернется в микрофон и приведет к ложному срабатыванию прерывания (модель перебьет сама себя).Float32Array в бинарный формат Int16Array (Linear PCM Little-Endian).threshold = 0.5) активируется состояние isSpeaking.isSpeaking в true в момент, когда агент воспроизводит речь, клиентское приложение выполняет синхронный сброс:
playbackQueue.clear()).response.cancel.Вызов getUserMedia с флагами echoCancellation: true и noiseSuppression: true для предотвращения акустической обратной связи.
Понижение частоты дискретизации до 24 кГц и упаковка в 16-битный целочисленный формат Int16Array.
Анализ порций звука по 512 сэмплов через нейросеть Silero VAD в среде ONNX Runtime Web.
При обнаружении речи во время ответа агента: моментальная очистка буфера вывода и отправка response.cancel.
Прием ответных чанков, сглаживание сетевого джиттера и непрерывная подача звука в AudioContext destination.
Разработчик создал голосового ассистента с локальным Silero VAD и функцией Barge-in. При тестировании в наушниках система работает идеально. Однако при воспроизведении через встроенные динамики ноутбука ассистент замолкает через 1 секунду после начала собственной речи, а система регистрирует ложное прерывание. В чем причина и как ее устранить?
Голосовые консультанты банков и авиакомпаний, способные вести эмоциональный диалог в реальном времени, понимать спонтанные уточнения клиента и мгновенно замолкать при словах «Подождите» или «Нет, стоп».
Платформы тренировки продаж и собеседований на английском языке: реалистичный собеседник со свободным темпом речи, перебиваниями и естественными паузами размышления.
Управление промышленными системами, медицинскими сканерами и 3D-моделями во время хирургических операций или полевого ремонта оборудования.
Сквозной перевод речи докладчика на конференции с минимальной задержкой менее 300 мс и адаптацией под интонацию спикера.
Для захвата аудиопотока микрофона с конвертацией в PCM16 и частоту 24 кГц создается специализированный ворклет, работающий в аудиопотоке ядра:
// src/audio/pcm-recorder-processor.ts
export const pcmRecorderProcessorCode = `
class PcmRecorderProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.targetSampleRate = 24000;
// Окно накопления сэмплов: 512 сэмплов = 21.3 мс при 24 кГц
this.bufferSize = 512;
this.outputBuffer = new Int16Array(this.bufferSize);
this.outputIndex = 0;
}
process(inputs) {
const input = inputs[0];
if (!input || !input[0]) return true;
const inputSamples = input[0];
// Коэффициент прореживания (децимации) с 48 кГц до 24 кГц
const decimationRatio = Math.round(sampleRate / this.targetSampleRate);
for (let i = 0; i < inputSamples.length; i += decimationRatio) {
const sample = Math.max(-1, Math.min(1, inputSamples[i]));
// Преобразование Float32 [-1.0, 1.0] в Signed 16-bit Integer [-32768, 32767]
const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
this.outputBuffer[this.outputIndex++] = int16;
if (this.outputIndex >= this.bufferSize) {
// Отправка бинарного фрейма в Main Thread через Structured Clone
this.port.postMessage(this.outputBuffer.slice());
this.outputIndex = 0;
}
}
return true;
}
}
registerProcessor('pcm-recorder-processor', PcmRecorderProcessor);
`;'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { pcmRecorderProcessorCode } from '../audio/pcm-recorder-processor';
export type AgentStatus = 'disconnected' | 'connecting' | 'listening' | 'thinking' | 'speaking';
export interface UseVoiceAgentOptions {
websocketUrl: string;
onTranscript?: (role: 'user' | 'assistant', text: string) => void;
}
export function useVoiceAgent({ websocketUrl, onTranscript }: UseVoiceAgentOptions) {
const [status, setStatus] = useState<AgentStatus>('disconnected');
const [isMuted, setIsMuted] = useState(false);
// Ссылки на Web Audio API и сетевой сокет
const audioContextRef = useRef<AudioContext | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Очередь воспроизведения аудио (Jitter / Playback Buffer)
const audioQueueRef = useRef<Float32Array[]>([]);
const isPlayingRef = useRef<boolean>(false);
const scheduledTimeRef = useRef<number>(0);
// 1. Механизм мгновенного прерывания (Barge-In)
const interruptAgent = useCallback(() => {
// 1. Немедленная очистка локальной очереди сэмплов
audioQueueRef.current = [];
isPlayingRef.current = false;
// 2. Отправка сигнала отмены генерации на сервер
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'response.cancel' }));
}
setStatus('listening');
}, []);
// 2. Локальный легковесный алгоритм VAD по энергии сигнала (RMS)
const processVadChunk = useCallback((pcmChunk: Int16Array) => {
let sumSquares = 0;
for (let i = 0; i < pcmChunk.length; i++) {
const normalized = pcmChunk[i] / 32768.0;
sumSquares += normalized * normalized;
}
const rms = Math.sqrt(sumSquares / pcmChunk.length);
// Порог детекции речи (при необходимости заменяется на инференс Silero VAD)
const SPEECH_THRESHOLD = 0.045;
if (rms > SPEECH_THRESHOLD) {
// Если агент в этот момент говорит -> срабатывает прерывание Barge-in!
if (status === 'speaking' || isPlayingRef.current) {
console.info('[VAD] Barge-in triggered by user speech!');
interruptAgent();
}
}
}, [status, interruptAgent]);
// 3. Воспроизведение входящих аудиочанков от модели (24 кГц PCM)
const enqueueAudioChunk = useCallback((float32Samples: Float32Array) => {
const ctx = audioContextRef.current;
if (!ctx) return;
// Создаем аудио-буфер для порции
const buffer = ctx.createBuffer(1, float32Samples.length, 24000);
buffer.getChannelData(0).set(float32Samples);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
// Планирование точного времени воспроизведения (сглаживание джиттера)
const currentTime = ctx.currentTime;
if (scheduledTimeRef.current < currentTime) {
// Буфер опустел: добавляем минимальный запас 40 мс для защиты от джиттера
scheduledTimeRef.current = currentTime + 0.04;
}
source.start(scheduledTimeRef.current);
scheduledTimeRef.current += buffer.duration;
isPlayingRef.current = true;
setStatus('speaking');
source.onended = () => {
if (ctx.currentTime >= scheduledTimeRef.current - 0.01) {
isPlayingRef.current = false;
setStatus('listening');
}
};
}, []);
// 4. Подключение и запуск сессии
const connect = useCallback(async () => {
try {
setStatus('connecting');
// Инициализация AudioContext
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)({
sampleRate: 48000,
latencyHint: 'interactive',
});
await ctx.resume();
audioContextRef.current = ctx;
// Регистрация ворклета захвата
const blob = new Blob([pcmRecorderProcessorCode], { type: 'application/javascript' });
const workletUrl = URL.createObjectURL(blob);
await ctx.audioWorklet.addModule(workletUrl);
URL.revokeObjectURL(workletUrl);
// Захват микрофона со строгим аппаратным эхоподавлением (AEC)
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // КРИТИЧНО для работы Barge-In!
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
});
mediaStreamRef.current = stream;
const sourceNode = ctx.createMediaStreamSource(stream);
const workletNode = new AudioWorkletNode(ctx, 'pcm-recorder-processor');
workletNodeRef.current = workletNode;
sourceNode.connect(workletNode);
// Подключение по WebSocket
const ws = new WebSocket(websocketUrl);
wsRef.current = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
setStatus('listening');
// Отправка стартовой конфигурации сессии (OpenAI/Gemini format)
ws.send(JSON.stringify({
type: 'session.update',
session: {
modalities: ['audio', 'text'],
voice: 'alloy',
input_audio_format: 'pcm16',
output_audio_format: 'pcm16',
},
}));
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const message = JSON.parse(event.data);
if (message.type === 'response.audio.delta' && message.delta) {
// Декодирование Base64 PCM чанка в сэмплы Float32
const binaryString = atob(message.delta);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const int16 = new Int16Array(bytes.buffer);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / 32768.0;
}
enqueueAudioChunk(float32);
} else if (message.type === 'response.audio_transcript.delta') {
onTranscript?.('assistant', message.delta);
}
}
};
ws.onerror = (e) => console.error('[VoiceAgent] WebSocket error:', e);
ws.onclose = () => setStatus('disconnected');
// Обработка чанков микрофона от ворклета
workletNode.port.onmessage = (event) => {
const pcmChunk: Int16Array = event.data;
// Локальная проверка VAD на прерывание
processVadChunk(pcmChunk);
// Отправка в сеть
if (ws.readyState === WebSocket.OPEN && !isMuted) {
ws.send(pcmChunk.buffer);
}
};
} catch (err) {
console.error('[VoiceAgent] Connection failed:', err);
setStatus('disconnected');
}
}, [websocketUrl, isMuted, onTranscript, processVadChunk, enqueueAudioChunk]);
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((t) => t.stop());
mediaStreamRef.current = null;
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
audioContextRef.current = null;
}
setStatus('disconnected');
}, []);
const toggleMute = useCallback(() => {
setIsMuted((prev) => !prev);
}, []);
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
return {
status,
isMuted,
connect,
disconnect,
toggleMute,
interruptAgent,
};
}'use client';
import * as React from 'react';
import { useVoiceAgent, AgentStatus } from '../hooks/useVoiceAgent';
export function VoiceAssistantDialog() {
const [transcripts, setTranscripts] = React.useState<Array<{ role: string; text: string }>>([]);
const handleTranscript = React.useCallback((role: 'user' | 'assistant', text: string) => {
setTranscripts((prev) => {
const last = prev[prev.length - 1];
if (last && last.role === role) {
return [...prev.slice(0, -1), { role, text: last.text + text }];
}
return [...prev, { role, text }];
});
}, []);
const { status, isMuted, connect, disconnect, toggleMute, interruptAgent } = useVoiceAgent({
websocketUrl: 'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview',
onTranscript: handleTranscript,
});
const getStatusColor = (s: AgentStatus) => {
switch (s) {
case 'listening': return 'border-accent-cyan bg-accent-cyan/10 text-accent-cyan';
case 'speaking': return 'border-accent-violet bg-accent-violet/20 text-accent-violet animate-pulse';
case 'thinking': return 'border-warning bg-warning/10 text-warning';
case 'connecting': return 'border-border bg-bg-elevated text-text-muted';
default: return 'border-border text-text-subtle';
}
};
return (
<div className="flex h-[520px] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border bg-[#0a0a12] p-6 shadow-2xl">
{/* Шапка со статусом агента */}
<div className="flex items-center justify-between border-b border-border/50 pb-4">
<div className="flex items-center gap-3">
<span className={`rounded-full border px-3 py-0.5 text-xs font-mono font-bold uppercase tracking-wider ${getStatusColor(status)}`}>
{status}
</span>
<span className="text-xs text-text-muted">PCM 24kHz • Silero VAD</span>
</div>
{status === 'speaking' && (
<button
onClick={interruptAgent}
className="rounded-lg bg-danger/20 border border-danger/40 px-2.5 py-1 text-xs font-bold text-danger hover:bg-danger/30 transition-colors"
>
Прервать (Barge-in)
</button>
)}
</div>
{/* Центральная визуальная сфера активности */}
<div className="flex flex-1 flex-col items-center justify-center py-6">
<div
className={`relative flex h-32 w-32 items-center justify-center rounded-full transition-all duration-300 ${
status === 'speaking'
? 'scale-110 shadow-[0_0_50px_rgba(139,92,246,0.6)] bg-gradient-to-tr from-accent-violet to-purple-600'
: status === 'listening'
? 'scale-100 shadow-[0_0_30px_rgba(0,240,255,0.4)] bg-gradient-to-tr from-accent-cyan to-blue-600'
: 'scale-90 bg-white/5 border border-white/10'
}`}
>
<div className="h-16 w-16 rounded-full bg-black/40 backdrop-blur-xs flex items-center justify-center">
<span className="text-2xl">
{status === 'speaking' ? '🗣️' : status === 'listening' ? '🎙️' : '💤'}
</span>
</div>
</div>
<p className="mt-4 text-xs font-mono text-text-muted">
{status === 'listening' ? 'Слушаю... Вы можете перебить ассистента в любой момент' : ''}
{status === 'speaking' ? 'Ассистент говорит...' : ''}
</p>
</div>
{/* Нижняя панель действий */}
<div className="flex items-center justify-center gap-4 pt-4 border-t border-border/50">
{status === 'disconnected' ? (
<button
onClick={connect}
className="rounded-xl bg-accent-cyan px-6 py-2.5 font-bold text-black text-sm hover:opacity-90 transition-opacity"
>
Начать разговор
</button>
) : (
<>
<button
onClick={toggleMute}
className={`rounded-xl px-4 py-2 text-xs font-bold transition-colors ${
isMuted ? 'bg-danger text-white' : 'bg-white/10 hover:bg-white/20 text-white'
}`}
>
{isMuted ? 'Микрофон ВЫКЛ' : 'Заглушить'}
</button>
<button
onClick={disconnect}
className="rounded-xl bg-white/10 hover:bg-danger/20 border border-white/10 hover:border-danger/40 px-4 py-2 text-xs font-bold text-text-muted hover:text-danger transition-colors"
>
Завершить
</button>
</>
)}
</div>
</div>
);
}При воспроизведении потокового PCM-звука от AI-модели звук воспроизводится с частыми щелчками (clicks / stuttering), хотя пропускной способности сети достаточно. Код воспроизведения вызывает ctx.createBufferSource().start() сразу в момент получения WebSocket-сообщения. В чем причина щелчков и каково решение?
Если микрофонный поток создан без { audio: { echoCancellation: true } }, звук ответа модели из колонок попадает обратно в микрофон, классификатор VAD ошибочно определяет человеческую речь, и ассистент мгновенно обрывает собственную фразу.
Решение: Всегда явно задавайте echoCancellation: true в ограничениях getUserMedia.
Серверы OpenAI и Gemini ожидают поток Linear PCM 16-bit Little-Endian. Передача данных без учета порядка байтов в архитектурах или ручная манипуляция строками превращает звук в белый шум (статический треск).
Решение: Используйте Int16Array поверх прямого ArrayBuffer, где Little-Endian является стандартом для браузеров на архитектурах x86 и ARM.
Запуск расчета весов ONNX Runtime в главном потоке интерфейса на каждые 32 мс звука блокирует React Fiber, вызывая просадку FPS страницы до 10 кадров и задержку кликов. Решение: Запускайте ONNX Runtime Web строго внутри Web Worker или WebAssembly потока.
Если при обнаружении речи пользователя клиент глушит звук локально, но не отправляет команду отмены на сервер, модель продолжает генерировать ответ впустую, расходуя дорогостоящие токены API и забивая сетевой сокет.
Решение: При прерывании синхронно посылайте ws.send(JSON.stringify({ type: 'response.cancel' })).
response.cancel на сервер при перебивании ассистента человеком.echoCancellation: true критически необходимо для предотвращения самопрерывания модели собственным звуком из колонок.