Skip to content

Commit f84a5f9

Browse files
authored
Merge pull request #3 from concil859856/ops
fix(stream): make /v1/stream actually work end-to-end
2 parents 2acb7bc + a170224 commit f84a5f9

7 files changed

Lines changed: 394 additions & 60 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,20 @@ See [`stt-streaming-implementation.md`](../stt-streaming-implementation.md) for
1212
# 1. Build
1313
docker build -t vocence/stt-streaming:dev .
1414

15-
# 2. Run on a CUDA-capable GPU
16-
docker run --rm --gpus all -p 8114:8114 \
15+
# 2. Run on a CUDA-capable GPU (serves on 8117 by default)
16+
docker run --rm --gpus all -p 8117:8117 \
1717
-e ASR_API_KEY=test_key_local_only \
1818
vocence/stt-streaming:dev
1919

2020
# 3. Smoke test
2121
python scripts/smoke_test.py \
22-
--url ws://localhost:8114/v1/stream \
22+
--url ws://localhost:8117/v1/stream \
2323
--api-key test_key_local_only \
24-
--wav tests/fixtures/librispeech_short.wav
24+
--wav tests/fixtures/jfk.wav
2525
```
2626

27-
Expected: at least one `partial`, one `final` with the known transcript, clean close (1000).
27+
Expected: streaming `partial`s, one or more `final`s ("And so, my fellow Americans...
28+
ask what you can do for your country."), clean close (1000).
2829

2930
## Endpoints
3031

src/stt_streaming/model.py

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -60,33 +60,50 @@ def __init__(self, model_dir: str) -> None:
6060
def reset_state(self) -> InferState:
6161
return InferState()
6262

63+
def transcribe_window(self, audio: np.ndarray) -> str:
64+
"""Stateless re-decode of a PCM window; returns the transcript.
65+
66+
Parakeet TDT via NeMo does not expose a stable per-chunk streaming step
67+
for all checkpoints, so we re-decode the (bounded) cumulative utterance
68+
window each call. The caller owns the audio buffer; this method keeps no
69+
audio state of its own, which is what avoids double-counting.
70+
"""
71+
if audio is None or audio.shape[0] == 0:
72+
return ""
73+
if audio.dtype != np.float32:
74+
audio = audio.astype(np.float32)
75+
# Bound the window so latency does not grow without limit on long utterances.
76+
if audio.shape[0] > _FALLBACK_CONTEXT_SAMPLES:
77+
audio = audio[-_FALLBACK_CONTEXT_SAMPLES:]
78+
return self._run_transcribe(audio)
79+
6380
def transcribe_chunk(
64-
self, audio_chunk: np.ndarray, state: Optional[InferState]
81+
self, audio_window: np.ndarray, state: Optional[InferState]
6582
) -> Tuple[str, InferState]:
66-
"""Run incremental transcription on one PCM chunk; returns (cumulative_text, new_state)."""
83+
"""Re-decode the current utterance window; returns (cumulative_text, new_state).
84+
85+
``audio_window`` is the full cumulative PCM for the utterance so far — the
86+
caller (ws session) owns that buffer. We re-decode it rather than appending
87+
to per-chunk state, so audio the caller has already buffered is never
88+
counted twice. The GPU lock in ``_run_transcribe`` serializes device work.
89+
"""
6790
if state is None:
6891
state = self.reset_state()
69-
70-
if audio_chunk.dtype != np.float32:
71-
audio_chunk = audio_chunk.astype(np.float32)
72-
73-
# Append to rolling buffer; we always re-decode the buffer because Parakeet TDT
74-
# via NeMo 2.0.0 does not expose a stable per-chunk streaming step for all checkpoints.
75-
# The lock keeps GPU access serialized; concurrency is achieved across sessions by queueing.
76-
state.audio = np.concatenate([state.audio, audio_chunk])
77-
# Trim to a bounded sliding window so latency does not grow without bound
78-
if state.audio.shape[0] > _FALLBACK_CONTEXT_SAMPLES:
79-
state.audio = state.audio[-_FALLBACK_CONTEXT_SAMPLES:]
80-
81-
text = self._run_transcribe(state.audio)
92+
text = self.transcribe_window(audio_window)
8293
state.last_text = text
8394
return text, state
8495

8596
def finalize(self, state: InferState) -> str:
86-
"""Final flush — return best hypothesis on whatever audio is buffered."""
87-
if state is None or state.audio.shape[0] == 0:
88-
return state.last_text if state else ""
89-
return self._run_transcribe(state.audio)
97+
"""Final flush — best hypothesis on the last decoded window.
98+
99+
Prefer :meth:`transcribe_window` on the caller's live buffer; this is kept
100+
for callers that only hold the opaque state.
101+
"""
102+
if state is None:
103+
return ""
104+
if state.audio.shape[0] == 0:
105+
return state.last_text
106+
return self.transcribe_window(state.audio)
90107

91108
def warm_up(self) -> None:
92109
"""One dummy transcribe over 1 s of silence to compile kernels."""
@@ -98,17 +115,51 @@ def warm_up(self) -> None:
98115
except Exception:
99116
logger.exception("Warm-up failed (continuing)")
100117

118+
def _bound(self, audio: np.ndarray) -> Optional[np.ndarray]:
119+
if audio is None or audio.shape[0] == 0:
120+
return None
121+
if audio.dtype != np.float32:
122+
audio = audio.astype(np.float32)
123+
if audio.shape[0] > _FALLBACK_CONTEXT_SAMPLES:
124+
audio = audio[-_FALLBACK_CONTEXT_SAMPLES:]
125+
return audio
126+
127+
def try_transcribe_window(self, audio: np.ndarray) -> Optional[str]:
128+
"""Best-effort partial decode: returns the transcript, or ``None`` if the
129+
GPU is already busy.
130+
131+
Each NeMo ``transcribe`` call costs a fixed ~55 ms (Python/dataloader
132+
overhead, independent of window length), so the lock-serialized model
133+
caps out around ~18 calls/s. Partials are disposable — if we blocked
134+
here, ticks from many concurrent sessions would queue without bound and
135+
starve the event loop (uvicorn's WS keepalive then drops the socket).
136+
Skipping a busy tick keeps the loop responsive; the next frame retries.
137+
"""
138+
bounded = self._bound(audio)
139+
if bounded is None:
140+
return ""
141+
if not self._lock.acquire(blocking=False):
142+
return None
143+
try:
144+
return self._infer(bounded)
145+
finally:
146+
self._lock.release()
147+
101148
def _run_transcribe(self, audio: np.ndarray) -> str:
102-
"""Underlying NeMo call. Holds the GPU lock for the duration."""
149+
"""Underlying NeMo call. Holds the GPU lock for the duration (blocking)."""
150+
with self._lock:
151+
return self._infer(audio)
152+
153+
def _infer(self, audio: np.ndarray) -> str:
154+
"""Run NeMo transcribe on one window. Caller must hold ``self._lock``."""
103155
import torch
104156

105-
with self._lock:
106-
with torch.inference_mode():
107-
# NeMo 2.0 transcribe accepts a list of numpy arrays in newer builds
108-
try:
109-
out = self.model.transcribe([audio], batch_size=1, verbose=False)
110-
except TypeError:
111-
out = self.model.transcribe([audio], batch_size=1)
157+
with torch.inference_mode():
158+
# NeMo 2.0 transcribe accepts a list of numpy arrays in newer builds
159+
try:
160+
out = self.model.transcribe([audio], batch_size=1, verbose=False)
161+
except TypeError:
162+
out = self.model.transcribe([audio], batch_size=1)
112163
return _extract_text(out)
113164

114165

src/stt_streaming/vad.py

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
from __future__ import annotations
22

33
import logging
4+
import threading
45
from typing import List, Tuple
56

67
import numpy as np
78

89
log = logging.getLogger(__name__)
910

11+
# Silero v5 consumes fixed 512-sample windows at 16 kHz (256 @ 8 kHz). Feeding it
12+
# isolated 20 ms WS frames returns *no* speech ever — it needs streaming context —
13+
# which is why per-frame get_speech_timestamps() silently gated out every partial.
14+
_WINDOW_SAMPLES = 512
15+
# Hysteresis thresholds: enter speech high, leave low, so brief dips mid-word do
16+
# not bounce the gate and prematurely commit utterances.
17+
_SPEECH_ON = 0.5
18+
_SPEECH_OFF = 0.35
19+
1020

1121
class SileroVAD:
1222
def __init__(self) -> None:
1323
self._model = None
1424
self._get_speech_timestamps = None
25+
# The silero model carries its LSTM state on the object itself, so a single
26+
# shared instance is not safe across concurrent sessions. We serialize the
27+
# restore->infer->save critical section and stash each session's state in
28+
# the per-session dict returned by reset_state().
29+
self._lock = threading.Lock()
1530
try:
1631
from silero_vad import load_silero_vad, get_speech_timestamps # type: ignore
1732

@@ -20,28 +35,67 @@ def __init__(self) -> None:
2035
except Exception as e: # pragma: no cover - exercised only without silero installed
2136
log.warning("silero-vad unavailable (%s); falling back to RMS energy threshold", e)
2237

23-
def reset_state(self):
24-
return None
38+
def reset_state(self) -> dict:
39+
# buf: leftover samples not yet forming a full 512 window
40+
# triggered: current speech/silence latch (hysteresis)
41+
# silero: this session's silero internals — recurrent _state plus the
42+
# 64-sample _context carried between windows (None until first window)
43+
return {"buf": np.zeros(0, dtype=np.float32), "triggered": False, "silero": None}
2544

2645
def is_speech(self, audio_chunk: np.ndarray, state=None, sample_rate: int = 16000):
2746
if isinstance(state, int):
2847
sample_rate = state
2948
state = None
30-
result = self._is_speech_inner(audio_chunk, sample_rate)
31-
# ws.py expects (bool, new_state); internal callers ignore the tuple via [0]
32-
return result, state
33-
34-
def _is_speech_inner(self, audio_chunk: np.ndarray, sample_rate: int = 16000) -> bool:
49+
if state is None:
50+
state = self.reset_state()
3551
if self._model is None:
36-
return _rms_is_speech(audio_chunk)
52+
return _rms_is_speech(audio_chunk), state
3753
try:
38-
import torch
39-
tensor = torch.from_numpy(_to_float32(audio_chunk))
40-
ts = self._get_speech_timestamps(tensor, self._model, sampling_rate=sample_rate)
41-
return len(ts) > 0
54+
triggered = self._silero_stream(audio_chunk, state, sample_rate)
4255
except Exception as e:
43-
log.warning("silero VAD chunk inference failed (%s); using RMS fallback", e)
44-
return _rms_is_speech(audio_chunk)
56+
log.warning("silero streaming VAD failed (%s); using RMS fallback", e)
57+
return _rms_is_speech(audio_chunk), state
58+
return triggered, state
59+
60+
def _silero_stream(self, audio_chunk: np.ndarray, state: dict, sample_rate: int) -> bool:
61+
"""Run silero over freshly-completed 512-sample windows, carrying this
62+
session's recurrent state across calls. Concurrency-safe: the shared
63+
model's state is swapped in/out under a lock per call."""
64+
import torch
65+
66+
buf = np.concatenate([state["buf"], _to_float32(audio_chunk)])
67+
max_prob: float | None = None
68+
with self._lock:
69+
# Load this session's silero internals into the shared model.
70+
if state["silero"] is None:
71+
self._model.reset_states()
72+
else:
73+
self._model._state = state["silero"]["state"]
74+
self._model._context = state["silero"]["context"]
75+
self._model._last_sr = sample_rate
76+
self._model._last_batch_size = 1
77+
with torch.no_grad():
78+
while buf.shape[0] >= _WINDOW_SAMPLES:
79+
window = buf[:_WINDOW_SAMPLES]
80+
buf = buf[_WINDOW_SAMPLES:]
81+
p = float(self._model(torch.from_numpy(window.copy()), sample_rate).item())
82+
max_prob = p if max_prob is None else max(max_prob, p)
83+
# Save this session's internals before another session borrows the model.
84+
st = getattr(self._model, "_state", None)
85+
ctx = getattr(self._model, "_context", None)
86+
state["silero"] = {
87+
"state": st.clone() if st is not None else None,
88+
"context": ctx.clone() if ctx is not None else None,
89+
}
90+
91+
state["buf"] = buf
92+
if max_prob is not None:
93+
if max_prob >= _SPEECH_ON:
94+
state["triggered"] = True
95+
elif max_prob < _SPEECH_OFF:
96+
state["triggered"] = False
97+
# No full window yet (sub-32 ms chunk): hold the previous latch.
98+
return state["triggered"]
4599

46100
def process_stream(self, audio_buffer: np.ndarray) -> List[Tuple[int, int, bool]]:
47101
sample_rate = 16000

src/stt_streaming/ws.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ async def handle_session(
163163
await _send_json(websocket, {
164164
"type": "ready",
165165
"session_id": sess.session_id,
166-
"model": getattr(config, "model_name", model.model_dir),
166+
"model": getattr(config, "model_name", None) or getattr(config, "model", None) or model.model_dir,
167167
"language": sess.language,
168168
"sample_rate": sess.sample_rate,
169169
})
@@ -219,14 +219,18 @@ async def _session_loop(
219219
if "bytes" in msg and msg["bytes"] is not None:
220220
frame = msg["bytes"]
221221
sample_count = len(frame) // 2
222-
if len(frame) % 2 != 0 or sample_count < MIN_FRAME_SAMPLES:
223-
await _send_error(ws, "audio_format_error", "frame too small or unaligned")
222+
# Empty or odd-length frames are malformed PCM16 and unrecoverable.
223+
if len(frame) == 0 or len(frame) % 2 != 0:
224+
await _send_error(ws, "audio_format_error", "frame empty or unaligned")
224225
await _close(ws, CLOSE_BAD_REQUEST, "bad frame")
225226
return
226227
if sample_count > MAX_FRAME_SAMPLES:
227228
await _send_error(ws, "audio_format_error", "frame too large")
228229
await _close(ws, CLOSE_FRAME_TOO_LARGE, "frame too large")
229230
return
231+
# Frames smaller than MIN_FRAME_SAMPLES (e.g. a real-time client's final
232+
# remainder chunk) are still valid audio — buffer them rather than killing
233+
# the session over a short tail.
230234

231235
sess.bytes_received += len(frame)
232236
chunk = _pcm16_to_float32(frame)
@@ -299,15 +303,15 @@ async def _session_loop(
299303
async def _emit_partial(ws: WebSocket, model: ParakeetModel, sess: WSSession) -> None:
300304
loop = asyncio.get_running_loop()
301305
audio = sess.utterance_buffer
302-
state = sess.model_state
303306
try:
304-
text, new_state = await loop.run_in_executor(
305-
None, model.transcribe_chunk, audio, state
306-
)
307+
# Best-effort: returns None if the GPU is busy, in which case we drop this
308+
# partial tick rather than queueing work behind the other live sessions.
309+
text = await loop.run_in_executor(None, model.try_transcribe_window, audio)
307310
except Exception:
308-
logger.exception("transcribe_chunk failed")
311+
logger.exception("partial decode failed")
312+
return
313+
if text is None:
309314
return
310-
sess.model_state = new_state
311315
if not text or text == sess.last_partial_text:
312316
return
313317
sess.last_partial_text = text
@@ -329,11 +333,18 @@ async def _finalize_utterance(
329333
if sess.utterance_buffer.shape[0] == 0 and not sess.last_partial_text:
330334
return
331335
loop = asyncio.get_running_loop()
332-
try:
333-
text = await loop.run_in_executor(None, model.finalize, sess.model_state)
334-
except Exception:
335-
logger.exception("finalize failed")
336-
text = sess.last_partial_text
336+
text = sess.last_partial_text
337+
if sess.utterance_buffer.shape[0] > 0:
338+
# Re-decode the caller-owned buffer directly. This is correct even when no
339+
# partial was ever emitted (e.g. enable_partials=False), where the model
340+
# state would otherwise hold no audio.
341+
try:
342+
text = await loop.run_in_executor(
343+
None, model.transcribe_window, sess.utterance_buffer
344+
)
345+
except Exception:
346+
logger.exception("finalize failed")
347+
text = sess.last_partial_text
337348

338349
if not text and not force:
339350
return

tests/fixtures/jfk.wav

344 KB
Binary file not shown.

0 commit comments

Comments
 (0)