ffmpeg-skill 2.6.0 → 2.7.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.
@@ -1,9 +1,11 @@
1
1
  """The optional local speech-to-text bridge, and the SRT the rest of the skill reads and writes.
2
2
 
3
- Whisper is never required. Nothing here runs unless a caller asked for a transcript:
3
+ No speech engine is ever required. Nothing here runs unless a caller asked for a transcript:
4
4
  `caption.py --transcribe` and `silence.py --filler --transcribe` share this one engine probe, so
5
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.
6
+ copied per tool. What a call used (engine, model, routing, words, notes) comes back in its
7
+ Transcription, never in module state. This module is neither ffprobe nor a decision, which is
8
+ why it is its own file.
7
9
  """
8
10
  from __future__ import annotations
9
11
 
@@ -11,20 +13,67 @@ import json
11
13
  import os
12
14
  import re
13
15
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional, Tuple
16
+ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
15
17
 
16
18
  from _common.decision import fmt_srt_time, parse_time
17
19
  from _common.emit import die, info
18
20
  from _common.runner import read_text_or_die
19
21
 
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")
22
+ # The engines this skill knows how to drive, and how to install each -- one string, so every
23
+ # tool that needs a transcript refuses in the same words. The two Parakeet engines are
24
+ # English-first (the default v2 model is English-only): under `--engine auto` they run only for
25
+ # English speech and the Whisper engines take every other language.
26
+ PARAKEET_ENGINES = ("parakeet-mlx", "parakeet.cpp")
27
+ WHISPER_ENGINES = ("whisper.cpp", "faster-whisper", "openai-whisper")
28
+ ASR_ENGINES = PARAKEET_ENGINES + WHISPER_ENGINES
29
+ ENGINE_CHOICES = ("auto",) + ASR_ENGINES
30
+ ASR_ENGINE_ENV = "FFMPEG_SKILL_ASR_ENGINE"
31
+ PARAKEET_MLX_DEFAULT_MODEL = "mlx-community/parakeet-tdt-0.6b-v2"
32
+ # Every engine transcribes on this machine: the audio is never uploaded. Three of them fetch
33
+ # their model weights the first time they run, which the hint says, so "offline" is never
34
+ # promised for a first run that is not. The skill's own code opens no connection, but
35
+ # faster-whisper is a library run inside this process, so its first-run fetch is made from here.
23
36
  ASR_INSTALL_HINT = (
24
- "Install one (all run offline):\n"
37
+ "Install one (each transcribes on this machine; the audio never leaves it):\n"
38
+ " parakeet-mlx: uv tool install parakeet-mlx (Apple Silicon; English; the first run\n"
39
+ " downloads the model from Hugging Face, " + PARAKEET_MLX_DEFAULT_MODEL + ")\n"
40
+ " parakeet.cpp: parakeet-cli from github.com/mudler/parakeet.cpp releases, plus a\n"
41
+ " tdt-0.6b-v2 .gguf in ~/.cache/parakeet.cpp/ (English)\n"
25
42
  " 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")
43
+ " faster-whisper: pip install faster-whisper (the first run downloads its model)\n"
44
+ " openai-whisper: pip install openai-whisper (the first run downloads its model)")
45
+
46
+
47
+ class Transcription:
48
+ """What one transcription produced, and what produced it.
49
+
50
+ transcribe_result() and transcribe_words_result() return one instead of leaving it in module
51
+ state, so two transcriptions in one process (mcp/server.py, batch.py, a test) can never read
52
+ each other's engine, words or routing.
53
+
54
+ cues [(start, end, text)]: what transcribe_result() wrote to the SRT (a Parakeet word
55
+ run fills them too; a whisper word run leaves them [])
56
+ words [{word, start, end}]: transcribe_words_result()'s word timings; from
57
+ transcribe_result(), a Parakeet engine's word timings for --karaoke ([] for a
58
+ whisper engine, whose word timings are read from a JSON next to the SRT instead)
59
+ engine the engine that produced them, or None
60
+ facts the result document's `transcription` block, in its key order:
61
+ {routing, detected_language?, engine, model, language}
62
+ notes sentences for the result document's top-level `notes`
63
+ """
64
+
65
+ def __init__(self, cues: Optional[List[Tuple[float, float, str]]] = None,
66
+ words: "Optional[List[Dict[str, Any]]]" = None, engine: Optional[str] = None,
67
+ facts: Optional[Dict[str, Any]] = None, notes: Optional[List[str]] = None) -> None:
68
+ self.cues: List[Tuple[float, float, str]] = list(cues or [])
69
+ self.words: "List[Dict[str, Any]]" = [dict(w) for w in (words or [])]
70
+ self.engine = engine
71
+ self.facts: Dict[str, Any] = dict(facts or {})
72
+ self.notes: List[str] = list(notes or [])
73
+
74
+ def __repr__(self) -> str:
75
+ return (f"Transcription(engine={self.engine!r}, cues={len(self.cues)}, words={len(self.words)}, "
76
+ f"facts={self.facts!r}, notes={self.notes!r})")
28
77
 
29
78
 
30
79
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
@@ -61,6 +110,67 @@ def die_no_speech(engine: str, video: str) -> None:
61
110
  hint="check that the input (and --audio-stream) carries the speech, or write the cues by hand with --text")
62
111
 
63
112
 
