NexaraNexara

Распознавание в реальном времени

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-апгрейдом. Параметры сессии передаются в строке запроса:

ПараметрЗначенияПо умолчаниюОписание
encodingpcm_s16le, pcm_f32le, pcm_mulaw, pcm_alawpcm_s16leФормат сэмплов — сырые, без контейнера
sample_rate8000, 16000, 24000, 44100, 4800016000Частота дискретизации; ресемплинг на сервере
channels1, 21Стерео — с чередованием L R L R
multichanneltrue, falsefalsetrue — каждый канал распознаётся отдельно; false — стерео сводится в моно
diarizetrue, falsefalseМетка спикера у каждого слова
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_msWER, чистая речьWER, шумная улица
800.170.77
2400.150.40
4800.110.30
6400.080.29
9600.100.24

Ошибки и коды закрытия

Сервер закрывает соединение с одним кодом на каждую причину. Клиентские библиотеки отдают его как код закрытия и строку причины.

КодПричинаКогда
1000doneВы отправили input_audio.end, и session.ended доставлен
4000invalid_configНедопустимый параметр или session.update после начала аудио
4003session_fullНет свободных мощностей — повторите позже
4004invalid_messageТекстовый фрейм — не JSON или с неизвестным type
4008idle_timeout30 с без аудио и без keepalive
4009audio_backlogБольше 60 минут аудио в буфере впереди распознавания
1011internalОшибка сервера
4402insufficient_balanceБаланс исчерпан посреди сессии; всё до этого момента доставлено
4409session_expiredБиллинговая сессия завершена платформой — переподключитесь
4500billing_unavailableСервис биллинга недоступен — переподключитесь с задержкой
1012service_restartСервер перезапускается — переподключайтесь сразу

Перед любым закрытием с кодом 4xxx или 1012 сервер отдаёт все финализированные слова, так что вы получили ровно то, за что заплатили. Переподключение — всегда новая сессия: идентификаторы спикеров начинаются с 0, возобновления нет.

Как эти ошибки выглядят в SDK — в разделах Python и TypeScript.

Лимиты

Одновременные сессии8 распознаваемых потоков на пользователя; сессия с multichannel считается по числу каналов
Максимальный размер фрейма1 МиБ
Буфер аудио впереди распознавания60 минут
Таймаут простоя30 с
Длительность сессиине ограничена

Тарификация

Сессия тарифицируется по отправленному аудио: billed_ms в session.ended — это audio_duration_ms, умноженное на число распознанных каналов. Стоимость минуты и надбавка за диаризацию — на странице тарифов.

On this page