Медиа-рантайм для real-time звонков.WebRTC, voice DSP, N:1 STT.
Pion SFU, H.264 1080p @ 30fps, Opus 256 kbps c DSP в AudioWorklet, N:1 mixed transcription. SDK, HMAC-webhook'и, OpenAPI.
Минимум деструктивной DSP на тракте.
AEC опционально, NS/AGC выключены там, где они портят тембр. Заменены на свой мягкий expander и медленный loudness normalizer в AudioWorklet. Opus 256 kbps VBR + FEC без DTX. SFU ничего не перекодирует.
Звук, ради которого приходят из продакшна.
Opus 256 kbps stereo, VBR, FEC, без DTX. Никакого перекодирования на сервере — SFU форвардит RTP бит-в-бит. Браузерный DSP выключаем там, где это портит тембр, и заменяем собственным мягким expander'ом и медленным loudness normalizer'ом на AudioWorklet. На приёмной стороне каждый удалённый участник проходит ещё один normalizer — тихий собеседник не остаётся тихим.
- Scene
- Подкаст, интервью, музыкальная сессия. Качественный микрофон + закрытые наушники.
- Echo cancellation
- Off
- Noise suppression
- Off
- Auto gain control
- Off — заменён нашим loudness normalizer
- Channels
- Stereo · 2 ch
- Sample rate
- 48 000 Hz
- Expander threshold
- -58 dBFS · ratio 2:1
- Loudness target
- −18 dBFS RMS · 400 ms window
- Loudness floor
- -52 dBFS · ниже — удерживаем gain
- Opus stereo fmtp
- stereo=1 · sprop-stereo=1
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;
useinbandfec=1;
usedtx=0;
stereo=1;
sprop-stereo=1;
maxaveragebitrate=256000;
maxplaybackrate=48000;
cbr=0
a=sender.priority high
a=sender.networkPriority high
a=track.contentHint musicУ всех публикующих RMS идеален на отправке. У тебя в ухе — всё равно разный уровень. Причины: разные микрофоны, концил-параметры jitter-буфера, разное расстояние до капсюля. Решаем на клиенте — отдельный worklet на каждого удалённого peer'а.
- window · 1000 ms
- target · −18 dBFS
- cap · +9 / −6 dB
- attack · 2 dB·s · release · 3 dB·s
- floor · −55 dBFS (не гонимся за шумом)
Один энкодер — один поток.
1080p @ 30 fps без simulcast'а: CPU тратит всё на единственный outbound layer. Screen share — на выделенной PC, с адаптивным тройным ladder'ом до 25 Mbps.
1080p камера. Выделенный PC под screen share.
Одна энкодинг-линия на камеру вместо трёх — CPU тратит все циклы на единственный поток, который реально уходит в сеть. H.264 в приоритете как самый стабильный real-time кодек. Screen share живёт на отдельной PeerConnection, чтобы не делить битрейт с камерой и не гасить LED-индикатор при остановке шеринга.
3-слойный simulcast на consumer-железе заставляет CPU держать два лишних энкодера параллельно, и top-layer (f) проседает по битрейту независимо от maxBitrate — удалённые видят 240p-feel. Single-layer отдаёт все такты одной линии, которая реально уходит в сеть.
- Resolution
- 1920 × 1080 · ideal · fallback 640+
- Framerate
- 30 fps · max 30
- Encoding
- 1 layer · no simulcast
- maxBitrate
- 4 Mbps @ 1080p · 2.5 Mbps @ 720p
- degradationPreference
- maintain-resolution
- Codec order
- H.264 > VP8 > VP9
H.264 — самый стабильный real-time видео-кодек в браузерах. VP8 остаётся доступен как mid-priority fallback, VP9 замыкает список — peer, который предлагает только VP9, всё равно подключится.
Adaptive screen share · 3 tier ladder
60 / 120 Hz source. Игра, анимация, демки с плавным движением. Уронит разрешение раньше, чем fps.
IDE, документы, дизайн-инструменты. Плавность 60 Hz, но fps просядет первым, чтобы текст не плыл.
Узкий канал или слабый CPU. Ниже этого — ухудшается читаемость.
Камера и микрофон живут на общей publish PeerConnection — добавление камеры после join'а идёт renegotiation'ом, а не новым PC. Screen share — отдельная publish PC, так что старт/стоп шеринга не трогает камеру, LED-индикатор остаётся честным, и пропадает целый класс гонок с track.enabled хаками.
Один моно-вход в провайдер STT.
N источников → один stream. До суммирования каждый канал проходит HPF, VAD, шумовой гейт и AGC; в микс попадают только активные кадры. Итог: ниже бил у провайдера и меньше misrecognition'ов — в API не уходит наложение из двух шумящих дорожек.
Предобработка по каналам до суммирования.
Стандартный путь — отдельный STT-стрим на каждого участника, провайдер борется с фоновым шумом на каждом. Здесь каналы сначала чистятся по отдельности (HPF, VAD, gate), взвешиваются по активности, суммируются в моно 16 kHz, проходят limiter — и только тогда уходят в API. В микс попадают только активные кадры говорящего; тишина остальных не суммируется.
- · hypotheses cleaner → меньше misrecognition'ов
- · fewer active frames → ниже билл у провайдера
- · speaker diarization идёт из наших VAD-событий
Десять стадий до первого токена.
~3000 строк production Go на rr-stt. End-to-end DSP ниже 5 мс. Каждая стадия направлена на то, чтобы провайдеру пришёл чистый, моно, 16 kHz PCM без шумов, без клиппинга, только с речью активных говорящих.
RTP decode
Разбор RTP-пакетов. Sequence, timestamp, SSRC, payload. Декодирование opus во float-сэмплы на 48 кГц.
Jitter buffer
Адаптивный буфер компенсирует сетевые колебания. Неровный вход → ровный выход, пропущенные пакеты интерполируются.
Resample
Polyphase FIR-децимация с anti-aliasing. 3:1 downsampling, Kaiser-window, фильтр в 128 tap.
High-pass
Biquad HPF. Срез низкочастотного гула, шума кондиционера, рокота. Наклон −12 дБ/октаву ниже среза.
VAD
Обрабатываем только фреймы с реальной речью. Тишина не идёт в STT — экономим API-вызовы и шум.
Noise gate
Гейт на каждом канале с гистерезисом. Фоновый шум не суммируется от всех N участников.
AGC
Нормализация громкости по каждому участнику. Тихий и громкий приходят на микс с одинаковой энергией.
Weighted N:1 mixer
N каналов → моно-поток. Активный говорящий получает вес в миксе, пассивные участники затухают.
Limiter
Look-ahead лимитер на мастере. Суммирование N каналов не уводит уровень в клиппинг.
STT
Один provider-stream на комнату вместо N. Смена провайдера — конфиг, не код.
Полный медиа-рантайм. Комнаты, участники, транскрипция в одной системе. Для новых голосовых продуктов.
const room = await rr.rooms.create({
stt_provider: "soniox",
webhook_url: "https://app/hook"
});
const client = new RelayRoom(room.join_url, room.token);
client.on("transcript", s => console.log(s.text));
await client.connect();
await client.enableMicrophone();Для тех, у кого уже есть LiveKit / Daily / Twilio. Подключи аудио — получи транскрипт без переписывания медиа-стека.
const { ws_url } = await fetch("/v1/ingest", {
method: "POST",
headers: { Authorization: "Bearer rr_..." },
body: JSON.stringify({ stt_provider: "soniox" })
}).then(r => r.json());
const ws = new WebSocket(ws_url);
onAudioFrame(({ participantId, pcm }) => {
ws.send(framePacket(participantId, pcm));
});Token server, Python agent, dispatch, deploy, client subscription, optional custom AudioMixer for N:1.
One API call to create a room, one webhook for the final transcript. That's the whole integration.
Один поток вместо N.
Стандартный путь — отдельный STT на каждого участника. Мы миксуем N в один поток после DSP. Посчитай разницу на своих объёмах.
* индикативные тарифы провайдеров
Минимальная интеграция.
API-ключ в консоли, снипет ниже — комната, подключение, транскрипт в one webhook.
import { RelayRooms, RelayRoom } from "@relayrooms/js";
const rr = new RelayRooms({ apiKey: process.env.RR_KEY! });
const room = await rr.rooms.create({
stt_provider: "soniox",
audio_preset: "studio", // "conversation" · "speakerphone"
});
const client = new RelayRoom(room.join_url, room.token);
client.on("transcript", s => console.log(s.text));
await client.connect();
await client.enableMicrophone();