ffmpeg-skill 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -73,10 +73,21 @@ from _common.probe import (
73
73
  from _common.decision import (
74
74
  aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
75
75
  description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
76
+ filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
77
+ FILLER_MIN_GAP, FILLER_PAD, normalise_filler_token,
78
+ beat_grid, snap_points, BEAT_MIN_CONFIDENCE, BEAT_ONSET_K, BEAT_OCTAVE_MARGIN,
79
+ BEAT_REFRACTORY_S, BEAT_WINDOW_S, BEAT_SUPPORT_DIVISOR, BEAT_ALIGN_DIVISOR,
80
+ BEAT_Z_FLOOR, BEAT_Z_SPAN,
81
+ _onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
76
82
  brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
77
83
  fmt_secs, fmt_smpte_time, fmt_srt_time, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
78
84
  signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
79
85
  )
86
+ from _common.asr import (
87
+ ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
88
+ transcribe_words, _words_from_openai_whisper_json, _words_from_whisper_cpp_json,
89
+ whisper_word_timings, write_srt
90
+ )
80
91
  from _common.color import (
81
92
  bt709_tag_args, color_hex, _COLOR_TOKEN_RE, _sdr_bt709, validate_color
82
93
  )
@@ -96,11 +107,12 @@ from _common.text import (
96
107
  PENALTY_NEUTRAL, PENALTY_OKURIGANA,
97
108
  PENALTY_PARTICLE, PENALTY_SENTENCE_END, _rebalance, _rebalance_phrase, SAFE_WIDTH_FRACTION, _split_hyphens,
98
109
  _particle_ends, _particle_starts, wrap_text, wrap_variants, WRAP_MODES,
110
+ fit_size, line_em_for_size, MIN_CAPTION_FRACTION, ass_units_local,
99
111
  script_font_for_text, script_font_status, _script_font_uncached, _SCRIPT_RANGES, SCRIPTS, _SHAPING_BUILD_CACHE,
100
112
  SHAPING_SCRIPTS, text_width_em, _VS15, _VS16, WINDOWS_FONTS, _ZWJ
101
113
  )
102
114
 
103
- from _common import color, decision, runner, text # noqa: F401,E402
115
+ from _common import asr, color, decision, runner, text # noqa: F401,E402
104
116
 
105
117
  # `_common.emit` and `_common.probe` are the FUNCTIONS, as they have always been -- the
106
118
  # from-imports above rebound the package attribute the submodule import had set. The two modules
@@ -111,7 +123,7 @@ from _common import color, decision, runner, text # noqa: F401,E402
111
123
  _emit_module = sys.modules["_common.emit"]
112
124
  _probe_module = sys.modules["_common.probe"]
113
125
 
114
- _MODULES = (runner, _emit_module, _probe_module, decision, color, text)
126
+ _MODULES = (runner, _emit_module, _probe_module, decision, color, text, asr)
115
127
 
116
128
 
117
129
  class _Facade(_types.ModuleType):
@@ -168,6 +180,12 @@ __all__ = [
168
180
  "char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
169
181
  "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
170
182
  "concat_list_line", "Context", "_CRF_DEFAULT", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
183
+ "filler_spans", "FILLER_WORDS", "FILLER_AMBIGUOUS", "FILLER_DISCOURSE_MARKERS",
184
+ "FILLER_MAX_WORD", "FILLER_MIN_GAP", "FILLER_PAD", "normalise_filler_token",
185
+ "beat_grid", "snap_points", "BEAT_MIN_CONFIDENCE", "BEAT_ONSET_K", "BEAT_OCTAVE_MARGIN",
186
+ "BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
187
+ "BEAT_Z_FLOOR", "BEAT_Z_SPAN",
188
+ "_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
171
189
  "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw",
172
190
  "_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
173
191
  "dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
@@ -193,6 +211,9 @@ __all__ = [
193
211
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "_V2_HANDLED", "validate_color", "verify_output",
194
212
  "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
195
213
  "_x264_raw", "_ZWJ",
214
+ "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
215
+ "_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
216
+ "_words_from_whisper_cpp_json", "whisper_word_timings", "write_srt",
196
217
  "_atoms", "best_break", "_bare_word", "break_penalty", "_break_spaced", "_cut_penalty", "_fix_orphans",
197
218
  "_fix_weak_lines", "_function_words", "FUNCTION_WORDS", "_HYPHENS", "_is_hiragana", "_is_ideograph",
198
219
  "_is_kana", "_is_weak_line", "JA_NO_LINE_END", "JA_NO_LINE_START", "JA_PARTICLE_WORDS", "JA_PARTICLES",
@@ -200,5 +221,6 @@ __all__ = [
200
221
  "JA_SENTENCE_END", "_join", "ORPHAN_MIN_EM", "PENALTY_FORBIDDEN", "PENALTY_FUNCTION_WORD",
201
222
  "PENALTY_IDEOGRAPHS", "PENALTY_NEUTRAL", "PENALTY_OKURIGANA", "PENALTY_PARTICLE", "PENALTY_SENTENCE_END",
202
223
  "_rebalance", "_rebalance_phrase", "SAFE_WIDTH_FRACTION", "_split_hyphens", "_particle_ends",
203
- "_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES"
224
+ "_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES",
225
+ "fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION", "ass_units_local"
204
226
  ]
@@ -0,0 +1,369 @@
1
+ """The optional local speech-to-text bridge, and the SRT the rest of the skill reads and writes.
2
+
3
+ Whisper is never required. Nothing here runs unless a caller asked for a transcript:
4
+ `caption.py --transcribe` and `silence.py --filler --transcribe` share this one engine probe, so
5
+ the "no engine found" message, its install lines and its exit code are stated once rather than
6
+ copied per tool. This module is neither ffprobe nor a decision, which is why it is its own file.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ from _common.decision import fmt_srt_time, parse_time
17
+ from _common.emit import die, info
18
+ from _common.runner import read_text_or_die
19
+
20
+ # The three engines this skill knows how to drive, and how to install each -- one string, so
21
+ # every tool that needs a transcript refuses in the same words.
22
+ ASR_ENGINES = ("whisper.cpp", "faster-whisper", "openai-whisper")
23
+ ASR_INSTALL_HINT = (
24
+ "Install one (all run offline):\n"
25
+ " whisper.cpp: brew install whisper-cpp (then download a model: ggml-base.bin)\n"
26
+ " faster-whisper: pip install faster-whisper\n"
27
+ " openai-whisper: pip install openai-whisper")
28
+
29
+
30
+ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
31
+ cues: List[Tuple[float, float, str]] = []
32
+ block: List[str] = []
33
+ content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
34
+ for line in content.split("\n"):
35
+ if line.strip():
36
+ block.append(line)
37
+ continue
38
+ if block:
39
+ times = next((b for b in block if "-->" in b), None)
40
+ if times:
41
+ a, b = times.split("-->")
42
+ text = "\n".join(block[block.index(times) + 1:]).strip()
43
+ try:
44
+ cues.append((parse_time(a), parse_time(b), text))
45
+ except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
46
+ die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
47
+ block = []
48
+ if not cues:
49
+ die(f"no cues found in {path}")
50
+ return cues
51
+
52
+
53
+ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
54
+ with open(path, "w", encoding="utf-8") as fh:
55
+ for i, (s, e, t) in enumerate(cues, 1):
56
+ # A blank line is SRT's own block separator (index/timecode/text, blank, next block).
57
+ # Cue text can contain one -- parse_text_cues() turns a bare "|" into "\n", so a source
58
+ # line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
59
+ # raw would split one cue into two malformed half-blocks (the second missing its own
60
+ # index/timecode). Collapse any run of blank lines within the cue text to a single
61
+ # newline so the cue's own text can never fake the format's block boundary.
62
+ t = re.sub(r"\n{2,}", "\n", t).strip("\n")
63
+ fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
64
+
65
+
66
+ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
67
+ """Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
68
+ whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
69
+ No engine installed -> clear error with install hints; the skill never depends on one."""
70
+ import shutil
71
+ import subprocess
72
+ import tempfile
73
+ from _common import require_tool, run_analysis, STATE
74
+ ffmpeg = require_tool("ffmpeg")
75
+ tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
76
+ try:
77
+ return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
78
+ finally:
79
+ shutil.rmtree(tmpdir, ignore_errors=True)
80
+
81
+
82
+ def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
83
+ """Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
84
+ from _common import STATE, die
85
+ limit = STATE.timeout or None
86
+ try:
87
+ return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
88
+ except subprocess.TimeoutExpired:
89
+ die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
90
+ code=124, kind="timeout")
91
+ return None # unreachable
92
+
93
+
94
+ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
95
+ ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
96
+ from _common import run_analysis, STATE, die
97
+ wav = os.path.join(tmpdir, "audio.wav")
98
+ # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
99
+ # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
100
+ # reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
101
+ run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
102
+ "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
103
+ # 1. whisper.cpp
104
+ cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
105
+ if not cli:
106
+ # older whisper.cpp builds ship the binary as plain `main`; accept it only when it lives
107
+ # in a directory that names whisper, so an unrelated /usr/bin/main is never run
108
+ main_bin = shutil.which("main")
109
+ if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
110
+ cli = main_bin
111
+ if cli:
112
+ model_path = model
113
+ if not os.path.exists(model_path):
114
+ for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"), f"models/ggml-{model}.bin", f"/usr/local/share/whisper/ggml-{model}.bin"):
115
+ if os.path.exists(cand):
116
+ model_path = cand
117
+ break
118
+ base = os.path.join(tmpdir, "out")
119
+ cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
120
+ if language:
121
+ cmd += ["-l", language]
122
+ proc = _asr_run(cmd, subprocess, "whisper.cpp")
123
+ if proc.returncode == 0 and os.path.exists(base + ".srt"):
124
+ info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
125
+ cues = parse_srt(base + ".srt")
126
+ write_srt(cues, out_srt)
127
+ return cues
128
+ info("whisper.cpp found but failed: " + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
129
+ # 2. faster-whisper (python package)
130
+ try:
131
+ from faster_whisper import WhisperModel # type: ignore
132
+ import threading
133
+ result: list = []
134
+
135
+ def work() -> None:
136
+ m = WhisperModel(model, device="cpu", compute_type="int8")
137
+ segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
138
+ result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
139
+
140
+ # An in-process engine gets the same wall-clock limit as the CLI engines and ffmpeg.
141
+ t = threading.Thread(target=work, daemon=True)
142
+ t.start()
143
+ t.join(STATE.timeout or None)
144
+ if t.is_alive():
145
+ die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise --timeout for a long recording", code=124, kind="timeout")
146
+ cues = list(result)
147
+ if cues:
148
+ info("transcribed with faster-whisper")
149
+ write_srt(cues, out_srt)
150
+ return cues
151
+ except ImportError:
152
+ pass
153
+ # 3. openai-whisper CLI
154
+ if shutil.which("whisper"):
155
+ cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
156
+ if language:
157
+ cmd += ["--language", language]
158
+ proc = _asr_run(cmd, subprocess, "openai-whisper")
159
+ srt = os.path.join(tmpdir, "audio.srt")
160
+ if proc.returncode == 0 and os.path.exists(srt):
161
+ info("transcribed with openai-whisper")
162
+ cues = parse_srt(srt)
163
+ write_srt(cues, out_srt)
164
+ return cues
165
+ die_no_engine("Or write the cues by hand with --text cues.txt (see format above).")
166
+ return []
167
+
168
+
169
+ def die_no_engine(alternative: str, flag: str = "--transcribe") -> None:
170
+ """The one "no local speech-to-text engine" refusal, in the one set of words.
171
+
172
+ kind: input, exit 1, and the three install lines -- caption.py and silence.py both land here
173
+ rather than each spelling out its own version of the same missing dependency.
174
+ """
175
+ die(f"no local speech-to-text engine found for {flag}.\n" + ASR_INSTALL_HINT + "\n" + alternative,
176
+ kind="input")
177
+
178
+
179
+ def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
180
+ """Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
181
+
182
+ whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
183
+ times; when they are there, --karaoke should follow the real speech instead of splitting the
184
+ cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
185
+ {"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
186
+ {"words": [...]} shape. Anything unreadable is simply "no word timings".
187
+ """
188
+ if not srt_path:
189
+ return []
190
+ stem = os.path.splitext(srt_path)[0]
191
+ for cand in (stem + ".words.json", stem + ".json"):
192
+ if not os.path.exists(cand):
193
+ continue
194
+ try:
195
+ data = json.loads(Path(cand).read_text(encoding="utf-8"))
196
+ except (OSError, ValueError):
197
+ continue
198
+ raw = []
199
+ if isinstance(data, dict):
200
+ raw = list(data.get("words") or [])
201
+ for seg in data.get("segments") or []:
202
+ raw.extend((seg or {}).get("words") or [])
203
+ words = []
204
+ for w in raw:
205
+ try:
206
+ text = str(w.get("word") or w.get("text") or "").strip()
207
+ if text:
208
+ words.append((float(w["start"]), float(w["end"]), text))
209
+ except (AttributeError, KeyError, TypeError, ValueError):
210
+ continue
211
+ if words:
212
+ info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
213
+ return sorted(words)
214
+ return []
215
+
216
+
217
+ # ------------------------------------------------------- word-level timings (1.17)
218
+ #
219
+ # transcribe() above produces an SRT, which is all --transcribe on caption.py ever needed: a cue
220
+ # has a start and an end and that is what gets burnt in. silence.py --filler needs something
221
+ # stricter -- a start and an end PER WORD -- and no amount of reading an SRT back produces one.
222
+ # Each engine has its own flag for it, and each writes a different shape, so each is driven and
223
+ # parsed here rather than in the tool.
224
+
225
+
226
+ def _words_from_whisper_cpp_json(path: str) -> "List[Dict[str, Any]]":
227
+ """whisper.cpp --output-json-full: transcription[].tokens[] with offsets in MILLISECONDS.
228
+
229
+ Token text carries leading spaces and the model's special tokens ([_BEG_], [_TT_123]); those
230
+ are dropped, and a token that is a word continuation (no leading space) is glued onto the
231
+ previous word so "un" + "believable" is one word with one span, not two.
232
+ """
233
+ try:
234
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
235
+ except (OSError, ValueError):
236
+ return []
237
+ out: "List[Dict[str, Any]]" = []
238
+ for seg in (doc.get("transcription") or []):
239
+ for tok in (seg.get("tokens") or []):
240
+ text = str(tok.get("text") or "")
241
+ if not text.strip() or text.strip().startswith("[_"):
242
+ continue
243
+ offsets = tok.get("offsets") or {}
244
+ try:
245
+ start, end = float(offsets["from"]) / 1000.0, float(offsets["to"]) / 1000.0
246
+ except (KeyError, TypeError, ValueError):
247
+ continue
248
+ if out and not text.startswith(" "):
249
+ out[-1]["word"] += text
250
+ out[-1]["end"] = end
251
+ else:
252
+ out.append({"word": text.strip(), "start": start, "end": end})
253
+ return [w for w in out if w["word"].strip()]
254
+
255
+
256
+ def _words_from_openai_whisper_json(path: str) -> "List[Dict[str, Any]]":
257
+ """openai-whisper --word_timestamps True --output_format json: segments[].words[]."""
258
+ try:
259
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
260
+ except (OSError, ValueError):
261
+ return []
262
+ out: "List[Dict[str, Any]]" = []
263
+ for seg in (doc.get("segments") or []):
264
+ for w in (seg.get("words") or []):
265
+ try:
266
+ out.append({"word": str(w.get("word") or w.get("text") or "").strip(),
267
+ "start": float(w["start"]), "end": float(w["end"])})
268
+ except (KeyError, TypeError, ValueError):
269
+ continue
270
+ return [w for w in out if w["word"]]
271
+
272
+
273
+ def transcribe_words(video: str, language: "Optional[str]" = None, model: str = "base",
274
+ audio_stream: int = 0) -> "Tuple[List[Dict[str, Any]], Optional[str]]":
275
+ """([{word, start, end}, ...], the engine that produced them) from a local whisper.
276
+
277
+ Drives whichever engine is installed with ITS word-timestamp option -- whisper.cpp
278
+ `--output-json-full`, faster-whisper `word_timestamps=True`, openai-whisper
279
+ `--word_timestamps True` -- and returns the words it measured. ([], engine) when the engine
280
+ ran but its build produced no word-level timings, so the caller can refuse naming that engine
281
+ instead of pretending the audio had no words in it. No engine at all raises through
282
+ die_no_engine(), the same refusal caption.py gives.
283
+ """
284
+ import shutil as _shutil
285
+ import subprocess as _subprocess
286
+ import tempfile as _tempfile
287
+ from _common import require_tool, run_analysis, STATE
288
+ ffmpeg = require_tool("ffmpeg")
289
+ tmpdir = _tempfile.mkdtemp(prefix="ffskill_asrw_")
290
+ try:
291
+ wav = os.path.join(tmpdir, "audio.wav")
292
+ run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
293
+ "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000",
294
+ "-c:a", "pcm_s16le", wav])
295
+
296
+ # 1. whisper.cpp
297
+ cli = _shutil.which("whisper-cli") or _shutil.which("whisper-cpp")
298
+ if not cli:
299
+ main_bin = _shutil.which("main")
300
+ if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
301
+ cli = main_bin
302
+ if cli:
303
+ model_path = model
304
+ if not os.path.exists(model_path):
305
+ for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"),
306
+ f"models/ggml-{model}.bin",
307
+ f"/usr/local/share/whisper/ggml-{model}.bin"):
308
+ if os.path.exists(cand):
309
+ model_path = cand
310
+ break
311
+ base = os.path.join(tmpdir, "out")
312
+ cmd = [cli, "-m", model_path, "-f", wav, "--output-json-full", "-of", base]
313
+ if language:
314
+ cmd += ["-l", language]
315
+ proc = _asr_run(cmd, _subprocess, "whisper.cpp")
316
+ if proc.returncode == 0 and os.path.exists(base + ".json"):
317
+ words = _words_from_whisper_cpp_json(base + ".json")
318
+ info(f"word timings from whisper.cpp ({len(words)} words)")
319
+ return words, "whisper.cpp"
320
+ info("whisper.cpp found but produced no word-timing JSON: "
321
+ + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
322
+ return [], "whisper.cpp"
323
+
324
+ # 2. faster-whisper
325
+ try:
326
+ from faster_whisper import WhisperModel # type: ignore
327
+ import threading
328
+ collected: list = []
329
+
330
+ def work() -> None:
331
+ m = WhisperModel(model, device="cpu", compute_type="int8")
332
+ segments, _ = m.transcribe(wav, language=language, word_timestamps=True)
333
+ for seg in segments:
334
+ for w in (getattr(seg, "words", None) or []):
335
+ collected.append({"word": str(w.word).strip(),
336
+ "start": float(w.start), "end": float(w.end)})
337
+
338
+ t = threading.Thread(target=work, daemon=True)
339
+ t.start()
340
+ t.join(STATE.timeout or None)
341
+ if t.is_alive():
342
+ die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise "
343
+ "--timeout for a long recording", code=124, kind="timeout")
344
+ info(f"word timings from faster-whisper ({len(collected)} words)")
345
+ return [w for w in collected if w["word"]], "faster-whisper"
346
+ except ImportError:
347
+ pass
348
+
349
+ # 3. openai-whisper CLI
350
+ if _shutil.which("whisper"):
351
+ cmd = ["whisper", wav, "--model", model, "--word_timestamps", "True",
352
+ "--output_format", "json", "--output_dir", tmpdir]
353
+ if language:
354
+ cmd += ["--language", language]
355
+ proc = _asr_run(cmd, _subprocess, "openai-whisper")
356
+ doc = os.path.join(tmpdir, "audio.json")
357
+ if proc.returncode == 0 and os.path.exists(doc):
358
+ words = _words_from_openai_whisper_json(doc)
359
+ info(f"word timings from openai-whisper ({len(words)} words)")
360
+ return words, "openai-whisper"
361
+ info("openai-whisper found but produced no word-timing JSON: "
362
+ + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
363
+ return [], "openai-whisper"
364
+
365
+ die_no_engine("or pass --words with a transcript you already have.",
366
+ flag="--filler --transcribe")
367
+ return [], None
368
+ finally:
369
+ _shutil.rmtree(tmpdir, ignore_errors=True)