babelscribe 0.3.2__tar.gz → 0.4.0__tar.gz

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.
Files changed (30) hide show
  1. {babelscribe-0.3.2 → babelscribe-0.4.0}/LICENSE +21 -21
  2. {babelscribe-0.3.2 → babelscribe-0.4.0}/PKG-INFO +12 -3
  3. {babelscribe-0.3.2 → babelscribe-0.4.0}/README.md +11 -2
  4. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/__init__.py +2 -2
  5. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/__main__.py +3 -3
  6. babelscribe-0.4.0/babelscribe/api.py +105 -0
  7. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/backend.py +118 -118
  8. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/cli.py +29 -4
  9. babelscribe-0.4.0/babelscribe/doctor.py +149 -0
  10. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/hybrid.py +4 -3
  11. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/mcp_server.py +7 -3
  12. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/models.py +96 -94
  13. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/transcribe.py +82 -70
  14. babelscribe-0.4.0/babelscribe/writers.py +257 -0
  15. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/PKG-INFO +12 -3
  16. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/SOURCES.txt +5 -1
  17. {babelscribe-0.3.2 → babelscribe-0.4.0}/pyproject.toml +48 -48
  18. babelscribe-0.4.0/tests/test_api_stability.py +58 -0
  19. babelscribe-0.4.0/tests/test_core.py +484 -0
  20. babelscribe-0.4.0/tests/test_doctor.py +46 -0
  21. babelscribe-0.4.0/tests/test_windows_packaging.py +90 -0
  22. babelscribe-0.3.2/babelscribe/api.py +0 -59
  23. babelscribe-0.3.2/babelscribe/writers.py +0 -29
  24. babelscribe-0.3.2/tests/test_core.py +0 -60
  25. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/align.py +0 -0
  26. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/dependency_links.txt +0 -0
  27. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/entry_points.txt +0 -0
  28. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/requires.txt +0 -0
  29. {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/top_level.txt +0 -0
  30. {babelscribe-0.3.2 → babelscribe-0.4.0}/setup.cfg +0 -0
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 babelscribe contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 babelscribe contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelscribe
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Transcribe any audio/video in 99 languages on any GPU (AMD, NVIDIA, Intel via Vulkan; NVIDIA via CUDA; Apple via Metal) or CPU — whisper.cpp with batteries included.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/phonology024/babelscribe
@@ -216,14 +216,20 @@ languages are the most valuable contribution.
216
216
  - **Long files that don't loop** — runs whisper with `--max-context 0`, which stops the classic "same sentence repeated forever" hallucination on long recordings.
217
217
  - **Hybrid mode for better spelling in your language** — community fine-tunes (e.g. Thai *Thonburian Whisper*) spell far better but often lose timestamps. `--text-model` takes the text from the fine-tune and the timing from `large-v3-turbo`, aligned character by character (works for languages without spaces), cut only at word boundaries, with the timing model filling any words the fine-tune skipped.
218
218
  - **`--accurate`** — slower, fewest errors: large-v3 with beam search, or for Thai and Hindi the best community fine-tune (hybrid). Chosen per language from the FLEURS benchmark above.
219
- - **Outputs** — `srt`, `vtt`, `txt`, `json` (segments with token timings).
219
+ - **Custom vocabulary** — `--prompt "Pitakpong, babelscribe"` gives whisper an initial prompt that biases the spelling of names and jargon. In the MCP `transcribe` tool the same thing is the `vocabulary` argument. While a prompt is set, `--max-context 0` is not passed (whisper.cpp ignores the prompt otherwise).
220
+ - **Translate to English** — `--translate` writes the transcript translated to English (whisper's translate task, any source language). It uses `large-v3` because turbo was not trained to translate, and cannot be combined with `--text-model`. In the MCP `transcribe` tool it is the `translate` argument.
221
+ - **Outputs** — `srt`, `vtt`, `txt`, `json` (segments with token timings), `ass` (karaoke: each word highlights
222
+ as it is spoken, from the token timings — for short-form video and players that read ASS).
220
223
 
221
224
  ## Usage
222
225
  ```bash
223
226
  babelscribe talk.mp4 -f srt,vtt,txt,json # all formats
227
+ babelscribe reel.mp4 -f ass # karaoke subtitles, one highlight per word
224
228
  babelscribe podcast.mp3 -l en -m large-v3 # pick language and model
225
229
  babelscribe talk.mp4 -l hi --accurate # slower, fewest errors (best model per language)
226
230
  babelscribe vo.wav -l th --text-model thai-thonburian # hybrid: pick a Thai fine-tune yourself
231
+ babelscribe talk.mp4 --prompt "Pitakpong, babelscribe" # bias the spelling of names and terms
232
+ babelscribe talk.mp4 --translate # subtitles translated to English
227
233
  babelscribe devices # GPUs whisper.cpp can see
228
234
  babelscribe models # models and fine-tunes
229
235
  babelscribe talk.mp4 --bin /path/to/whisper-cli # use your own whisper.cpp build
@@ -231,13 +237,16 @@ babelscribe talk.mp4 --bin /path/to/whisper-cli # use your own whisper.cpp bu
231
237
  From Python:
232
238
  ```python
233
239
  from babelscribe.api import transcribe_file
234
- r = transcribe_file("talk.mp4", lang="auto", formats=["srt", "txt"]) # or accurate=True
240
+ r = transcribe_file("talk.mp4", lang="auto", formats=["srt", "txt"]) # or accurate=True, prompt="names, terms"
235
241
  print(r["lang"], r["device"], r["files"]); print(r["text"][:200])
236
242
  ```
237
243
  Models download on first use to `~/.babelscribe/models` (`BABELSCRIBE_MODELS` to change). Hybrid fine-tunes are converted on your
238
244
  machine from their original Hugging Face repo — install `pip install "babelscribe[finetune]"` once; converted weights are never
239
245
  redistributed, so each fine-tune keeps its own licence. Thai word boundaries: `pip install "babelscribe[thai]"`.
240
246
 
247
+ ## Roadmap
248
+ What's planned next (subtitle line wrapping, MCP progress for long files, folder batches, translate-to-English, more fine-tunes) is in [ROADMAP.md](ROADMAP.md) and the [open issues](https://github.com/phonology024/babelscribe/issues).
249
+
241
250
  ## Add a language fine-tune
242
251
  Add one entry to `FINETUNES` in `babelscribe/models.py` (Hugging Face repo, language code, whether it needs beam search) and open a PR.
243
252
 
@@ -173,14 +173,20 @@ languages are the most valuable contribution.
173
173
  - **Long files that don't loop** — runs whisper with `--max-context 0`, which stops the classic "same sentence repeated forever" hallucination on long recordings.
174
174
  - **Hybrid mode for better spelling in your language** — community fine-tunes (e.g. Thai *Thonburian Whisper*) spell far better but often lose timestamps. `--text-model` takes the text from the fine-tune and the timing from `large-v3-turbo`, aligned character by character (works for languages without spaces), cut only at word boundaries, with the timing model filling any words the fine-tune skipped.
175
175
  - **`--accurate`** — slower, fewest errors: large-v3 with beam search, or for Thai and Hindi the best community fine-tune (hybrid). Chosen per language from the FLEURS benchmark above.
176
- - **Outputs** — `srt`, `vtt`, `txt`, `json` (segments with token timings).
176
+ - **Custom vocabulary** — `--prompt "Pitakpong, babelscribe"` gives whisper an initial prompt that biases the spelling of names and jargon. In the MCP `transcribe` tool the same thing is the `vocabulary` argument. While a prompt is set, `--max-context 0` is not passed (whisper.cpp ignores the prompt otherwise).
177
+ - **Translate to English** — `--translate` writes the transcript translated to English (whisper's translate task, any source language). It uses `large-v3` because turbo was not trained to translate, and cannot be combined with `--text-model`. In the MCP `transcribe` tool it is the `translate` argument.
178
+ - **Outputs** — `srt`, `vtt`, `txt`, `json` (segments with token timings), `ass` (karaoke: each word highlights
179
+ as it is spoken, from the token timings — for short-form video and players that read ASS).
177
180
 
178
181
  ## Usage
179
182
  ```bash
180
183
  babelscribe talk.mp4 -f srt,vtt,txt,json # all formats
184
+ babelscribe reel.mp4 -f ass # karaoke subtitles, one highlight per word
181
185
  babelscribe podcast.mp3 -l en -m large-v3 # pick language and model
182
186
  babelscribe talk.mp4 -l hi --accurate # slower, fewest errors (best model per language)
183
187
  babelscribe vo.wav -l th --text-model thai-thonburian # hybrid: pick a Thai fine-tune yourself
188
+ babelscribe talk.mp4 --prompt "Pitakpong, babelscribe" # bias the spelling of names and terms
189
+ babelscribe talk.mp4 --translate # subtitles translated to English
184
190
  babelscribe devices # GPUs whisper.cpp can see
185
191
  babelscribe models # models and fine-tunes
186
192
  babelscribe talk.mp4 --bin /path/to/whisper-cli # use your own whisper.cpp build
@@ -188,13 +194,16 @@ babelscribe talk.mp4 --bin /path/to/whisper-cli # use your own whisper.cpp bu
188
194
  From Python:
189
195
  ```python
190
196
  from babelscribe.api import transcribe_file
191
- r = transcribe_file("talk.mp4", lang="auto", formats=["srt", "txt"]) # or accurate=True
197
+ r = transcribe_file("talk.mp4", lang="auto", formats=["srt", "txt"]) # or accurate=True, prompt="names, terms"
192
198
  print(r["lang"], r["device"], r["files"]); print(r["text"][:200])
193
199
  ```
194
200
  Models download on first use to `~/.babelscribe/models` (`BABELSCRIBE_MODELS` to change). Hybrid fine-tunes are converted on your
195
201
  machine from their original Hugging Face repo — install `pip install "babelscribe[finetune]"` once; converted weights are never
196
202
  redistributed, so each fine-tune keeps its own licence. Thai word boundaries: `pip install "babelscribe[thai]"`.
197
203
 
204
+ ## Roadmap
205
+ What's planned next (subtitle line wrapping, MCP progress for long files, folder batches, translate-to-English, more fine-tunes) is in [ROADMAP.md](ROADMAP.md) and the [open issues](https://github.com/phonology024/babelscribe/issues).
206
+
198
207
  ## Add a language fine-tune
199
208
  Add one entry to `FINETUNES` in `babelscribe/models.py` (Hugging Face repo, language code, whether it needs beam search) and open a PR.
200
209
 
@@ -1,2 +1,2 @@
1
- """babelscribe — any language, any GPU speech-to-text built on whisper.cpp."""
2
- __version__ = "0.3.2"
1
+ """babelscribe — any language, any GPU speech-to-text built on whisper.cpp."""
2
+ __version__ = "0.4.0"
@@ -1,3 +1,3 @@
1
- from .cli import main
2
-
3
- main()
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,105 @@
1
+ """One function the CLI and the MCP server share: media file in -> subtitle/transcript files out."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from . import __version__, backend, hybrid, models, transcribe, writers
9
+
10
+
11
+ def _quantizer(binary: Path) -> Path:
12
+ return binary.parent / ("whisper-quantize.exe" if binary.suffix == ".exe" else "whisper-quantize")
13
+
14
+
15
+ def _device_id(device: str | int, found: list[dict]) -> int:
16
+ """Turn a user-supplied device into an id whisper.cpp actually reported.
17
+
18
+ `--device foo` used to escape as a bare ValueError from int(), and an id that is not in the list (5, -1)
19
+ was passed straight to whisper-cli. Both now fail here, before the run, with the ids that do exist."""
20
+ try:
21
+ dev = int(device)
22
+ except (TypeError, ValueError):
23
+ raise ValueError(f"device must be 'auto' or a GPU id, not {device!r}") from None
24
+ ids = [d["id"] for d in found]
25
+ if dev not in ids:
26
+ listed = ", ".join(f"{i} ({d['backend']} {d['name']})" for i, d in zip(ids, found)) or "none"
27
+ raise ValueError(f"no device {dev}; available: {listed}")
28
+ return dev
29
+
30
+
31
+ def setup(model: str = "turbo", bin: str | None = None, flavor: str | None = None) -> tuple[Path, Path, list[dict]]:
32
+ """Find (or download) whisper-cli and the model, and list the devices it can use."""
33
+ binary = backend.find_binary(bin, flavor)
34
+ timing = models.ensure(model, _quantizer(binary))
35
+ binary, found = backend.probe_or_refetch(binary, timing, flavor)
36
+ return binary, timing, found
37
+
38
+
39
+ def transcribe_file(
40
+ src: str | Path,
41
+ lang: str = "auto",
42
+ model: str = "turbo",
43
+ text_model: str | None = None,
44
+ accurate: bool = False,
45
+ formats: list[str] | None = None,
46
+ out: str | Path | None = None,
47
+ device: str | int = "auto",
48
+ bin: str | None = None,
49
+ flavor: str | None = None,
50
+ verbose: bool = False,
51
+ log: Callable[[str], None] = print,
52
+ max_line_chars: int | None = None,
53
+ prompt: str | None = None,
54
+ translate: bool = False,
55
+ ) -> dict:
56
+ """Transcribe one audio/video file. Returns a summary dict (files written, language, device, segments, text)."""
57
+ src = Path(src).expanduser()
58
+ if not src.is_file():
59
+ raise FileNotFoundError(f"no such file: {src}")
60
+ base = Path(out).expanduser() if out else src.with_suffix("")
61
+ # Before any download or inference: -o into a folder that does not exist used to fail at the very end with a
62
+ # bare "[Errno 2] ... .srt", throwing away a transcription that may have taken minutes.
63
+ base.parent.mkdir(parents=True, exist_ok=True)
64
+ if translate:
65
+ if text_model:
66
+ raise ValueError("translate cannot be combined with a text model (fine-tunes write the source language)")
67
+ if models.ALIASES.get(model, model) == "large-v3-turbo": # turbo was not trained for translation
68
+ model = "large-v3"
69
+ log("translate: using large-v3 (turbo does not translate)")
70
+ binary, timing, found = setup(model, bin, flavor)
71
+ dev = backend.pick_device(found) if device in ("auto", None) else _device_id(device, found)
72
+ gpu = next((d for d in found if d["id"] == dev), found[0])
73
+ if lang == "auto":
74
+ lang = transcribe.detected_language(binary, timing, src, dev)
75
+ log(f"language: {lang}")
76
+ beam = 5 if translate and accurate else None
77
+ if accurate and not text_model and not translate:
78
+ if lang in models.ACCURATE:
79
+ text_model = models.ACCURATE[lang] # fine-tune text + turbo timing
80
+ elif model == "turbo":
81
+ model, beam = "large-v3", 5
82
+ timing = models.ensure(model, _quantizer(binary))
83
+ t0 = time.time()
84
+ log(f"transcribing with {model} on {gpu['backend']} {gpu['name']} ...")
85
+ segs = transcribe.run(binary, timing, src, lang, dev, beam=beam, verbose=verbose, prompt=prompt, translate=translate)
86
+ meta = {"tool": f"babelscribe {__version__}", "model": model, "lang": lang, "device": f"{gpu['backend']} {gpu['name']}"}
87
+ if translate:
88
+ meta["translate"] = "en"
89
+ if text_model:
90
+ _, ft = models.resolve(text_model)
91
+ tm = models.ensure(text_model, _quantizer(binary))
92
+ log(f"text pass with {text_model} ...")
93
+ segs = hybrid.run(binary, tm, src, segs, ft["lang"] if ft else lang, dev, (ft or {}).get("beam"), verbose, prompt=prompt)
94
+ meta["text_model"] = text_model
95
+ files = writers.write(
96
+ segs,
97
+ base,
98
+ formats or ["srt", "json"],
99
+ meta,
100
+ max_line_chars=max_line_chars,
101
+ )
102
+ took = time.time() - t0
103
+ log(f"{len(segs)} segments in {took:.1f}s -> " + ", ".join(str(f) for f in files))
104
+ return {**meta, "files": [str(f) for f in files], "segments": len(segs), "seconds": round(took, 1),
105
+ "text": "\n".join(s["text"].strip() for s in segs)}
@@ -1,118 +1,118 @@
1
- """Find or fetch a whisper.cpp `whisper-cli` build for this machine and list its GPU devices.
2
-
3
- Search order: --bin / BABELSCRIBE_WHISPER_BIN -> cached download -> `whisper-cli` on PATH -> download a prebuilt
4
- release asset for this OS (Vulkan on Windows/Linux = AMD + NVIDIA + Intel, Metal on macOS, CPU fallback)."""
5
- from __future__ import annotations
6
-
7
- import io
8
- import json
9
- import os
10
- import platform
11
- import re
12
- import shutil
13
- import subprocess
14
- import urllib.request
15
- import zipfile
16
- from pathlib import Path
17
-
18
- CACHE = Path(os.environ.get("BABELSCRIBE_HOME", Path.home() / ".babelscribe"))
19
- # Prebuilt binaries are produced by .github/workflows/build-binaries.yml and attached to a GitHub release.
20
- RELEASES = os.environ.get("BABELSCRIBE_RELEASES", "https://github.com/phonology024/babelscribe/releases/latest/download")
21
- EXE = "whisper-cli.exe" if os.name == "nt" else "whisper-cli"
22
-
23
-
24
- def asset_name(flavor: str | None = None) -> str:
25
- system = {"Windows": "windows", "Linux": "linux", "Darwin": "macos"}[platform.system()]
26
- arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64"
27
- flavor = flavor or ("metal" if system == "macos" else "vulkan")
28
- return f"whisper-cli-{system}-{arch}-{flavor}.zip"
29
-
30
-
31
- def find_binary(explicit: str | None = None, flavor: str | None = None, download: bool = True) -> Path:
32
- for cand in (explicit, os.environ.get("BABELSCRIBE_WHISPER_BIN")):
33
- if cand and Path(cand).is_file():
34
- return Path(cand)
35
- cached = next(iter(sorted((CACHE / "bin").rglob(EXE))), None) if (CACHE / "bin").exists() else None
36
- if cached:
37
- return cached
38
- on_path = shutil.which("whisper-cli")
39
- if on_path:
40
- return Path(on_path)
41
- if not download:
42
- raise FileNotFoundError("whisper-cli not found; pass --bin or allow download")
43
- return fetch_binary(flavor)
44
-
45
-
46
- def fetch_binary(flavor: str | None = None) -> Path:
47
- name = asset_name(flavor)
48
- dest = CACHE / "bin" / name[:-4]
49
- print(f"downloading {name} ...")
50
- data = urllib.request.urlopen(f"{RELEASES}/{name}").read()
51
- zipfile.ZipFile(io.BytesIO(data)).extractall(dest)
52
- exe = next(dest.rglob(EXE))
53
- if os.name != "nt":
54
- exe.chmod(0o755)
55
- return exe
56
-
57
-
58
- def probe_or_refetch(binary: Path, model: Path, flavor: str | None = None) -> tuple[Path, list[dict]]:
59
- """Probe devices; a cached binary that crashes with 'illegal instruction' is deleted and downloaded again once."""
60
- try:
61
- return binary, probe_devices(binary, model)
62
- except IllegalInstruction:
63
- cache = (CACHE / "bin").resolve()
64
- if cache not in binary.resolve().parents:
65
- raise SystemExit(f"{binary} crashed with 'illegal instruction': it was built for a newer CPU than this one.")
66
- shutil.rmtree(binary.parent)
67
- print("cached whisper-cli does not run on this CPU; downloading the current build ...")
68
- binary = fetch_binary(flavor)
69
- try:
70
- return binary, probe_devices(binary, model)
71
- except IllegalInstruction:
72
- raise SystemExit("the prebuilt whisper-cli does not run on this CPU; build whisper.cpp yourself and pass --bin.")
73
-
74
-
75
- def devices(binary: Path) -> list[dict]:
76
- """Ask whisper.cpp which GPUs it can see (it prints them while loading a model)."""
77
- out = subprocess.run([str(binary), "--help"], capture_output=True, text=True, errors="replace")
78
- text = out.stdout + out.stderr
79
- found = []
80
- for m in re.finditer(r"ggml_(vulkan|cuda|metal): *(\d+) = ([^|\n]+)", text):
81
- found.append({"backend": m.group(1), "id": int(m.group(2)), "name": m.group(3).strip()})
82
- return found
83
-
84
-
85
- class IllegalInstruction(RuntimeError):
86
- """The binary was compiled for CPU features this machine lacks (e.g. an old AVX-512 build)."""
87
-
88
-
89
- def probe_devices(binary: Path, model: Path) -> list[dict]:
90
- """Load a model on an empty clip to see the GPU list and which device whisper.cpp picks."""
91
- import tempfile, wave
92
- with tempfile.TemporaryDirectory() as d:
93
- wav = Path(d) / "s.wav"
94
- with wave.open(str(wav), "wb") as w:
95
- w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000); w.writeframes(b"\0\0" * 16000)
96
- # no -np: static builds route the GPU list through the same logger -np silences
97
- out = subprocess.run([str(binary), "-m", str(model), "-f", str(wav)], capture_output=True, text=True, errors="replace")
98
- if out.returncode in (0xC000001D, -4, 132): # Windows STATUS_ILLEGAL_INSTRUCTION / SIGILL
99
- raise IllegalInstruction(str(binary))
100
- text = out.stdout + out.stderr
101
- found = [{"backend": m.group(1), "id": int(m.group(2)), "name": m.group(3).strip(), "detail": m.group(4).strip()}
102
- for m in re.finditer(r"ggml_(vulkan|cuda|metal): *(\d+) = ([^|\n]+)\|?([^\n]*)", text)]
103
- if not found and "Metal" in text:
104
- found.append({"backend": "metal", "id": 0, "name": "Apple GPU", "detail": ""})
105
- return found or [{"backend": "cpu", "id": 0, "name": platform.processor() or "CPU", "detail": ""}]
106
-
107
-
108
- def pick_device(found: list[dict]) -> int:
109
- """Prefer a discrete GPU: NVIDIA/AMD over integrated Intel/AMD APU (uma: 1)."""
110
- gpus = [d for d in found if d["backend"] != "cpu"]
111
- if not gpus:
112
- return 0
113
- discrete = [d for d in gpus if "uma: 0" in d.get("detail", "")] or gpus
114
- return discrete[0]["id"]
115
-
116
-
117
- if __name__ == "__main__":
118
- print(json.dumps({"asset": asset_name(), "binary": str(find_binary(download=False))}, indent=1))
1
+ """Find or fetch a whisper.cpp `whisper-cli` build for this machine and list its GPU devices.
2
+
3
+ Search order: --bin / BABELSCRIBE_WHISPER_BIN -> cached download -> `whisper-cli` on PATH -> download a prebuilt
4
+ release asset for this OS (Vulkan on Windows/Linux = AMD + NVIDIA + Intel, Metal on macOS, CPU fallback)."""
5
+ from __future__ import annotations
6
+
7
+ import io
8
+ import json
9
+ import os
10
+ import platform
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import urllib.request
15
+ import zipfile
16
+ from pathlib import Path
17
+
18
+ CACHE = Path(os.environ.get("BABELSCRIBE_HOME", Path.home() / ".babelscribe"))
19
+ # Prebuilt binaries are produced by .github/workflows/build-binaries.yml and attached to a GitHub release.
20
+ RELEASES = os.environ.get("BABELSCRIBE_RELEASES", "https://github.com/phonology024/babelscribe/releases/latest/download")
21
+ EXE = "whisper-cli.exe" if os.name == "nt" else "whisper-cli"
22
+
23
+
24
+ def asset_name(flavor: str | None = None) -> str:
25
+ system = {"Windows": "windows", "Linux": "linux", "Darwin": "macos"}[platform.system()]
26
+ arch = "arm64" if platform.machine().lower() in ("arm64", "aarch64") else "x64"
27
+ flavor = flavor or ("metal" if system == "macos" else "vulkan")
28
+ return f"whisper-cli-{system}-{arch}-{flavor}.zip"
29
+
30
+
31
+ def find_binary(explicit: str | None = None, flavor: str | None = None, download: bool = True) -> Path:
32
+ for cand in (explicit, os.environ.get("BABELSCRIBE_WHISPER_BIN")):
33
+ if cand and Path(cand).is_file():
34
+ return Path(cand)
35
+ cached = next(iter(sorted((CACHE / "bin").rglob(EXE))), None) if (CACHE / "bin").exists() else None
36
+ if cached:
37
+ return cached
38
+ on_path = shutil.which("whisper-cli")
39
+ if on_path:
40
+ return Path(on_path)
41
+ if not download:
42
+ raise FileNotFoundError("whisper-cli not found; pass --bin or allow download")
43
+ return fetch_binary(flavor)
44
+
45
+
46
+ def fetch_binary(flavor: str | None = None) -> Path:
47
+ name = asset_name(flavor)
48
+ dest = CACHE / "bin" / name[:-4]
49
+ print(f"downloading {name} ...")
50
+ data = urllib.request.urlopen(f"{RELEASES}/{name}").read()
51
+ zipfile.ZipFile(io.BytesIO(data)).extractall(dest)
52
+ exe = next(dest.rglob(EXE))
53
+ if os.name != "nt":
54
+ exe.chmod(0o755)
55
+ return exe
56
+
57
+
58
+ def probe_or_refetch(binary: Path, model: Path, flavor: str | None = None) -> tuple[Path, list[dict]]:
59
+ """Probe devices; a cached binary that crashes with 'illegal instruction' is deleted and downloaded again once."""
60
+ try:
61
+ return binary, probe_devices(binary, model)
62
+ except IllegalInstruction:
63
+ cache = (CACHE / "bin").resolve()
64
+ if cache not in binary.resolve().parents:
65
+ raise SystemExit(f"{binary} crashed with 'illegal instruction': it was built for a newer CPU than this one.")
66
+ shutil.rmtree(binary.parent)
67
+ print("cached whisper-cli does not run on this CPU; downloading the current build ...")
68
+ binary = fetch_binary(flavor)
69
+ try:
70
+ return binary, probe_devices(binary, model)
71
+ except IllegalInstruction:
72
+ raise SystemExit("the prebuilt whisper-cli does not run on this CPU; build whisper.cpp yourself and pass --bin.")
73
+
74
+
75
+ def devices(binary: Path) -> list[dict]:
76
+ """Ask whisper.cpp which GPUs it can see (it prints them while loading a model)."""
77
+ out = subprocess.run([str(binary), "--help"], capture_output=True, text=True, errors="replace")
78
+ text = out.stdout + out.stderr
79
+ found = []
80
+ for m in re.finditer(r"ggml_(vulkan|cuda|metal): *(\d+) = ([^|\n]+)", text):
81
+ found.append({"backend": m.group(1), "id": int(m.group(2)), "name": m.group(3).strip()})
82
+ return found
83
+
84
+
85
+ class IllegalInstruction(RuntimeError):
86
+ """The binary was compiled for CPU features this machine lacks (e.g. an old AVX-512 build)."""
87
+
88
+
89
+ def probe_devices(binary: Path, model: Path) -> list[dict]:
90
+ """Load a model on an empty clip to see the GPU list and which device whisper.cpp picks."""
91
+ import tempfile, wave
92
+ with tempfile.TemporaryDirectory() as d:
93
+ wav = Path(d) / "s.wav"
94
+ with wave.open(str(wav), "wb") as w:
95
+ w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000); w.writeframes(b"\0\0" * 16000)
96
+ # no -np: static builds route the GPU list through the same logger -np silences
97
+ out = subprocess.run([str(binary), "-m", str(model), "-f", str(wav)], capture_output=True, text=True, errors="replace")
98
+ if out.returncode in (0xC000001D, -4, 132): # Windows STATUS_ILLEGAL_INSTRUCTION / SIGILL
99
+ raise IllegalInstruction(str(binary))
100
+ text = out.stdout + out.stderr
101
+ found = [{"backend": m.group(1), "id": int(m.group(2)), "name": m.group(3).strip(), "detail": m.group(4).strip()}
102
+ for m in re.finditer(r"ggml_(vulkan|cuda|metal): *(\d+) = ([^|\n]+)\|?([^\n]*)", text)]
103
+ if not found and "Metal" in text:
104
+ found.append({"backend": "metal", "id": 0, "name": "Apple GPU", "detail": ""})
105
+ return found or [{"backend": "cpu", "id": 0, "name": platform.processor() or "CPU", "detail": ""}]
106
+
107
+
108
+ def pick_device(found: list[dict]) -> int:
109
+ """Prefer a discrete GPU: NVIDIA/AMD over integrated Intel/AMD APU (uma: 1)."""
110
+ gpus = [d for d in found if d["backend"] != "cpu"]
111
+ if not gpus:
112
+ return 0
113
+ discrete = [d for d in gpus if "uma: 0" in d.get("detail", "")] or gpus
114
+ return discrete[0]["id"]
115
+
116
+
117
+ if __name__ == "__main__":
118
+ print(json.dumps({"asset": asset_name(), "binary": str(find_binary(download=False))}, indent=1))
@@ -4,6 +4,7 @@ Vulkan, NVIDIA via CUDA, Apple via Metal) or the CPU.
4
4
  babelscribe talk.mp4 # auto language, turbo model, best GPU -> talk.srt + talk.json
5
5
  babelscribe vo.wav -l th --text-model thai-thonburian # hybrid: Thai fine-tune text + turbo timing
6
6
  babelscribe talk.mp4 --accurate # slower, fewest errors: large-v3 + beam search, or the best fine-tune
7
+ babelscribe talk.mp4 --prompt "Pitakpong, babelscribe" # bias spelling of names and terms
7
8
  babelscribe mcp # MCP server for AI apps (Claude, Codex, Gemini CLI, ...)
8
9
  babelscribe devices # list GPUs whisper.cpp can use
9
10
  babelscribe models # list models and fine-tunes"""
@@ -12,11 +13,14 @@ from __future__ import annotations
12
13
  import argparse
13
14
  import sys
14
15
 
15
- from . import __version__, api, backend, models
16
+ from . import __version__, api, backend, doctor, models
16
17
 
17
18
 
18
19
  def main(argv: list[str] | None = None) -> None:
19
20
  argv = sys.argv[1:] if argv is None else argv
21
+ if argv[:1] == ["doctor"]:
22
+ doctor.main()
23
+ return
20
24
  if argv[:1] == ["mcp"]: # MCP server for AI apps (stdio)
21
25
  from .mcp_server import main as serve
22
26
  return serve()
@@ -32,7 +36,14 @@ def main(argv: list[str] | None = None) -> None:
32
36
  ap.add_argument("--text-model", help="language fine-tune for the text (hybrid mode), e.g. thai-thonburian")
33
37
  ap.add_argument("--accurate", action="store_true",
34
38
  help="slower but fewest errors: large-v3 with beam search, or the language's best fine-tune (hybrid)")
35
- ap.add_argument("-f", "--formats", default="srt,json", help="comma list: srt,vtt,txt,json")
39
+ ap.add_argument("--prompt", help='names and terms to spell correctly, e.g. --prompt "Pitakpong, babelscribe" (whisper initial prompt)')
40
+ ap.add_argument("--translate", action="store_true", help="translate the speech to English (uses large-v3; not with --text-model)")
41
+ ap.add_argument("-f", "--formats", default="srt,json", help="comma list: srt,vtt,txt,json,ass")
42
+ ap.add_argument(
43
+ "--max-line-chars",
44
+ type=int,
45
+ help="maximum characters per subtitle line (default: 42, smaller for CJK/Thai)",
46
+ )
36
47
  ap.add_argument("-o", "--out", help="output base path (default: next to the input)")
37
48
  ap.add_argument("--device", default="auto", help="GPU id from `babelscribe devices`, or auto")
38
49
  ap.add_argument("--bin", help="path to a whisper-cli you built yourself")
@@ -47,8 +58,22 @@ def main(argv: list[str] | None = None) -> None:
47
58
  print(f" [{d['id']}] {d['backend']:6} {d['name']} {d.get('detail', '')}")
48
59
  print(f"auto picks device {backend.pick_device(found)}"); return
49
60
  try:
50
- api.transcribe_file(a.input, a.lang, a.model, a.text_model, a.accurate,
51
- [f.strip() for f in a.formats.split(",") if f.strip()], a.out, a.device, a.bin, a.flavor, a.verbose)
61
+ api.transcribe_file(
62
+ src=a.input,
63
+ lang=a.lang,
64
+ model=a.model,
65
+ text_model=a.text_model,
66
+ accurate=a.accurate,
67
+ formats=[f.strip() for f in a.formats.split(",") if f.strip()],
68
+ out=a.out,
69
+ device=a.device,
70
+ bin=a.bin,
71
+ flavor=a.flavor,
72
+ verbose=a.verbose,
73
+ max_line_chars=a.max_line_chars,
74
+ prompt=a.prompt,
75
+ translate=a.translate,
76
+ )
52
77
  except FileNotFoundError as e:
53
78
  raise SystemExit(str(e))
54
79