Библиотека - готовый каркас для голосового/текстового чата с OpenAI Realtime (WebRTC + DataChannel) в React Native.
- Введение
- Быстрый старт
- Архитектура
- Lifecycle и инициализация
- Компонент RealTimeClient (провайдер)
- Императивный API через ref (RealTimeClientHandle)
- Контекст: RealtimeContextValue
- Хуки
- useRealtime
- useSpeechActivity
- useMicrophoneActivity
- useSessionOptions
- События: onEvent и удобные client.on(…)
- Карта входящих событий → «удобные» события
- Обработка ошибок парсинга JSON
- Middleware (incoming/outgoing)
- Встроенный чат: ChatStore/ChatAdapter/ExtendedChatMsg
- clearAdded() vs clearChatHistory()
- Нормализация сообщений
- Отправка сообщений
- sendRaw
- response.create / sendResponse / sendResponseStrict
- response.cancel
- session.update
- function_call_output / sendToolOutput
- Сессия (SessionConfig)
- Модель, голос, модальности
- Встроенная VAD (turn_detection)
- Транскрипция аудио (input_audio_transcription)
- Tools (function calling)
- Instructions
- Greet
- Политика «осмысленности»: policy vs chat (isMeaningfulText)
- Статусы и логирование
- Низкоуровневый RealtimeClientClass (для продвинутых)
- Методы, геттеры, события
- SuccessHandler / SuccessCallbacks (все)
- ErrorHandler / ErrorStage / Severity
- Менеджеры (PeerConnection/Media/DataChannel/MessageSender/OpenAI API)
- Concurrent Guards (защита от конкурентных вызовов)
- EventRouter.setContext() (внутренний метод)
- Константы, DEFAULTS и applyDefaults
- Best Practices
- Эфемерные токены
- PTT (push-to-talk) ручной буфер
- Tools: авто-режим vs ручной
- VAD: тюнинг и fallback
- Встроенный чат vs ручной чат
- Аудио-сессия и эхоподавление (InCallManager)
- GlobalRealtimeProvider с ref и onToolCall
- TypeScript Tips
- Troubleshooting / FAQ
Библиотека делает за вас всю «тяжелую» работу по Realtime с OpenAI: WebRTC-подключение, SDP-обмен, DataChannel, локальные/удаленные аудио-потоки, DataChannel события, router событий, VAD/речевая активность, инструменты (tools), встроенный адаптируемый чат и простой React-контекст.
- Поднимите простой сервер для эфемерных токенов (Node/Express). Он будет ходить к OpenAI и создавать Realtime-сессию:
// server/index.ts
app.get('/realtime/session', async (_req, res) => {
const r = await fetch('https://api.openai.com/v1/realtime/sessions', { ... });
const j = await r.json();
res.json(j); // в ответе есть j.client_secret.value
});- В RN-приложении оберните UI провайдером и передайте tokenProvider:
import {
RealTimeClient,
createSpeechActivityMiddleware,
useSpeechActivity,
} from 'react-native-openai-realtime';
const tokenProvider = async () => {
const r = await fetch('http://localhost:8787/realtime/session');
const j = await r.json();
return j.client_secret.value;
};
export default function App() {
return (
<RealTimeClient
tokenProvider={tokenProvider}
incomingMiddleware={[createSpeechActivityMiddleware()]}
session={{
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'alloy',
modalities: ['audio', 'text'],
input_audio_transcription: { model: 'whisper-1', language: 'ru' },
turn_detection: {
type: 'server_vad',
silence_duration_ms: 700,
threshold: 0.5,
prefix_padding_ms: 300,
},
tools: [
/* ваш tools spec */
],
instructions: 'Краткие и полезные ответы.',
}}
greetEnabled
greetInstructions="Привет! Я на связи."
greetModalities={['audio', 'text']}
>
<YourScreen />
</RealTimeClient>
);
}- В любом компоненте используйте хук useRealtime и показывайте статус речи:
import { useRealtime, useSpeechActivity } from 'react-native-openai-realtime';
function YourScreen() {
const { status, connect, disconnect, chat, sendRaw, sendResponseStrict } =
useRealtime();
const { isUserSpeaking, isAssistantSpeaking } = useSpeechActivity();
return (
<View>
{isUserSpeaking && <Text>🎤 Вы говорите...</Text>}
{isAssistantSpeaking && <Text>🔊 Ассистент отвечает...</Text>}
{/* ваш UI */}
</View>
);
}- RealTimeClient — React-провайдер. Создаёт RealtimeClientClass, подписывается на статусы, создаёт встроенный чат (опционально), прокидывает API в контекст.
- RealtimeClientClass — «ядро»: WebRTC с OpenAI, менеджеры Peer/Media/DataChannel/OpenAIApi, router событий, ChatStore.
- EventRouter — принимает JSON из DataChannel, обрабатывает middleware и маршрутизирует в «удобные» события (user:, assistant:, tool:*).
- ChatStore — построение ленты сообщений из дельт/финишей «пользователь/ассистент».
- Middleware — входящие и исходящие перехватчики событий.
- Хуки — useRealtime/useSpeechActivity/useMicrophoneActivity.
Компонент RealTimeClient использует паттерн ленивой инициализации через ensureClient():
- Клиент создается только при первом вызове
connect()или еслиautoConnect={true} - Это позволяет обновлять
tokenProviderдо создания клиента - После создания клиента,
tokenProviderможно обновить черезclient.setTokenProvider()
- Монтирование компонента → создание контекста, но клиент еще
null - Вызов connect() или autoConnect={true} →
ensureClient()создает клиент - WebRTC подключение:
- Получение эфемерного токена через
tokenProvider - Создание RTCPeerConnection
- getUserMedia для локального потока
- Создание DataChannel
- SDP обмен с OpenAI
- ICE gathering
- Получение эфемерного токена через
- DataChannel открыт:
- Если
autoSessionUpdate={true}→ отправкаsession.update - Если
greetEnabled={true}→ отправка приветственногоresponse.create - Вызов
onOpen(dc)
- Если
Класс RealtimeClientClass защищен от повторных вызовов:
- connecting флаг — предотвращает повторный
connect()во время подключения - disconnecting флаг — предотвращает повторный
disconnect()во время отключения - При попытке повторного вызова логируется warning
В методе connect() проверка микрофона происходит ДО создания PeerConnection:
// ✅ ПРАВИЛЬНЫЙ порядок:
1. Получение токена (tokenProvider)
2. Проверка микрофона (getUserMedia) // ← ДО создания PC!
3. Создание PeerConnection
4. Добавление треков или recvonly transceiverПочему это важно:
- Нельзя добавлять треки в закрытый PeerConnection
- Если микрофон недоступен, создаем recvonly transceiver сразу
- Избегаем ошибок "Cannot add tracks: PeerConnection is closed"
Логика:
// 1. Сначала пытаемся получить микрофон
let localStream = null;
let needsRecvOnlyTransceiver = false;
if (shouldTryMic) {
try {
localStream = await this.mediaManager.getUserMedia();
} catch (e) {
if (this.options.allowConnectWithoutMic === false) {
throw e; // Критическая ошибка
}
needsRecvOnlyTransceiver = true; // Fallback
}
}
// 2. ТЕПЕРЬ создаем PeerConnection
const pc = this.peerConnectionManager.create();
// 3. Добавляем треки или transceiver
if (localStream) {
this.mediaManager.addLocalStreamToPeerConnection(pc, localStream);
} else if (needsRecvOnlyTransceiver) {
pc.addTransceiver('audio', { direction: 'recvonly' });
}После disconnect() внутренние подписки EventRouter очищаются (chatWired = false). При новом connect() библиотека восстанавливает подписки ChatStore (если chatWired=false). История чата сохраняется, если deleteChatHistoryOnDisconnect={false}.
Экспорт: RealTimeClient: FC<RealTimeClientProps>
Назначение: создаёт и конфигурирует RealtimeClientClass, обеспечивает контекст(useRealtime), можно сразу автоматически подключаться и/или прикрепить встроенный чат.
Поддерживаемые пропсы (основные):
| Prop | Тип | Default | Назначение |
|---|---|---|---|
| tokenProvider | () => Promise | required | Возвращает эфемерный токен (client_secret.value). |
| webrtc | { iceServers, dataChannelLabel, offerOptions, configuration } | см. DEFAULTS | Настройки WebRTC/ICE/DataChannel. |
| media | { getUserMedia?: Constraints } | audio: true | Параметры микрофона/видео для mediaDevices.getUserMedia. |
| session | Partial | см. DEFAULTS | Начальная session.update (модель/голос/модальности/VAD/инструкции/tools). |
| autoSessionUpdate | boolean | true | При открытии DataChannel автоматически отправлять session.update. |
| greetEnabled | boolean | true | Автоприветствие после подключения. |
| greetInstructions | string | "Привет! Я на связи и готов помочь." | Текст приветствия. |
| greetModalities | Array<'audio' | 'text'> | ['audio', 'text'] | Модальности приветствия. |
| onOpen | (dc) => void | - | DataChannel открыт. |
| onEvent | (evt) => void | - | Сырые события DataChannel (1:1 с сервером). |
| onError | (errorEvent) => void | - | Ошибки в библиотеке/события error от сервера. |
| onUserTranscriptionDelta | ({itemId, delta}) => 'consume' | void | - | Нон-стоп дельты транскрипции пользователя. Верните 'consume', чтобы «съесть» событие. |
| onUserTranscriptionCompleted | ({itemId, transcript}) => 'consume' | void | - | Финал пользовательской транскрипции. |
| onAssistantTextDelta | ({responseId, delta, channel}) => 'consume' | void | - | Дельты ассистента: текст и аудио-транскрипт. |
| onAssistantCompleted | ({responseId, status}) => 'consume' | void | - | Конец ответа. |
| onToolCall | ({ name, args, call_id }) => Promise | any | - | Если вернёте значение — по умолчанию отправится function_call_output и начнётся новый response.create. |
| incomingMiddleware | IncomingMiddleware[] | [] | Перехватчики входящих событий. |
| outgoingMiddleware | OutgoingMiddleware[] | [] | Перехватчики исходящих событий. |
| policyIsMeaningfulText | (text) => boolean | t => !!t.trim() | Глобальная политика «текст осмысленный?». |
| chatEnabled | boolean | true | Включить встроенный ChatStore. |
| chatIsMeaningfulText | (text) => boolean | - | Политика «осмысленности» именно для чата (перекрывает policy). |
| chatUserAddOnDelta | boolean | true | Создавать юзер-сообщение при первой дельте. |
| chatUserPlaceholderOnStart | boolean | false | Плейсхолдер на user:item_started. |
| chatAssistantAddOnDelta | boolean | true | Создавать ассистентское сообщение при первой дельте. |
| chatAssistantPlaceholderOnStart | boolean | false | Плейсхолдер на response_started. |
| chatInverted | boolean | false | Инвертировать порядок сообщений в чате (от старых к новым). |
| deleteChatHistoryOnDisconnect | boolean | true | Очищать историю при disconnect() (по умолчанию true в компоненте, не указан в DEFAULTS). |
| logger | {debug,info,warn,error} | console.* | Логгер. |
| autoConnect | boolean | false | Подключиться сразу. |
| attachChat | boolean | true | Прикрепить встроенный чат в контекст. |
| allowConnectWithoutMic | boolean | true | Разрешить подключение без микрофона (recvonly transceiver). |
| children | ReactNode или (ctx) => ReactNode | - | Если функция — получаете RealtimeContextValue. |
Примечания:
- Компонент
RealTimeClientпринимает все Success callbacks изRealtimeSuccessCallbacks(onPeerConnectionCreated, onDataChannelOpen и т.д.). - chatInverted управляет сортировкой в mergedChat: false = новые сверху, true = старые сверху
- Компонент поддерживает
forwardRefдля императивного API (см. раздел "Императивный API через ref")
Success Callbacks (опциональные):
| Prop | Тип | Описание |
|---|---|---|
| onHangUpStarted | () => void | Начало отключения. |
| onHangUpDone | () => void | Отключение завершено. |
| onPeerConnectionCreatingStarted | () => void | Начало создания PeerConnection. |
| onPeerConnectionCreated | (pc) => void | PeerConnection создан. |
| onRTCPeerConnectionStateChange | (state) => void | Изменение состояния PeerConnection. |
| onGetUserMediaSetted | (stream) => void | getUserMedia выполнен. |
| onLocalStreamSetted | (stream) => void | Локальный поток установлен. |
| onLocalStreamAddedTrack | (track) => void | Трек добавлен в локальный поток. |
| onLocalStreamRemovedTrack | (track) => void | Трек удален из локального потока. |
| onRemoteStreamSetted | (stream) => void | Удаленный поток установлен. |
| onDataChannelOpen | (channel) => void | DataChannel открыт. |
| onDataChannelMessage | (message) => void | Сообщение из DataChannel. |
| onDataChannelClose | () => void | DataChannel закрыт. |
| onIceGatheringComplete | () => void | ICE gathering завершен. |
| onIceGatheringTimeout | () => void | Таймаут ICE gathering. |
| onIceGatheringStateChange | (state) => void | Изменение состояния ICE gathering. |
| onMicrophonePermissionGranted | () => void | Разрешение на микрофон получено. |
| onMicrophonePermissionDenied | () => void | Разрешение на микрофон отклонено. |
| onIOSTransceiverSetted | () => void | iOS transceiver установлен. |
| onSuccess | (stage, data?) => void | Универсальный коллбек успешных операций. |
Управляет подпиской встроенного ChatStore на контекст:
- attachChat={true} (по умолчанию) —
ctx.chatполучает обновления от встроенного ChatStore - attachChat={false} —
ctx.chatостаётся пустым массивом[], но ChatStore продолжает работать внутри клиента
Когда использовать attachChat={false}:
- Вы строите собственный UI чата через
client.onChatUpdate()напрямую - Хотите избежать лишних ре-рендеров контекста
- Используете несколько провайдеров с разными чатами
Пример:
<RealTimeClient attachChat={false} chatEnabled={true}>
<MyCustomChat /> {/* ctx.chat будет [], но client.getChat() работает */}
</RealTimeClient>Важно: attachChat не влияет на работу ChatStore — он продолжает накапливать сообщения, доступные через client.getChat().
RealTimeClient поддерживает forwardRef и экспортирует императивный API — удобно вызывать методы вне React‑дерева (например, в onToolCall, Portal, глобальных обработчиках).
export type RealTimeClientHandle = {
// Статусы/ссылки
getClient: () => RealtimeClientClass | null;
getStatus: () =>
| 'idle'
| 'connecting'
| 'connected'
| 'disconnected'
| 'error';
setTokenProvider: (tp: TokenProvider) => void;
// Соединение
connect: () => Promise<void>;
disconnect: () => Promise<void>;
// Микрофон
enableMicrophone: () => Promise<void>;
// Отправка
sendRaw: (e: any) => Promise<void> | void;
sendResponse: (opts?: any) => void;
sendResponseStrict: (opts: {
instructions: string;
modalities?: Array<'audio' | 'text'>;
conversation?: 'auto' | 'none';
}) => void;
updateSession: (patch: Partial<any>) => void;
// Чат (локальные UI-бабблы)
addMessage: (m: AddableMessage | AddableMessage[]) => string | string[];
clearAdded: () => void;
clearChatHistory: () => void;
// Утилита: следующий корректный ts
getNextTs: () => number;
};import React, { useRef } from 'react';
import {
RealTimeClient,
type RealTimeClientHandle,
} from 'react-native-openai-realtime';
export default function App() {
const rtcRef = useRef<RealTimeClientHandle>(null);
const addUi = () => {
rtcRef.current?.addMessage({
type: 'ui',
role: 'system',
kind: 'hint',
payload: { text: 'Подсказка ✨' },
});
};
return (
<RealTimeClient ref={rtcRef} tokenProvider={async () => 'EPHEMERAL_TOKEN'}>
{/* ... */}
</RealTimeClient>
);
}- Контекст/хуки: реактивный UI (
ctx.chat,ctx.status,sendResponseи т. п.) — используйтеuseRealtime()/useSpeechActivity()/useMicrophoneActivity() - ref: императивные вызовы из
onToolCall/ эффектов / порталов (добавление UI‑сообщений, быстрыйupdateSession, массовые операции)
Примечания:
ref— дополнение к контексту (хукиuseRealtime/useSpeechActivity/useMicrophoneActivityпродолжают работать как раньше)addMessageчерезrefне отправляет событие на сервер — это локальные UI‑пузырьки
Возвращает следующий корректный ts для ручного добавления сообщений:
const rtcRef = useRef<RealTimeClientHandle>(null);
// Добавление сообщения с ручным контролем порядка
const addCustomMessage = () => {
const nextTs = rtcRef.current?.getNextTs() ?? Date.now();
rtcRef.current?.addMessage({
type: 'ui',
kind: 'custom',
role: 'assistant',
ts: nextTs, // явный порядок
payload: { text: 'Custom message' },
});
};Когда нужно:
- Вставка сообщений с гарантированным порядком
- Синхронизация с внешними системами (например, websocket чат)
- Дебаггинг и тестирование сортировки
Примечание: Подробнее о ts и time см. раздел "Контекст → Нормализация сообщений".
Включить микрофон после подключения (ре-негоциация WebRTC).
Автоматическое использование:
Метод enableMicrophone() автоматически вызывается в хуке useSessionOptions() при переключении режима:
// В useSessionOptions.ts
const enforceVoiceSession = async () => {
await clientRef.current?.sendRaw({
type: 'session.update',
session: {
modalities: ['audio', 'text'],
turn_detection: { type: 'server_vad', ... },
},
});
// ← Здесь вызывается enableMicrophone()
await clientRef.current?.enableMicrophone?.();
await switchModeRef.current('voice');
};То есть:
- При вызове
initializeMode('voice')→enforceVoiceSession()→enableMicrophone() - При вызове
closeVoiceMode()→ микрофон НЕ отключается (треки остаются) - Для ручного отключения используйте
getLocalStream().getAudioTracks().forEach(t => t.enabled = false)
Что происходит внутри:
- Получение микрофона через
getUserMedia - Добавление треков в существующий PeerConnection
- Ре-негоциация WebRTC (offer/answer/ICE)
- Обмен SDP с OpenAI
Важно: Метод требует активного PeerConnection и открытого DataChannel. Вызывается автоматически в enforceVoiceSession() (хук useSessionOptions).
То, что возвращает useRealtime():
| Поле/Метод | Тип | Описание |
|---|---|---|
| client | RealtimeClientClass | null | Ссылка на ядро (низкоуровневые методы/геттеры). |
| status | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | Текущий статус соединения. |
| chat | ExtendedChatMsg[] | История чата (встроенный ChatStore + ваши UI-сообщения). |
| connect | () => Promise | Подключиться. |
| disconnect | () => Promise | Отключиться. |
| sendResponse | (opts?) => void | Обёртка над response.create. |
| sendResponseStrict | ({ instructions, modalities, conversation? }) => void | Строгая версия response.create. |
| updateSession | (patch) => void | Отправить session.update (частичное обновление сессии). |
| sendRaw | (event: any) => void | Отправить «сырое» событие в DataChannel (через middleware). |
| addMessage | (AddableMessage | AddableMessage[]) => string | string[] | Добавить ваши сообщения в локальную ленту. Возвращает ID созданного сообщения (или массив ID). |
| clearAdded | () => void | Очистить локальные UI-сообщения (не трогает ChatStore). |
| clearChatHistory | () => void | Очистить встроенный ChatStore (см. раздел "Встроенный чат"). |
Внутри RealTimeClient используется метод normalize() для обработки входящих сообщений:
- id генерируется автоматически (если не указан)
- ts выставляется автоматически как монотонная последовательность:
nextTs = max(ts в текущей ленте) + 1 - time всегда проставляется как
Date.now()для всех типов сообщений (и дляtext, и дляui) - Если
type='text', сообщение получаетstatus: 'done'(для совместимости сChatMsg)
Это гарантирует стабильный порядок при объединении сообщений ChatStore и ваших UI‑сообщений.
- ts (timestamp sequence) — порядковый номер для сортировки в ленте. Монотонный счётчик, не привязан к реальному времени.
- time — реальная метка времени (
Date.now()) создания сообщения.
Зачем нужны оба:
ts— корректная сортировка при объединенииChatStoreиaddedMessages(независимо от задержек сети)time— отображение времени создания в UI (например, "2 минуты назад")
Пример:
// Сообщения могут приходить не по порядку, но ts гарантирует правильную сортировку
{ id: 'msg1', ts: 100, time: 1704067200000 } // 10:00:00
{ id: 'msg2', ts: 101, time: 1704067199000 } // 09:59:59 (пришло позже, но ts больше)
// После сортировки по ts:
[msg1, msg2] // корректный порядок диалогаВ addMessage(): оба поля проставляются автоматически, но можно переопределить ts для ручного управления порядком.
// Встроенное сообщение чата (от ChatStore)
type ChatMsg = {
id: string;
type: 'text' | 'ui'; // в типе указано 'text' | 'ui'
role: 'user' | 'assistant';
text?: string;
ts: number; // порядковый номер для сортировки
time: number; // unix timestamp создания сообщения
status: 'streaming' | 'done' | 'canceled';
responseId?: string;
itemId?: string;
};
// Ваше UI-сообщение (добавляемое через addMessage)
type UIChatMsg = {
id: string;
type: 'ui';
role: 'assistant' | 'user' | 'system' | 'tool';
ts: number; // порядковый номер
time?: number; // может отсутствовать в UI-сообщениях
kind: string; // тип вашего UI-сообщения
payload: any; // любые данные для рендера
};
// Объединенный тип
type ExtendedChatMsg = ChatMsg | UIChatMsg;Примечание: ChatStore создаёт только сообщения с type: 'text'. Значение 'ui' используется для пользовательских UI-сообщений через addMessage().
ExtendedChatMsg объединяет два типа сообщений:
- ChatMsg (
type: 'text') — сообщения из реального диалога (создаются ChatStore) - UIChatMsg (
type: 'ui') — пользовательские UI-элементы (создаются через addMessage)
Важное различие:
- ts — порядковый номер для корректной сортировки сообщений
- time — реальная метка времени создания (
Date.now()), всегда проставляется автоматически
Это значит, что для простого UI‑баббла достаточно указать type='ui' / kind / payload: порядок (ts) и время (time) будут корректными без ручной установки.
// Простое UI-уведомление
addMessage({
type: 'ui',
kind: 'system_notification',
role: 'system',
payload: {
text: 'Соединение установлено',
icon: 'checkmark',
severity: 'success',
},
});
// Карточка с данными
addMessage({
type: 'ui',
kind: 'weather_card',
role: 'assistant',
payload: {
city: 'Киев',
temp: 22,
condition: 'Солнечно',
humidity: 60,
},
});
// Массовое добавление
const ids = addMessage([
{ type: 'text', text: 'Начинаем...', role: 'system' },
{ type: 'ui', kind: 'loader', payload: { loading: true } },
]);
console.log('Created message IDs:', ids); // ['msg-123', 'msg-124']Возвращает RealtimeContextValue (см. выше). Используйте в любом компоненте внутри RealTimeClient.
Отслеживает активность речи (обновляется createSpeechActivityMiddleware):
Возвращает:
| Поле | Тип | Описание |
|---|---|---|
| isUserSpeaking | boolean | Пользователь «говорит» (по событиям буфера/дельтам). |
| isAssistantSpeaking | boolean | Ассистент «говорит» (выходной буфер аудио). |
| inputBuffered | boolean | Входной аудио буфер в состоянии «коммита»/активности. |
| outputBuffered | boolean | Выходной аудио буфер активен. |
| lastUserEventAt | number | null | Время последнего пользовательского события. |
| lastAssistantEventAt | number | null | Время последнего ассистентского события. |
Зачем: чтобы строить UI-анимации, индикаторы речи, VU-метры и т.д.
Пример использования:
import { useSpeechActivity, createSpeechActivityMiddleware } from 'react-native-openai-realtime';
// В провайдере обязательно добавьте middleware
<RealTimeClient
incomingMiddleware={[createSpeechActivityMiddleware()]}
// ...
>
// В компоненте
function SpeechIndicator() {
const { isUserSpeaking, isAssistantSpeaking } = useSpeechActivity();
return (
<View>
{isUserSpeaking && <Text>🎤 Вы говорите...</Text>}
{isAssistantSpeaking && <Text>🔊 Ассистент отвечает...</Text>}
</View>
);
}Пробует оценить активность микрофона двумя путями: server-события (дельты) и getStats у локального sender (уровень сигнала).
Опции:
| Опция | Тип | Default | Описание |
|---|---|---|---|
| client | RealtimeClientClass | из контекста | Ядро (для нестандартных сценариев). |
| mode | 'server' | 'stats' | 'auto' | 'auto' | 'server' — по событиям; 'stats' — по audioLevel; 'auto' — гибрид. |
| silenceMs | number | 600 | Таймаут молчания. |
| levelThreshold | number | 0.02 | Порог для stats. |
| pollInterval | number | 250 | Период опроса getStats. |
Возвращает:
| Поле | Тип | Описание |
|---|---|---|
| isMicActive | boolean | Активность микрофона в текущий момент. |
| level | number (0..1) | Оценка уровня аудио потока. |
| isCapturing | boolean | true когда микрофон активен и передаёт данные (есть enabled трек со статусом 'live'). |
Зачем: чтобы рисовать индикатор уровней, подсвечивать PTT, детектировать молчание/речь.
Назначение: Управление режимами работы (voice/text) и манипуляции с сессией.
Параметры:
| Параметр | Тип | Описание |
|---|---|---|
| client | RealtimeClientClass | null |
Экземпляр клиента (из контекста или переданный вручную) |
| switchMode | (mode: 'voice' | 'text') => Promise<void> |
Функция переключения режима |
| onSuccess | (stage: string) => void |
Callback успешных операций |
| onError | (stage: string, error: any) => void |
Callback ошибок |
Возвращаемые методы:
| Метод | Описание |
|---|---|
initializeMode(mode) |
Инициализация режима (voice/text) после подключения |
closeVoiceMode() |
Корректное закрытие голосового режима |
cancelAssistant() |
Отмена текущего ответа ассистента |
handleSendMessage(text, onSuccess, onError) |
Отправка текстового сообщения |
enforceTextSession() |
Принудительное переключение в текстовый режим |
enforceVoiceSession() |
Принудительное переключение в голосовой режим |
setRemoteTracksEnabled(enabled) |
Вкл/выкл удаленные аудио треки |
Пример использования:
import { useSessionOptions, useRealtime } from 'react-native-openai-realtime';
import { useState } from 'react';
function ChatScreen() {
const { client } = useRealtime();
const [mode, setMode] = useState<'voice' | 'text'>('text');
const { initializeMode, closeVoiceMode, cancelAssistant, handleSendMessage } =
useSessionOptions({
client: client!,
switchMode: async (newMode) => {
setMode(newMode);
console.log(`Switched to ${newMode} mode`);
},
onSuccess: (stage) => console.log('✅', stage),
onError: (stage, error) => console.error('❌', stage, error),
});
// Переключение в голосовой режим
const enableVoice = async () => {
try {
await initializeMode('voice'); // ← Вызовет enableMicrophone()
} catch (e) {
console.error('Failed to enable voice:', e);
}
};
// Переключение в текстовый режим
const enableText = async () => {
try {
await closeVoiceMode(); // ← Отменит ассистента, переключит modalities
} catch (e) {
console.error('Failed to enable text:', e);
}
};
// Отправка текста
const sendText = (text: string) => {
handleSendMessage(
text,
() => console.log('Sent'),
(e) => console.error('Failed:', e)
);
};
// Прерывание ассистента
const stopAssistant = async () => {
await cancelAssistant();
};
return (
<View>
<Button title="Voice Mode" onPress={enableVoice} />
<Button title="Text Mode" onPress={enableText} />
<Button title="Stop Assistant" onPress={stopAssistant} />
</View>
);
}Внутренняя механика:
// enforceVoiceSession() последовательность:
1. updateSession({ modalities: ['audio', 'text'], turn_detection: {...} })
2. delay(300) // Ждем применения сессии
3. enableMicrophone() // ← Ре-негоциация WebRTC
4. switchMode('voice')
5. setRemoteTracksEnabled(true)
6. onSuccess('voice_initialized')
// closeVoiceMode() последовательность:
1. cancelAssistant() // response.cancel + output_audio_buffer.clear
2. setRemoteTracksEnabled(false)
3. enforceTextSession() // updateSession({ modalities: ['text'] })
4. switchMode('text')
5. onSuccess('voice_closed')Важно:
- Методы асинхронные, обрабатывайте try/catch
initializeMode()требует активного соединения (status='connected')closeVoiceMode()не отключает микрофон физически (треки остаются enabled)
- onEvent(evt): проп RealTimeClient — raw JSON из DataChannel. Хорош для логов и троттлинга.
- client.on('type', handler): «удобные» события, смонтированные на EventRouter.
Поддерживаемые «удобные» типы:
-
user:item_started— { itemId } -
user:delta— { itemId, delta } -
user:completed— { itemId, transcript } -
user:failed— { itemId, error } -
user:truncated— { itemId } -
assistant:response_started— { responseId } -
assistant:delta— { responseId, delta, channel: 'audio_transcript' | 'output_text' } -
assistant:completed— { responseId, status: 'done' | 'canceled' } -
tool:call_delta— { call_id, name, delta } — дельты аргументов tool -
tool:call_done— { call_id, name, args } — собранные аргументы JSON -
error— { scope, error } — ошибки от сервера (realtime)
| Raw event (onEvent.type) | Эмит | Payload |
|---|---|---|
| conversation.item.created (role=user) | user:item_started | { itemId } |
| conversation.item.created (role=assistant) | assistant:item_started | { itemId } |
| conversation.item.input_audio_transcription.delta | user:delta | { itemId, delta } |
| conversation.item.input_audio_transcription.completed | user:completed | { itemId, transcript } |
| conversation.item.input_audio_transcription.failed | user:failed | { itemId, error } |
| conversation.item.truncated | user:truncated | { itemId } |
| response.created | assistant:response_started | { responseId } |
| response.text.delta | assistant:delta | { responseId, delta, channel: 'output_text' } |
| response.output_item.text.delta | assistant:delta | { responseId, delta, channel: 'output_text' } |
| response.output_text.delta | assistant:delta | { responseId, delta, channel: 'output_text' } |
| response.audio_transcript.delta | assistant:delta | { responseId, delta, channel: 'audio_transcript' } |
| response.text.done | assistant:completed | { responseId, status: 'done' } |
| response.output_item.text.done | assistant:completed | { responseId, status: 'done' } |
| response.output_text.done | assistant:completed | { responseId, status: 'done' } |
| response.completed | assistant:completed | { responseId, status: 'done' } |
| response.canceled | assistant:completed | { responseId, status: 'canceled' } |
| response.audio_transcript.done | assistant:completed | { responseId, status: 'done' } |
| response.function_call_arguments.delta | tool:call_delta | { call_id, name, delta } |
| response.function_call_arguments.done | tool:call_done | { call_id, name, args } |
| error | error | { scope: 'server', error } |
DataChannelManager обрабатывает ошибки парсинга входящих сообщений:
- При ошибке парсинга JSON вызывается
errorHandler.handle()с severity='warning' - В контекст ошибки включается обрезанный raw текст (до 2000 символов)
- Добавляется hint: 'Failed to JSON.parse DataChannel message'
- Ошибка recoverable (не критическая), соединение продолжает работать
Примечание: если onToolCall вернёт значение — по умолчанию отправляется function_call_output и тут же делается response.create (follow-up).
'user:*' не поддерживаются. Подписывайтесь на точные строки:
user:item_started|user:delta|user:completed|user:failed|user:truncatedassistant:item_started|assistant:response_started|assistant:delta|assistant:completedtool:call_delta|tool:call_doneerror
Библиотека обрабатывает ВСЕ типы текстовых дельт от ассистента:
response.text.delta— основной тип для текстовых ответовresponse.output_item.text.delta— альтернативный форматresponse.output_text.delta— legacy форматresponse.audio_transcript.delta— транскрипция голосового ответа
Все они маршрутизируются в одно событие assistant:delta с разными channel:
channel: 'output_text'— текстовый ответchannel: 'audio_transcript'— транскрипция аудио
Обработка typed-ввода пользователя:
При отправке текстового сообщения через sendTextMessage():
// Создается conversation item
{
type: 'conversation.item.created',
item: {
role: 'user',
content: [{ type: 'input_text', text: 'Привет' }]
}
}
// Router автоматически:
1. Эмитит 'user:item_started' { itemId }
2. Извлекает текст из content[0]
3. Эмитит 'user:completed' { itemId, transcript: 'Привет' }То есть текстовые сообщения пользователя обрабатываются синхронно (нет дельт).
Два вида:
- incoming(ctx) — на входящих событиях до маршрутизации.
- outgoing(event) — на исходящих событиях до отправки в DataChannel.
Подписи:
type MiddlewareCtx = {
event: any; // входящее сообщение (можно менять)
send: (e: any) => Promise<void> | void; // можно послать в канал (например, cancel)
client: RealtimeClientClass; // ядро для низкоуровневого доступа
};
type IncomingMiddleware =
(ctx: MiddlewareCtx) => any | 'stop' | null | void | Promise<...>;
type OutgoingMiddleware =
(event: any) => any | null | 'stop' | Promise<...>;Поведение:
- Верните
'stop', чтобы прекратить обработку (не маршрутизировать/не отправлять). - Верните модифицированный объект — он пойдёт дальше.
- Ничего не вернёте — событие проходит «как есть».
Типичные кейсы:
- incoming: дергать setState по audio_buffer событиям, «косметика» дельт, автокоррекция входящих.
- outgoing: тримминг пустых input_text, добавление метаданных, блокировка cancel.
- Incoming middleware — перехватчики обрабатывают сырое событие
- Router (createDefaultRouter) — маршрутизирует в "удобные" события
- hooks.onEvent — вызывается внутри router (после middleware, но до emit)
Схема:
DataChannel → incoming middleware (может изменить/остановить) → router → onEvent hook → emit('user:*', 'assistant:*', ...) → ваши подписки on()
Пример влияния middleware на onEvent:
incomingMiddleware={[
({ event }) => {
if (event.type === 'response.audio_transcript.delta' && !event.delta.trim()) {
return 'stop'; // onEvent НЕ вызовется для этого события
}
}
]}Важно: Если middleware вернёт 'stop', событие не дойдёт до router, onEvent и ваши подписки client.on() не будут вызваны.
Важное обновление: С версии 0.4.2 middleware получают полный доступ к клиенту (RealtimeClientClass):
type MiddlewareCtx = {
event: any; // входящее сообщение
send: (e: any) => Promise<void>; // отправка в DataChannel
client: RealtimeClientClass; // ← ПОЛНЫЙ экземпляр класса
};Схема выполнения:
DataChannel message
↓
Incoming Middleware[0] ← ctx.client доступен
↓
Incoming Middleware[1] ← ctx.client доступен
↓
EventRouter (createDefaultRouter)
↓
hooks.onEvent()
↓
Emit удобные события
↓
Ваши client.on() подписки
Важно: До вызова middleware клиент уже инициализирован, поэтому можно безопасно:
const myMiddleware: IncomingMiddleware = async ({ event, send, client }) => {
// ✅ Доступны все методы клиента
const chat = client.getChat();
const status = client.getStatus();
const dc = client.getDataChannel();
// ✅ Можно отправлять события
if (event.type === 'user:delta' && chat.length > 100) {
await send({ type: 'conversation.item.truncate' });
}
// ✅ Можно модифицировать событие
if (event.type === 'response.audio_transcript.delta') {
return {
...event,
delta: event.delta.toUpperCase(), // Капслок для транскриптов
};
}
};- ChatStore — отслеживает дельты user/assistant, создаёт/обновляет/финализирует сообщения, фильтрует «пустое» по isMeaningfulText.
- ChatAdapter (
attachChatAdapter) — подписывает внешнийsetChatна обновления встроенногоChatStore. Он не меняет политикуisMeaningfulText. Для изменения политики используйтеchatIsMeaningfulTextилиpolicyIsMeaningfulTextв пропсахRealTimeClient. - ExtendedChatMsg — объединяет ChatMsg (type='text') и ваши UI-сообщения (type='ui').
Важное различие:
- clearAdded() — удаляет только ваши UI-сообщения, добавленные через
addMessage(). Не трогает встроенный ChatStore. - clearChatHistory() — очищает встроенный ChatStore (user/assistant сообщения из реального диалога). Не трогает ваши UI-сообщения.
// Пример использования
const { chat, addMessage, clearAdded, clearChatHistory } = useRealtime();
// Добавляем UI-сообщение
addMessage({ type: 'ui', kind: 'hint', payload: { text: 'Подсказка' } });
// chat теперь содержит: [...chatStoreMessages, uiMessage]
clearAdded(); // Удалит только UI-сообщение
clearChatHistory(); // Удалит только chatStore сообщенияПроп chatInverted управляет сортировкой сообщений в mergedChat:
chatInverted: false(по умолчанию) — новые сообщения сверху (нисходящий порядок поts)chatInverted: true— старые сообщения сверху (восходящий порядок поts)
<RealTimeClient
chatInverted={true} // старые сообщения сверху
// ...
>
<YourScreen />
</RealTimeClient>Примечание: сортировка применяется к объединенному массиву [...chatStoreMessages, ...addedMessages] и влияет на порядок отображения в UI.
Опции ChatStore:
| Опция | Тип | Default | Описание |
|---|---|---|---|
| isMeaningfulText | (t:string) => boolean | !!t.trim() | Политика «осмысленности». |
| userAddOnDelta | boolean | true | Добавлять юзер-сообщение при 1-й дельте. |
| userPlaceholderOnStart | boolean | false | Создавать пустышку при user:item_started. |
| assistantAddOnDelta | boolean | true | Добавлять ассистентское сообщение при 1-й дельте. |
| assistantPlaceholderOnStart | boolean | false | Плейсхолдер при response_started. |
Низкоуровневый отправитель. Используется и внутри обёрток. Проходит через outgoingMiddleware.
Популярные события:
- Создать пользовательское текстовое сообщение + запросить ответ:
await sendRaw({
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Привет' }],
},
});
await sendRaw({ type: 'response.create' });- PTT (ручной буфер):
await sendRaw({ type: 'input_audio_buffer.commit' });
await sendRaw({ type: 'response.create' });
await sendRaw({ type: 'input_audio_buffer.clear' });- Отменить текущий ответ:
await sendRaw({ type: 'response.cancel' });Обёртка над response.create. Если opts не переданы — отправится пустой объект {} (сервер использует дефолтные настройки сессии).
sendResponse({ instructions: 'Скажи тост', modalities: ['audio', 'text'] });
// Без параметров (сервер использует текущую session)
sendResponse();Отличие от sendResponseStrict(): instructions НЕ обязателен, можно вызывать без аргументов.
Строгая версия с обязательными инструкциями.
Примеры разных кейсов:
// Простой текстовый ответ
sendResponseStrict({
instructions: 'Объясни квантовую физику простыми словами',
modalities: ['text'],
});
// Аудио-ответ с контекстом истории
sendResponseStrict({
instructions: 'Продолжи разговор и ответь на последний вопрос',
modalities: ['audio', 'text'],
conversation: 'default',
});
// One-shot вопрос без сохранения в истории
sendResponseStrict({
instructions: 'Какая погода в Киеве? Ответь одним предложением',
modalities: ['text'],
conversation: 'none',
});
// Смена языка ответа
sendResponseStrict({
instructions: 'Reply in English from now on',
modalities: ['audio', 'text'],
});Параметры:
- instructions (обязательный) — инструкции для модели
- modalities — массив модальностей ответа: ['audio'], ['text'] или ['audio', 'text']
- conversation — 'default' (с историей) или 'none' (без истории)
Отправить текстовое сообщение (используется в useSessionOptions):
client.sendTextMessage('Привет', {
responseModality: 'text', // 'text' или 'audio'
instructions: 'Ответь кратко',
conversation: 'auto', // 'auto' или 'none'
});Что происходит внутри:
- Создается
conversation.item.createс типомmessage - Отправляется
response.createс указанными параметрами
Параметры:
text— текст сообщенияoptions.responseModality— модальность ответа ('text' или 'audio')options.instructions— инструкции для моделиoptions.conversation— 'auto' (с историей) или 'none' (без истории)
Важно: По умолчанию conversation: 'auto' сохраняет контекст разговора.
Отменяет текущую генерацию ответа:
sendRaw({ type: 'response.cancel' });Частичное обновление session:
updateSession({
voice: 'ash',
turn_detection: {
type: 'server_vad',
silence_duration_ms: 800,
threshold: 0.6,
prefix_padding_ms: 300,
},
modalities: ['text', 'audio'],
tools: [], // отключить инструменты
});Ручной вывод результата инструмента:
client.sendToolOutput(call_id, { temperature: 22, city: 'Kyiv' });
// ВАЖНО: response.create НЕ вызывается автоматически!
client.sendResponse(); // нужно вызвать вручнуюЧто происходит внутри:
Метод отправляет событие conversation.item.create с типом function_call_output:
sendToolOutput(call_id: string, output: any) {
this.sendRaw({
type: 'conversation.item.create',
item: {
type: 'function_call_output',
call_id,
output: JSON.stringify(output), // output сериализуется в JSON строку
},
});
}Отличие от onToolCall:
- onToolCall (если вернёте значение) → автоматически отправляет
function_call_output+ делаетresponse.create - sendToolOutput → только отправляет
function_call_output,response.createнужно вызывать вручную
Когда использовать:
- Ручной контроль — не возвращайте значение в
onToolCall, слушайтеtool:call_doneи используйтеsendToolOutput+sendResponse() - Автоматический режим — возвращайте значение в
onToolCall(библиотека сделает всё сама)
Пример ручного режима:
<RealTimeClient
onToolCall={async ({ name, args, call_id }) => {
// Не возвращаем значение — ручной режим
const result = await callAPI(name, args);
// Библиотека НЕ отправит function_call_output автоматически
}}
/>
// Слушаем tool:call_done и отправляем вручную
client.on('tool:call_done', async ({ call_id, name, args }) => {
const output = await processTool(name, args);
client.sendToolOutput(call_id, output);
client.sendResponse(); // вручную инициируем ответ
});Пример полного компонента:
function MyComponent() {
const { client } = useRealtime();
useEffect(() => {
if (!client) return;
// Подписываемся на завершение tool call
const unsubscribe = client.on(
'tool:call_done',
async ({ call_id, name, args }) => {
try {
// Обрабатываем tool вручную
const output = await handleTool(name, args);
// Отправляем результат
client.sendToolOutput(call_id, output);
// ВАЖНО: вручную инициируем response.create
client.sendResponse();
} catch (error) {
// Обработка ошибок
client.sendToolOutput(call_id, { error: error.message });
client.sendResponse();
}
}
);
return unsubscribe;
}, [client]);
return <View>{/* ваш UI */}</View>;
}type SessionConfig = {
model?: string; // 'gpt-4o-realtime-preview-2024-12-17'
voice?: VoiceId; // 'alloy' | 'ash' | 'verse' | ...
modalities?: Array<'audio' | 'text'>; // ['audio','text'] или ['text']
turn_detection?: {
type: 'server_vad';
silence_duration_ms?: number;
threshold?: number;
prefix_padding_ms?: number;
};
input_audio_transcription?: { model: string; language?: string }; // 'whisper-1' или 'gpt-4o-transcribe'
tools?: any[]; // Realtime tools spec (проксируется в OpenAI)
instructions?: string; // системные инструкции
};Подсказка: библиотека экспортирует VOICE_IDS и тип VoiceId — можно строить picker голосов без «ручных» массивов:
import { VOICE_IDS, type VoiceId } from 'react-native-openai-realtime';
VOICE_IDS.map(v => /* отрисуйте pill и вызовите updateSession({ voice: v as VoiceId }) */);Формат tools:
tools: [
{
type: 'function',
name: 'get_weather',
description: 'Return weather by city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
},
];Назначение: Автоматически отправляет session.update при открытии DataChannel.
- autoSessionUpdate={true} (по умолчанию) — библиотека отправляет
session.updateсразу после подключения - autoSessionUpdate={false} — вы управляете сессией вручную через
updateSession()
Когда отключать:
- Динамическая конфигурация сессии (например, выбор модели после подключения)
- Условное применение настроек (разные tools для разных пользователей)
- A/B тестирование параметров VAD
Пример ручного управления:
<RealTimeClient
autoSessionUpdate={false}
session={undefined} // не передаём начальную сессию
onOpen={async (dc) => {
const userPrefs = await fetchUserPreferences();
client.updateSession({
voice: userPrefs.voice,
tools: userPrefs.enabledTools,
turn_detection: userPrefs.vadConfig
});
}}
/>Важно: Если autoSessionUpdate={false}, но session передана — она НЕ отправится автоматически. Нужно вызвать updateSession() вручную.
- greetEnabled (по умолчанию
true) — автоприветствие после подключения - greetInstructions — текст приветствия
- greetModalities — ['audio','text'] и т.д.
По умолчанию greetEnabled=true. При открытии DataChannel отправляется response.create с указанным приветствием.
Важно: Если greetEnabled=true, но greetInstructions не заданы, applyDefaults подставит дефолтные instructions и modalities из DEFAULTS.
Предикат isMeaningfulText определяет: считать ли текст «содержательным».
- policyIsMeaningfulText — глобальный дефолт (используют разные модули).
- chatIsMeaningfulText — перекрывает policy только для встроенного чата.
Приоритет: chat.isMeaningfulText ?? policy.isMeaningfulText ?? (t => !!t.trim())
Частые сценарии:
- Жёсткий глобальный фильтр, мягкий в чате.
- Отключить встроенный чат и применять политику в своих middleware/хуках.
- status: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'
- logger: { debug, info, warn, error } — можно прокинуть в RealTimeClient.
Статус 'error' устанавливается при:
- Критических ошибках (severity='critical') через ErrorHandler
- Провале WebRTC соединения (pc.connectionState='failed')
- Ошибке получения токена (fetch_token)
- Неожиданных исключениях в процессе подключения
useSpeechActivity() хук.
Конструктор:
new RealtimeClientClass(
options: RealtimeClientOptionsBeforePrune,
successHandler?: SuccessHandler,
errorHandler?: ErrorHandler
)Методы:
| Метод | Описание |
|---|---|
| connect() | Установить WebRTC, сделать SDP-обмен, открыть DataChannel. |
| disconnect() | Закрыть DataChannel/Media/Peer, почистить подписки и (по настройке) чат. |
| sendRaw(event) | Отправить событие (через outgoingMiddleware). |
| sendResponse(opts?) | Обёртка над response.create. |
| sendResponseStrict({ instructions, modalities, conversation? }) | Строгая версия response.create. |
| updateSession(patch) | Отправить session.update. |
| sendToolOutput(call_id, output) | Ручной вывод инструмента. |
| setTokenProvider(fn) | Обновить токен-провайдер на лету. |
| on(type, handler) | Подписаться на удобные события (user:, assistant:, tool:*, error). |
| onConnectionStateChange(fn) | Подписка на смену статуса ('connecting','connected',...). |
| onChatUpdate(fn) | Подписка на обновления встроенного чата. |
| clearChatHistory() | Очистить историю чата. |
| isConnected() | Соединение установлено? |
| getConnectionState() | Текущий connectionState. |
| getStatus() | Текущий connectionState (алиас). |
| getPeerConnection() | RTCPeerConnection. |
| getDataChannel() | RTCDataChannel. |
| getLocalStream() | MediaStream (локальный). |
| getRemoteStream() | MediaStream (удалённый). |
| getChat() | Текущая история встроенного чат-стора. |
| enableMicrophone() | Включить микрофон после подключения (ре-негоциация). |
Класс защищен от повторных вызовов:
- connecting флаг — блокирует повторный connect() во время подключения
- disconnecting флаг — блокирует повторный disconnect() во время отключения
- При попытке повторного вызова connect() во время подключения — логируется warning через ErrorHandler с severity='warning'
Используются только с RealtimeClientClass (низкоуровневый сценарий). Позволяют слушать успехи стадий/событий.
SuccessHandler принимает два типа коллбеков:
- Детализированные коллбеки из
SuccessCallbacks— для конкретных событий - Универсальный коллбек
onSuccess(stage: string, data?: any)— для любых успешных операций
type SuccessCallbacks = {
onHangUpStarted?(): void;
onHangUpDone?(): void;
onPeerConnectionCreatingStarted?(): void;
onPeerConnectionCreated?(pc: RTCPeerConnection): void;
onRTCPeerConnectionStateChange?(
state:
| 'new'
| 'connecting'
| 'connected'
| 'disconnected'
| 'failed'
| 'closed'
): void;
onGetUserMediaSetted?(stream: MediaStream): void;
onLocalStreamSetted?(stream: MediaStream): void;
onLocalStreamAddedTrack?(track: MediaStreamTrack): void;
onLocalStreamRemovedTrack?(track: MediaStreamTrack): void;
onRemoteStreamSetted?(stream: MediaStream): void;
onDataChannelOpen?(channel: RTCDataChannel): void;
onDataChannelMessage?(message: any): void;
onDataChannelClose?(): void;
onIceGatheringComplete?(): void;
onIceGatheringTimeout?(): void;
onIceGatheringStateChange?(state: string): void;
onMicrophonePermissionGranted?(): void;
onMicrophonePermissionDenied?(): void;
onIOSTransceiverSetted?(): void;
};
// Универсальный коллбек
type BaseProps = SuccessCallbacks & {
onSuccess?: (stage: string, data?: any) => void;
};Приоритет: Если для стадии указан детализированный коллбек (например, onDataChannelOpen), вызываются оба: сначала детализированный, затем универсальный onSuccess.
const successHandler = new SuccessHandler(
{
onDataChannelOpen: (dc) => console.log('DC opened:', dc),
onPeerConnectionCreated: (pc) => console.log('PC created:', pc),
},
(stage, data) => {
// Универсальный коллбек для всех успешных операций
console.log(`[SUCCESS] ${stage}`, data);
// Можно централизованно обрабатывать все успехи
if (stage === 'ice_gathering_complete') {
analytics.track('ICE_GATHERING_SUCCESS');
}
if (stage === 'data_channel_open') {
// Вызывается после onDataChannelOpen
metrics.record('dc_open_time', Date.now() - connectionStartTime);
}
}
);
const client = new RealtimeClientClass(options, successHandler);Примечание: Универсальный onSuccess вызывается для всех успешных операций, даже если для стадии есть детализированный коллбек.
ErrorHandler вызывает ваш onError при любой ошибке и логирует её.
type ErrorStage =
| 'hangup'
| 'ice_gathering'
| 'peer_connection'
| 'microphone_permission'
| 'remote_stream'
| 'local_stream'
| 'data_channel'
| 'get_user_media'
| 'ios_transceiver'
| 'init_peer_connection'
| 'create_offer'
| 'set_local_description'
| 'set_remote_description'
| 'fetch_token'
| 'openai_api';
type ErrorSeverity = 'critical' | 'warning' | 'info';
type ErrorEvent = {
stage: ErrorStage;
error: Error;
severity: ErrorSeverity;
recoverable: boolean;
timestamp: number;
context?: Record<string, any>;
};DEFAULTS — значения по умолчанию:
{
tokenProvider: async () => throw new Error('tokenProvider is required'),
webrtc: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
],
dataChannelLabel: 'oai-events',
offerOptions: {
offerToReceiveAudio: true,
voiceActivityDetection: true,
},
configuration: { iceCandidatePoolSize: 10 }
},
media: {
getUserMedia: {
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false
}
},
session: {
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'alloy',
modalities: ['audio', 'text'],
input_audio_transcription: { model: 'whisper-1' },
turn_detection: {
type: 'server_vad',
silence_duration_ms: 700,
prefix_padding_ms: 300,
threshold: 0.5
}
},
autoSessionUpdate: true,
greet: {
enabled: true,
response: {
instructions: 'Привет! Я на связи и готов помочь.',
modalities: ['audio', 'text']
}
},
policy: { isMeaningfulText: (t) => !!t.trim() },
chat: { enabled: true, isMeaningfulText: (t) => !!t.trim() },
logger: { debug, info, warn, error: console.* }
}Важно:
deleteChatHistoryOnDisconnectНЕ указан в DEFAULTS- В компоненте
RealTimeClientзначение по умолчанию —true - В классе
RealtimeClientClassиспользуетсяoptions.deleteChatHistoryOnDisconnect !== false(т.е. если не указано, считаетсяtrue)
Значение по умолчанию зависит от точки входа:
- RealTimeClient компонент:
deleteChatHistoryOnDisconnect = true(по умолчанию) - RealtimeClientClass: проверка
!== false(т.е. если не передано, считаетсяtrue) - DEFAULTS: поле отсутствует (не применяется через applyDefaults)
Логика:
// Компонент (явное значение по умолчанию)
const { deleteChatHistoryOnDisconnect = true } = props;
// Класс (проверка на !== false)
if (this.options.deleteChatHistoryOnDisconnect !== false) {
chatStore.destroy();
}Итог: Если хотите сохранять историю — явно передайте deleteChatHistoryOnDisconnect={false} в компонент или { deleteChatHistoryOnDisconnect: false } в класс.
applyDefaults — глубокое слияние пользовательских опций (deepMerge) с DEFAULTS.
Особая логика для greet: если greet.enabled=true, но greet.response.instructions не задан — подставляется полный объект greet.response из DEFAULTS (включая instructions и modalities):
// Если greet.enabled=true, но instructions не задан
if (
merged.greet?.enabled &&
(!merged.greet.response || !merged.greet.response.instructions)
) {
merged.greet = {
enabled: true,
response: {
instructions: DEFAULTS.greet!.response!.instructions!,
modalities: DEFAULTS.greet!.response!.modalities,
},
};
}prune() — рекурсивно очищает объект от undefined значений:
prune({ a: 1, b: undefined, c: { d: 2, e: undefined } });
// => { a: 1, c: { d: 2 } }- Массивы не затрагиваются (проходят "как есть")
- Рекурсивно обрабатывает вложенные объекты
- Удаляет только
undefined,nullостается
deepMerge() — глубокое слияние объектов:
deepMerge({ arr: [1, 2], obj: { a: 1 } }, { arr: [3], obj: { b: 2 } });
// => { arr: [3], obj: { a: 1, b: 2 } }- Массивы заменяются целиком, а не мержатся
- Объекты мержатся рекурсивно
- Примитивы перезаписываются
- Никогда не храните OpenAI API Key в клиенте!
- Реализуйте серверный endpoint /realtime/session, который создаёт ephemeral session и возвращает client_secret.value.
- tokenProvider на клиенте обращается к вашему серверу.
- Для ручного PTT отключите серверный VAD (updateSession({ turn_detection: undefined })).
- В момент отпускания кнопки PTT отправьте:
- input_audio_buffer.commit
- response.create
- input_audio_buffer.clear (опционально)
- Включайте/выключайте локальные треки микрофона через
getLocalStream().getAudioTracks().forEach(t => t.enabled = …).
- Авто: onToolCall возвращает output → библиотека отправит function_call_output и сделает response.create.
- Ручной: не возвращайте output в onToolCall, а слушайте tool:call_done и используйте client.sendToolOutput(call_id, output) вручную, затем client.sendResponse().
- Для серверного VAD: threshold ~0.4–0.6, silence_duration_ms ~600–1000.
- Для детекции без серверных событий используйте useMicrophoneActivity(mode='stats').
- Встроенный ChatStore покрывает 90% кейсов (стриминг, финализация, пустышки).
- Для полного контроля — chatEnabled={false} и соберите чат через client.on('user:','assistant:').
- Если
webrtc.configurationне передан, библиотека создаёт минимальную конфигурацию сiceServersизwebrtc.iceServersиiceCandidatePoolSize: 10. - Для полного контроля передавайте
webrtc.configurationявно.
Библиотека поддерживает подключение без микрофона:
- allowConnectWithoutMic={true} (по умолчанию) — при отсутствии микрофона создается recvonly transceiver
- allowConnectWithoutMic={false} — отсутствие микрофона считается критической ошибкой
Когда использовать:
- Режим "только прослушивание" (assistant говорит, пользователь не отвечает голосом)
- Тестирование без физического микрофона
- Ограниченные права доступа (корпоративные устройства)
Пример:
<RealTimeClient
allowConnectWithoutMic={true}
session={{
modalities: ['audio', 'text'], // Получаем аудио, отправляем текст
turn_detection: null, // VAD не нужен без микрофона
}}
/>- Компонент
RealTimeClientавтоматически разрывает соединение при переходе приложения в фоновый режим или неактивное состояние. - При возврате в приложение нужно вызвать
connect()заново.
Для голосового общения используйте react-native-incall-manager, чтобы система включила AEC (Acoustic Echo Cancellation):
- iOS: AVAudioSessionCategoryPlayAndRecord + mode=VoiceChat
- Android: MODE_IN_COMMUNICATION
expo-av в состоянии connected — это может «сбить» AEC и привести к эху («ассистент слышит сам себя»).
Пример:
import InCallManager from 'react-native-incall-manager';
useEffect(() => {
if (status === 'connected') {
InCallManager.start({ media: 'audio' });
InCallManager.setForceSpeakerphoneOn(true); // маршрут: на динамик (true) или в ухо (false)
} else {
InCallManager.stop();
}
}, [status]);(Опционально) Глушение микрофона на время речи ассистента:
client.on('assistant:response_started', () => {
const local = client.getLocalStream?.();
local?.getAudioTracks?.()?.forEach((t) => (t.enabled = false));
});
client.on('assistant:completed', () => {
const local = client.getLocalStream?.();
local?.getAudioTracks?.()?.forEach((t) => (t.enabled = true));
});Пример глобального провайдера, который использует ref для добавления UI‑сообщений из onToolCall:
import React, { useMemo, useRef } from 'react';
import {
RealTimeClient,
type RealTimeClientHandle,
createSpeechActivityMiddleware,
} from 'react-native-openai-realtime';
const SERVER_BASE = 'http://localhost:8787';
const tokenProvider = async () => {
const r = await fetch(`${SERVER_BASE}/realtime/session`);
const j = await r.json();
return j.client_secret.value;
};
export const GlobalRealtimeProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const incomingMiddleware = useMemo(
() => [createSpeechActivityMiddleware()],
[]
);
const rtcRef = useRef<RealTimeClientHandle | null>(null);
return (
<RealTimeClient
ref={rtcRef}
tokenProvider={tokenProvider}
onError={(e) => console.error('Realtime error:', e)}
onToolCall={async ({ name, args }) => {
try {
const isFlights =
name === 'search_flights' || name === 'SearchTripByAirplane';
const isPros =
name === 'search_professionals' || name === 'SearchProfessionals';
if (!isFlights && !isPros) return undefined;
const url = isFlights
? `${SERVER_BASE}/api/search_flights`
: `${SERVER_BASE}/api/search`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args ?? {}),
});
const data = await resp.json();
rtcRef.current?.addMessage({
type: 'ui',
role: 'assistant',
kind: isFlights ? 'flights' : 'professionals',
payload: {
total: Number(data?.total ?? 0),
items: Array.isArray(data?.items) ? data.items : [],
},
});
// Верните результат — библиотека отправит function_call_output и инициирует response.create
return data;
} catch (e: any) {
rtcRef.current?.addMessage({
type: 'ui',
role: 'assistant',
kind: 'error',
payload: { error: e?.message || String(e) },
});
return { error: e?.message || String(e) };
}
}}
session={{
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'shimmer',
input_audio_transcription: { model: 'whisper-1' }, // или 'gpt-4o-transcribe'
modalities: ['audio', 'text'],
turn_detection: {
type: 'server_vad',
threshold: 0.7,
prefix_padding_ms: 250,
silence_duration_ms: 800,
},
}}
incomingMiddleware={incomingMiddleware}
chatEnabled
chatInverted={false}
deleteChatHistoryOnDisconnect={false}
autoConnect={false}
>
{children}
</RealTimeClient>
);
};import type {
IncomingMiddleware,
OutgoingMiddleware,
MiddlewareCtx,
} from 'react-native-openai-realtime';
const myIncomingMiddleware: IncomingMiddleware = async (ctx: MiddlewareCtx) => {
const { event, send, client } = ctx;
if (event.type === 'custom') {
// Модифицируем событие
return { ...event, processed: true };
}
if (event.type === 'skip') {
return 'stop'; // Блокируем событие
}
// Пропускаем как есть
return;
};
const myOutgoingMiddleware: OutgoingMiddleware = (event: any) => {
if (!event.type) return 'stop'; // Блокируем невалидные
return event; // Пропускаем
};import type {
RealtimeClientHooks,
RealtimeContextValue,
} from 'react-native-openai-realtime';
const hooks: Partial<RealtimeClientHooks> = {
onToolCall: async ({ name, args, call_id }) => {
// TypeScript знает типы параметров
if (name === 'get_weather') {
return { temp: 22, city: args.city };
}
},
};
const MyComponent: React.FC = () => {
const ctx: RealtimeContextValue = useRealtime();
// TypeScript знает все поля контекста
};import type { ExtendedChatMsg, ChatMsg, UIChatMsg } from 'react-native-openai-realtime';
function renderMessage(msg: ExtendedChatMsg) {
if (msg.type === 'text') {
// TypeScript знает, что это ChatMsg
const textMsg = msg as ChatMsg;
return <Text>{textMsg.text}</Text>;
} else {
// TypeScript знает, что это UIChatMsg
const uiMsg = msg as UIChatMsg;
return <CustomUI kind={uiMsg.kind} payload={uiMsg.payload} />;
}
}import { useRef } from 'react';
import type { RealTimeClientHandle } from 'react-native-openai-realtime';
const rtcRef = useRef<RealTimeClientHandle | null>(null);
<RealTimeClient
ref={rtcRef}
onToolCall={async ({ name, args }) => {
const data = await callYourApi(name, args);
rtcRef.current?.addMessage({
type: 'ui',
kind: 'tool_result',
role: 'assistant',
payload: data
});
return data;
}}
/>Самая частая проблема — неверная конфигурация сессии!
ОБЯЗАТЕЛЬНАЯ конфигурация для работы транскрипции:
session={{
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'shimmer',
modalities: ['audio', 'text'],
input_audio_transcription: {
model: 'whisper-1' // Быстрая транскрипция (рекомендуется)
// или 'gpt-4o-transcribe' // Более точная, но медленнее
// БЕЗ ЭТОГО НЕ БУДЕТ ТРАНСКРИПЦИИ!
},
turn_detection: {
type: 'server_vad',
threshold: 0.7,
prefix_padding_ms: 250,
silence_duration_ms: 800
}
}}Чек‑лист:
- ✅
input_audio_transcription.modelуказан (whisper-1илиgpt-4o-transcribe) - ✅
chatEnabled !== false(по умолчаниюtrue) - ✅
attachChat !== false(по умолчаниюtrue) - ✅ Микрофон работает (разрешение granted)
- ✅ Соединение
status='connected' - ✅ Хотя бы один
response.createотправлен (greet или manual) - ✅ События приходят (проверьте
onEventлог) - ✅ Для текстовых сообщений: используйте
handleSendMessage()(из useSessionOptions) или manual sequence
Правильный минимум:
<RealTimeClient
session={{
model: 'gpt-4o-realtime-preview-2024-12-17',
voice: 'shimmer',
input_audio_transcription: {
model: 'whisper-1', // Быстрая транскрипция (рекомендуется)
// или 'gpt-4o-transcribe' // Более точная, но медленнее
},
modalities: ['audio', 'text'],
turn_detection: {
type: 'server_vad',
threshold: 0.7,
prefix_padding_ms: 250,
silence_duration_ms: 800,
},
}}
autoSessionUpdate={true}
greetEnabled={true}
// ...
/>Если отключаете server_vad (ручной PTT):
await sendRaw({ type: 'input_audio_buffer.commit' });
await sendRaw({ type: 'response.create' });
await sendRaw({ type: 'input_audio_buffer.clear' });Для текстового режима:
// ✅ Правильно - через sendTextMessage() (в useSessionOptions)
handleSendMessage(text, onSuccess, onError);
// ✅ Правильно - ручная отправка
await sendRaw({
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Привет' }]
}
});
await sendResponseStrict({
instructions: 'Ответь на вопрос',
modalities: ['text'],
conversation: 'auto' // ← Важно для контекста!
});
// ❌ Неправильно - только item без response
await sendRaw({ type: 'conversation.item.create', ... }); // Ничего не произойдетПри ручном чате (chatEnabled={false}) собирайте ленту сами по client.on('user:*', 'assistant:*').
- Проверьте SDP обмен (fetch_token/SDP ошибки)
- Проверьте ICE-серверы и сеть
- Убедитесь, что токен валидный
- iOS: проверьте Info.plist (NSMicrophoneUsageDescription)
- Android: проверьте AndroidManifest (RECORD_AUDIO permission)
- Проверьте Media constraints
Используйте фильтрацию:
- policyIsMeaningfulText/chatIsMeaningfulText
- Фильтруйте дельты в onAssistantTextDelta/onUserTranscriptionDelta
- Используйте middleware для очистки
- Проверьте сервер/ключ/заголовки
- Используйте ErrorHandler.onError для детальных логов
- Убедитесь, что сервер возвращает client_secret.value
- Убедитесь, что remote stream получает track (ontrack)
- Проверьте громкость/аудио-вывод
- Убедитесь, что modalities включают 'audio'
- Проверьте логи ErrorHandler — там будет первые 2000 символов сырого сообщения и хинт "Failed to JSON.parse DataChannel message"
- Ошибка не критическая (recoverable), соединение продолжает работать
- Возможно, сервер отправляет не-JSON данные
Детали обработки: DataChannelManager перехватывает ошибки парсинга и передает их в ErrorHandler с severity='warning', включая обрезанный raw текст для отладки.
- Библиотека защищена от конкурентных вызовов
- Повторный вызов во время подключения будет проигнорирован с warning
- Дождитесь завершения первого connect() или используйте status для проверки
Примечание: Компонент поддерживает forwardRef:
const rtcRef = useRef<RealTimeClientHandle>(null);
<RealTimeClient ref={rtcRef} {...props} />;Методы ref см. в разделе "Императивный API через ref (RealTimeClientHandle)".
type RealTimeClientProps = {
// Основные
tokenProvider?: () => Promise<string>;
deleteChatHistoryOnDisconnect?: boolean; // default: true
autoConnect?: boolean; // default: false
attachChat?: boolean; // default: true
allowConnectWithoutMic?: boolean; // default: true
// WebRTC
webrtc?: {
iceServers?: RTCIceServer[];
dataChannelLabel?: string;
offerOptions?: RTCOfferOptions & { voiceActivityDetection?: boolean };
configuration?: RTCConfiguration;
};
// Media
media?: { getUserMedia?: Constraints };
// Session
session?: Partial<SessionConfig>;
autoSessionUpdate?: boolean; // default: true
// Greet
greetEnabled?: boolean; // default: true
greetInstructions?: string;
greetModalities?: Array<'audio' | 'text'>;
// Hooks
onOpen?: (dc: any) => void;
onEvent?: (evt: any) => void;
onError?: (error: any) => void;
onUserTranscriptionDelta?: (p: {
itemId: string;
delta: string;
}) => 'consume' | void;
onUserTranscriptionCompleted?: (p: {
itemId: string;
transcript: string;
}) => 'consume' | void;
onAssistantTextDelta?: (p: {
responseId: string;
delta: string;
channel: string;
}) => 'consume' | void;
onAssistantCompleted?: (p: {
responseId: string;
status: string;
}) => 'consume' | void;
onToolCall?: (p: {
name: string;
args: any;
call_id: string;
}) => Promise<any> | any;
// Middleware
incomingMiddleware?: IncomingMiddleware[];
outgoingMiddleware?: OutgoingMiddleware[];
// Policy
policyIsMeaningfulText?: (t: string) => boolean;
// Chat
chatEnabled?: boolean; // default: true
chatIsMeaningfulText?: (t: string) => boolean;
chatUserAddOnDelta?: boolean; // default: true
chatUserPlaceholderOnStart?: boolean; // default: false
chatAssistantAddOnDelta?: boolean; // default: true
chatAssistantPlaceholderOnStart?: boolean; // default: false
chatInverted?: boolean; // default: false
// Logger
logger?: {
debug?: (...a: any[]) => void;
info?: (...a: any[]) => void;
warn?: (...a: any[]) => void;
error?: (...a: any[]) => void;
};
// Children
children?: React.ReactNode | ((ctx: RealtimeContextValue) => React.ReactNode);
};class RealtimeClientClass {
// Lifecycle
connect(): Promise<void>;
disconnect(): Promise<void>;
// Messaging
sendRaw(event: any): Promise<void>;
sendResponse(opts?: ResponseCreateParams): void;
sendResponseStrict(options: ResponseCreateStrict): void;
updateSession(patch: Partial<any>): void;
sendToolOutput(call_id: string, output: any): void;
// Events
on(type: string, handler: (p: any) => void): () => void;
onConnectionStateChange(fn: (state: ConnectionState) => void): () => void;
onChatUpdate(fn: (chat: ChatMsg[]) => void): () => void;
// State
clearChatHistory(): void;
setTokenProvider(tp: TokenProvider): void;
isConnected(): boolean;
getConnectionState(): ConnectionState;
getStatus(): ConnectionState; // alias
// Getters
getPeerConnection(): RTCPeerConnection | null;
getDataChannel(): RTCDataChannel | null;
getLocalStream(): MediaStream | null;
getRemoteStream(): MediaStream | null;
getChat(): ChatMsg[];
}