Skip to content

Commit dcf190f

Browse files
committed
Improve API transcription: progress bars, timing, and hallucination filtering
Progress: - Full audio extraction (Step 1) now shows an ffmpeg-based tqdm progress bar - Per-chunk log shows extraction size, API call elapsed time, and segment count - Chunk format: "Chunk N/M: Xs–Ys X.X MB lang=en sending… done in Xs N segs" Quality filtering (API): - Added _filter_api_segments() using the same thresholds as the local backend: no_speech_prob > 0.6, compression_ratio > 2.4, or empty text → dropped - Dropped count printed at end of each chunk and in final save line
1 parent 07b2373 commit dcf190f

1 file changed

Lines changed: 92 additions & 15 deletions

File tree

extract_caption.py

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,45 @@ def _video_duration(video_path: Path) -> float:
136136

137137

138138
def _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+
172226
def 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

Comments
 (0)