113
+ ENGINE_FAILED_REASON = "engine_failed"
114
+ ENGLISH_ONLY_REASON = "english_only"
115
+
116
+
117
+ def die_engine_failed(engine: str, video: str, tried: "List[Dict[str, str]]", installed: bool) -> None:
118
+ """A named --engine that did not transcribe. kind missing_tool only when it is not installed;
119
+ one that is installed and failed is kind input, `reason: "engine_failed"`, with the engine's
120
+ own last line in `detail` -- a JSON caller never sees the log line above it."""
121
+ if not installed:
122
+ die(f"--engine {engine} is not installed" + (
123
+ " (parakeet-cli and a .gguf model it can find: --model, PARAKEET_CPP_MODEL or ~/.cache/parakeet.cpp/)"
124
+ if engine == "parakeet.cpp" else ""),
125
+ kind="missing_tool", hint="install it (see --help), or use --engine auto to use whichever engine is installed")
126
+ detail = next((t["detail"] for t in tried if t["engine"] == engine), "failed")
127
+ die(f"--engine {engine} could not transcribe {video}: {engine} {detail}", kind="input",
128
+ reason=ENGINE_FAILED_REASON, engine=engine, detail=detail,
129
+ hint="fix the engine (its own message is in detail), or use --engine auto to fall back to another installed engine")
130
+
131
+
132
+ def die_engines_failed(tried: "List[Dict[str, str]]", video: str, flag: str, alternative: str) -> None:
133
+ """--engine auto found engines and none transcribed: name each and why. Not die_no_engine():
134
+ its install lines would tell the caller to install what is installed. kind input;
135
+ `reason` is "english_only" when the only engines found were Parakeet engines auto passed over
136
+ for a language that is not English, else "engine_failed"; `engines` lists them all."""
137
+ failed = [t for t in tried if t["reason"] != ENGLISH_ONLY_REASON]
138
+ if failed:
139
+ hint = "fix the engine that failed (its own message is in engines[].detail)"
140
+ else:
141
+ hint = ("install a Whisper engine for this language (see --help), or name a Parakeet engine with --engine "
142
+ "and give it a multilingual (v3) model")
143
+ die(f"no installed speech-to-text engine could transcribe {video} for {flag}:\n"
144
+ + "\n".join(f" {t['engine']} {t['detail']}" for t in tried) + "\n" + alternative,
145
+ kind="input", reason=ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
146
+ engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint)
147
+
148
+
149
+ def _failed(engine: str, detail: str) -> "Dict[str, str]":
150
+ return {"engine": engine, "reason": ENGINE_FAILED_REASON, "detail": detail}
151
+
152
+
153
+ def _failure_line(proc: Any, errors_on_stdout: bool = False) -> str:
154
+ """The line that says why an engine run failed, for the log and the failure document.
155
+
156
+ Its last stderr line, else its last stdout line. parakeet-mlx (`errors_on_stdout`) prints its
157
+ errors to stdout through rich -- "Error loading model ...", wrapped onto continuation lines --
158
+ while stderr may hold only a download's progress bars, so its last error line comes first."""
159
+ if errors_on_stdout:
160
+ lines = [ln.strip() for ln in (proc.stdout or "").splitlines()]
161
+ for i in range(len(lines) - 1, -1, -1):
162
+ if "error" in lines[i].lower():
163
+ end = i + 1
164
+ while end < len(lines) and lines[end]:
165
+ end += 1
166
+ return " ".join(lines[i:end])[:200]
167
+ for text in (proc.stderr, proc.stdout):
168
+ lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
169
+ if lines:
170
+ return lines[-1][:200]
171
+ return "?"
172
+
173
+
64
174
  def _engine_cues(srt: str, engine: str, video: str) -> List[Tuple[float, float, str]]:
65
175
  """The cues of an SRT an engine wrote; an SRT with no cue in it is die_no_speech()."""
66
176
  try:
@@ -85,79 +195,179 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
85
195
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
86
196
 
87
197
 
88
- def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
89
- """Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
90
- whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
91
- No engine installed -> clear error with install hints; the skill never depends on one."""
198
+ def transcribe_result(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0,
199
+ engine: Optional[str] = None) -> Transcription:
200
+ """Optional local ASR bridge: the SRT written to `out_srt`, and a Transcription saying what
201
+ produced it. `engine` (else $FFMPEG_SKILL_ASR_ENGINE, else auto) picks the engine; auto tries
202
+ Parakeet (parakeet-mlx, parakeet.cpp) for English speech, then whisper-cli / main
203
+ (whisper.cpp), faster-whisper (python), whisper (openai-whisper CLI). No engine installed ->
204
+ clear error with install hints; the skill never depends on one."""
92
205
  import shutil
93
206
  import subprocess
94
207
  import tempfile
95
- from _common import require_tool, run_analysis, STATE
208
+ from _common import require_tool
96
209
  ffmpeg = require_tool("ffmpeg")
210
+ wanted = requested_engine(engine)
97
211
  tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
98
212
  try:
99
- return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
213
+ return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess, wanted)
100
214
  finally:
101
215
  shutil.rmtree(tmpdir, ignore_errors=True)
102
216
 
103
217
 
