@@ -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
0 commit comments