ChatGPT сказал:
Думал на протяжении 41s
Коротко: «как в Vosk по WebSocket, только на Whisper» делается тремя рабочими путями:
локально через faster-whisper (Python, CTranslate2, есть квазистриминг);локально через whisper.cpp (есть готовый WS/HTTP сервер);облачный провайдер со стримингом (если не критичен self-host).Ниже даю самый практичный способ — WebSocket-сервер на Python + faster-whisper с браузерным PCM-стримом (16 kHz mono, int16). Это максимально похоже на ваш Vosk-сетап.
Вариант A. WebSocket + faster-whisper (self-host)
1) Клиент (браузер): снимаем микрофон → 16 kHz PCM16 → WebSocket
a. Подключаем AudioWorklet, чтобы сразу отдать на сервер «сырые» 16-битные фреймы.
public/pcm16-worklet.js
class PCM16Processor extends AudioWorkletProcessor { constructor() { super(); this.targetRate = 16000; this._resampleBuffer = []; this._srcRate = sampleRate; // системная частота, напр. 48000 this._ratio = this._srcRate / this.targetRate; this._acc = 0; }
process(inputs) { const input = inputs[0]; if (!input || !input[0]) return true;
</span><span><span class="hljs-keyword">const</span></span><span> ch0 = input[</span><span><span class="hljs-number">0</span></span><span>]; </span><span><span class="hljs-comment">// Float32Array [ -1..1 ]</span></span><span></span><span><span class="hljs-comment">// Ресэмплинг простым децимационным методом (для прод — лучше sinc/soxr)</span></span><span></span><span><span class="hljs-keyword">for</span></span><span> (</span><span><span class="hljs-keyword">let</span></span><span> i = </span><span><span class="hljs-number">0</span></span><span>; i < ch0.</span><span><span class="hljs-property">length</span></span><span>; i++) { </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_acc</span></span><span> += </span><span><span class="hljs-number">1</span></span><span>; </span><span><span class="hljs-keyword">if</span></span><span> (</span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_acc</span></span><span> >= </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_ratio</span></span><span>) { </span><span><span class="hljs-keyword">const</span></span><span> s = </span><span><span class="hljs-title class_">Math</span></span><span>.</span><span><span class="hljs-title function_">max</span></span><span>(-</span><span><span class="hljs-number">1</span></span><span>, </span><span><span class="hljs-title class_">Math</span></span><span>.</span><span><span class="hljs-title function_">min</span></span><span>(</span><span><span class="hljs-number">1</span></span><span>, ch0[i])); </span><span><span class="hljs-keyword">const</span></span><span> int16 = s < </span><span><span class="hljs-number">0</span></span><span> ? s * </span><span><span class="hljs-number">0x8000</span></span><span> : s * </span><span><span class="hljs-number">0x7FFF</span></span><span>; </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_resampleBuffer</span></span><span>.</span><span><span class="hljs-title function_">push</span></span><span>(int16); </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_acc</span></span><span> -= </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_ratio</span></span><span>; }}</span><span><span class="hljs-comment">// Пакуем по 100 мс (1600 сэмплов @16k) — баланс латентности/нагрузки</span></span><span></span><span><span class="hljs-keyword">const</span></span><span> </span><span><span class="hljs-variable constant_">CHUNK</span></span><span> = </span><span><span class="hljs-number">1600</span></span><span>;</span><span><span class="hljs-keyword">while</span></span><span> (</span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_resampleBuffer</span></span><span>.</span><span><span class="hljs-property">length</span></span><span> >= </span><span><span class="hljs-variable constant_">CHUNK</span></span><span>) { </span><span><span class="hljs-keyword">const</span></span><span> chunk = </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">_resampleBuffer</span></span><span>.</span><span><span class="hljs-title function_">splice</span></span><span>(</span><span><span class="hljs-number">0</span></span><span>, </span><span><span class="hljs-variable constant_">CHUNK</span></span><span>); </span><span><span class="hljs-keyword">const</span></span><span> buf = </span><span><span class="hljs-keyword">new</span></span><span> </span><span><span class="hljs-title class_">Int16Array</span></span><span>(chunk); </span><span><span class="hljs-variable language_">this</span></span><span>.</span><span><span class="hljs-property">port</span></span><span>.</span><span><span class="hljs-title function_">postMessage</span></span><span>(buf.</span><span><span class="hljs-property">buffer</span></span><span>, [buf.</span><span><span class="hljs-property">buffer</span></span><span>]);}</span><span><span class="hljs-keyword">return</span></span><span> </span><span><span class="hljs-literal">true</span></span><span>;
}}
registerProcessor('pcm16-processor', PCM16Processor);
b. Инициализация в приложении:
const ws = new WebSocket('wss://YOUR_HOST:2700/stt'); // ваш порт/путьws.binaryType = 'arraybuffer';
await audioContext.audioWorklet.addModule('/pcm16-worklet.js');const stream = await navigator.mediaDevices.getUserMedia({ audio: true });const source = audioContext.createMediaStreamSource(stream);const node = new AudioWorkletNode(audioContext, 'pcm16-processor');
node.
port.onmessage = (e) => { if (ws.readyState === 1) ws.send(e.data); // raw PCM16LE};
ws.
onopen = () => { // «приветствие» как в vosk: meta-json перед бинарными фреймами ws.send(JSON.stringify({ op: 'start', format: 'pcm16le', rate: 16000, channels: 1, lang: 'ru' })); source.connect(node).connect(audioContext.destination); // dest можно не подключать, если не нужно эхо};
ws.
onmessage = (e) => { // сервер шлёт JSON с partial/final const msg = JSON.parse(e.data); if (msg.type === 'partial') console.log('PARTIAL:', msg.text); if (msg.type === 'final') console.log('FINAL :', msg.text, msg.ts, msg.te);};
Если у вас уже есть свой AudioWorklet под Vosk — можно оставить его, лишь поменяв частоту/формат на 16 kHz PCM16 и протокол шины.
2) Сервер (Python): WebSocket, буферизация, «квазистриминг» на faster-whisper
Установка:
python -m venv .venv && source .venv/bin/activatepip install faster-whisper websockets numpy webrtcvad uvloop# Для GPU: pip install ctranslate2>=4.0 && поставьте CUDA/cuDNN; faster-whisper сам его использует# Скачайте модель при первом старте: tiny / base / small / medium / large-v3
Сервер server.py:
import asyncio, json, numpy as np, websockets, webrtcvad, timefrom faster_whisper import WhisperModel
MODEL_SIZE =
"small" # баланс: tiny/base/small/medium/large-v3DEVICE = "cuda" # "cpu" или "cuda"COMPUTE_TYPE = "auto" # для GPU: "float16" / "int8_float16"CHUNK_MS = 100 # длина входного фрейма (как на клиенте)WINDOW_SEC = 8.0 # длина скользящего окна для расшифровкиSTEP_SEC = 2.0 # как часто запускать инференсSAMPLE_RATE = 16000
model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)vad = webrtcvad.Vad(
2) # 0..3, выше — строже к шуму
async def transcribe_session(ws): buf = np.zeros(0, dtype=np.int16) last_infer = 0.0 started = False
</span><span><span class="hljs-keyword">async</span></span><span> </span><span><span class="hljs-keyword">for</span></span><span> msg </span><span><span class="hljs-keyword">in</span></span><span> ws: </span><span><span class="hljs-comment"># первые сообщения могут быть JSON (метаданные)</span></span><span> </span><span><span class="hljs-keyword">if</span></span><span> </span><span><span class="hljs-built_in">isinstance</span></span><span>(msg, (</span><span><span class="hljs-built_in">str</span></span><span>, </span><span><span class="hljs-built_in">bytes</span></span><span>)) </span><span><span class="hljs-keyword">and</span></span><span> </span><span><span class="hljs-built_in">isinstance</span></span><span>(msg, </span><span><span class="hljs-built_in">str</span></span><span>): </span><span><span class="hljs-keyword">try</span></span><span>: meta = json.loads(msg) </span><span><span class="hljs-keyword">if</span></span><span> meta.get(</span><span><span class="hljs-string">"op"</span></span><span>) == </span><span><span class="hljs-string">"start"</span></span><span>: started = </span><span><span class="hljs-literal">True</span></span><span> </span><span><span class="hljs-keyword">await</span></span><span> ws.send(json.dumps({</span><span><span class="hljs-string">"type"</span></span><span>:</span><span><span class="hljs-string">"ready"</span></span><span>})) </span><span><span class="hljs-keyword">continue</span></span><span> </span><span><span class="hljs-keyword">except</span></span><span> Exception: </span><span><span class="hljs-keyword">pass</span></span><span> </span><span><span class="hljs-comment"># далее ожидаем бинарные PCM16LE чанки</span></span><span> </span><span><span class="hljs-keyword">if</span></span><span> </span><span><span class="hljs-built_in">isinstance</span></span><span>(msg, (</span><span><span class="hljs-built_in">bytes</span></span><span>, </span><span><span class="hljs-built_in">bytearray</span></span><span>)): pcm = np.frombuffer(msg, dtype=np.int16) </span><span><span class="hljs-comment"># простая VAD-обрезка тишины по краям чанка (опционально)</span></span><span> frame_bytes = pcm.tobytes() is_speech = vad.is_speech(frame_bytes[:</span><span><span class="hljs-number">320</span></span><span>*</span><span><span class="hljs-number">2</span></span><span>], SAMPLE_RATE) </span><span><span class="hljs-keyword">if</span></span><span> </span><span><span class="hljs-built_in">len</span></span><span>(pcm) >= </span><span><span class="hljs-number">320</span></span><span> </span><span><span class="hljs-keyword">else</span></span><span> </span><span><span class="hljs-literal">True</span></span><span> </span><span><span class="hljs-keyword">if</span></span><span> is_speech: buf = np.concatenate([buf, pcm]) now = time.time() </span><span><span class="hljs-keyword">if</span></span><span> now - last_infer >= STEP_SEC </span><span><span class="hljs-keyword">and</span></span><span> buf.size > </span><span><span class="hljs-number">0</span></span><span>: last_infer = now </span><span><span class="hljs-comment"># ограничим окно</span></span><span> max_samples = </span><span><span class="hljs-built_in">int</span></span><span>(WINDOW_SEC * SAMPLE_RATE) </span><span><span class="hljs-keyword">if</span></span><span> buf.size > max_samples: buf = buf[-max_samples:] </span><span><span class="hljs-comment"># faster-whisper не «истинно» стримит, но быстро обрабатывает окно</span></span><span> segments, info = model.transcribe( buf.astype(np.float32) / </span><span><span class="hljs-number">32768.0</span></span><span>, language=</span><span><span class="hljs-string">"ru"</span></span><span>, vad_filter=</span><span><span class="hljs-literal">True</span></span><span>, beam_size=</span><span><span class="hljs-number">1</span></span><span>, best_of=</span><span><span class="hljs-number">1</span></span><span>, no_speech_threshold=</span><span><span class="hljs-number">0.6</span></span><span>, condition_on_previous_text=</span><span><span class="hljs-literal">False</span></span><span>, word_timestamps=</span><span><span class="hljs-literal">True</span></span><span> ) </span><span><span class="hljs-comment"># берём последнюю гипотезу как partial</span></span><span> partial_text = </span><span><span class="hljs-string">""</span></span><span> ts, te = </span><span><span class="hljs-literal">None</span></span><span>, </span><span><span class="hljs-literal">None</span></span><span> </span><span><span class="hljs-keyword">for</span></span><span> seg </span><span><span class="hljs-keyword">in</span></span><span> segments: partial_text += seg.text ts, te = seg.start, seg.end </span><span><span class="hljs-keyword">await</span></span><span> ws.send(json.dumps({ </span><span><span class="hljs-string">"type"</span></span><span>: </span><span><span class="hljs-string">"partial"</span></span><span>, </span><span><span class="hljs-string">"text"</span></span><span>: partial_text.strip(), </span><span><span class="hljs-string">"ts"</span></span><span>: ts, </span><span><span class="hljs-string">"te"</span></span><span>: te }))</span><span><span class="hljs-comment"># на закрытие допрожимаем «финал»</span></span><span></span><span><span class="hljs-keyword">if</span></span><span> buf.size > </span><span><span class="hljs-number">0</span></span><span>: segments, info = model.transcribe( buf.astype(np.float32) / </span><span><span class="hljs-number">32768.0</span></span><span>, language=</span><span><span class="hljs-string">"ru"</span></span><span>, vad_filter=</span><span><span class="hljs-literal">True</span></span><span>, beam_size=</span><span><span class="hljs-number">1</span></span><span>, best_of=</span><span><span class="hljs-number">1</span></span><span>, condition_on_previous_text=</span><span><span class="hljs-literal">False</span></span><span>, word_timestamps=</span><span><span class="hljs-literal">True</span></span><span> ) final_text = </span><span><span class="hljs-string">""</span></span><span>.join(seg.text </span><span><span class="hljs-keyword">for</span></span><span> seg </span><span><span class="hljs-keyword">in</span></span><span> segments).strip() </span><span><span class="hljs-keyword">if</span></span><span> final_text: </span><span><span class="hljs-keyword">await</span></span><span> ws.send(json.dumps({</span><span><span class="hljs-string">"type"</span></span><span>:</span><span><span class="hljs-string">"final"</span></span><span>,</span><span><span class="hljs-string">"text"</span></span><span>:final_text}))
async def main(): async with websockets.serve(transcribe_session, host="0.0.0.0", port=2700, max_size=None): print("WS STT server on ws://0.0.0.0:2700/stt") await asyncio.Future()
if name == "main": try: import uvloop; uvloop.install() except Exception: pass asyncio.run(main())
Что важно знать про «стрим» в Whisper:
У «ванильного» Whisper нет настоящего онлайн-декодера как у Vosk/kaldi. Трюк — крутить скользящее окно (например 6–10 сек) и регулярно пересобирать гипотезу.Для низкой задержки удерживайте WINDOW_SEC небольшим (6–8 сек), а STEP_SEC — 0.5–2 сек.faster-whisper на GPU работает существенно быстрее; на CPU берите tiny/base/small и compute_type=int8.Можно улучшить устойчивость через webrtcvad (как в примере) и/или post-VAD по RMS.Вариант B. whisper.cpp с готовым сервером
Если нравится C++/без Python:
Сборка:
git clone https://github.com/ggerganov/whisper.cppcd whisper.cpp && make -j
Есть примеры examples/server (HTTP/WS) и examples/stream.Запускаете сервер с вашей моделью ggml/gguf, а браузер шлёт PCM16 по WS.Плюсы: минимальные зависимости, быстрый запуск; Минусы: меньше гибкости по VAD/постобработке, чем в Python-стеке.Советы по миграции с Vosk
Формат аудио: оставьте тот же протокол (JSON «start» → бинарные PCM16 чанки → «end»), просто поменяйте серверную часть на Whisper.Части/partial: у Vosk они «настоящие», у Whisper — «пересобранные» по окну. Для UX покажите «серую» подсказку (partial), а «чёрным» финализируйте по паузе (VAD) или по закрытию utterance (silence ≥ 500–800 мс).Язык: фиксируйте language="ru" (или autodetect для многоязычия, но это +латентность).Таймкоды: берите word_timestamps=True в faster-whisper — удобно подсвечивать слова.Диааризация: при необходимости добавьте pyannote.audio поверх PCM-стрима параллельно.Если хочется прям «настоящий» realtime (низкая задержка <300 мс)
Это уже не Whisper в чистом виде. Либо используйте провайдеры со streaming ASR (Deepgram, AssemblyAI и т.п.), либо OpenAI-модели с realtime-интерфейсами (нужна отдельная интеграция и иной протокол). Для self-host — пока лучший компромисс именно через faster-whisper со скользящим окном.
Мини-чеклист продакшена
Транспорт: WS ping/pong, авто-reconnect, очереди при backpressure.Ограничения: max сессий, max битрейт (16 kHz mono достаточно).Безопасность: JWT в первом JSON (как у вас с Vosk), CORS/origins.Логи/метрики: время до первого partial, WER/длина фраз, GPU util.Горячая смена модели: tiny/base/small/medium по настройке комнаты.Хочешь — адаптирую этот пример под твой текущий Vosk-клиент (у тебя уже есть AudioWorklet и JWT на сокете) и под твой деплой (GPU/CPU, systemd, Nginx proxy, wss).