Распознавание в реальном времени
Realtime API по WebSocket: отправляете аудио — получаете слова в реальном времени, с временными метками и метками спикеров.
Realtime API распознаёт речь в реальном времени. Вы открываете WebSocket-соединение, отправляете аудио в формате PCM чанками и получаете слова обратно, как только модель их распознала (задержку можно настроить от 80 до 960 мс).
Быстрый старт
Замените nx-XXXXXXXXXXXXXXXXXXXXXXXX на свой API-ключ.
Официальная библиотека nexara с дополнением realtime (pip install "nexara[realtime]"). Realtime работает только через асинхронный клиент:
import asyncio
import soundfile as sf
import numpy as np
from nexara import AsyncNexara
async def main():
audio, sr = sf.read("call.wav", dtype="float32") # любая частота из поддерживаемых
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes()
async def chunks():
step = int(sr * 0.1) * 2 # 100 мс int16
for offset in range(0, len(pcm), step):
yield pcm[offset:offset + step] # для живого аудио — с паузами
client = AsyncNexara() # ключ из переменной окружения NEXARA_API_KEY
async with client.realtime.connect(sample_rate=sr, diarize=True) as session:
async for event in session.stream(chunks()):
first = event.words[0]
print(f"spk{event.speaker} {first.start / 1000:6.2f}s {event.text}")
print("Полный текст:", session.ended.text)
asyncio.run(main())stream() отправляет аудио и отдаёт события; когда итератор закончится, SDK сам пошлёт input_audio.end и дождётся session.ended. Подробнее — в разделе Realtime в Python SDK.
Официальная библиотека nexara-sdk (npm install nexara-sdk). Нужен WebSocket — он встроен в браузеры и Node.js 22+, на Node.js 20 передайте класс из пакета ws:
import { readFileSync } from "node:fs";
import { Nexara } from "nexara-sdk";
const client = new Nexara(); // ключ из переменной окружения NEXARA_API_KEY
// call.pcm — сырой int16 LE, 16 кГц, моно (см. пример websocat ниже)
const pcm = readFileSync("call.pcm");
async function* chunks() {
for (let offset = 0; offset < pcm.length; offset += 3200) {
yield pcm.subarray(offset, offset + 3200); // 100 мс
}
}
const session = await client.realtime.connect({ sampleRate: 16000, diarize: true });
for await (const event of session.stream(chunks())) {
const first = event.words[0]!;
console.log(`spk${event.speaker} ${(first.start / 1000).toFixed(2)}s ${event.text}`);
}
console.log("Полный текст:", session.ended?.text);Подробнее — в разделе Realtime в TypeScript SDK.
Без SDK, на библиотеке websockets:
import asyncio, json
import numpy as np, soundfile as sf, websockets
async def transcribe(path, key):
audio, sr = sf.read(path, dtype="float32")
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes()
url = f"wss://streaming.nexara.ru/v1/transcribe?sample_rate={sr}&diarize=true"
headers = {"Authorization": f"Bearer {key}"}
async with websockets.connect(url, additional_headers=headers) as ws:
print(await ws.recv()) # session.created
async def receive():
async for raw in ws:
ev = json.loads(raw)
if ev["type"] == "transcript":
print(f"spk{ev.get('speaker')} {ev['words'][0]['start'] / 1000:7.2f}s {ev['text']}")
elif ev["type"] == "session.ended":
print("Полный текст:", ev["text"])
elif ev["type"] == "error":
print("Ошибка:", ev["code"], ev["message"])
rx = asyncio.create_task(receive())
chunk = int(sr * 0.1) * 2 # 100 мс
for off in range(0, len(pcm), chunk):
await ws.send(pcm[off:off + chunk]) # для живого аудио — с паузами
await ws.send(json.dumps({"type": "input_audio.end"}))
await rx
asyncio.run(transcribe("call.wav", "nx-XXXXXXXXXXXXXXXXXXXXXXXX"))Браузер не умеет задавать заголовки у WebSocket, поэтому ключ передаётся в списке субпротоколов — сервер выберет nexara-key:
const ctx = new AudioContext({ sampleRate: 16000 });
const url = `wss://streaming.nexara.ru/v1/transcribe?encoding=pcm_f32le&sample_rate=${ctx.sampleRate}`;
const ws = new WebSocket(url, ["nexara-key", apiKey]);
ws.binaryType = "arraybuffer";
ws.onmessage = (e) => {
const ev = JSON.parse(e.data);
if (ev.type === "transcript") transcriptEl.textContent += ev.text;
if (ev.type === "session.ended") console.log("Готово:", ev.audio_duration_ms, "мс");
};
ws.onclose = (e) => console.log("Закрыто:", e.code, e.reason); // 1000 или код из таблицы ниже
// Захват: AudioWorklet, который отдаёт Float32-буферы по ~100 мс
node.port.onmessage = (e) => ws.readyState === 1 && ws.send(e.data);
// Остановка:
// ws.send(JSON.stringify({ type: "input_audio.end" }));Ключ, вшитый в страницу, увидит любой посетитель. Для публичных демо используйте демо-эндпоинт или проксируйте соединение через свой сервер.
Для проверки из терминала. ffmpeg переводит любой файл в сырой PCM, websocat отправляет его в сокет:
ffmpeg -i call.mp3 -f s16le -ac 1 -ar 16000 call.pcm
# аудио, затем 2 секунды тишины, чтобы модель отдала последние слова, затем EOF
( cat call.pcm; head -c 64000 /dev/zero ) | websocat -b \
-H "Authorization: Bearer nx-XXXXXXXXXXXXXXXXXXXXXXXX" \
"wss://streaming.nexara.ru/v1/transcribe?sample_rate=16000&diarize=true"События приходят построчно в JSON. По EOF websocat закрывает сокет сам, а не отправляет input_audio.end, поэтому события session.ended не будет — последние слова выталкивает именно тишина в конце.
Подключение
GET wss://streaming.nexara.ru/v1/transcribe с WebSocket-апгрейдом. Параметры сессии передаются в строке запроса:
| Параметр | Значения | По умолчанию | Описание |
|---|---|---|---|
encoding | pcm_s16le, pcm_f32le, pcm_mulaw, pcm_alaw | pcm_s16le | Формат сэмплов — сырые, без контейнера |
sample_rate | 8000, 16000, 24000, 44100, 48000 | 16000 | Частота дискретизации; ресемплинг на сервере |
channels | 1, 2 | 1 | Стерео — с чередованием L R L R |
multichannel | true, false | false | true — каждый канал распознаётся отдельно; false — стерео сводится в моно |
diarize | true, false | false | Метка спикера у каждого слова |
delay_ms | кратно 80, из разрешённого набора | 480 | Задержка выдачи, см. ниже |
client_id | строка до 128 символов | — | Возвращается в session.created, удобно для сопоставления логов |
Неизвестный параметр или недопустимое значение сервер отклоняет: присылает событие error с кодом invalid_config и закрывает соединение с кодом 4000. Не рассчитывайте, что лишние параметры будут проигнорированы.
Те же параметры можно отправить текстовым сообщением session.update до первого куска аудио — сервер ответит session.updated в формате session.created. После начала аудио session.update отклоняется с invalid_config. Для большинства клиентов строка запроса проще.
Аутентификация
Ключ передаётся так же, как в REST API, — заголовком:
Authorization: Bearer nx-XXXXXXXXXXXXXXXXXXXXXXXXБраузеры не умеют задавать заголовки у WebSocket. Передайте ключ в списке субпротоколов — сервер выберет nexara-key в качестве субпротокола соединения:
const ws = new WebSocket(url, ["nexara-key", "nx-XXXXXXXXXXXXXXXXXXXXXXXX"]);В строке запроса ключ не принимается.
Ключ проверяется до апгрейда соединения, поэтому отказ приходит обычным HTTP-статусом, а сокет не открывается:
| Статус | Причина |
|---|---|
401 | Ключ не передан, некорректен или недействителен |
402 | Недостаточно средств для первого интервала тарификации |
429 | Достигнут лимит одновременных сессий |
503 | Сервис авторизации недоступен — повторите с задержкой |
Первое сообщение
При успешном подключении первым всегда приходит session.created:
{
"type": "session.created",
"session_id": "ses_3e32dc553aca75d2",
"model": "nexara-realtime",
"delay_ms": 480,
"audio": {
"encoding": "pcm_s16le",
"sample_rate": 16000,
"channels": 1,
"multichannel": false
},
"diarization": { "max_speakers": 4, "latency_ms": 1040 },
"client_id": "my-app"
}Объект diarization есть только при diarize=true, client_id — только если вы его передали.
Отправка аудио
Отправляйте бинарные WebSocket-фреймы с сырым PCM в объявленном формате. Подойдёт любой размер фрейма — от ~20 мс до 1 с аудио; 80 мс — хороший вариант по умолчанию. Фреймы не обязаны выравниваться по границам сэмплов: сэмпл может быть разрезан между двумя фреймами. Максимальный размер фрейма — 1 МиБ.
Для стерео чередуйте каналы посэмплово. При multichannel=false сервер усредняет каналы, при multichannel=true каждый канал распознаётся отдельно, и у каждого события появляется поле channel. Обратите внимание, что при использовании данного параметра каждый канал тарифицируется отдельно.
Служебные сообщения
Текстовые фреймы — это JSON с полем type:
{
"type": "session.update",
"sample_rate": 48000,
"channels": 2,
"multichannel": true
}Те же ключи, что в строке запроса. Разрешено только до первого фрейма с аудио.
{ "type": "keepalive" }Сбрасывает таймер простоя. Сессия без аудио и без keepalive в течение 30 секунд закрывается с idle_timeout (4008). Живому микрофону это не нужно; отправляйте keepalive, если ставите захват на паузу.
{ "type": "input_audio.end" }Аудио больше не будет. Сервер распознаёт то, что у него накопилось, вытаскивает последние слова из линии задержки, присылает оставшиеся transcript, затем session.ended и закрывает соединение с кодом 1000. Пустой бинарный фрейм работает как синоним.
Просто закрыть сокет вместо input_audio.end — значит потерять последние
delay_ms речи, которые ещё находятся внутри модели.
Получение результата
transcript
{
"type": "transcript",
"text": " Book me a table",
"words": [
{ "text": "Book", "start": 12000, "end": 12160, "speaker": 1 },
{ "text": "me", "start": 12160, "end": 12240, "speaker": 1 },
{ "text": "table", "start": 12320, "end": 12480, "speaker": 1 }
],
"speaker": 1,
"channel": 0
}- Финально. Текст никогда не пересматривается. Дописывайте
textк транскрипции и не оглядывайтесь назад. Промежуточных результатов и флагаis_finalнет, потому что ничего не бывает «неокончательным». text— сырой вывод модели для этих слов, включая ведущий пробел, поэтому конкатенацияtextпо всем событиям в точности воспроизводит транскрипцию.words[].text— без пробелов.words[].startиend— целые миллисекунды по часам аудио, то есть от первого отправленного сэмпла. Разрешение — 80 мс.- Пунктуация прикреплена к предыдущему слову:
"table.", а не отдельный элемент. speakerпоявляется у события и у каждого слова только приdiarize=true. У всех слов одного события один спикер; смена спикера всегда начинает новое событие.channelпоявляется только приmultichannel=true.
Обычно в одном transcript одно слово, иногда несколько. События одной сессии приходят в порядке аудио.
session.ended
{
"type": "session.ended",
"audio_duration_ms": 61234,
"channels_transcribed": 1,
"billed_ms": 61234,
"text": "Book me a table for two. ..."
}Приходит после input_audio.end, когда всё распознано. text — полная транскрипция; при multichannel=true это массив с одной строкой на канал. audio_duration_ms считается по отправленным байтам, billed_ms — это audio_duration_ms, умноженное на channels_transcribed.
error
{ "type": "error", "code": "session_full", "message": "all slots are in use" }За ним всегда следует закрытие соединения с соответствующим кодом. message — для людей, в коде ориентируйтесь на code.
Диаризация
При diarize=true у каждого слова есть поле speaker — целое число от 0 до 3 или null.
- Идентификаторы назначаются по порядку появления в сессии: спикер 0 — тот, кто заговорил первым. Между сессиями они ничего не значат.
- Максимум четыре спикера. Пятый будет отнесён к одному из существующих.
nullозначает, что диаризатор оценил аудио этого слова как тишину, и никто не говорил в предыдущую секунду. Чаще всего это очень короткие слова в начале фразы после паузы. Считайте это «неизвестно», а не отдельным спикером.- Слова придерживаются, пока спикер не определён, поэтому метки тоже финальные. Модель диаризации принимает решение каждые 1,04 секунды аудио, соответственно, при использовании диаризации рекомендуем использовать максимальную задержку на распознавание (960 мс), чтобы получить самое высокое качество распознавания.
При multichannel=true каждый канал диаризуется независимо, и идентификаторы спикеров локальны для канала: человека определяет пара (channel, speaker). Для телефонии, где на каждом канале один собеседник, диаризация не нужна — используйте channel как спикера.
Задержка выдачи
delay_ms — сколько модель ждёт, прежде чем зафиксировать текст. Чем дольше ждет модель перед выдачей токена, тем точнее получается ответ. Допустимые значения задержки представлены в таблице с примерным Word Error Rate по записям:
delay_ms | WER, чистая речь | WER, шумная улица |
|---|---|---|
| 80 | 0.17 | 0.77 |
| 240 | 0.15 | 0.40 |
| 480 | 0.11 | 0.30 |
| 640 | 0.08 | 0.29 |
| 960 | 0.10 | 0.24 |
Ошибки и коды закрытия
Сервер закрывает соединение с одним кодом на каждую причину. Клиентские библиотеки отдают его как код закрытия и строку причины.
| Код | Причина | Когда |
|---|---|---|
1000 | done | Вы отправили input_audio.end, и session.ended доставлен |
4000 | invalid_config | Недопустимый параметр или session.update после начала аудио |
4003 | session_full | Нет свободных мощностей — повторите позже |
4004 | invalid_message | Текстовый фрейм — не JSON или с неизвестным type |
4008 | idle_timeout | 30 с без аудио и без keepalive |
4009 | audio_backlog | Больше 60 минут аудио в буфере впереди распознавания |
1011 | internal | Ошибка сервера |
4402 | insufficient_balance | Баланс исчерпан посреди сессии; всё до этого момента доставлено |
4409 | session_expired | Биллинговая сессия завершена платформой — переподключитесь |
4500 | billing_unavailable | Сервис биллинга недоступен — переподключитесь с задержкой |
1012 | service_restart | Сервер перезапускается — переподключайтесь сразу |
Перед любым закрытием с кодом 4xxx или 1012 сервер отдаёт все финализированные слова, так что вы получили ровно то, за что заплатили. Переподключение — всегда новая сессия: идентификаторы спикеров начинаются с 0, возобновления нет.
Как эти ошибки выглядят в SDK — в разделах Python и TypeScript.
Лимиты
| Одновременные сессии | 8 распознаваемых потоков на пользователя; сессия с multichannel считается по числу каналов |
| Максимальный размер фрейма | 1 МиБ |
| Буфер аудио впереди распознавания | 60 минут |
| Таймаут простоя | 30 с |
| Длительность сессии | не ограничена |
Тарификация
Сессия тарифицируется по отправленному аудио: billed_ms в session.ended — это audio_duration_ms, умноженное на число распознанных каналов. Стоимость минуты и надбавка за диаризацию — на странице тарифов.