textflowkit 0.1.3__py3-none-any.whl
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.
- textflowkit/__init__.py +8 -0
- textflowkit/adapters/__init__.py +11 -0
- textflowkit/adapters/http_server.py +465 -0
- textflowkit/adapters/mcp_server.py +554 -0
- textflowkit/cli.py +473 -0
- textflowkit/core/__init__.py +5 -0
- textflowkit/core/batch.py +186 -0
- textflowkit/core/bind.py +66 -0
- textflowkit/core/cancel.py +19 -0
- textflowkit/core/checkpoint.py +389 -0
- textflowkit/core/diarize.py +229 -0
- textflowkit/core/engine.py +127 -0
- textflowkit/core/executor.py +301 -0
- textflowkit/core/jobs.py +241 -0
- textflowkit/core/model.py +98 -0
- textflowkit/core/paths.py +226 -0
- textflowkit/core/pipeline.py +368 -0
- textflowkit/core/retrieval.py +148 -0
- textflowkit/core/runner.py +146 -0
- textflowkit/core/service.py +146 -0
- textflowkit/core/sqlite_store.py +233 -0
- textflowkit/core/submission.py +220 -0
- textflowkit/core/timeutil.py +20 -0
- textflowkit/core/translate.py +244 -0
- textflowkit/render/__init__.py +191 -0
- textflowkit/render/docx.py +71 -0
- textflowkit/render/fonts/NotoSans.ttf +0 -0
- textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
- textflowkit/render/fonts/NotoSansSC.ttf +0 -0
- textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
- textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
- textflowkit/render/fonts/README.md +19 -0
- textflowkit/render/markdown.py +33 -0
- textflowkit/render/pdf.py +136 -0
- textflowkit/render/srt.py +22 -0
- textflowkit/render/txt.py +19 -0
- textflowkit/render/vtt.py +20 -0
- textflowkit/sources/__init__.py +16 -0
- textflowkit/sources/acquire.py +437 -0
- textflowkit/sources/detect.py +200 -0
- textflowkit/sources/scratch.py +32 -0
- textflowkit-0.1.3.dist-info/METADATA +266 -0
- textflowkit-0.1.3.dist-info/RECORD +46 -0
- textflowkit-0.1.3.dist-info/WHEEL +4 -0
- textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
- textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
textflowkit/cli.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""textflowkit command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from textflowkit import __version__
|
|
12
|
+
from textflowkit.core.batch import run_batch
|
|
13
|
+
from textflowkit.core.engine import get_engine
|
|
14
|
+
from textflowkit.core.jobs import JobState, get_default_store
|
|
15
|
+
from textflowkit.core.model import Transcript
|
|
16
|
+
from textflowkit.core.paths import default_input_root, output_root
|
|
17
|
+
from textflowkit.core.pipeline import TranscribeResult
|
|
18
|
+
from textflowkit.core.runner import transcript_for
|
|
19
|
+
from textflowkit.core.submission import SubmissionRequest, submit_request
|
|
20
|
+
from textflowkit.render import (
|
|
21
|
+
BINARY_FORMATS,
|
|
22
|
+
SUPPORTED_FORMATS,
|
|
23
|
+
atomic_write_bytes,
|
|
24
|
+
render,
|
|
25
|
+
render_bytes,
|
|
26
|
+
)
|
|
27
|
+
from textflowkit.sources.acquire import AcquisitionError
|
|
28
|
+
from textflowkit.sources.detect import PLATFORMS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
p = argparse.ArgumentParser(
|
|
33
|
+
prog="textflowkit",
|
|
34
|
+
description="Transcribe media from a URL or local file into timestamped text and subtitles.",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument("--version", action="version", version=f"textflowkit {__version__}")
|
|
37
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
t = sub.add_parser("transcribe", help="transcribe a URL or local media file")
|
|
40
|
+
t.add_argument("source", help="media URL or path to a local file")
|
|
41
|
+
t.add_argument("--formats", default="json,srt,txt",
|
|
42
|
+
help=f"comma-separated outputs (default: json,srt,txt; available: {', '.join(SUPPORTED_FORMATS)})")
|
|
43
|
+
t.add_argument("--output-dir", "-o", default=None, help="directory for written outputs")
|
|
44
|
+
t.add_argument("--language", default=None, help="source language code (e.g. en); default auto-detect")
|
|
45
|
+
t.add_argument("--model", default="small", help="whisper model size (tiny/base/small/medium/large); default small")
|
|
46
|
+
t.add_argument("--device", default=None, help="torch device (cuda/cpu); default auto")
|
|
47
|
+
t.add_argument(
|
|
48
|
+
"--diarize",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help=(
|
|
51
|
+
"label speakers. Requires the optional 'diarize' extra and a Hugging "
|
|
52
|
+
"Face token with access to the gated pyannote model; fails loudly if either is missing"
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
t.add_argument(
|
|
56
|
+
"--translate-to",
|
|
57
|
+
default=None,
|
|
58
|
+
metavar="LANG",
|
|
59
|
+
help=(
|
|
60
|
+
"translate the transcript into LANG (e.g. es) using the configured "
|
|
61
|
+
"backend; the job fails loudly if the backend is unreachable"
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
t.add_argument("--cookies-from-browser", default=None,
|
|
65
|
+
help="pass cookies to yt-dlp from a browser (e.g. firefox) for access-controlled content")
|
|
66
|
+
t.add_argument("--stdout", action="store_true", help="print transcript to stdout instead of writing files")
|
|
67
|
+
t.add_argument("--stdout-format", default="txt", help="format for --stdout (default txt)")
|
|
68
|
+
t.add_argument(
|
|
69
|
+
"--resume",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="reuse a completed checkpoint for the same source and options when one exists",
|
|
72
|
+
)
|
|
73
|
+
t.add_argument("--quiet", "-q", action="store_true", help="suppress progress messages")
|
|
74
|
+
|
|
75
|
+
b = sub.add_parser("batch", help="transcribe many sources in one invocation")
|
|
76
|
+
b.add_argument("sources", nargs="+", help="media URLs or paths to local files")
|
|
77
|
+
b.add_argument("--formats", default="json,srt,txt",
|
|
78
|
+
help=f"comma-separated outputs (default: json,srt,txt; available: {', '.join(SUPPORTED_FORMATS)})")
|
|
79
|
+
b.add_argument("--output-dir", "-o", default=None, help="directory for written outputs")
|
|
80
|
+
b.add_argument("--language", default=None, help="source language code (e.g. en); default auto-detect")
|
|
81
|
+
b.add_argument("--model", default="small", help="whisper model size (tiny/base/small/medium/large); default small")
|
|
82
|
+
b.add_argument("--device", default=None, help="torch device (cuda/cpu); default auto")
|
|
83
|
+
b.add_argument("--diarize", action="store_true", help="label speakers (same requirements as transcribe)")
|
|
84
|
+
b.add_argument("--translate-to", default=None, metavar="LANG", help="translate transcript into LANG")
|
|
85
|
+
b.add_argument("--cookies-from-browser", default=None,
|
|
86
|
+
help="pass cookies to yt-dlp from a browser (e.g. firefox)")
|
|
87
|
+
b.add_argument(
|
|
88
|
+
"--resume",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="reuse a completed checkpoint for each matching source and options",
|
|
91
|
+
)
|
|
92
|
+
b.add_argument("--quiet", "-q", action="store_true", help="suppress per-item progress messages")
|
|
93
|
+
|
|
94
|
+
l = sub.add_parser("export", help="re-render an existing transcript JSON")
|
|
95
|
+
l.add_argument("transcript", help="path to a transcript .json file")
|
|
96
|
+
l.add_argument("--format", "-f", default="srt", help=f"output format ({', '.join(SUPPORTED_FORMATS)})")
|
|
97
|
+
l.add_argument("--output", "-o", default=None, help="output file (default stdout)")
|
|
98
|
+
|
|
99
|
+
sub.add_parser("sources", help="list recognised platforms")
|
|
100
|
+
sub.add_parser("doctor", help="report the versions and tools this install will use")
|
|
101
|
+
st = sub.add_parser(
|
|
102
|
+
"selftest",
|
|
103
|
+
help="run a real end-to-end check on this machine (compute device + a tiny transcription)",
|
|
104
|
+
)
|
|
105
|
+
st.add_argument("--model", default="tiny", help="whisper model for the check (default tiny)")
|
|
106
|
+
st.add_argument(
|
|
107
|
+
"--skip-transcribe",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="only check the compute device, do not load a model",
|
|
110
|
+
)
|
|
111
|
+
return p
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _formats(raw: str) -> list[str]:
|
|
115
|
+
return [f.strip().lower().lstrip(".") for f in raw.split(",") if f.strip()]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _store_is_durable() -> bool:
|
|
119
|
+
"""Whether the default store survives this process.
|
|
120
|
+
|
|
121
|
+
``_make_store`` falls back to ``MemoryJobStore`` when TEXTFLOWKIT_DB is
|
|
122
|
+
unset, and an in-memory store cannot carry a checkpoint into a later
|
|
123
|
+
invocation. Resume depends on that carrying, so callers warn instead of
|
|
124
|
+
silently redoing the work.
|
|
125
|
+
"""
|
|
126
|
+
return bool(os.environ.get("TEXTFLOWKIT_DB"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cmd_transcribe(args: argparse.Namespace) -> int:
|
|
130
|
+
formats = _formats(args.formats)
|
|
131
|
+
output_dir = args.output_dir
|
|
132
|
+
if output_dir is None and not args.stdout:
|
|
133
|
+
output_dir = "."
|
|
134
|
+
if args.resume and not _store_is_durable():
|
|
135
|
+
print(
|
|
136
|
+
"warning: --resume needs a durable store; TEXTFLOWKIT_DB is not set, "
|
|
137
|
+
"so no checkpoint from an earlier run can be found. Re-running from "
|
|
138
|
+
"scratch. Set TEXTFLOWKIT_DB to persist jobs and checkpoints.",
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
request = SubmissionRequest(
|
|
144
|
+
source=args.source, language=args.language, formats=formats,
|
|
145
|
+
output_dir=output_dir, model=args.model, device=args.device,
|
|
146
|
+
cookies_from_browser=args.cookies_from_browser,
|
|
147
|
+
diarize=args.diarize, translate_to=args.translate_to,
|
|
148
|
+
)
|
|
149
|
+
store = get_default_store()
|
|
150
|
+
job = submit_request(store, request, background=False, resume=args.resume)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
if job.state is not JobState.DONE:
|
|
155
|
+
print(f"error: {job.error or job.state.value}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
transcript = transcript_for(job)
|
|
158
|
+
if transcript is None:
|
|
159
|
+
print("error: completed job contains no transcript", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
return _finish_transcribe(
|
|
162
|
+
args, TranscribeResult(transcript=transcript, outputs=[Path(p) for p in job.outputs]),
|
|
163
|
+
job, store,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _finish_transcribe(
|
|
168
|
+
args: argparse.Namespace,
|
|
169
|
+
result,
|
|
170
|
+
job,
|
|
171
|
+
store,
|
|
172
|
+
) -> int:
|
|
173
|
+
"""Record and print a result that came from the pipeline or reuse."""
|
|
174
|
+
from textflowkit.core.pipeline import TranscribeResult
|
|
175
|
+
|
|
176
|
+
if not isinstance(result, TranscribeResult):
|
|
177
|
+
transcript, outputs = result
|
|
178
|
+
result = TranscribeResult(transcript=transcript, outputs=list(outputs))
|
|
179
|
+
store.update(
|
|
180
|
+
job.id,
|
|
181
|
+
state=JobState.DONE,
|
|
182
|
+
progress="complete",
|
|
183
|
+
transcript=result.transcript.to_dict(),
|
|
184
|
+
outputs=[str(p) for p in result.outputs],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
tr = result.transcript
|
|
188
|
+
if not args.quiet:
|
|
189
|
+
segs = len(tr.segments)
|
|
190
|
+
print(f"platform : {tr.platform}", file=sys.stderr)
|
|
191
|
+
print(f"language : {tr.language or 'unknown'}", file=sys.stderr)
|
|
192
|
+
print(f"segments : {segs}", file=sys.stderr)
|
|
193
|
+
if tr.metadata.get("model"):
|
|
194
|
+
print(f"engine : {tr.engine} ({tr.metadata.get('model')} on {tr.metadata.get('device')})", file=sys.stderr)
|
|
195
|
+
|
|
196
|
+
if args.stdout:
|
|
197
|
+
sys.stdout.write(render(tr, args.stdout_format))
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
for path in result.outputs:
|
|
201
|
+
print(str(path))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cmd_batch(args: argparse.Namespace) -> int:
|
|
206
|
+
if args.resume and not _store_is_durable():
|
|
207
|
+
print(
|
|
208
|
+
"warning: batch --resume cannot survive a process restart without "
|
|
209
|
+
"TEXTFLOWKIT_DB; this run may repeat completed transcription. "
|
|
210
|
+
"Set TEXTFLOWKIT_DB for durable resume.",
|
|
211
|
+
file=sys.stderr,
|
|
212
|
+
)
|
|
213
|
+
report = run_batch(
|
|
214
|
+
list(args.sources),
|
|
215
|
+
store=get_default_store(),
|
|
216
|
+
resume=args.resume,
|
|
217
|
+
language=args.language,
|
|
218
|
+
formats=_formats(args.formats),
|
|
219
|
+
output_dir=args.output_dir or ".",
|
|
220
|
+
model=args.model,
|
|
221
|
+
device=args.device,
|
|
222
|
+
cookies_from_browser=args.cookies_from_browser,
|
|
223
|
+
diarize=args.diarize,
|
|
224
|
+
translate_to=args.translate_to,
|
|
225
|
+
)
|
|
226
|
+
if not args.quiet:
|
|
227
|
+
for item in report.items:
|
|
228
|
+
detail = item.error or ", ".join(item.outputs)
|
|
229
|
+
suffix = f" - {detail}" if detail else ""
|
|
230
|
+
print(f"{item.status:<9} {item.source}{suffix}")
|
|
231
|
+
print(
|
|
232
|
+
f"batch: {report.total} total, {report.succeeded} succeeded, "
|
|
233
|
+
f"{report.failed} failed, {report.skipped} skipped"
|
|
234
|
+
)
|
|
235
|
+
return 0 if report.ok else 1
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _cmd_export(args: argparse.Namespace) -> int:
|
|
239
|
+
path = Path(args.transcript)
|
|
240
|
+
if not path.exists():
|
|
241
|
+
print(f"error: no such transcript: {path}", file=sys.stderr)
|
|
242
|
+
return 1
|
|
243
|
+
try:
|
|
244
|
+
tr = Transcript.load_json(path)
|
|
245
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
246
|
+
print(f"error: could not read transcript: {exc}", file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
try:
|
|
249
|
+
fmt = args.format.lower().lstrip(".")
|
|
250
|
+
if fmt in BINARY_FORMATS and not args.output:
|
|
251
|
+
raise ValueError(f"--output is required for binary {fmt} export")
|
|
252
|
+
content = render_bytes(tr, fmt, title=path.stem)
|
|
253
|
+
except (ValueError, ImportError) as exc:
|
|
254
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
255
|
+
return 1
|
|
256
|
+
if args.output:
|
|
257
|
+
try:
|
|
258
|
+
atomic_write_bytes(Path(args.output), content, replace=True)
|
|
259
|
+
except OSError as exc:
|
|
260
|
+
print(f"error: could not write export: {exc}", file=sys.stderr)
|
|
261
|
+
return 1
|
|
262
|
+
print(str(Path(args.output)))
|
|
263
|
+
else:
|
|
264
|
+
sys.stdout.write(content.decode("utf-8"))
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _cmd_doctor(_: argparse.Namespace) -> int:
|
|
269
|
+
"""Print the environment this install will actually use.
|
|
270
|
+
|
|
271
|
+
Platform support depends on yt-dlp continuing to work against sites it does
|
|
272
|
+
not own, and that breaks from the outside. When a site stops working, the
|
|
273
|
+
first question is which yt-dlp and which JavaScript runtime are in play -
|
|
274
|
+
this answers it without guessing.
|
|
275
|
+
"""
|
|
276
|
+
import shutil
|
|
277
|
+
|
|
278
|
+
def line(label: str, value: str) -> None:
|
|
279
|
+
print(f"{label:<18} {value}")
|
|
280
|
+
|
|
281
|
+
line("textflowkit", __version__)
|
|
282
|
+
line("python", sys.version.split()[0])
|
|
283
|
+
|
|
284
|
+
# ffmpeg
|
|
285
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
286
|
+
if ffmpeg:
|
|
287
|
+
try:
|
|
288
|
+
out = subprocess.run(
|
|
289
|
+
[ffmpeg, "-version"], capture_output=True, text=True, check=False
|
|
290
|
+
).stdout.splitlines()
|
|
291
|
+
line("ffmpeg", out[0] if out else ffmpeg)
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
line("ffmpeg", f"{ffmpeg} (could not run: {exc})")
|
|
294
|
+
else:
|
|
295
|
+
line("ffmpeg", "MISSING - required")
|
|
296
|
+
|
|
297
|
+
# yt-dlp: which one, and how
|
|
298
|
+
from textflowkit.sources.acquire import detect_js_runtime, require_tool
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
ytdlp_path = require_tool("yt-dlp", module="yt_dlp")
|
|
302
|
+
except AcquisitionError as exc:
|
|
303
|
+
line("yt-dlp", f"MISSING - {exc}")
|
|
304
|
+
else:
|
|
305
|
+
try:
|
|
306
|
+
import yt_dlp
|
|
307
|
+
version = getattr(getattr(yt_dlp, "version", None), "__version__", "unknown")
|
|
308
|
+
except ImportError:
|
|
309
|
+
version = "unknown"
|
|
310
|
+
how = "module (in-process)" if ytdlp_path is None else ytdlp_path
|
|
311
|
+
line("yt-dlp", f"{version} via {how}")
|
|
312
|
+
|
|
313
|
+
runtime = detect_js_runtime()
|
|
314
|
+
line("js runtime", runtime or "none found (YouTube formats may be limited)")
|
|
315
|
+
|
|
316
|
+
# optional extras
|
|
317
|
+
for label, module in (
|
|
318
|
+
("mcp", "mcp"),
|
|
319
|
+
("fastapi", "fastapi"),
|
|
320
|
+
("pyannote", "pyannote.audio"),
|
|
321
|
+
("python-docx", "docx"),
|
|
322
|
+
("reportlab", "reportlab"),
|
|
323
|
+
):
|
|
324
|
+
try:
|
|
325
|
+
__import__(module)
|
|
326
|
+
except ImportError:
|
|
327
|
+
line(label, "not installed")
|
|
328
|
+
else:
|
|
329
|
+
line(label, "installed")
|
|
330
|
+
|
|
331
|
+
# Compute devices are separate decisions: pyannote may be pinned to CPU
|
|
332
|
+
# while Whisper uses ROCm/CUDA, or vice versa.
|
|
333
|
+
try:
|
|
334
|
+
import torch
|
|
335
|
+
|
|
336
|
+
if torch.cuda.is_available():
|
|
337
|
+
name = torch.cuda.get_device_name(0)
|
|
338
|
+
auto_device = "cuda"
|
|
339
|
+
device_label = f"{name} (torch {torch.__version__})"
|
|
340
|
+
else:
|
|
341
|
+
auto_device = "cpu"
|
|
342
|
+
device_label = f"cpu (torch {torch.__version__})"
|
|
343
|
+
except ImportError:
|
|
344
|
+
auto_device = "cpu"
|
|
345
|
+
device_label = "torch not installed"
|
|
346
|
+
|
|
347
|
+
line("whisper device", f"{auto_device}: {device_label}")
|
|
348
|
+
from textflowkit.core.diarize import ENV_DIARIZE_DEVICE
|
|
349
|
+
|
|
350
|
+
diarize_device = os.environ.get(ENV_DIARIZE_DEVICE) or auto_device
|
|
351
|
+
line("diarize device", f"{diarize_device}: {device_label if diarize_device == auto_device else 'configured'}")
|
|
352
|
+
|
|
353
|
+
from textflowkit.core.translate import ENV_OLLAMA_MODEL, OllamaTranslator
|
|
354
|
+
|
|
355
|
+
translation_model = os.environ.get(ENV_OLLAMA_MODEL)
|
|
356
|
+
line("translation model", translation_model or f"not configured (set {ENV_OLLAMA_MODEL})")
|
|
357
|
+
if translation_model:
|
|
358
|
+
line("translation route", OllamaTranslator().route)
|
|
359
|
+
|
|
360
|
+
line("input root", str(default_input_root() or "unconfined (CLI default)"))
|
|
361
|
+
line("output root", str(output_root()))
|
|
362
|
+
line("jobs store", os.environ.get("TEXTFLOWKIT_DB", "in-memory (not durable)"))
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _cmd_selftest(args: argparse.Namespace) -> int:
|
|
367
|
+
"""Prove the compute path works on THIS machine, end to end.
|
|
368
|
+
|
|
369
|
+
The GPU path cannot run in hosted CI - no runner has an AMD GPU - so the
|
|
370
|
+
honest way to keep it verified is to make the check reproducible and runnable
|
|
371
|
+
on demand rather than relying on one engineer's memory of a good run.
|
|
372
|
+
"""
|
|
373
|
+
import tempfile
|
|
374
|
+
import wave
|
|
375
|
+
from pathlib import Path
|
|
376
|
+
|
|
377
|
+
failures: list[str] = []
|
|
378
|
+
|
|
379
|
+
def ok(label: str, detail: str = "") -> None:
|
|
380
|
+
print(f" PASS {label}" + (f" ({detail})" if detail else ""))
|
|
381
|
+
|
|
382
|
+
def bad(label: str, detail: str) -> None:
|
|
383
|
+
failures.append(label)
|
|
384
|
+
print(f" FAIL {label} ({detail})")
|
|
385
|
+
|
|
386
|
+
print("compute")
|
|
387
|
+
try:
|
|
388
|
+
import torch
|
|
389
|
+
|
|
390
|
+
print(f" torch {torch.__version__}, hip={torch.version.hip}, cuda_available={torch.cuda.is_available()}")
|
|
391
|
+
device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
|
|
392
|
+
a = torch.randn(512, 512, device=device)
|
|
393
|
+
b = torch.randn(512, 512, device=device)
|
|
394
|
+
c = a @ b
|
|
395
|
+
torch.cuda.synchronize() if torch.cuda.is_available() else None
|
|
396
|
+
assert c.shape == (512, 512)
|
|
397
|
+
if torch.cuda.is_available():
|
|
398
|
+
name = torch.cuda.get_device_name(0)
|
|
399
|
+
ok("matmul on device", name)
|
|
400
|
+
else:
|
|
401
|
+
ok("matmul on device", "cpu (no GPU visible)")
|
|
402
|
+
except Exception as exc: # noqa: BLE001 - this is a diagnostic
|
|
403
|
+
bad("matmul on device", f"{type(exc).__name__}: {exc}")
|
|
404
|
+
|
|
405
|
+
def summarise() -> int:
|
|
406
|
+
print()
|
|
407
|
+
if failures:
|
|
408
|
+
print(f"SELFTEST FAILED: {', '.join(failures)}")
|
|
409
|
+
return 1
|
|
410
|
+
print("SELFTEST PASSED")
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
if args.skip_transcribe:
|
|
414
|
+
return summarise()
|
|
415
|
+
|
|
416
|
+
print("transcribe")
|
|
417
|
+
with tempfile.TemporaryDirectory(prefix="tfk-selftest-") as scratch:
|
|
418
|
+
wav = Path(scratch) / "probe.wav"
|
|
419
|
+
try:
|
|
420
|
+
# 1 second of silence, written as real PCM wav.
|
|
421
|
+
with wave.open(str(wav), "wb") as w:
|
|
422
|
+
w.setnchannels(1)
|
|
423
|
+
w.setsampwidth(2)
|
|
424
|
+
w.setframerate(16000)
|
|
425
|
+
w.writeframes(b"\x00\x00" * 16000)
|
|
426
|
+
ok("generated probe audio", f"{wav.stat().st_size} bytes")
|
|
427
|
+
except Exception as exc: # noqa: BLE001 - diagnostic
|
|
428
|
+
bad("generated probe audio", f"{type(exc).__name__}: {exc}")
|
|
429
|
+
return 1
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
engine = get_engine("whisper", model=args.model)
|
|
433
|
+
transcript = engine.transcribe(wav)
|
|
434
|
+
ok(
|
|
435
|
+
"whisper ran on this device",
|
|
436
|
+
f"model={args.model} device={transcript.metadata.get('device')} segments={len(transcript.segments)}",
|
|
437
|
+
)
|
|
438
|
+
except Exception as exc: # noqa: BLE001 - diagnostic
|
|
439
|
+
bad("whisper ran on this device", f"{type(exc).__name__}: {exc}")
|
|
440
|
+
|
|
441
|
+
return summarise()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _cmd_sources(_: argparse.Namespace) -> int:
|
|
445
|
+
for name in sorted(PLATFORMS):
|
|
446
|
+
print(name)
|
|
447
|
+
print("local")
|
|
448
|
+
print("direct")
|
|
449
|
+
return 0
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main(argv: list[str] | None = None) -> int:
|
|
453
|
+
parser = _build_parser()
|
|
454
|
+
args = parser.parse_args(argv)
|
|
455
|
+
if args.command == "transcribe":
|
|
456
|
+
return _cmd_transcribe(args)
|
|
457
|
+
if args.command == "batch":
|
|
458
|
+
return _cmd_batch(args)
|
|
459
|
+
if args.command == "export":
|
|
460
|
+
return _cmd_export(args)
|
|
461
|
+
if args.command == "sources":
|
|
462
|
+
return _cmd_sources(args)
|
|
463
|
+
if args.command == "doctor":
|
|
464
|
+
return _cmd_doctor(args)
|
|
465
|
+
if args.command == "selftest":
|
|
466
|
+
return _cmd_selftest(args)
|
|
467
|
+
parser.print_help()
|
|
468
|
+
return 2
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
raise SystemExit(main())
|
|
473
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Batch transcription orchestration.
|
|
2
|
+
|
|
3
|
+
Batch is deliberately a thin loop over one-item work. It does not know about
|
|
4
|
+
media acquisition, engines, or rendering; it only creates jobs through the
|
|
5
|
+
existing runner and reports what each job produced.
|
|
6
|
+
|
|
7
|
+
The contract that matters operationally is the same as the ticket's wording:
|
|
8
|
+
one bad source must never erase the results of the other sources. Every item is
|
|
9
|
+
attempted, and every item gets an explicit outcome in the returned report.
|
|
10
|
+
|
|
11
|
+
Resuming a batch item uses the same `prepare_resume` path as the CLI, so an
|
|
12
|
+
interrupted ERROR/CANCELLED job is reopened rather than silently skipped.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from textflowkit.core.checkpoint import (
|
|
21
|
+
find_resumable_checkpoint,
|
|
22
|
+
prepare_resume,
|
|
23
|
+
reusable_done_result,
|
|
24
|
+
)
|
|
25
|
+
from textflowkit.core.executor import JobCancelled
|
|
26
|
+
from textflowkit.core.jobs import JobState, JobStore
|
|
27
|
+
from textflowkit.core.runner import run_job
|
|
28
|
+
from textflowkit.core.submission import SubmissionRequest
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class BatchItem:
|
|
33
|
+
"""One source's outcome in a batch run."""
|
|
34
|
+
|
|
35
|
+
source: str
|
|
36
|
+
status: str
|
|
37
|
+
job_id: str | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
outputs: list[str] = field(default_factory=list)
|
|
40
|
+
resumed: bool = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def succeeded(self) -> bool:
|
|
44
|
+
return self.status == "succeeded"
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"source": self.source,
|
|
49
|
+
"status": self.status,
|
|
50
|
+
"job_id": self.job_id,
|
|
51
|
+
"error": self.error,
|
|
52
|
+
"outputs": list(self.outputs),
|
|
53
|
+
"resumed": self.resumed,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class BatchReport:
|
|
59
|
+
"""Aggregate and per-item outcomes for a batch invocation."""
|
|
60
|
+
|
|
61
|
+
items: list[BatchItem] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def total(self) -> int:
|
|
65
|
+
return len(self.items)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def succeeded(self) -> int:
|
|
69
|
+
return sum(item.status == "succeeded" for item in self.items)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def failed(self) -> int:
|
|
73
|
+
return sum(item.status == "failed" for item in self.items)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def skipped(self) -> int:
|
|
77
|
+
return sum(item.status == "skipped" for item in self.items)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ok(self) -> bool:
|
|
81
|
+
return self.failed == 0
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"total": self.total,
|
|
86
|
+
"succeeded": self.succeeded,
|
|
87
|
+
"failed": self.failed,
|
|
88
|
+
"skipped": self.skipped,
|
|
89
|
+
"items": [item.to_dict() for item in self.items],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_batch(
|
|
94
|
+
sources: list[str],
|
|
95
|
+
*,
|
|
96
|
+
store: JobStore,
|
|
97
|
+
resume: bool = False,
|
|
98
|
+
**kwargs: Any,
|
|
99
|
+
) -> BatchReport:
|
|
100
|
+
"""Run every source through the single-item runner and return a report.
|
|
101
|
+
|
|
102
|
+
`run_job` already records terminal state on the store. This layer adds no
|
|
103
|
+
second pipeline path; it simply catches per-item failures so one bad source
|
|
104
|
+
does not stop the rest.
|
|
105
|
+
"""
|
|
106
|
+
report = BatchReport()
|
|
107
|
+
for source in sources:
|
|
108
|
+
item = BatchItem(source=source, status="failed")
|
|
109
|
+
try:
|
|
110
|
+
request = SubmissionRequest(source=source, **kwargs)
|
|
111
|
+
except (TypeError, ValueError) as exc:
|
|
112
|
+
item.error = str(exc)
|
|
113
|
+
report.items.append(item)
|
|
114
|
+
continue
|
|
115
|
+
item_kwargs = request.run_kwargs()
|
|
116
|
+
item_kwargs.pop("source")
|
|
117
|
+
if resume:
|
|
118
|
+
found = find_resumable_checkpoint(
|
|
119
|
+
store,
|
|
120
|
+
source=source,
|
|
121
|
+
model=item_kwargs.get("model", "small"),
|
|
122
|
+
language=item_kwargs.get("language"),
|
|
123
|
+
engine=item_kwargs.get("engine", "whisper"),
|
|
124
|
+
device=item_kwargs.get("device"),
|
|
125
|
+
options=_resume_options(item_kwargs),
|
|
126
|
+
)
|
|
127
|
+
if found is not None:
|
|
128
|
+
prior, checkpoint = found
|
|
129
|
+
item.job_id = prior.id
|
|
130
|
+
prepared = prepare_resume(store, prior, checkpoint)
|
|
131
|
+
if prepared is None:
|
|
132
|
+
reused = reusable_done_result(store, prior)
|
|
133
|
+
if reused is not None:
|
|
134
|
+
_transcript, outputs = reused
|
|
135
|
+
item.status = "succeeded"
|
|
136
|
+
item.resumed = True
|
|
137
|
+
item.outputs = [str(p) for p in outputs]
|
|
138
|
+
item.error = None
|
|
139
|
+
report.items.append(item)
|
|
140
|
+
continue
|
|
141
|
+
job = store.create(source, request=request.to_dict())
|
|
142
|
+
item.job_id = job.id
|
|
143
|
+
else:
|
|
144
|
+
job, payload = prepared
|
|
145
|
+
item.resumed = True
|
|
146
|
+
item_kwargs["resume_checkpoint"] = payload
|
|
147
|
+
else:
|
|
148
|
+
job = store.create(source, request=request.to_dict())
|
|
149
|
+
item.job_id = job.id
|
|
150
|
+
else:
|
|
151
|
+
job = store.create(source, request=request.to_dict())
|
|
152
|
+
item.job_id = job.id
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
run_job(job, store, source=source, **item_kwargs)
|
|
156
|
+
current = store.get(job.id)
|
|
157
|
+
if current is None:
|
|
158
|
+
item.status = "failed"
|
|
159
|
+
item.error = "job disappeared from the store"
|
|
160
|
+
elif current.state is JobState.DONE:
|
|
161
|
+
item.status = "succeeded"
|
|
162
|
+
item.outputs = list(current.outputs)
|
|
163
|
+
elif current.state is JobState.CANCELLED:
|
|
164
|
+
item.status = "skipped"
|
|
165
|
+
item.error = current.error or "cancelled"
|
|
166
|
+
else:
|
|
167
|
+
item.status = "failed"
|
|
168
|
+
item.error = current.error or f"job ended in state {current.state.value}"
|
|
169
|
+
except JobCancelled:
|
|
170
|
+
item.status = "skipped"
|
|
171
|
+
item.error = "cancelled"
|
|
172
|
+
except Exception as exc: # noqa: BLE001 - isolate one item from the rest
|
|
173
|
+
item.status = "failed"
|
|
174
|
+
item.error = f"{type(exc).__name__}: {exc}"
|
|
175
|
+
report.items.append(item)
|
|
176
|
+
return report
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resume_options(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
180
|
+
return {
|
|
181
|
+
"formats": list(kwargs.get("formats") or []),
|
|
182
|
+
"diarize": bool(kwargs.get("diarize", False)),
|
|
183
|
+
"diarizer_backend": kwargs.get("diarizer_backend", "pyannote"),
|
|
184
|
+
"translate_to": kwargs.get("translate_to"),
|
|
185
|
+
"translator_backend": kwargs.get("translator_backend", "ollama"),
|
|
186
|
+
}
|