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