Традиционный подход к созданию голосовых интерфейсов опирался на каскадный конвейер трех независимых систем: Speech-to-Text (STT), Large Language Model (LLM) и Text-to-Speech (TTS). В рамках этой схемы клиент записывает аудиофрагмент речи, отправляет его через HTTP POST в сервис транскрипции (например, Whisper), ожидает полный текст, передает текст в LLM, ждет завершения генерации хотя бы первого предложения и отправляет полученный текст в движок синтеза речи (TTS).
Такой каскад страдает от эффекта накапливающейся задержки (Waterfall Latency):
Суммарная задержка от окончания фразы человека до первого слышимого звука ответа (Glass-to-Glass Latency) достигает 2.5–4 секунд. В человеческом диалоге нормативная пауза между репликами составляет 200–300 мс; задержка более 500 мс воспринимается как разрыв контакта, превращая сессию в режим полудуплексной рации (walkie-talkie). Кроме того, каскадный пайплайн полностью теряет просодию: интонации, эмоции, темп, акценты и паузы говорящего отбрасываются на этапе STT, а TTS генерирует синтетическую интонацию, лишенную контекстной экспрессии.
Каскадный HTTP-пайплайн (Waterfall: 2500 - 4000 мс):
[Mic] -> (HTTP) -> [STT Whisper] -> (HTTP) -> [LLM GPT-4] -> (HTTP) -> [TTS] -> (HTTP) -> [Speaker]
+600ms +700ms +600ms +500ms +200ms
Потоковый Speech-to-Speech через WebRTC (< 300 мс):
[Mic] ===(WebRTC RTP / Opus 20ms)====> [LiveKit SFU / Gateway] ===> [Multimodal Live LLM]
[Speaker] <==(WebRTC RTP / Opus 20ms)== [LiveKit SFU / Gateway] <=== (Audio Tokens Direct)В 2026 году промышленным стандартом стали нативные мультимодальные Speech-to-Speech модели (OpenAI Realtime API, Google Gemini Multimodal Live API). Модель обучается непосредственно на аудиотокенах, принимая и возвращая сжатый звуковой поток без промежуточной конвертации в текст. Единственным транспортным протоколом, способным обеспечить двунаправленный стриминг с задержкой менее 300 мс через публичный интернет, является WebRTC (Web Real-Time Communication), работающий поверх ненадежного, но быстрого UDP с протоколами RTP/RTCP и SRTP-шифрованием.
Взаимодействие между браузером и мультимодальным агентом строится как полнодуплексный сессионный конвейер. Сессия состоит из двух независимых плоскостей передачи: Media Plane (аудиопотоки) и Signaling/Control Plane (управляющие события и вызовы инструментов).
WebRTC LiveKit SFU SDP (Session Description Protocol) Opus Codec Jitter Buffer Glass-to-Glass LatencyАРХИТЕКТУРА ГОЛОСОВОГО АГЕНТА (WEBRTC + LIVEKIT)
+-----------------------------------------------------------------------------------------+
| БРАУЗЕР КЛИЕНТА (NEXT.JS 15 CLIENT COMPONENT) |
| |
| +-------------------+ +-----------------------+ +---------------------+ |
| | getUserMedia() | ----> | Web Audio API / VAD | ----> | RTCPeerConnection | |
| | (Echo Cancel: on) | | (PCM 24kHz / Opus) | | | |
| +-------------------+ +-----------------------+ +----------+----------+ |
| | |
| +-------------------+ +-----------------------+ | |
| | AudioContext Play | <---- | Remote MediaStream | <----------------+ |
| | (Jitter Buffer) | | (Opus 20ms Frames) | | |
| +-------------------+ +-----------------------+ | |
| | |
| +---------------------------------------------------+ | |
| | RTCDataChannel ("oai-events" / Control Channel) | <----------------+ |
| | JSON: session.update, response.cancel, tool_calls | |
| +---------------------------------------------------+ |
+--------------------------------------------|--------------------------------------------+
| (SRTP / UDP + SCTP DataChannel)
v
+-----------------------------------------------------------------------------------------+
| LIVEKIT SFU / WEBRTC GATEWAY (EDGE NODE) |
| |
| - ICE / STUN / TURN Терминация |
| - Демультиплексирование RTP/RTCP пакетов |
| - Стриминг бинарных аудиофреймов Opus (20ms payload) по защищенному каналу |
+--------------------------------------------|--------------------------------------------+
| (gRPC / Bidirectional TLS Stream)
v
+-----------------------------------------------------------------------------------------+
| MULTIMODAL REALTIME MODEL (OPENAI REALTIME API / GEMINI LIVE) |
| |
| - Нативный Speech-to-Speech инференс аудио-токенов |
| - Встроенный серверный VAD (Voice Activity Detection) |
| - Стриминг аудио-ответа фреймами по 20 мс |
| - Параллельная эмиссия JSON Function Calls через DataChannel |
+-----------------------------------------------------------------------------------------+Чтобы человеческий диалог оставался естественным, суммарное время прохождения сигнала должно укладываться в строгий бюджет:
+---------------------------------------------------------------+----------+
| Этап конвейера обработки | Задержка |
+---------------------------------------------------------------+----------+
| 1. Захват аудио, нативное эхоподавление (AEC) и фрейминг | 20 мс |
| 2. Кодирование Opus (фрейм 20 мс + Lookahead 2.5 мс) | 22.5 мс |
| 3. Сетевой транзит: Браузер -> Edge SFU (UDP/RTP) | 25-45 мс |
| 4. Транзит: Edge SFU -> Инференс-кластер модели | 10-25 мс |
| 5. Инференс первого аудио-токена модели (TTFT Audio) | 80-130 мс|
| 6. Обратный транзит: Модель -> Edge SFU -> Браузер | 35-65 мс |
| 7. Декодирование Opus, адаптивный Jitter Buffer браузера | 20-30 мс |
+---------------------------------------------------------------+----------+
| ИТОГОВАЯ GLASS-TO-GLASS LATENCY: | ~215-330 |
+---------------------------------------------------------------+----------+Для интеграции браузера с OpenAI Realtime API через прямой WebRTC без раскрытия мастер-ключа OPENAI_API_KEY используется двухэтапный протокол:
client_secret), действующий 60 секунд.RTCPeerConnection, захватывает микрофон с нативным эхоподавлением, создает SDP Offer, отправляет его в эндпоинт OpenAI Realtime с эфемерным токеном, получает SDP Answer и открывает RTCDataChannel.import { NextResponse } from "next/server";
interface OpenAISessionResponse {
id: string;
client_secret: {
value: string;
expires_at: number;
};
}
export async function POST() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "OPENAI_API_KEY не сконфигурирован на сервере" },
{ status: 500 }
);
}
try {
// Запрашиваем эфемерный ключ с ограниченным временем жизни
const response = await fetch("https://api.openai.com/v1/realtime/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-realtime-preview-2024-12-17",
voice: "verse",
input_audio_format: "pcm16",
output_audio_format: "pcm16",
turn_detection: {
type: "server_vad",
threshold: 0.5,
prefix_padding_ms: 300,
silence_duration_ms: 500,
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI API error [${response.status}]: ${errorText}`);
}
const data: OpenAISessionResponse = await response.json();
return NextResponse.json({ clientSecret: data.client_secret.value });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Неизвестная ошибка создания сессии";
console.error("[Realtime Session Error]:", message);
return NextResponse.json({ error: message }, { status: 500 });
}
}"use client";
import { useState, useRef, useCallback, useEffect } from "react";
export interface RealtimeMessage {
type: string;
[key: string]: unknown;
}
export function useRealtimeVoiceWebRTC() {
const [isConnected, setIsConnected] = useState<boolean>(false);
const [isSpeaking, setIsSpeaking] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
const disconnect = useCallback(() => {
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((track) => track.stop());
localStreamRef.current = null;
}
if (remoteAudioRef.current) {
remoteAudioRef.current.srcObject = null;
}
setIsConnected(false);
setIsSpeaking(false);
}, []);
const connect = useCallback(async () => {
try {
setError(null);
// 1. Получаем эфемерный токен через наш API
const sessionRes = await fetch("/api/realtime/session", { method: "POST" });
if (!sessionRes.ok) {
throw new Error("Не удалось получить эфемерный токен сессии");
}
const { clientSecret } = await sessionRes.json();
// 2. Инициализируем RTCPeerConnection с STUN сервером
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
pcRef.current = pc;
// 3. Подключаем аудиоэлемент для удаленного трека
const remoteAudio = document.createElement("audio");
remoteAudio.autoplay = true;
remoteAudioRef.current = remoteAudio;
pc.ontrack = (event: RTCTrackEvent) => {
if (event.streams[0]) {
remoteAudio.srcObject = event.streams[0];
}
};
// 4. Захватываем микрофон с аппаратным подавлением эха и шума
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 24000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
localStreamRef.current = mediaStream;
// Добавляем локальный аудио-трек в PeerConnection
mediaStream.getTracks().forEach((track) => pc.addTrack(track, mediaStream));
// 5. Открываем DataChannel для управляющих JSON-событий
const dataChannel = pc.createDataChannel("oai-events");
dcRef.current = dataChannel;
dataChannel.onopen = () => {
setIsConnected(true);
};
dataChannel.onmessage = (event: MessageEvent) => {
try {
const msg: RealtimeMessage = JSON.parse(event.data);
if (msg.type === "input_audio_buffer.speech_started") {
setIsSpeaking(true);
} else if (msg.type === "input_audio_buffer.speech_stopped") {
setIsSpeaking(false);
}
} catch (err: unknown) {
console.warn("[DataChannel] Ошибка парсинга события:", err);
}
};
// 6. Формируем SDP Offer и отправляем в OpenAI Realtime WebRTC Gateway
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const baseUrl = "https://api.openai.com/v1/realtime";
const model = "gpt-4o-realtime-preview-2024-12-17";
const sdpResponse = await fetch(`${baseUrl}?model=${model}`, {
method: "POST",
body: offer.sdp,
headers: {
Authorization: `Bearer ${clientSecret}`,
"Content-Type": "application/sdp",
},
});
if (!sdpResponse.ok) {
throw new Error(`Ошибка SDP Handshake: ${sdpResponse.statusText}`);
}
const answerSdp = await sdpResponse.text();
const answer: RTCSessionDescriptionInit = {
type: "answer",
sdp: answerSdp,
};
await pc.setRemoteDescription(answer);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Ошибка инициализации WebRTC";
setError(msg);
disconnect();
}
}, [disconnect]);
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
const sendEvent = useCallback((event: RealtimeMessage) => {
if (dcRef.current && dcRef.current.readyState === "open") {
dcRef.current.send(JSON.stringify(event));
}
}, []);
return {
isConnected,
isSpeaking,
error,
connect,
disconnect,
sendEvent,
};
}В enterprise-архитектуре прямое P2P-подключение браузера к OpenAI имеет ограничения:
В таких сценариях используется LiveKit Agents Framework. Браузер соединяется с комнатой LiveKit по стандартному SDK (livekit-client), а на сервере запускается фоновый worker (livekit-agents на Python или Node.js), который подключается к комнате как участник, слушает аудиопоток пользователя и проксирует его в Realtime API через высокоскоростной gRPC-канал.
[Browser Client]
| WebRTC (RTP/Opus 20ms)
v
[LiveKit SFU Room]
| Internal Audio Subscription (zero-copy)
v
[LiveKit Agent Worker (Node.js / Python)]
| Bidirectional Realtime Stream
v
[OpenAI / Gemini Multimodal API]Прямая отправка запросов на инициализацию сессии с постоянным API-ключом в заголовке Authorization из браузера компрометирует инфраструктуру. Всегда генерируйте краткосрочный эфемерный токен сессии (client_secret) через защищенный Server Action или API Route на сервере Next.js 15.
Если при вызове navigator.mediaDevices.getUserMedia передать audio: true без явного указания { echoCancellation: true }, аудиопоток из динамиков компьютера попадет обратно в микрофон. В результате агент услышит собственный голос, расценит это как перебивание (Barge-in) и мгновенно прервет свою речь.
По умолчанию захват аудио в некоторых браузерах может активировать стерео-режим с частотой 48 кГц и битрейтом 128 кбит/с. Для передачи голоса моделям достаточно монофонического сигнала с частотой 24 кГц (PCM16). Избыточный битрейт увеличивает задержку буферизации и нагрузку на мобильные CPU.
Около 15–20% корпоративных сетей и мобильных операторов используют жесткие симметричные NAT (Symmetric NAT) и блокируют произвольные UDP-порты. Если в конфигурации RTCPeerConnection указан только публичный STUN-сервер и отсутствует TURN-сервер (работающий по TCP/TLS через порт 443), соединение завершится с ошибкой ICE Connection Failed.
Сервер Next.js 15 запрашивает у OpenAI эндпоинта /v1/realtime/sessions эфемерный токен client_secret с временем жизни 60 секунд.
Браузер захватывает локальный микрофон с нативным AEC, фильтрацией шумов и формирует монофонический аудио-трек 24 кГц.
RTCPeerConnection создает DataChannel 'oai-events', регистрирует локальный аудио-трек и формирует локальный SDP Offer.
Браузер отправляет SDP Offer методом POST на шлюз Realtime API с эфемерным токеном и получает взамен SDP Answer.
Устанавливается SRTP-соединение поверх UDP. Аудиофреймы Opus (20 мс) передаются непрерывно; модель отвечает с задержкой менее 300 мс.