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Задержка выдачи, см. ниже
turn_detectionnone, smartnoneОпределение реплик для голосовых агентов, см. ниже
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.
  • turn появляется только при turn_detection=smart — номер реплики, к которой относятся слова.

Обычно в одном 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 как спикера.

Определение реплик

Голосовому агенту нужно знать не только что сказал пользователь, но и когда он закончил говорить. Параметр turn_detection=smart включает определение реплик: сервер сообщает, когда пользователь начал говорить, замолчал и когда его реплика закончена — вместе с её текстом.

wss://streaming.nexara.ru/v1/transcribe?turn_detection=smart

Или в session.update до первого фрейма аудио: {"type": "session.update", "turn_detection": "smart"}. Других параметров нет. Работает с любым delay_ms, с diarize=true и с multichannel=true (тогда у каждого события есть channel, а реплики нумеруются в каждом канале отдельно).

Добавляются три события, и у каждого transcript появляется поле turn. Все времена — целые миллисекунды по часам аудио, как у слов.

{ "type": "speech.started", "turn": 3, "start": 12340 }
{ "type": "speech.ended", "turn": 3, "end": 15200 }
{
  "type": "turn.end",
  "turn": 3,
  "start": 12340,
  "end": 15200,
  "confidence": 0.93,
  "text": "Book me a table for two tomorrow.",
  "words": [{ "text": "Book", "start": 12480, "end": 12560 }, ...]
}
{ "type": "transcript", "turn": 3, "text": " Book me", "words": [...] }
ПолеТипЗначение
turnintСчётчик реплик, с 0 в каждой сессии (при multichannel — в каждом канале). Есть у всех трёх событий и у каждого transcript
startint, мсПервый момент, когда в этой реплике была слышна речь
endint, мсПоследний момент, когда была слышна речь
confidencefloat или nullВероятность конца реплики. ≥ 0.5 — модель решила, что говорящий закончил. < 0.5 — реплику закрыл лимит тишины в 3 секунды. null — реплику закрыл input_audio.end
textstringВесь текст реплики без пробелов по краям — конкатенация её transcript.text
wordsarrayСлова реплики, каждое {text, start, end} плюс speaker при диаризации. Может быть пустым, если за речь был принят шум
channelintТолько при multichannel=true

Как этим пользоваться:

  • speech.started приходит примерно через 100 мс после того, как пользователь начал говорить. Используйте его, чтобы прервать агента. Может прийти раньше, чем turn.end предыдущей реплики, — номера turn их различают.
  • speech.ended приходит примерно через 280 мс после того, как пользователь замолчал. Это «сейчас тишина», а не «реплика закончена»: speech.ended, за которым следует speech.started с тем же turn, — это пауза внутри реплики.
  • turn.end — то, что нужно передать языковой модели. Порядок гарантирован в пределах канала: все transcript с turn: n приходят до turn.end для n, а все transcript с turn: n+1 — после него, так что text полон.

Задержка turn.end от момента, когда пользователь замолчал: около 720 мс при задержке выдачи 480 мс (p90 около 800), около 500 мс при 240, около 1200 мс при 960; около 1 с при 480 с diarize=true. Плюс сеть.

turn_detection с любым значением, кроме none и smart, отклоняется с invalid_config (код закрытия 4000) — так же, как smart на сервере, запущенном без определения реплик.

Задержка выдачи

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Недопустимый параметр (в том числе turn_detection не из none/smart) или 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