11"""Speech-to-text using ffmpeg pipe + sherpa-onnx SenseVoice + silero VAD."""
22
33import os
4+ import re
45import subprocess
56import threading
67import time
@@ -20,6 +21,7 @@ def __init__(self):
2021 self ._vad_config = None
2122 self ._last_duration = 0.0 # audio seconds from last transcription
2223 self ._last_transcript = "" # transcript from last transcription
24+ self ._media_duration = None # total media duration parsed from ffmpeg stderr
2325
2426 def _init (self ):
2527 if self ._recognizer is not None :
@@ -189,10 +191,27 @@ def _drain_stderr():
189191 stderr_thread .join (timeout = 5 )
190192 stderr_output = b"" .join (stderr_chunks )
191193
194+ # Parse total media duration from ffmpeg stderr (e.g. "Duration: 01:23:45.67")
195+ self ._media_duration = None
196+ dur_match = re .search (
197+ rb"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)" , stderr_output ,
198+ )
199+ if dur_match :
200+ h , m , s = dur_match .groups ()
201+ self ._media_duration = int (h ) * 3600 + int (m ) * 60 + float (s )
202+
192203 elapsed = time .time () - t0
193204 duration = total_read / sample_rate
194205 self ._last_duration = duration
195206
207+ if self ._media_duration :
208+ print (
209+ f"[Transcriber] Media duration: { self ._media_duration :.0f} s"
210+ f" ({ self ._media_duration / 60 :.1f} min),"
211+ f" received: { duration :.0f} s ({ duration / 60 :.1f} min)" ,
212+ flush = True ,
213+ )
214+
196215 if proc .returncode not in (0 , - 9 , None ):
197216 stderr_text = stderr_output .decode (errors = "replace" )[- 500 :]
198217 raise RuntimeError (
@@ -269,21 +288,18 @@ def probe_duration(url: str, http_headers: str | None = None,
269288 return None
270289
271290 def transcribe_url (self , url : str , timeout : int = 7200 ,
272- http_headers : str | None = None ,
273- expected_duration : float | None = None ) -> str :
291+ http_headers : str | None = None ) -> str :
274292 """Stream audio directly from a URL (no video download needed).
275293
276294 Args:
277295 url: Video/audio URL (can be a WebVPN URL).
278296 timeout: Max seconds before killing the process.
279297 http_headers: ffmpeg-compatible HTTP headers string,
280298 e.g. "Cookie: x=y\\ r\\ nUser-Agent: z\\ r\\ n"
281- expected_duration: If provided, raise IncompleteAudioError when
282- received audio is < 90% of this value.
283299
284300 Raises:
285- IncompleteAudioError: If audio duration is significantly less than
286- expected (likely a connection drop).
301+ IncompleteAudioError: If received audio is < 90% of the media's
302+ total duration (likely a connection drop).
287303 """
288304 cmd = ["ffmpeg" ]
289305 if http_headers :
@@ -299,16 +315,16 @@ def transcribe_url(self, url: str, timeout: int = 7200,
299315 ]
300316 transcript = self ._transcribe_from_cmd (cmd , timeout = timeout )
301317
302- # Check if we received enough audio
303- if expected_duration and expected_duration > 0 :
318+ # Check completeness using duration parsed from ffmpeg stderr
319+ if self . _media_duration and self . _media_duration > 0 :
304320 actual = self ._last_duration
305- ratio = actual / expected_duration
321+ ratio = actual / self . _media_duration
306322 if ratio < 0.9 :
307323 raise IncompleteAudioError (
308- f"Only received { actual :.0f} s of { expected_duration :.0f} s"
324+ f"Only received { actual :.0f} s of { self . _media_duration :.0f} s"
309325 f" audio ({ ratio :.0%} ). Connection may have dropped." ,
310326 actual_duration = actual ,
311- expected_duration = expected_duration ,
327+ expected_duration = self . _media_duration ,
312328 )
313329
314330 return transcript
0 commit comments