@@ -136,14 +136,45 @@ def _video_duration(video_path: Path) -> float:
136136
137137
138138def _extract_audio (video_path : Path , out_path : Path ,
139- start : float = 0.0 , duration : float | None = None ) -> None :
140- """Extract a low-bitrate mono mp3 clip suitable for the OpenAI API."""
139+ start : float = 0.0 , duration : float | None = None ,
140+ desc : str | None = None , total_sec : float | None = None ) -> None :
141+ """Extract a low-bitrate mono mp3 clip suitable for the OpenAI API.
142+
143+ When *desc* is given, a tqdm progress bar is shown via ffmpeg-progress-yield.
144+ *total_sec* (or *duration*) is used as the known duration for the bar.
145+ """
141146 cmd = ["ffmpeg" , "-y" , "-i" , str (video_path )]
142147 if start > 0 :
143148 cmd += ["-ss" , str (start )]
144149 if duration is not None :
145150 cmd += ["-t" , str (duration )]
146151 cmd += ["-vn" , "-ar" , "16000" , "-ac" , "1" , "-b:a" , "32k" , str (out_path )]
152+
153+ if desc :
154+ dur = duration or total_sec
155+ try :
156+ from ffmpeg_progress_yield import FfmpegProgress
157+ from tqdm import tqdm
158+ ff = FfmpegProgress (cmd )
159+ bar = tqdm (
160+ total = 100 , unit = "%" , desc = desc ,
161+ bar_format = "{desc}: {percentage:3.0f}%|{bar}| [{elapsed}<{remaining}]" ,
162+ dynamic_ncols = True ,
163+ )
164+ last_pct = 0
165+ for pct in ff .run_command_with_progress (
166+ popen_kwargs = {"creationflags" : _SUBPROCESS_FLAGS },
167+ duration_override = dur ,
168+ ):
169+ p = int (pct )
170+ if p > last_pct :
171+ bar .update (p - last_pct )
172+ last_pct = p
173+ bar .update (100 - last_pct )
174+ bar .close ()
175+ return
176+ except Exception :
177+ pass # fall through to silent mode
147178 subprocess .run (cmd , capture_output = True , check = True , creationflags = _SUBPROCESS_FLAGS )
148179
149180
@@ -169,13 +200,38 @@ def _api_segments_to_schema(api_segs: list, time_offset: float = 0.0) -> list:
169200 return out
170201
171202
203+ def _filter_api_segments (api_segs : list ) -> tuple [list , int ]:
204+ """
205+ Remove hallucinated or silent segments returned by the OpenAI Whisper API.
206+ Uses the same thresholds as the local faster-whisper backend.
207+ Returns (filtered_list, n_dropped).
208+ """
209+ good = []
210+ dropped = 0
211+ for seg in api_segs :
212+ text = (seg .get ("text" ) or "" ).strip ()
213+ if not text :
214+ dropped += 1
215+ continue
216+ if seg .get ("no_speech_prob" , 0.0 ) > 0.6 :
217+ dropped += 1
218+ continue
219+ if seg .get ("compression_ratio" , 1.0 ) > 2.4 :
220+ dropped += 1
221+ continue
222+ good .append (seg )
223+ return good , dropped
224+
225+
172226def transcribe_api (video_path : Path , caption_path : Path ) -> bool :
173227 """
174228 Transcribe using OpenAI Whisper API (whisper-1).
175229 Step 1 — extract full audio from video to [course_dir]/audio/[stem].mp3
176230 (reused on retry if already present).
177231 Step 2 — split audio into ≤ _API_CHUNK_MINUTES chunks and call the API.
178232 """
233+ import time as _time
234+
179235 if caption_path .exists ():
180236 print (f" [skip] Caption already exists: { caption_path .name } " )
181237 return True
@@ -198,41 +254,48 @@ def transcribe_api(video_path: Path, caption_path: Path) -> bool:
198254 audio_path = audio_dir / (video_path .stem + ".mp3" )
199255 audio_dir .mkdir (parents = True , exist_ok = True )
200256
257+ total_dur = _video_duration (video_path )
258+
201259 if audio_path .exists ():
202- print (f" Audio already extracted, reusing : { audio_path .name } " )
260+ print (f" Audio already extracted: { audio_path .name } ( { total_dur :.0f } s) " )
203261 else :
204- print (f" Extracting audio from video: { video_path .name } " )
205- _extract_audio (video_path , audio_path )
262+ print (f" Extracting audio from video ({ total_dur :.0f} s)..." )
263+ _extract_audio (video_path , audio_path ,
264+ desc = " extracting audio" , total_sec = total_dur )
206265 size_mb = audio_path .stat ().st_size / (1024 ** 2 )
207266 print (f" Audio saved: { audio_path .name } ({ size_mb :.1f} MB)" )
208267
209268 # ── Step 2: chunk & transcribe ────────────────────────────────────────────
210- print (f" Transcribing via OpenAI Whisper API: { video_path .name } " )
211- total_dur = _video_duration (audio_path )
212- print (f" Duration: { total_dur :.0f} s" )
213-
214269 chunk_sec = _API_CHUNK_MINUTES * 60
215270 offsets = [i * chunk_sec for i in range (int (total_dur // chunk_sec ) + 1 )
216271 if i * chunk_sec < total_dur ]
272+ n_chunks = len (offsets )
273+
274+ print (f" Transcribing via Whisper API: { n_chunks } chunk(s) × "
275+ f"≤{ _API_CHUNK_MINUTES } min (total { total_dur :.0f} s)" )
217276
218277 all_segments : list = []
219278 detected_lang : str | None = None # set after first chunk; locked for all subsequent chunks
220279 lang_prob = 1.0
280+ total_dropped = 0
221281
222282 with tempfile .TemporaryDirectory () as tmp :
223283 for i , start in enumerate (offsets ):
224- dur = min (chunk_sec , total_dur - start )
284+ dur = min (chunk_sec , total_dur - start )
225285 chunk_file = Path (tmp ) / f"chunk_{ i :03d} .mp3"
226286
227- print (f" Extracting chunk { i + 1 } /{ len ( offsets ) } "
228- f"( { start :.0f } s – { start + dur :.0f } s)..." )
287+ print (f" Chunk { i + 1 } /{ n_chunks } : { start :.0f } s – { start + dur :.0f } s "
288+ f"extracting..." , end = "" , flush = True )
229289 _extract_audio (audio_path , chunk_file , start = start , duration = dur )
230290 chunk_mb = chunk_file .stat ().st_size / (1024 ** 2 )
231291
232292 # Chunk 1: auto-detect (or use explicit setting).
233293 # Chunk 2+: lock to the language detected from chunk 1 to prevent drift.
234294 chunk_lang = WHISPER_LANGUAGE if i == 0 else (detected_lang or WHISPER_LANGUAGE )
235- print (f" Sending to API ({ chunk_mb :.1f} MB, lang={ chunk_lang or 'auto' } )..." )
295+ print (f" { chunk_mb :.1f} MB lang={ chunk_lang or 'auto' } "
296+ f"sending to API..." , end = "" , flush = True )
297+
298+ t0 = _time .monotonic ()
236299 with open (chunk_file , "rb" ) as f :
237300 response = client .audio .transcriptions .create (
238301 model = "whisper-1" ,
@@ -241,12 +304,12 @@ def transcribe_api(video_path: Path, caption_path: Path) -> bool:
241304 timestamp_granularities = ["word" , "segment" ],
242305 language = chunk_lang ,
243306 )
307+ elapsed = _time .monotonic () - t0
244308
245309 # First chunk determines language for all subsequent chunks
246310 if i == 0 :
247311 lang_full = getattr (response , "language" , "english" ) or "english"
248312 detected_lang = _LANG_NAMES .get (lang_full .lower (), lang_full [:2 ].lower ())
249- print (f" Language detected: { lang_full } → locking to '{ detected_lang } ' for remaining chunks" )
250313
251314 resp_dict = response .model_dump ()
252315 api_segs = resp_dict .get ("segments" , [])
@@ -262,13 +325,26 @@ def transcribe_api(video_path: Path, caption_path: Path) -> bool:
262325 seg ["words" ].append (api_words [w_idx ])
263326 w_idx += 1
264327
328+ # Filter hallucinated / silent segments (same thresholds as local backend)
329+ api_segs , n_dropped = _filter_api_segments (api_segs )
330+ total_dropped += n_dropped
331+
265332 segs = _api_segments_to_schema (api_segs , time_offset = start )
266333 # Re-number segment IDs to be globally unique
267334 base_id = len (all_segments )
268335 for j , s in enumerate (segs ):
269336 s ["id" ] = base_id + j
270337 all_segments .extend (segs )
271338
339+ drop_note = f" ({ n_dropped } dropped)" if n_dropped else ""
340+ lang_note = (f" lang={ detected_lang } " if i == 0 else "" )
341+ print (f" done in { elapsed :.0f} s { len (segs )} segs{ drop_note } { lang_note } " )
342+
343+ if detected_lang and i == 0 : # only one chunk — print lang info here
344+ pass # already printed above
345+ elif detected_lang :
346+ print (f" Language locked to '{ detected_lang } ' from first chunk" )
347+
272348 result = {
273349 "language" : detected_lang or "en" ,
274350 "language_probability" : lang_prob ,
@@ -282,7 +358,8 @@ def transcribe_api(video_path: Path, caption_path: Path) -> bool:
282358
283359 n_seg = len (result ["segments" ])
284360 n_word = sum (len (s ["words" ]) for s in result ["segments" ])
285- print (f" Saved: { n_seg } segments / { n_word } words -> { caption_path } " )
361+ drop_note = f" ({ total_dropped } hallucinated segments removed)" if total_dropped else ""
362+ print (f" Saved: { n_seg } segments / { n_word } words -> { caption_path } { drop_note } " )
286363 return True
287364
288365
0 commit comments