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.
- {babelscribe-0.3.2 → babelscribe-0.4.0}/LICENSE +21 -21
- {babelscribe-0.3.2 → babelscribe-0.4.0}/PKG-INFO +12 -3
- {babelscribe-0.3.2 → babelscribe-0.4.0}/README.md +11 -2
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/__init__.py +2 -2
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/__main__.py +3 -3
- babelscribe-0.4.0/babelscribe/api.py +105 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/backend.py +118 -118
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/cli.py +29 -4
- babelscribe-0.4.0/babelscribe/doctor.py +149 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/hybrid.py +4 -3
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/mcp_server.py +7 -3
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/models.py +96 -94
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/transcribe.py +82 -70
- babelscribe-0.4.0/babelscribe/writers.py +257 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/PKG-INFO +12 -3
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/SOURCES.txt +5 -1
- {babelscribe-0.3.2 → babelscribe-0.4.0}/pyproject.toml +48 -48
- babelscribe-0.4.0/tests/test_api_stability.py +58 -0
- babelscribe-0.4.0/tests/test_core.py +484 -0
- babelscribe-0.4.0/tests/test_doctor.py +46 -0
- babelscribe-0.4.0/tests/test_windows_packaging.py +90 -0
- babelscribe-0.3.2/babelscribe/api.py +0 -59
- babelscribe-0.3.2/babelscribe/writers.py +0 -29
- babelscribe-0.3.2/tests/test_core.py +0 -60
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe/align.py +0 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/dependency_links.txt +0 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/entry_points.txt +0 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/requires.txt +0 -0
- {babelscribe-0.3.2 → babelscribe-0.4.0}/babelscribe.egg-info/top_level.txt +0 -0
- {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
|
+
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
|
-
- **
|
|
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
|
-
- **
|
|
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.
|
|
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("
|
|
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(
|
|
51
|
-
|
|
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
|
|