104
- def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
218
+ def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0,
219
+ engine: Optional[str] = None) -> List[Tuple[float, float, str]]:
220
+ """transcribe_result()'s cues alone: the call shape caption.transcribe has always had. A caller
221
+ that reports which engine ran (caption.py does) calls transcribe_result() instead."""
222
+ return transcribe_result(video, out_srt, language, model, audio_stream, engine).cues
223
+
224
+
225
+ def _asr_run(cmd: List[str], subprocess, name: str, env: "Optional[Dict[str, str]]" = None) -> "subprocess.CompletedProcess":
105
226
  """Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
106
227
  from _common import STATE, die
107
228
  limit = STATE.timeout or None
108
229
  try:
109
- return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
230
+ return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
231
+ timeout=limit, **({"env": env} if env is not None else {}))
110
232
  except subprocess.TimeoutExpired:
111
233
  die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
112
234
  code=124, kind="timeout")
113
235
  return None # unreachable
114
236
 
115
237
 
238
+ def assumed_english_note(language_flag: str = "--language", whisper_failed: bool = False,
239
+ parakeet_model: bool = False) -> str:
240
+ """The top-level `notes` line for a transcript the English-only Parakeet model made from speech
241
+ whose language nobody named and nothing here could detect. The fix it names: the Whisper
242
+ engine that failed (`whisper_failed`), a multilingual Parakeet model when --model named a
243
+ Parakeet one (`parakeet_model`), else a Whisper engine to install."""
244
+ if whisper_failed:
245
+ fix = "or fix the Whisper engine that failed (the log names it)"
246
+ elif parakeet_model:
247
+ fix = "or name a multilingual (v3) Parakeet model with --model"
248
+ else:
249
+ fix = "or install a Whisper engine with a multilingual model, which --engine auto then uses for speech it cannot identify"
250
+ return (f"English was assumed: no {language_flag} was given and the spoken language could not be "
251
+ "detected here (that needs whisper.cpp with a multilingual ggml model), so the English-only "
252
+ "Parakeet model made this transcript. If the speech is not English the transcript is wrong: "
253
+ f"name the language with {language_flag}, " + fix)
254
+
255
+
256
+ def _parakeet_attempt(engines: "List[str]", facts: Dict[str, Any], wanted: str, language: Optional[str],
257
+ wav: str, tmpdir: str, model: Optional[str], shutil, subprocess, video: str,
258
+ language_flag: str, need_words: bool = False,
259
+ tried: "Optional[List[Dict[str, str]]]" = None) -> Optional[Tuple[Transcription, str]]:
260
+ """(Transcription, the model's file name) from the first of `engines` that transcribes, or None.
261
+
262
+ Under --engine auto with no language named or detected, the English-only model ran on an
263
+ assumption: the Transcription then carries assumed_english_note(), and a warning is logged. A
264
+ multilingual (v3) model assumed nothing, so it gets no note and its routing drops "assumed
265
+ English". `need_words`: an engine whose cues read but whose word times do not is passed over
266
+ (silence.py --filler can cut nothing on cues), unless it was asked for by name. An engine that
267
+ ran and did not transcribe is added to `tried`."""
268
+ for eng in engines:
269
+ got = run_parakeet(eng, wav, tmpdir, model, language, shutil, subprocess, video, tried)
270
+ if got and need_words and not got[1] and wanted not in PARAKEET_ENGINES:
271
+ # cues read but no word times (a caption could use them; this caller cannot); a
272
+ # named engine keeps its own "no word-level timings" refusal instead
273
+ info(f"{eng} gave cues but no readable word timings; trying the next engine")
274
+ if tried is not None:
275
+ tried.append(_failed(eng, "gave cues but no readable word timings"))
276
+ got = None
277
+ if not got:
278
+ continue
279
+ cues, words, chosen = got
280
+ run = Transcription(cues=cues, words=words, engine=eng,
281
+ facts=dict(facts, engine=eng, model=chosen, language=language or facts.get("detected_language")))
282
+ if wanted == "auto" and not language and not facts.get("detected_language"):
283
+ if _parakeet_multilingual(chosen):
284
+ run.facts["routing"] = run.facts["routing"].replace(", assumed English", "; the Parakeet model is multilingual")
285
+ else:
286
+ run.notes.append(assumed_english_note(language_flag, whisper_failed=facts.get("routing") == ROUTING_LAST_RESORT,
287
+ parakeet_model=facts.get("routing") == ROUTING_PARAKEET_MODEL))
288
+ info("warning: " + run.notes[-1])
289
+ return run, os.path.basename(chosen)
290
+ return None
291
+
292
+
293
+ def _skipped_parakeet(route: "Route", language: Optional[str], language_flag: str) -> "List[Dict[str, str]]":
294
+ """The installed Parakeet engines --engine auto passed over for a language that is not English."""
295
+ what = f"{language_flag} {language}" if language else f"the detected language {route.facts.get('detected_language')}"
296
+ return [{"engine": e, "reason": ENGLISH_ONLY_REASON,
297
+ "detail": f"is installed, but --engine auto runs Parakeet only for English speech, and {what} is not English"}
298
+ for e in route.skipped]
299
+
300
+
116
301
  def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
117
- ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
118
- from _common import run_analysis, STATE, die
302
+ ffmpeg: str, shutil, subprocess, wanted: str = "auto") -> Transcription:
303
+ from _common import run_analysis, STATE
119
304
  wav = os.path.join(tmpdir, "audio.wav")
120
305
  # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
121
306
  # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
122
307
  # reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
123
308
  run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
124
309
  "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
310
+ # every engine found that did not transcribe, and why: the refusal names them rather than
311
+ # telling the caller to install what is installed
312
+ tried: "List[Dict[str, str]]" = []
313
+
314
+ def parakeet(engines: "List[str]", facts: Dict[str, Any]) -> Optional[Transcription]:
315
+ done = _parakeet_attempt(engines, facts, wanted, language, wav, tmpdir, model, shutil, subprocess, video, "--language",
316
+ tried=tried)
317
+ if not done:
318
+ return None
319
+ run, model_name = done
320
+ info(f"transcribed with {run.engine} (model {model_name})")
321
+ write_srt(run.cues, out_srt)
322
+ return run
323
+
324
+ # 0. Parakeet (English), when auto-routing picks it or it was asked for by name
325
+ route = parakeet_route(wanted, language, wav, shutil, subprocess, model)
326
+ done = parakeet(route.first, route.facts)
327
+ if done:
328
+ return done
329
+ tried.extend(_skipped_parakeet(route, language, "--language"))
330
+ if wanted in PARAKEET_ENGINES:
331
+ die_engine_failed(wanted, video, tried, _parakeet_available(wanted, shutil, model))
332
+ facts = route.facts
333
+ if route.first:
334
+ # auto chose Parakeet and no Parakeet engine transcribed it: the routing says why Whisper ran
335
+ facts = dict(facts, routing=facts["routing"] + ROUTING_PARAKEET_FAILED)
336
+ # the language each Whisper engine is told: the named one, else the one auto's detector named
337
+ lang = language or facts.get("detected_language")
125
338
  # 1. whisper.cpp
126
- cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
127
- if not cli:
128
- # older whisper.cpp builds ship the binary as plain `main`; accept it only when it lives
129
- # in a directory that names whisper, so an unrelated /usr/bin/main is never run
130
- main_bin = shutil.which("main")
131
- if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
132
- cli = main_bin
339
+ cli = _whisper_cpp_cli(shutil) if wanted in ("auto", "whisper.cpp") else None
133
340
  if cli:
134
- model_path = model
135
- if not os.path.exists(model_path):
136
- 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"):
137
- if os.path.exists(cand):
138
- model_path = cand
139
- break
341
+ model_path = _whisper_cpp_model(model)
140
342
  base = os.path.join(tmpdir, "out")
141
- cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
142
- if language:
143
- cmd += ["-l", language]
343
+ # whisper.cpp's own default is -l en: without "auto" it decodes any speech as English
344
+ cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base, "-l", lang or "auto"]
144
345
  proc = _asr_run(cmd, subprocess, "whisper.cpp")
145
346
  if proc.returncode == 0 and os.path.exists(base + ".srt"):
146
347
  info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
147
348
  cues = _engine_cues(base + ".srt", "whisper.cpp", video)
148
349
  write_srt(cues, out_srt)
149
- return cues
150
- info("whisper.cpp found but failed: " + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
350
+ return Transcription(cues=cues, engine="whisper.cpp",
351
+ facts=dict(facts, engine="whisper.cpp", model=model_path, language=lang))
352
+ line = _failure_line(proc)
353
+ info("whisper.cpp found but failed: " + line)
354
+ tried.append(_failed("whisper.cpp", "found but failed: " + line))
151
355
  # 2. faster-whisper (python package)
152
356
  try:
357
+ if wanted not in ("auto", "faster-whisper"):
358
+ raise ImportError
153
359
  from faster_whisper import WhisperModel # type: ignore
154
360
  import threading
155
361
  result: list = []
362
+ crashed: list = []
156
363
 
157
364
  def work() -> None:
158
- m = WhisperModel(model, device="cpu", compute_type="int8")
159
- segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
160
- result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
365
+ try:
366
+ m = WhisperModel(model, device="cpu", compute_type="int8")
367
+ segments, _ = m.transcribe(wav, language=lang, word_timestamps=False)
368
+ result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
369
+ except Exception as exc: # noqa: BLE001 -- a model that would not load or run: a failed engine
370
+ crashed.append(exc)
161
371
 
162
372
  # An in-process engine gets the same wall-clock limit as the CLI engines and ffmpeg.
163
373
  t = threading.Thread(target=work, daemon=True)
@@ -165,28 +375,50 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
165
375
  t.join(STATE.timeout or None)
166
376
  if t.is_alive():
167
377
  die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise --timeout for a long recording", code=124, kind="timeout")
168
- cues = list(result)
169
- if not cues:
170
- die_no_speech("faster-whisper", video)
171
- info("transcribed with faster-whisper")
172
- write_srt(cues, out_srt)
173
- return cues
378
+ if crashed:
379
+ # it failed (a model it could not fetch or load), which is not "found no speech":
380
+ # move on to the next engine, as a failed whisper.cpp does
381
+ line = str(crashed[0])[:200] or type(crashed[0]).__name__
382
+ info("faster-whisper found but failed: " + line)
383
+ tried.append(_failed("faster-whisper", "found but failed: " + line))
384
+ else:
385
+ cues = list(result)
386
+ if not cues:
387
+ die_no_speech("faster-whisper", video)
388
+ info("transcribed with faster-whisper")
389
+ write_srt(cues, out_srt)
390
+ return Transcription(cues=cues, engine="faster-whisper",
391
+ facts=dict(facts, engine="faster-whisper", model=model, language=lang))
174
392
  except ImportError:
175
393
  pass
176
394
  # 3. openai-whisper CLI
177
- if shutil.which("whisper"):
395
+ if wanted in ("auto", "openai-whisper") and shutil.which("whisper"):
178
396
  cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
179
- if language:
180
- cmd += ["--language", language]
397
+ if lang:
398
+ cmd += ["--language", lang]
181
399
  proc = _asr_run(cmd, subprocess, "openai-whisper")
182
400
  srt = os.path.join(tmpdir, "audio.srt")
183
401
  if proc.returncode == 0 and os.path.exists(srt):
184
402
  info("transcribed with openai-whisper")
185
403
  cues = _engine_cues(srt, "openai-whisper", video)
186
404
  write_srt(cues, out_srt)
187
- return cues
405
+ return Transcription(cues=cues, engine="openai-whisper",
406
+ facts=dict(facts, engine="openai-whisper", model=model, language=lang))
407
+ line = _failure_line(proc)
408
+ info("openai-whisper found but failed: " + line)
409
+ tried.append(_failed("openai-whisper", "found but failed: " + line))
410
+ # 4. Parakeet after all, when the language was never known and every Whisper engine failed
411
+ if route.last:
412
+ info("no Whisper engine transcribed it; trying Parakeet with English assumed")
413
+ done = parakeet(list(route.last), {"routing": ROUTING_LAST_RESORT})
414
+ if done:
415
+ return done
416
+ if wanted != "auto":
417
+ die_engine_failed(wanted, video, tried, any(t["engine"] == wanted for t in tried))
418
+ if tried:
419
+ die_engines_failed(tried, video, "--transcribe", "Or write the cues by hand with --text cues.txt (see format above).")
188
420
  die_no_engine("Or write the cues by hand with --text cues.txt (see format above).")
189
- return []
421
+ return Transcription()
190
422
 
191
423
 
192
424
  def die_no_engine(alternative: str, flag: str = "--transcribe") -> None:
@@ -293,22 +525,395 @@ def _words_from_openai_whisper_json(path: str) -> "List[Dict[str, Any]]":
293
525
  return [w for w in out if w["word"]]
294
526
 
295
527
 
528
+ # ------------------------------------------------------- Parakeet engines and routing
529
+
530
+
531
+ def _words_from_parakeet_mlx_json(path: str) -> "List[Dict[str, Any]]":
532
+ """parakeet-mlx --output-format json: sentences[].tokens[] of SUB-word pieces, times in seconds.
533
+
534
+ A piece with a leading space starts a word; a bare " " piece is a separator, so the piece
535
+ after it starts a word too (" m" "is" "ter" " " "Q" "u" "il" "ter" -> "mister", "Quilter").
536
+ """
537
+ try:
538
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
539
+ except (OSError, ValueError):
540
+ return []
541
+ out: "List[Dict[str, Any]]" = []
542
+ for sent in _dicts(doc.get("sentences")) if isinstance(doc, dict) else []:
543
+ brk = True # every sentence starts a new word
544
+ for tok in _dicts(sent.get("tokens")):
545
+ text = str(tok.get("text") or "")
546
+ try:
547
+ start, end = float(tok["start"]), float(tok["end"])
548
+ except (KeyError, TypeError, ValueError):
549
+ continue
550
+ if not text.strip():
551
+ brk = True
552
+ continue
553
+ if brk or text.startswith(" ") or not out:
554
+ out.append({"word": text.strip(), "start": start, "end": end})
555
+ else:
556
+ out[-1]["word"] += text
557
+ out[-1]["end"] = end
558
+ brk = False
559
+ return [w for w in out if w["word"]]
560
+
561
+
562
+ def _cues_from_parakeet_mlx_json(path: str) -> List[Tuple[float, float, str]]:
563
+ """parakeet-mlx's own sentence segmentation, as cues."""
564
+ try:
565
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
566
+ except (OSError, ValueError):
567
+ return []
568
+ cues: List[Tuple[float, float, str]] = []
569
+ for sent in _dicts(doc.get("sentences")) if isinstance(doc, dict) else []:
570
+ try:
571
+ text = str(sent.get("text") or "").strip()
572
+ if text:
573
+ cues.append((float(sent["start"]), float(sent["end"]), text))
574
+ except (KeyError, TypeError, ValueError):
575
+ continue
576
+ return cues
577
+
578
+
579
+ def _dicts(value: Any) -> "List[Dict[str, Any]]":
580
+ """The dict items of a JSON list; anything else (a number, a string, null) is no items. The
581
+ engines' JSON is parsed defensively: a wrong shape yields no words, never a traceback."""
582
+ return [v for v in value if isinstance(v, dict)] if isinstance(value, list) else []
583
+
584
+
585
+ def _words_from_parakeet_cpp_json(text: str) -> "List[Dict[str, Any]]":
586
+ """parakeet-cli transcribe --json: {"words": [{"w", "start", "end", "conf"}]}, times in seconds."""
587
+ try:
588
+ doc = json.loads(text)
589
+ except ValueError:
590
+ return []
591
+ out: "List[Dict[str, Any]]" = []
592
+ for w in _dicts(doc.get("words")) if isinstance(doc, dict) else []:
593
+ try:
594
+ word = str(w.get("w") or w.get("word") or "").strip()
595
+ if word:
596
+ out.append({"word": word, "start": float(w["start"]), "end": float(w["end"])})
597
+ except (AttributeError, KeyError, TypeError, ValueError):
598
+ continue
599
+ return out
600
+
601
+
602
+ # Cue boundaries for an engine that gives words but no sentences (parakeet.cpp): the same limits
603
+ # parakeet-mlx's own segmentation is run with, so both Parakeet engines cut cues alike.
604
+ CUE_MAX_SECONDS = 7.0
605
+ CUE_MAX_CHARS = 84
606
+ CUE_GAP_SECONDS = 0.8
607
+
608
+
609
+ def cues_from_words(words: "List[Dict[str, Any]]") -> List[Tuple[float, float, str]]:
610
+ """Group timed words into cues: a cue ends after sentence-final punctuation, before a pause of
611
+ CUE_GAP_SECONDS or more, or before it would pass CUE_MAX_SECONDS / CUE_MAX_CHARS."""
612
+ cues: List[Tuple[float, float, str]] = []
613
+ cur: "List[Dict[str, Any]]" = []
614
+
615
+ def flush() -> None:
616
+ if cur:
617
+ cues.append((cur[0]["start"], cur[-1]["end"], " ".join(w["word"] for w in cur)))
618
+ cur.clear()
619
+
620
+ for w in words:
621
+ if cur:
622
+ text_len = len(" ".join(x["word"] for x in cur)) + 1 + len(w["word"])
623
+ if (w["start"] - cur[-1]["end"] >= CUE_GAP_SECONDS or w["end"] - cur[0]["start"] > CUE_MAX_SECONDS
624
+ or text_len > CUE_MAX_CHARS):
625
+ flush()
626
+ cur.append(w)
627
+ if w["word"][-1:] in ".?!":
628
+ flush()
629
+ flush()
630
+ return cues
631
+
632
+
633
+ def requested_engine(engine: Optional[str]) -> str:
634
+ """--engine, else $FFMPEG_SKILL_ASR_ENGINE, else "auto"; an unknown env value is refused."""
635
+ name = engine or os.environ.get(ASR_ENGINE_ENV, "").strip() or "auto"
636
+ if name not in ENGINE_CHOICES:
637
+ die(f"{ASR_ENGINE_ENV}={name!r} is not a speech engine this skill drives (one of {', '.join(ENGINE_CHOICES)})",
638
+ kind="input", hint=f"unset {ASR_ENGINE_ENV} or set it to one of {', '.join(ENGINE_CHOICES)}")
639
+ return name
640
+
641
+
642
+ def _is_english(language: Optional[str]) -> bool:
643
+ # "eng" too: caption.py's --language is also the mux track tag, where ISO 639-2 is natural
644
+ return (language or "").lower().split("-")[0].split("_")[0] in ("en", "eng", "english")
645
+
646
+
647
+ def _whisper_cpp_cli(shutil) -> Optional[str]:
648
+ cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
649
+ if not cli:
650
+ # older whisper.cpp builds ship the binary as plain `main`; accept it only when it lives
651
+ # in a directory that names whisper, so an unrelated /usr/bin/main is never run
652
+ main_bin = shutil.which("main")
653
+ if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
654
+ cli = main_bin
655
+ return cli
656
+
657
+
658
+ def _whisper_cpp_model(model: str) -> str:
659
+ if os.path.exists(model):
660
+ return model
661
+ for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"), f"models/ggml-{model}.bin",
662
+ f"/usr/local/share/whisper/ggml-{model}.bin"):
663
+ if os.path.exists(cand):
664
+ return cand
665
+ return model
666
+
667
+
668
+ def detect_language(wav: str, shutil, subprocess) -> Optional[str]:
669
+ """The spoken language of `wav` from whisper.cpp's detector (-dl), or None when it cannot tell.
670
+
671
+ The smallest installed ggml model is used: detection reads the first 30 s once, and a small
672
+ model answers it in well under a second on the GPU.
673
+ """
674
+ cli = _whisper_cpp_cli(shutil)
675
+ if not cli:
676
+ return None
677
+ model = next((p for p in (_whisper_cpp_model(m) for m in ("tiny", "base", "small", "tiny.en"))
678
+ if os.path.exists(p) and not p.endswith(".en.bin")), None)
679
+ if not model:
680
+ return None
681
+ proc = _asr_run([cli, "-m", model, "-f", wav, "-dl", "-l", "auto"], subprocess, "whisper.cpp language detection")
682
+ m = re.search(r"auto-detected language:\s*([a-z]{2,3})", (proc.stderr or "") + (proc.stdout or ""))
683
+ return m.group(1) if m else None
684
+
685
+
686
+ ROUTING_ASSUMED_ENGLISH = "auto: language not detectable here and no Whisper engine, assumed English"
687
+ ROUTING_UNDETECTED_WHISPER = "auto: language not detectable here, Whisper first (Parakeet is English-only)"
688
+ ROUTING_LAST_RESORT = "auto: language not detectable here and no Whisper engine transcribed it, assumed English"
689
+ ROUTING_PARAKEET_MODEL = "auto: --model names a Parakeet model and the language is not detectable here, assumed English"
690
+ # appended to the routing auto chose Parakeet for, when no Parakeet engine transcribed it and a
691
+ # Whisper engine did
692
+ ROUTING_PARAKEET_FAILED = ", no Parakeet engine transcribed it"
693
+
694
+
695
+ class Route(NamedTuple):
696
+ """parakeet_route()'s answer: which Parakeet engines run, and when."""
697
+ first: List[str] # Parakeet engines to try before any Whisper engine
698
+ facts: Dict[str, Any] # the routing facts for the result document's `transcription`
699
+ last: Tuple[str, ...] = () # Parakeet engines to try once every Whisper engine has failed
700
+ skipped: Tuple[str, ...] = () # installed Parakeet engines auto passed over: the language is not English
701
+
702
+
703
+ def parakeet_route(engine: str, language: Optional[str], wav: str, shutil, subprocess,
704
+ model: Optional[str] = None, whisper_model: Optional[str] = None) -> Route:
705
+ """Route(the Parakeet engines to try first, routing facts for the result document, the ones to
706
+ try last, the installed ones passed over).
707
+
708
+ `auto` tries Parakeet only for English: an explicit --language, else whisper.cpp's detector.
709
+ When neither can say, the English-only model is not trusted with it: a Whisper engine that
710
+ can run (`whisper_model`, default `model`) goes first and Parakeet only after every Whisper
711
+ engine failed; with no Whisper engine (or a --model that names a Parakeet model, which no
712
+ Whisper engine can load), Parakeet runs on English assumed and the caller's result carries
713
+ assumed_english_note(). A named Parakeet engine always runs; with a non-English --language it
714
+ needs a multilingual (v3) model, which _parakeet_model_for() checks.
715
+ """
716
+ if engine in PARAKEET_ENGINES:
717
+ return Route([engine], {"routing": "requested"})
718
+ if engine != "auto":
719
+ return Route([], {"routing": "requested"})
720
+ installed = tuple(e for e in PARAKEET_ENGINES if _parakeet_available(e, shutil, model))
721
+ if not installed:
722
+ return Route([], {"routing": "auto: no Parakeet engine installed"})
723
+ if language:
724
+ if _is_english(language):
725
+ return Route(list(PARAKEET_ENGINES), {"routing": "auto: --language is English"})
726
+ info(f"{' and '.join(installed)} passed over: --engine auto runs Parakeet only for English speech (--language {language})")
727
+ return Route([], {"routing": f"auto: --language {language} is not English"}, (), installed)
728
+ detected = detect_language(wav, shutil, subprocess)
729
+ if detected is None:
730
+ wmodel = whisper_model if whisper_model is not None else model
731
+ if _names_parakeet_model(wmodel or "base"):
732
+ return Route(list(PARAKEET_ENGINES), {"routing": ROUTING_PARAKEET_MODEL})
733
+ if _whisper_ready(shutil, wmodel):
734
+ info("the spoken language could not be detected here; a Whisper engine takes it (Parakeet's model is English-only)")
735
+ return Route([], {"routing": ROUTING_UNDETECTED_WHISPER}, PARAKEET_ENGINES)
736
+ return Route(list(PARAKEET_ENGINES), {"routing": ROUTING_ASSUMED_ENGLISH})
737
+ if _is_english(detected):
738
+ return Route(list(PARAKEET_ENGINES), {"routing": "auto: detected English", "detected_language": detected})
739
+ info(f"{' and '.join(installed)} passed over: --engine auto runs Parakeet only for English speech (detected {detected})")
740
+ return Route([], {"routing": f"auto: detected {detected}, not English", "detected_language": detected}, (), installed)
741
+
742
+
743
+ def _module_importable(name: str) -> bool:
744
+ """`import name` would find something, without importing it (faster-whisper pulls in ctranslate2)."""
745
+ import importlib.util
746
+ import sys
747
+ try:
748
+ return importlib.util.find_spec(name) is not None
749
+ except (ImportError, ValueError): # a module already in sys.modules with no __spec__ (a stub)
750
+ return sys.modules.get(name) is not None
751
+
752
+
753
+ def _names_parakeet_model(name: str) -> bool:
754
+ """`name` is a Parakeet model (an mlx-community/parakeet-* repo or a .gguf), which no Whisper engine loads."""
755
+ return "parakeet" in name.lower() or name.lower().endswith(".gguf")
756
+
757
+
758
+ def _english_only_whisper_model(name: str) -> bool:
759
+ """An English-only Whisper model: tiny.en, base.en, ... or a ggml-*.en.bin file."""
760
+ base = os.path.basename(name).lower()
761
+ return (base[:-4] if base.endswith(".bin") else base).endswith(".en")
762
+
763
+
764
+ def _whisper_ready(shutil, model: Optional[str]) -> bool:
765
+ """A Whisper engine that --engine auto would run with `model` on speech in any language:
766
+ whisper.cpp with that model on disk, faster-whisper importable, or the openai-whisper CLI. A
767
+ Parakeet model name is no Whisper model, and an English-only one (base.en, ggml-*.en.bin)
768
+ handles English alone, so both answer False."""
769
+ name = (model or "base")
770
+ if _names_parakeet_model(name) or _english_only_whisper_model(name):
771
+ return False
772
+ if _whisper_cpp_cli(shutil):
773
+ path = _whisper_cpp_model(name)
774
+ if os.path.exists(path) and not _english_only_whisper_model(path):
775
+ return True
776
+ return _module_importable("faster_whisper") or bool(shutil.which("whisper"))
777
+
778
+
779
+ def _parakeet_available(engine: str, shutil, model: Optional[str] = None) -> bool:
780
+ if engine == "parakeet-mlx":
781
+ return bool(shutil.which("parakeet-mlx"))
782
+ gguf = _parakeet_cpp_gguf(model)
783
+ return bool(shutil.which("parakeet-cli")) and gguf is not None and os.path.isfile(gguf)
784
+
785
+
786
+ def _parakeet_cpp_gguf(model: Optional[str]) -> Optional[str]:
787
+ """--model when it is a .gguf, else $PARAKEET_CPP_MODEL, else an English tdt-0.6b-v2 .gguf in
788
+ ~/.cache/parakeet.cpp/ (f16 first, then q8_0, then any other quantisation)."""
789
+ if model and model.lower().endswith(".gguf"):
790
+ return os.path.expanduser(model)
791
+ env = os.environ.get("PARAKEET_CPP_MODEL", "").strip()
792
+ if env:
793
+ return os.path.expanduser(env)
794
+ cache = Path(os.path.expanduser("~/.cache/parakeet.cpp"))
795
+ found = sorted(cache.glob("tdt-0.6b-v2-*.gguf")) if cache.is_dir() else []
796
+ for pref in ("-f16.gguf", "-q8_0.gguf"):
797
+ for p in found:
798
+ if p.name.endswith(pref):
799
+ return str(p)
800
+ return str(found[0]) if found else None
801
+
802
+
803
+ def _parakeet_model_for(engine: str, model: Optional[str], language: Optional[str]) -> str:
804
+ """The model the Parakeet engine runs: --model when it names a Parakeet model, else the
805
+ engine's own environment variable, else the English v2 default. A non-English --language on a
806
+ model without "v3" in its name is refused: v2 would transcribe it as English gibberish."""
807
+ if engine == "parakeet-mlx":
808
+ chosen = model if model and "parakeet" in model.lower() and not model.lower().endswith(".gguf") else \
809
+ (os.environ.get("PARAKEET_MODEL", "").strip() or PARAKEET_MLX_DEFAULT_MODEL)
810
+ else:
811
+ chosen = _parakeet_cpp_gguf(model) or ""
812
+ if language and not _is_english(language) and not _parakeet_multilingual(chosen):
813
+ die(f"{engine} with model {os.path.basename(chosen) or '?'} is English-only; --language {language} needs a multilingual (v3) Parakeet model",
814
+ kind="input", hint="pass --model with a parakeet-tdt-0.6b-v3 model, or --engine auto / whisper.cpp for this language")
815
+ return chosen
816
+
817
+
818
+ def _parakeet_multilingual(model: str) -> bool:
819
+ """A multilingual Parakeet model (tdt-0.6b-v3, 25 European languages) rather than the English-only v2."""
820
+ return "v3" in os.path.basename(model or "").lower()
821
+
822
+
823
+ def run_parakeet(engine: str, wav: str, tmpdir: str, model: Optional[str], language: Optional[str], shutil, subprocess,
824
+ video: str, tried: "Optional[List[Dict[str, str]]]" = None,
825
+ ) -> Optional[Tuple[List[Tuple[float, float, str]], "List[Dict[str, Any]]", str]]:
826
+ """(cues, words, model) from one Parakeet engine, or None when it is not installed or failed
827
+ (an info line says which, and a failed run is added to `tried`), so the caller moves on to the
828
+ next engine. Output that yields no words is only "no speech" when it is the engine's own empty
829
+ answer; anything else it could not be read, which is a failed run too."""
830
+ if not _parakeet_available(engine, shutil, model):
831
+ info(f"{engine} not available" + (" (no readable .gguf: --model, PARAKEET_CPP_MODEL, or tdt-0.6b-v2 in ~/.cache/parakeet.cpp)"
832
+ if engine == "parakeet.cpp" and shutil.which("parakeet-cli") else ""))
833
+ return None
834
+ chosen = _parakeet_model_for(engine, model, language)
835
+
836
+ def failed(detail: str) -> None:
837
+ info(f"{engine} {detail}")
838
+ if tried is not None:
839
+ tried.append(_failed(engine, detail))
840
+
841
+ if engine == "parakeet-mlx":
842
+ outdir = os.path.join(tmpdir, "pmlx")
843
+ cmd = ["parakeet-mlx", wav, "--model", chosen, "--output-format", "json", "--output-dir", outdir,
844
+ "--max-duration", f"{CUE_MAX_SECONDS:g}", "--silence-gap", f"{CUE_GAP_SECONDS:g}"]
845
+ # the JSON is looked for as <outdir>/audio.json: a host PARAKEET_OUTPUT_TEMPLATE would name it otherwise
846
+ env = {k: v for k, v in os.environ.items() if k != "PARAKEET_OUTPUT_TEMPLATE"}
847
+ proc = _asr_run(cmd, subprocess, "parakeet-mlx", env=env)
848
+ doc = os.path.join(outdir, os.path.splitext(os.path.basename(wav))[0] + ".json")
849
+ if proc.returncode != 0 or not os.path.exists(doc):
850
+ failed("found but failed: " + _failure_line(proc, errors_on_stdout=True))
851
+ return None
852
+ cues, words = _cues_from_parakeet_mlx_json(doc), _words_from_parakeet_mlx_json(doc)
853
+ try:
854
+ raw, key = Path(doc).read_text(encoding="utf-8", errors="replace"), "sentences"
855
+ except OSError:
856
+ raw, key = "", "sentences"
857
+ else:
858
+ cmd = ["parakeet-cli", "transcribe", "--model", chosen, "--input", wav, "--json"]
859
+ proc = _asr_run(cmd, subprocess, "parakeet.cpp")
860
+ if proc.returncode != 0:
861
+ failed("found but failed: " + _failure_line(proc))
862
+ return None
863
+ words = _words_from_parakeet_cpp_json(proc.stdout)
864
+ cues = cues_from_words(words)
865
+ raw, key = proc.stdout or "", "words"
866
+ if not cues:
867
+ if _heard_nothing(raw, key):
868
+ die_no_speech(engine, video)
869
+ failed(f"ran but wrote output this skill cannot read as a transcript ({(raw.strip() or 'nothing')[:80]!r})")
870
+ return None
871
+ return cues, words, chosen
872
+
873
+
874
+ def _heard_nothing(text: str, key: str) -> bool:
875
+ """True when an engine's JSON is its well-formed empty answer, {key: []}: what parakeet-cli
876
+ ("words") and parakeet-mlx ("sentences") both write for silence (measured on 3 s of it).
877
+ Anything else that yields no words -- not JSON, the wrong shape, entries that do not parse --
878
+ is output that could not be read, not a finding that nobody spoke."""
879
+ try:
880
+ doc = json.loads(text)
881
+ except ValueError:
882
+ return False
883
+ return isinstance(doc, dict) and doc.get(key) == []
884
+
885
+
296
886
  def transcribe_words(video: str, language: "Optional[str]" = None, model: str = "base",
297
- audio_stream: int = 0) -> "Tuple[List[Dict[str, Any]], Optional[str]]":
298
- """([{word, start, end}, ...], the engine that produced them) from a local whisper.
887
+ audio_stream: int = 0, engine: "Optional[str]" = None) -> "Tuple[List[Dict[str, Any]], Optional[str]]":
888
+ """transcribe_words_result()'s (words, engine) alone: the call shape this function has had
889
+ since 1.17. A caller that reports which engine ran (silence.py does) calls
890
+ transcribe_words_result() instead."""
891
+ run = transcribe_words_result(video, language, model, audio_stream, engine)
892
+ return run.words, run.engine
893
+
894
+
895
+ def transcribe_words_result(video: str, language: "Optional[str]" = None, model: str = "base",
896
+ audio_stream: int = 0, engine: "Optional[str]" = None,
897
+ language_flag: str = "--filler-lang") -> Transcription:
898
+ """[{word, start, end}, ...] from a local speech engine, as a Transcription naming the engine.
299
899
 
