ffmpeg-skill 2.5.1 → 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.
- package/README.md +4 -4
- package/SKILL.md +5 -5
- package/docs/contract.md +68 -13
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +44 -10
- package/scripts/_common/asr.py +747 -94
- package/scripts/_common/decision.py +125 -2
- package/scripts/_common/emit.py +145 -1
- package/scripts/_common/runner.py +258 -13
- package/scripts/_contract.py +73 -12
- package/scripts/batch.py +30 -1
- package/scripts/caption.py +22 -6
- package/scripts/export.py +10 -1
- package/scripts/render.py +23 -1
- package/scripts/silence.py +21 -6
- package/scripts/waveform.py +19 -8
package/scripts/_common/asr.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
21
|
-
#
|
|
22
|
-
|
|
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 (
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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",
|
|
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) ->
|
|
118
|
-
from _common import run_analysis, STATE
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
180
|
-
cmd += ["--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
|
-
"""(
|
|
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
|
-
|
|
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.
|
|
303
|
-
ran but its build produced no word-level timings, so the caller can refuse
|
|
304
|
-
instead of pretending the audio had no words in it. No engine at all
|
|
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
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
for
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
|
377
|
-
cmd += ["--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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
1043
|
+
return Transcription()
|
|
391
1044
|
finally:
|
|
392
1045
|
_shutil.rmtree(tmpdir, ignore_errors=True)
|