300
- Drives whichever engine is installed with ITS word-timestamp option -- whisper.cpp
900
+ `engine` (else $FFMPEG_SKILL_ASR_ENGINE, else auto) picks it; auto tries Parakeet for English
901
+ speech first (parakeet-mlx sub-word tokens merged into words, parakeet.cpp `--json` words),
902
+ then drives whichever whisper is installed with ITS word-timestamp option -- whisper.cpp
301
903
  `--output-json-full`, faster-whisper `word_timestamps=True`, openai-whisper
302
- `--word_timestamps True` -- and returns the words it measured. ([], engine) when the engine
303
- ran but its build produced no word-level timings, so the caller can refuse naming that engine
304
- instead of pretending the audio had no words in it. No engine at all raises through
305
- die_no_engine(), the same refusal caption.py gives.
904
+ `--word_timestamps True` -- and returns the words it measured. Words [] with the engine set
905
+ when the engine ran but its build produced no word-level timings, so the caller can refuse
906
+ naming that engine instead of pretending the audio had no words in it. No engine at all
907
+ raises through die_no_engine(), the same refusal caption.py gives. `language_flag` is the
908
+ caller's flag for the language, named in assumed_english_note().
306
909
  """
307
910
  import shutil as _shutil
308
911
  import subprocess as _subprocess
309
912
  import tempfile as _tempfile
310
913
  from _common import require_tool, run_analysis, STATE
311
914
  ffmpeg = require_tool("ffmpeg")
915
+ wanted = requested_engine(engine)
916
+ parakeet_model = None if model == "base" else model
312
917
  tmpdir = _tempfile.mkdtemp(prefix="ffskill_asrw_")
313
918
  try:
314
919
  wav = os.path.join(tmpdir, "audio.wav")
@@ -316,47 +921,72 @@ def transcribe_words(video: str, language: "Optional[str]" = None, model: str =
316
921
  "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000",
317
922
  "-c:a", "pcm_s16le", wav])
318
923
 
924
+ tried: "List[Dict[str, str]]" = [] # engines found that gave no words, and why
925
+
926
+ def parakeet(engines: "List[str]", facts: Dict[str, Any]) -> Optional[Transcription]:
927
+ done = _parakeet_attempt(engines, facts, wanted, language, wav, tmpdir, parakeet_model,
928
+ _shutil, _subprocess, video, language_flag, need_words=True, tried=tried)
929
+ if not done:
930
+ return None
931
+ run, model_name = done
932
+ info(f"word timings from {run.engine} ({len(run.words)} words, model {model_name})")
933
+ return run
934
+
935
+ # 0. Parakeet (English), when auto-routing picks it or it was asked for by name
936
+ route = parakeet_route(wanted, language, wav, _shutil, _subprocess, parakeet_model, whisper_model=model)
937
+ done = parakeet(route.first, route.facts)
938
+ if done:
939
+ return done
940
+ tried.extend(_skipped_parakeet(route, language, language_flag))
941
+ if wanted in PARAKEET_ENGINES:
942
+ die_engine_failed(wanted, video, tried, _parakeet_available(wanted, _shutil, parakeet_model))
943
+ facts = route.facts
944
+ if route.first:
945
+ facts = dict(facts, routing=facts["routing"] + ROUTING_PARAKEET_FAILED)
946
+ lang = language or facts.get("detected_language")
947
+ failed: Optional[str] = None # the first Whisper engine that ran and wrote no word-timing JSON
948
+ # Undetected speech (Parakeet waits in route.last) was routed to Whisper because some
949
+ # Whisper engine can take it, so each one gets its turn; otherwise the first that ran
950
+ # and failed is the one the caller's refusal names, as before.
951
+ every_whisper = bool(route.last)
952
+
319
953
  # 1. whisper.cpp
320
- cli = _shutil.which("whisper-cli") or _shutil.which("whisper-cpp")
321
- if not cli:
322
- main_bin = _shutil.which("main")
323
- if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
324
- cli = main_bin
954
+ cli = _whisper_cpp_cli(_shutil) if wanted in ("auto", "whisper.cpp") else None
325
955
  if cli:
326
- model_path = model
327
- if not os.path.exists(model_path):
328
- for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"),
329
- f"models/ggml-{model}.bin",
330
- f"/usr/local/share/whisper/ggml-{model}.bin"):
331
- if os.path.exists(cand):
332
- model_path = cand
333
- break
956
+ model_path = _whisper_cpp_model(model)
334
957
  base = os.path.join(tmpdir, "out")
335
- cmd = [cli, "-m", model_path, "-f", wav, "--output-json-full", "-of", base]
336
- if language:
337
- cmd += ["-l", language]
958
+ # whisper.cpp's own default is -l en: without "auto" it decodes any speech as English
959
+ cmd = [cli, "-m", model_path, "-f", wav, "--output-json-full", "-of", base, "-l", lang or "auto"]
338
960
  proc = _asr_run(cmd, _subprocess, "whisper.cpp")
339
961
  if proc.returncode == 0 and os.path.exists(base + ".json"):
340
962
  words = _words_from_whisper_cpp_json(base + ".json")
341
963
  info(f"word timings from whisper.cpp ({len(words)} words)")
342
- return words, "whisper.cpp"
343
- info("whisper.cpp found but produced no word-timing JSON: "
344
- + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
345
- return [], "whisper.cpp"
964
+ return Transcription(words=words, engine="whisper.cpp",
965
+ facts=dict(facts, engine="whisper.cpp", model=model_path, language=lang))
966
+ line = _failure_line(proc)
967
+ info("whisper.cpp found but produced no word-timing JSON: " + line)
968
+ tried.append(_failed("whisper.cpp", "found but produced no word-timing JSON: " + line))
969
+ failed = "whisper.cpp"
346
970
 
347
971
  # 2. faster-whisper
348
972
  try:
973
+ if (failed and not every_whisper) or wanted not in ("auto", "faster-whisper"):
974
+ raise ImportError
349
975
  from faster_whisper import WhisperModel # type: ignore
350
976
  import threading
351
977
  collected: list = []
978
+ crashed: list = []
352
979
 
353
980
  def work() -> None:
354
- m = WhisperModel(model, device="cpu", compute_type="int8")
355
- segments, _ = m.transcribe(wav, language=language, word_timestamps=True)
356
- for seg in segments:
357
- for w in (getattr(seg, "words", None) or []):
358
- collected.append({"word": str(w.word).strip(),
359
- "start": float(w.start), "end": float(w.end)})
981
+ try:
982
+ m = WhisperModel(model, device="cpu", compute_type="int8")
983
+ segments, _ = m.transcribe(wav, language=lang, word_timestamps=True)
984
+ for seg in segments:
985
+ for w in (getattr(seg, "words", None) or []):
986
+ collected.append({"word": str(w.word).strip(),
987
+ "start": float(w.start), "end": float(w.end)})
988
+ except Exception as exc: # noqa: BLE001 -- a model that would not load or run
989
+ crashed.append(exc)
360
990
 
361
991
  t = threading.Thread(target=work, daemon=True)
362
992
  t.start()
@@ -364,29 +994,52 @@ def transcribe_words(video: str, language: "Optional[str]" = None, model: str =
364
994
  if t.is_alive():
365
995
  die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise "
366
996
  "--timeout for a long recording", code=124, kind="timeout")
367
- info(f"word timings from faster-whisper ({len(collected)} words)")
368
- return [w for w in collected if w["word"]], "faster-whisper"
997
+ if crashed:
998
+ line = str(crashed[0])[:200] or type(crashed[0]).__name__
999
+ info("faster-whisper found but failed: " + line)
1000
+ tried.append(_failed("faster-whisper", "found but failed: " + line))
1001
+ failed = failed or "faster-whisper"
1002
+ else:
1003
+ info(f"word timings from faster-whisper ({len(collected)} words)")
1004
+ return Transcription(words=[w for w in collected if w["word"]], engine="faster-whisper",
1005
+ facts=dict(facts, engine="faster-whisper", model=model, language=lang))
369
1006
  except ImportError:
370
1007
  pass
371
1008
 
372
1009
  # 3. openai-whisper CLI
373
- if _shutil.which("whisper"):
1010
+ if (not failed or every_whisper) and wanted in ("auto", "openai-whisper") and _shutil.which("whisper"):
374
1011
  cmd = ["whisper", wav, "--model", model, "--word_timestamps", "True",
375
1012
  "--output_format", "json", "--output_dir", tmpdir]
376
- if language:
377
- cmd += ["--language", language]
1013
+ if lang:
1014
+ cmd += ["--language", lang]
378
1015
  proc = _asr_run(cmd, _subprocess, "openai-whisper")
379
1016
  doc = os.path.join(tmpdir, "audio.json")
380
1017
  if proc.returncode == 0 and os.path.exists(doc):
381
1018
  words = _words_from_openai_whisper_json(doc)
382
1019
  info(f"word timings from openai-whisper ({len(words)} words)")
383
- return words, "openai-whisper"
384
- info("openai-whisper found but produced no word-timing JSON: "
385
- + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
386
- return [], "openai-whisper"
1020
+ return Transcription(words=words, engine="openai-whisper",
1021
+ facts=dict(facts, engine="openai-whisper", model=model, language=lang))
1022
+ line = _failure_line(proc)
1023
+ info("openai-whisper found but produced no word-timing JSON: " + line)
1024
+ tried.append(_failed("openai-whisper", "found but produced no word-timing JSON: " + line))
1025
+ failed = failed or "openai-whisper"
387
1026
 
1027
+ # 4. Parakeet after all, when the language was never known and no Whisper engine gave words
1028
+ if route.last:
1029
+ info("no Whisper engine produced word timings; trying Parakeet with English assumed")
1030
+ done = parakeet(list(route.last), {"routing": ROUTING_LAST_RESORT})
1031
+ if done:
1032
+ return done
1033
+ if failed and not route.last:
1034
+ # it ran: the caller refuses naming it ("no word-level timings"), not "no engine".
1035
+ # After a last-resort Parakeet run, the refusal below names every engine tried.
1036
+ return Transcription(engine=failed, facts=facts)
1037
+ if wanted != "auto":
1038
+ die_engine_failed(wanted, video, tried, False)
1039
+ if tried:
1040
+ die_engines_failed(tried, video, "--filler --transcribe", "or pass --words with a transcript you already have.")
388
1041
  die_no_engine("or pass --words with a transcript you already have.",
389
1042
  flag="--filler --transcribe")
390
- return [], None
1043
+ return Transcription()
391
1044
  finally:
392
1045
  _shutil.rmtree(tmpdir, ignore_errors=True)