legendaria 0.1.0.dev0__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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: legendaria
3
+ Version: 0.1.0.dev0
4
+ Summary: Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub).
5
+ Project-URL: Homepage, https://github.com/flou-ainan/Legendaria
6
+ Project-URL: Issues, https://github.com/flou-ainan/Legendaria/issues
7
+ Author: flou-ainan
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: captions,srt,subtitles,transcription,vtt,whisper
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
17
+ Classifier: Topic :: Multimedia :: Video
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: faster-whisper<2,>=1.2
20
+ Requires-Dist: platformdirs>=4
21
+ Provides-Extra: cuda
22
+ Requires-Dist: nvidia-cublas-cu12>=12.1; (sys_platform == 'linux' or sys_platform == 'win32') and extra == 'cuda'
23
+ Requires-Dist: nvidia-cudnn-cu12<10,>=9; (sys_platform == 'linux' or sys_platform == 'win32') and extra == 'cuda'
24
+ Provides-Extra: test
25
+ Requires-Dist: jsonschema>=4; extra == 'test'
26
+ Requires-Dist: pytest>=8; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Legendária engine (`flosub`)
30
+
31
+ The transcription engine of [Legendária](https://github.com/flou-ainan/Legendaria): it runs Whisper (faster-whisper) locally and writes subtitles with per-word timings. The Blender add-on uses it, and it also works on its own:
32
+
33
+ ```bash
34
+ flosub transcribe video.mp4 # writes video.json, video.srt, video.vtt
35
+ flosub transcribe talk.mp3 --language pt --model medium --format srt
36
+ flosub export video.json --format vtt
37
+ flosub models list
38
+ flosub models download medium
39
+ flosub config show
40
+ ```
41
+
42
+ GPU acceleration works on NVIDIA cards (Linux and Windows) with the `cuda` extra; everything else runs on the CPU.
43
+
44
+ ## Development
45
+
46
+ ```bash
47
+ cd engine
48
+ uv sync --extra test # add --extra cuda for NVIDIA GPUs
49
+ uv run flosub --help
50
+ uv run pytest
51
+ ```
52
+
53
+ License: MIT.
@@ -0,0 +1,18 @@
1
+ legendaria_engine/__init__.py,sha256=MPAwSSVJZYAAb3BiB2icS6JDIIUx4XASImZnl02Pzrw,68
2
+ legendaria_engine/__main__.py,sha256=ehub8H7TSg1vaWOEpxRpoUvrSGtWqyezQmbALhLzdbg,109
3
+ legendaria_engine/cli.py,sha256=zyPVYRTD-BuJAqKtA9t6aAQPJ-J6Lu5sMYWojAwJqf0,7518
4
+ legendaria_engine/config.py,sha256=KPOsLvbeqMu2ELroYFTCjPHXeSMcmJvm6hfrm6Ey09Q,3394
5
+ legendaria_engine/cuda_libs.py,sha256=qM0aledDWNvRdhh0C-PKyVpj1S0WdV1IBQmf5Stzf_s,2130
6
+ legendaria_engine/events.py,sha256=w5_1RqjVpABAPKIiU5F1-f0x42ZYDyUcDAkHHSs-1T8,4181
7
+ legendaria_engine/formats.py,sha256=VC0kKrw-wYyicbywyLSCvYxDC2I6oTollmPYM6ZeJ8Y,1285
8
+ legendaria_engine/models.py,sha256=jmboG325nOG3Kuo8AAjHfURuyG6sKyMGACTRZIn6dls,3763
9
+ legendaria_engine/paths.py,sha256=zmheUq0xUszXjafHJM-sSqb4FAhkso3Xe_G4btJS_gw,1277
10
+ legendaria_engine/schema.py,sha256=HrH8SyWt0dIfxk1scLzZny8csI5Cr5Int1i3lGWuPHs,2261
11
+ legendaria_engine/transcriber.py,sha256=8YTo8OONDdi49ovOTG_QK-baKYXNNJqTQxIltjK0SOo,4627
12
+ legendaria_engine/defaults/config.json,sha256=drJf1ulVDsy1DrsVIjOBEBfm3EvDXCYum91oSNZHVUY,402
13
+ legendaria_engine/defaults/models.json,sha256=mF7pq7KOPXA34DDMb_B7LB2thwgG2eKq4JQyhoxxEl0,1098
14
+ legendaria-0.1.0.dev0.dist-info/METADATA,sha256=guhlqGJJoxc0MksWElmuEce_tNx_y7dxS0i5wLR4tz8,2048
15
+ legendaria-0.1.0.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
16
+ legendaria-0.1.0.dev0.dist-info/entry_points.txt,sha256=Id1pLQ07QxLpnLa9x05Hz9D6xwlnxYpOUSIrJ0oezBU,54
17
+ legendaria-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=tLprFKV4YZ3aIIwJcfMGx4iKfPJbBA0hP0XOBNAtm8s,1067
18
+ legendaria-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flosub = legendaria_engine.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 flou-ainan
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.
@@ -0,0 +1,3 @@
1
+ """Legendária transcription engine."""
2
+
3
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m legendaria_engine`."""
2
+
3
+ from legendaria_engine.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,194 @@
1
+ """`flosub` command line: transcribe, export, models, config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from legendaria_engine import __version__, paths
11
+ from legendaria_engine.config import ConfigError, load_config
12
+ from legendaria_engine.events import Reporter, make_reporter
13
+ from legendaria_engine.formats import FORMATS, WRITERS
14
+ from legendaria_engine.schema import Transcript
15
+
16
+
17
+ def write_outputs(transcript: Transcript, stem: Path, formats: list[str]) -> list[str]:
18
+ """Write the requested formats next to `stem` (no extension); returns the paths."""
19
+ stem.parent.mkdir(parents=True, exist_ok=True)
20
+ written = []
21
+ for fmt in formats:
22
+ target = stem.with_suffix(f".{fmt}")
23
+ if fmt == "json":
24
+ transcript.save(target)
25
+ else:
26
+ target.write_text(WRITERS[fmt](transcript), encoding="utf-8")
27
+ written.append(str(target))
28
+ return written
29
+
30
+
31
+ def parse_formats(value: str | None) -> list[str] | None:
32
+ """argparse type: comma-separated format list, validated against FORMATS."""
33
+ if value is None:
34
+ return None
35
+ formats = [f.strip().lower() for f in value.split(",") if f.strip()]
36
+ unknown = [f for f in formats if f not in FORMATS]
37
+ if unknown:
38
+ raise argparse.ArgumentTypeError(f"unknown format(s): {', '.join(unknown)}")
39
+ return formats
40
+
41
+
42
+ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
43
+ """`flosub transcribe`: resolve config and model, transcribe, write the outputs."""
44
+ media = Path(args.file).expanduser().resolve()
45
+ if not media.is_file():
46
+ reporter.error(f"File not found: {media}")
47
+ return 2
48
+ config = load_config(
49
+ {
50
+ "model": args.model,
51
+ "language": args.language,
52
+ "device": args.device,
53
+ "formats": args.format,
54
+ }
55
+ )
56
+ if config["device"] != "cpu": # make pip-installed CUDA libraries loadable (may re-exec)
57
+ from legendaria_engine.cuda_libs import ensure_cuda_libs
58
+
59
+ ensure_cuda_libs()
60
+ from legendaria_engine import models
61
+ from legendaria_engine.transcriber import Options, get_backend
62
+
63
+ language = None if config["language"] == "auto" else config["language"]
64
+ options = Options(
65
+ model=config["model"],
66
+ model_path=models.resolve(config["model"], reporter),
67
+ language=language,
68
+ device=config["device"],
69
+ compute_type=config["compute_type"],
70
+ beam_size=config["beam_size"],
71
+ vad_filter=config["vad_filter"],
72
+ word_timestamps=config["word_timestamps"],
73
+ )
74
+ transcript = get_backend(config.get("backend", "faster-whisper")).transcribe(
75
+ media, options, reporter
76
+ )
77
+ out_dir = Path(args.output_dir).expanduser() if args.output_dir else media.parent
78
+ outputs = write_outputs(transcript, out_dir / media.stem, config["formats"])
79
+ reporter.emit(
80
+ "done", outputs=outputs, segments=len(transcript.segments), language=transcript.language
81
+ )
82
+ return 0
83
+
84
+
85
+ def cmd_export(args: argparse.Namespace, reporter: Reporter) -> int:
86
+ """`flosub export`: convert a transcript JSON to SRT/VTT."""
87
+ source = Path(args.file).expanduser()
88
+ transcript = Transcript.load(source)
89
+ stem = Path(args.output).with_suffix("") if args.output else source.with_suffix("")
90
+ outputs = write_outputs(transcript, stem, args.format)
91
+ reporter.emit("done", outputs=outputs, segments=len(transcript.segments))
92
+ return 0
93
+
94
+
95
+ def cmd_models(args: argparse.Namespace, reporter: Reporter) -> int:
96
+ """`flosub models list|download`."""
97
+ from legendaria_engine import models
98
+
99
+ if args.action == "download":
100
+ path = models.download(args.name, reporter)
101
+ reporter.emit("done", outputs=[str(path)])
102
+ return 0
103
+ entries = models.list_models()
104
+ if args.json:
105
+ print(
106
+ json.dumps(
107
+ {"models_dir": str(paths.models_dir()), "models": entries}, ensure_ascii=False
108
+ )
109
+ )
110
+ else:
111
+ default = load_config()["model"]
112
+ for m in entries:
113
+ mark = "✓" if m["installed"] else " "
114
+ star = " (default)" if m["name"] == default else ""
115
+ print(
116
+ f"[{mark}] {m['name']:<16} {m.get('size_mb', '?'):>5} MB {m.get('notes', '')}{star}"
117
+ )
118
+ print(f"\nModels folder: {paths.models_dir()}")
119
+ return 0
120
+
121
+
122
+ def cmd_config(args: argparse.Namespace, reporter: Reporter) -> int:
123
+ """`flosub config show|path`."""
124
+ if args.action == "path":
125
+ print(paths.user_config_file())
126
+ else:
127
+ print(json.dumps(load_config(), ensure_ascii=False, indent=2))
128
+ return 0
129
+
130
+
131
+ def build_parser() -> argparse.ArgumentParser:
132
+ """The argparse tree for all subcommands."""
133
+ parser = argparse.ArgumentParser(
134
+ prog="flosub", description="Legendária: local automatic subtitles."
135
+ )
136
+ parser.add_argument("--version", action="version", version=f"flosub {__version__}")
137
+ parser.add_argument(
138
+ "--progress",
139
+ choices=["auto", "json", "human"],
140
+ default="auto",
141
+ help="progress output: JSON lines (for programs) or human-readable (default: human in a terminal)",
142
+ )
143
+ sub = parser.add_subparsers(dest="command", required=True)
144
+
145
+ t = sub.add_parser("transcribe", help="transcribe an audio or video file")
146
+ t.add_argument("file")
147
+ t.add_argument("--model", help="model name (see `flosub models list`) or a model folder")
148
+ t.add_argument("--language", help="language code (pt, en, ...) or 'auto'")
149
+ t.add_argument("--device", choices=["auto", "cuda", "cpu"])
150
+ t.add_argument("--format", type=parse_formats, help="comma-separated: json,srt,vtt")
151
+ t.add_argument("--output-dir", help="where to write the files (default: next to the input)")
152
+ t.set_defaults(func=cmd_transcribe)
153
+
154
+ e = sub.add_parser("export", help="convert a transcript JSON to SRT/VTT")
155
+ e.add_argument("file")
156
+ e.add_argument("--format", type=parse_formats, default=["srt"], help="comma-separated: srt,vtt")
157
+ e.add_argument("--output", help="output path without extension (default: next to the JSON)")
158
+ e.set_defaults(func=cmd_export)
159
+
160
+ m = sub.add_parser("models", help="list or download Whisper models")
161
+ m_sub = m.add_subparsers(dest="action", required=True)
162
+ m_list = m_sub.add_parser("list")
163
+ m_list.add_argument("--json", action="store_true", help="machine-readable output")
164
+ m_dl = m_sub.add_parser("download")
165
+ m_dl.add_argument("name")
166
+ m.set_defaults(func=cmd_models)
167
+
168
+ c = sub.add_parser("config", help="show the merged config or the user config path")
169
+ c.add_argument("action", choices=["show", "path"])
170
+ c.set_defaults(func=cmd_config)
171
+ return parser
172
+
173
+
174
+ def main(argv: list[str] | None = None) -> int:
175
+ """Entry point: run a subcommand and turn every failure into an `error` event."""
176
+ args = build_parser().parse_args(argv)
177
+ reporter = make_reporter(args.progress)
178
+ try:
179
+ return args.func(args, reporter)
180
+ except (ConfigError, KeyError, ValueError, RuntimeError, OSError) as exc:
181
+ reporter.error(str(exc).strip("'\""))
182
+ return 1
183
+ except KeyboardInterrupt:
184
+ reporter.error("Cancelled.")
185
+ return 130
186
+ except Exception as exc: # unexpected: still report it as an event the add-on can show
187
+ reporter.error(f"{type(exc).__name__}: {exc}")
188
+ return 1
189
+ finally:
190
+ reporter.close()
191
+
192
+
193
+ if __name__ == "__main__":
194
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ """Layered JSON configuration: shipped defaults → user file → command-line values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from legendaria_engine import paths
10
+
11
+ DEVICES = ("auto", "cuda", "cpu")
12
+
13
+
14
+ class ConfigError(ValueError):
15
+ """A config file is malformed or holds an invalid value."""
16
+
17
+
18
+ def read_json(path: Path) -> dict[str, Any]:
19
+ """Read a JSON object from a file, dropping `$schema`; raise ConfigError on bad JSON."""
20
+ try:
21
+ data = json.loads(path.read_text(encoding="utf-8"))
22
+ except json.JSONDecodeError as exc:
23
+ raise ConfigError(f"{path}: invalid JSON at line {exc.lineno}: {exc.msg}") from exc
24
+ if not isinstance(data, dict):
25
+ raise ConfigError(f"{path}: the top level must be a JSON object")
26
+ data.pop("$schema", None)
27
+ return data
28
+
29
+
30
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
31
+ """Return `base` updated with `override`; nested objects are merged key by key."""
32
+ merged = dict(base)
33
+ for key, value in override.items():
34
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
35
+ merged[key] = deep_merge(merged[key], value)
36
+ else:
37
+ merged[key] = value
38
+ return merged
39
+
40
+
41
+ def validate(config: dict[str, Any], source: str = "config") -> None:
42
+ """Check the values the engine relies on, with errors that name the bad key."""
43
+ checks: list[tuple[str, type | tuple[type, ...]]] = [
44
+ ("model", str),
45
+ ("language", str),
46
+ ("device", str),
47
+ ("compute_type", dict),
48
+ ("beam_size", int),
49
+ ("vad_filter", bool),
50
+ ("word_timestamps", bool),
51
+ ("formats", list),
52
+ ]
53
+ for key, expected in checks:
54
+ if not isinstance(config.get(key), expected):
55
+ raise ConfigError(
56
+ f"{source}: '{key}' must be of type {getattr(expected, '__name__', expected)}"
57
+ )
58
+ if config["device"] not in DEVICES:
59
+ raise ConfigError(f"{source}: 'device' must be one of {', '.join(DEVICES)}")
60
+ from legendaria_engine.formats import FORMATS
61
+
62
+ unknown = [f for f in config["formats"] if f not in FORMATS]
63
+ if unknown:
64
+ raise ConfigError(f"{source}: unknown format(s) {unknown}; use {', '.join(FORMATS)}")
65
+
66
+
67
+ def load_config(overrides: dict[str, Any] | None = None) -> dict[str, Any]:
68
+ """Defaults, then the user's `config.json` if present, then non-None `overrides`."""
69
+ config = read_json(paths.defaults_dir() / "config.json")
70
+ user_file = paths.user_config_file()
71
+ if user_file.is_file():
72
+ config = deep_merge(config, read_json(user_file))
73
+ if overrides:
74
+ config = deep_merge(config, {k: v for k, v in overrides.items() if v is not None})
75
+ validate(config)
76
+ return config
77
+
78
+
79
+ def load_models() -> dict[str, dict[str, Any]]:
80
+ """The model catalog: shipped `models.json` plus the user's additions or changes."""
81
+ catalog = read_json(paths.defaults_dir() / "models.json")["models"]
82
+ user_file = paths.user_models_file()
83
+ if user_file.is_file():
84
+ catalog = deep_merge(catalog, read_json(user_file).get("models", {}))
85
+ for name, entry in catalog.items():
86
+ if not isinstance(entry, dict) or not isinstance(entry.get("repo"), str):
87
+ raise ConfigError(f"models.json: model '{name}' needs a 'repo' string")
88
+ return catalog
@@ -0,0 +1,56 @@
1
+ """Make the NVIDIA libraries installed by pip (cuBLAS, cuDNN) visible to CTranslate2.
2
+
3
+ The `nvidia-*-cu12` wheels put their shared libraries inside site-packages, where the
4
+ dynamic loader does not look. Without this, GPU transcription aborts with
5
+ "Unable to load libcudnn_ops.so.9".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ _REEXEC_FLAG = "LEGENDARIA_CUDA_LIBS_SET"
16
+
17
+
18
+ def nvidia_lib_dirs() -> list[Path]:
19
+ """Library folders of the installed `nvidia.*` packages (empty when not installed)."""
20
+ spec = importlib.util.find_spec("nvidia")
21
+ if spec is None or not spec.submodule_search_locations:
22
+ return []
23
+ sub = "bin" if sys.platform == "win32" else "lib"
24
+ dirs = []
25
+ for root in spec.submodule_search_locations:
26
+ for pkg in ("cublas", "cudnn"):
27
+ candidate = Path(root) / pkg / sub
28
+ if candidate.is_dir():
29
+ dirs.append(candidate)
30
+ return dirs
31
+
32
+
33
+ def ensure_cuda_libs() -> None:
34
+ """Expose the NVIDIA libraries to the current process. Call before importing ctranslate2.
35
+
36
+ - Linux: `LD_LIBRARY_PATH` is only read at process start, so the process re-executes
37
+ itself once with the folders added.
38
+ - Windows: register the DLL folders and prepend them to PATH.
39
+ - macOS / no NVIDIA packages: nothing to do.
40
+ """
41
+ dirs = nvidia_lib_dirs()
42
+ if not dirs:
43
+ return
44
+ if sys.platform == "win32":
45
+ for d in dirs:
46
+ os.add_dll_directory(str(d))
47
+ os.environ["PATH"] = os.pathsep.join([*map(str, dirs), os.environ.get("PATH", "")])
48
+ elif sys.platform.startswith("linux"):
49
+ current = os.environ.get("LD_LIBRARY_PATH", "")
50
+ missing = [str(d) for d in dirs if str(d) not in current.split(os.pathsep)]
51
+ if missing and not os.environ.get(_REEXEC_FLAG):
52
+ os.environ["LD_LIBRARY_PATH"] = os.pathsep.join(
53
+ [*missing, current] if current else missing
54
+ )
55
+ os.environ[_REEXEC_FLAG] = "1"
56
+ os.execv(sys.executable, [sys.executable, "-m", "legendaria_engine", *sys.argv[1:]])
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/config.schema.json",
3
+ "version": 1,
4
+ "model": "small",
5
+ "language": "auto",
6
+ "device": "auto",
7
+ "compute_type": {
8
+ "cuda": "float16",
9
+ "cpu": "int8"
10
+ },
11
+ "beam_size": 5,
12
+ "vad_filter": true,
13
+ "word_timestamps": true,
14
+ "formats": ["json", "srt", "vtt"],
15
+ "ui_languages": ["auto", "pt", "en"]
16
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/models.schema.json",
3
+ "version": 1,
4
+ "models": {
5
+ "tiny": {"repo": "Systran/faster-whisper-tiny", "size_mb": 75, "vram_mb": 1000, "notes": "Fastest, lowest accuracy. Good for quick drafts."},
6
+ "base": {"repo": "Systran/faster-whisper-base", "size_mb": 145, "vram_mb": 1000, "notes": "Fast, fair accuracy."},
7
+ "small": {"repo": "Systran/faster-whisper-small", "size_mb": 485, "vram_mb": 2000, "notes": "Default: good balance of speed and accuracy."},
8
+ "medium": {"repo": "Systran/faster-whisper-medium", "size_mb": 1530, "vram_mb": 5000, "notes": "More accurate, especially for technical terms. Needs a GPU for comfortable speed."},
9
+ "large-v3": {"repo": "Systran/faster-whisper-large-v3", "size_mb": 3100, "vram_mb": 10000, "notes": "Most accurate. Slow on CPU; in fp16 needs about 4.5 GB of VRAM."},
10
+ "large-v3-turbo": {"repo": "mobiuslabsgmbh/faster-whisper-large-v3-turbo", "size_mb": 1620, "vram_mb": 6000, "notes": "Near large-v3 accuracy, much faster. Great on 8 GB GPUs."}
11
+ }
12
+ }
@@ -0,0 +1,111 @@
1
+ """Progress reporting: JSON lines for programs (the Blender add-on), a progress bar for humans.
2
+
3
+ Every JSON event is one object per line on stdout with an `event` key:
4
+ `start`, `progress`, `model_download`, `log`, `warning`, `done`, `error`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from typing import Any, TextIO
12
+
13
+
14
+ class Reporter:
15
+ """Base reporter. Subclasses decide how events are shown."""
16
+
17
+ def emit(self, event: str, **data: Any) -> None:
18
+ """Send one event."""
19
+ raise NotImplementedError
20
+
21
+ def progress(self, current: float, total: float) -> None:
22
+ """Transcription progress in seconds of media."""
23
+ self.emit("progress", t=round(current, 2), total=round(total, 2))
24
+
25
+ def log(self, message: str) -> None:
26
+ """Informational message."""
27
+ self.emit("log", message=message)
28
+
29
+ def warning(self, message: str) -> None:
30
+ """Non-fatal problem (e.g. GPU fallback)."""
31
+ self.emit("warning", message=message)
32
+
33
+ def error(self, message: str) -> None:
34
+ """Fatal problem; the command exits with a non-zero code."""
35
+ self.emit("error", message=message)
36
+
37
+ def close(self) -> None:
38
+ """Release any resource (e.g. a progress bar)."""
39
+
40
+
41
+ class JsonReporter(Reporter):
42
+ """Machine-readable events, one JSON object per line."""
43
+
44
+ def __init__(self, stream: TextIO | None = None) -> None:
45
+ self.stream = stream or sys.stdout
46
+
47
+ def emit(self, event: str, **data: Any) -> None:
48
+ """Write the event as one JSON line and flush, so readers see it at once."""
49
+ self.stream.write(json.dumps({"event": event, **data}, ensure_ascii=False) + "\n")
50
+ self.stream.flush()
51
+
52
+
53
+ class HumanReporter(Reporter):
54
+ """Readable output on stderr, with a tqdm bar for transcription progress."""
55
+
56
+ def __init__(self, stream: TextIO | None = None) -> None:
57
+ self.stream = stream or sys.stderr
58
+ self._bar = None
59
+ self._last = 0.0
60
+
61
+ def emit(self, event: str, **data: Any) -> None:
62
+ """Render the event as text: progress bar, messages, saved files."""
63
+ if event == "progress":
64
+ self._progress(data["t"], data["total"])
65
+ elif event == "model_download":
66
+ if data.get("finished"):
67
+ self.stream.write(f"\rDownloading model {data.get('model', '')}: done \n")
68
+ else:
69
+ done, total = data.get("downloaded", 0), data.get("total") or 0
70
+ pct = f"{100 * done / total:.0f}%" if total else f"{done / 1e6:.0f} MB"
71
+ self.stream.write(f"\rDownloading model {data.get('model', '')}: {pct} ")
72
+ self.stream.flush()
73
+ elif event in ("log", "warning", "error"):
74
+ self._close_bar()
75
+ prefix = {"log": "", "warning": "Warning: ", "error": "Error: "}[event]
76
+ self.stream.write(f"{prefix}{data.get('message', '')}\n")
77
+ elif event == "done":
78
+ self._close_bar()
79
+ for path in data.get("outputs", []):
80
+ self.stream.write(f"Saved: {path}\n")
81
+ elif event == "start":
82
+ self.stream.write(
83
+ f"Model {data.get('model')} on {data.get('device')} ({data.get('compute_type')}), "
84
+ f"language: {data.get('language') or 'auto'}\n"
85
+ )
86
+
87
+ def _progress(self, current: float, total: float) -> None:
88
+ from tqdm import tqdm
89
+
90
+ if self._bar is None:
91
+ self._bar = tqdm(total=round(total), unit="s", desc="Transcribing", file=self.stream)
92
+ step = round(current) - round(self._last)
93
+ if step > 0:
94
+ self._bar.update(step)
95
+ self._last = current
96
+
97
+ def _close_bar(self) -> None:
98
+ if self._bar is not None:
99
+ self._bar.close()
100
+ self._bar = None
101
+
102
+ def close(self) -> None:
103
+ """Close the progress bar, if any."""
104
+ self._close_bar()
105
+
106
+
107
+ def make_reporter(mode: str = "auto") -> Reporter:
108
+ """`json`, `human`, or `auto` (human when stdout is a terminal, JSON otherwise)."""
109
+ if mode == "auto":
110
+ mode = "human" if sys.stdout.isatty() else "json"
111
+ return HumanReporter() if mode == "human" else JsonReporter()
@@ -0,0 +1,37 @@
1
+ """Subtitle writers: transcript → SRT / VTT text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from legendaria_engine.schema import Transcript
6
+
7
+ FORMATS = ("json", "srt", "vtt")
8
+
9
+
10
+ def format_timestamp(seconds: float, separator: str = ",") -> str:
11
+ """`HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (VTT). Hours keep counting past 24."""
12
+ total_ms = max(0, round(seconds * 1000))
13
+ hours, rest = divmod(total_ms, 3_600_000)
14
+ minutes, rest = divmod(rest, 60_000)
15
+ secs, ms = divmod(rest, 1000)
16
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{ms:03d}"
17
+
18
+
19
+ def to_srt(transcript: Transcript) -> str:
20
+ """Numbered SRT cues, one per segment."""
21
+ blocks = []
22
+ for index, seg in enumerate(transcript.segments, start=1):
23
+ start, end = format_timestamp(seg.start), format_timestamp(seg.end)
24
+ blocks.append(f"{index}\n{start} --> {end}\n{seg.text.strip()}\n")
25
+ return "\n".join(blocks)
26
+
27
+
28
+ def to_vtt(transcript: Transcript) -> str:
29
+ """WebVTT cues, one per segment."""
30
+ blocks = ["WEBVTT\n"]
31
+ for seg in transcript.segments:
32
+ start, end = format_timestamp(seg.start, "."), format_timestamp(seg.end, ".")
33
+ blocks.append(f"{start} --> {end}\n{seg.text.strip()}\n")
34
+ return "\n".join(blocks)
35
+
36
+
37
+ WRITERS = {"srt": to_srt, "vtt": to_vtt}
@@ -0,0 +1,105 @@
1
+ """List, download and locate Whisper models (stored in the per-OS data dir)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from legendaria_engine import paths
10
+ from legendaria_engine.config import load_models
11
+ from legendaria_engine.events import Reporter
12
+
13
+ REQUIRED_FILES = ("config.json", "model.bin", "tokenizer.json")
14
+ ALLOW_PATTERNS = [
15
+ "config.json",
16
+ "preprocessor_config.json",
17
+ "model.bin",
18
+ "tokenizer.json",
19
+ "vocabulary.*",
20
+ ]
21
+
22
+
23
+ def model_path(name: str) -> Path:
24
+ """Folder where a catalog model is (or will be) stored."""
25
+ return paths.models_dir() / name
26
+
27
+
28
+ def is_installed(name: str) -> bool:
29
+ """True when the model folder has all the files CTranslate2 needs."""
30
+ folder = model_path(name)
31
+ return all((folder / f).is_file() for f in REQUIRED_FILES)
32
+
33
+
34
+ def list_models() -> list[dict[str, Any]]:
35
+ """Catalog entries with an `installed` flag, in catalog order."""
36
+ return [
37
+ {"name": name, **entry, "installed": is_installed(name)}
38
+ for name, entry in load_models().items()
39
+ ]
40
+
41
+
42
+ def _progress_class(reporter: Reporter, name: str, expected_bytes: int | None):
43
+ """A tqdm-compatible class that turns Hugging Face download progress into events.
44
+
45
+ It never draws anything; it counts bytes and emits `model_download` events at most
46
+ every 0.25 s. Hugging Face grows the byte total while it discovers files, so the
47
+ catalog size is used until the real total is known.
48
+ """
49
+ from tqdm.auto import tqdm
50
+
51
+ class EventTqdm(tqdm):
52
+ def __init__(self, *args, **kwargs):
53
+ self._is_bytes = kwargs.get("unit") == "B" # skip the per-file counter
54
+ self._done = kwargs.get("initial", 0) or 0
55
+ self._last_emit = 0.0
56
+ kwargs["disable"] = True
57
+ super().__init__(*args, **kwargs)
58
+
59
+ def update(self, n=1):
60
+ self._done += n or 0
61
+ now = time.monotonic()
62
+ if self._is_bytes and now - self._last_emit >= 0.25:
63
+ self._last_emit = now
64
+ total = max(getattr(self, "total", None) or 0, expected_bytes or 0) or None
65
+ reporter.emit("model_download", model=name, downloaded=self._done, total=total)
66
+
67
+ return EventTqdm
68
+
69
+
70
+ def download(name: str, reporter: Reporter) -> Path:
71
+ """Download a catalog model into the data dir; returns its folder."""
72
+ catalog = load_models()
73
+ if name not in catalog:
74
+ raise KeyError(f"Unknown model '{name}'. Available: {', '.join(catalog)}")
75
+ from huggingface_hub import snapshot_download
76
+ from huggingface_hub.utils import logging as hf_logging
77
+
78
+ hf_logging.set_verbosity_error() # hide hub notices (e.g. about tokens) on stderr
79
+
80
+ target = model_path(name)
81
+ target.mkdir(parents=True, exist_ok=True)
82
+ expected = int(catalog[name]["size_mb"] * 1_000_000) if catalog[name].get("size_mb") else None
83
+ reporter.log(f"Downloading model '{name}' ({catalog[name].get('size_mb', '?')} MB)...")
84
+ snapshot_download(
85
+ catalog[name]["repo"],
86
+ local_dir=target,
87
+ allow_patterns=ALLOW_PATTERNS,
88
+ tqdm_class=_progress_class(reporter, name, expected),
89
+ )
90
+ if not is_installed(name):
91
+ raise RuntimeError(f"Download of '{name}' finished but files are missing in {target}")
92
+ reporter.emit("model_download", model=name, finished=True)
93
+ return target
94
+
95
+
96
+ def resolve(name: str, reporter: Reporter) -> Path:
97
+ """Folder of an installed model, downloading it first when needed.
98
+
99
+ A path to an existing folder is accepted too, for custom CTranslate2 models.
100
+ """
101
+ if Path(name).is_dir():
102
+ return Path(name)
103
+ if not is_installed(name):
104
+ download(name, reporter)
105
+ return model_path(name)
@@ -0,0 +1,42 @@
1
+ """Per-OS locations for user config, data and models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from platformdirs import user_config_path, user_data_path
9
+
10
+ APP_NAME = "legendaria"
11
+
12
+
13
+ def config_dir() -> Path:
14
+ """Directory of the user config file; `LEGENDARIA_CONFIG_DIR` overrides it."""
15
+ override = os.environ.get("LEGENDARIA_CONFIG_DIR")
16
+ return Path(override) if override else user_config_path(APP_NAME, appauthor=False)
17
+
18
+
19
+ def data_dir() -> Path:
20
+ """Directory for downloaded models and other data; `LEGENDARIA_DATA_DIR` overrides it."""
21
+ override = os.environ.get("LEGENDARIA_DATA_DIR")
22
+ return Path(override) if override else user_data_path(APP_NAME, appauthor=False)
23
+
24
+
25
+ def user_config_file() -> Path:
26
+ """The user config overrides: `<config dir>/config.json`."""
27
+ return config_dir() / "config.json"
28
+
29
+
30
+ def user_models_file() -> Path:
31
+ """User additions to the model catalog: `<config dir>/models.json`."""
32
+ return config_dir() / "models.json"
33
+
34
+
35
+ def models_dir() -> Path:
36
+ """Where downloaded models live: `<data dir>/models`."""
37
+ return data_dir() / "models"
38
+
39
+
40
+ def defaults_dir() -> Path:
41
+ """Shipped default JSON files inside the package."""
42
+ return Path(__file__).resolve().parent / "defaults"
@@ -0,0 +1,79 @@
1
+ """Transcript format (JSON v1): the contract between the engine and the Blender add-on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ SCHEMA_VERSION = 1
11
+
12
+
13
+ @dataclass
14
+ class Word:
15
+ """One recognized word with its timing (seconds) and confidence."""
16
+
17
+ start: float
18
+ end: float
19
+ word: str
20
+ probability: float = 1.0
21
+
22
+
23
+ @dataclass
24
+ class Segment:
25
+ """A sentence-like chunk of speech; becomes one subtitle cue."""
26
+
27
+ start: float
28
+ end: float
29
+ text: str
30
+ words: list[Word] = field(default_factory=list)
31
+
32
+
33
+ @dataclass
34
+ class Transcript:
35
+ """The whole transcription of one media file (JSON v1)."""
36
+
37
+ source: str
38
+ language: str | None
39
+ duration: float
40
+ model: str
41
+ device: str
42
+ segments: list[Segment] = field(default_factory=list)
43
+ language_probability: float | None = None
44
+ version: int = SCHEMA_VERSION
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ """Plain dict ready for JSON."""
48
+ return asdict(self)
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict[str, Any]) -> Transcript:
52
+ """Build from a dict, rejecting other schema versions."""
53
+ version = data.get("version")
54
+ if version != SCHEMA_VERSION:
55
+ raise ValueError(
56
+ f"Unsupported transcript version {version!r} (expected {SCHEMA_VERSION})"
57
+ )
58
+ segments = [
59
+ Segment(
60
+ start=s["start"],
61
+ end=s["end"],
62
+ text=s["text"],
63
+ words=[Word(**w) for w in s.get("words", [])],
64
+ )
65
+ for s in data["segments"]
66
+ ]
67
+ fields = {k: data[k] for k in ("source", "language", "duration", "model", "device")}
68
+ return cls(
69
+ **fields, segments=segments, language_probability=data.get("language_probability")
70
+ )
71
+
72
+ def save(self, path: Path) -> None:
73
+ """Write as pretty UTF-8 JSON."""
74
+ path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
75
+
76
+ @classmethod
77
+ def load(cls, path: Path) -> Transcript:
78
+ """Read a transcript JSON file."""
79
+ return cls.from_dict(json.loads(path.read_text(encoding="utf-8")))
@@ -0,0 +1,133 @@
1
+ """Speech-to-text. A small backend interface keeps room for other engines (e.g. Apple Silicon)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from legendaria_engine.events import Reporter
10
+ from legendaria_engine.schema import Segment, Transcript, Word
11
+
12
+
13
+ @dataclass
14
+ class Options:
15
+ """Everything a backend needs to run one transcription."""
16
+
17
+ model: str
18
+ model_path: Path
19
+ language: str | None # None = auto-detect
20
+ device: str # auto | cuda | cpu
21
+ compute_type: dict[str, str]
22
+ beam_size: int = 5
23
+ vad_filter: bool = True
24
+ word_timestamps: bool = True
25
+
26
+
27
+ class Backend(Protocol):
28
+ """Interface for speech-to-text engines (faster-whisper today; Apple Silicon later)."""
29
+
30
+ name: str
31
+
32
+ def transcribe(self, media: Path, options: Options, reporter: Reporter) -> Transcript:
33
+ """Transcribe `media`, reporting progress, and return the transcript."""
34
+ ...
35
+
36
+
37
+ def cuda_available() -> bool:
38
+ """True when CTranslate2 sees at least one CUDA device."""
39
+ try:
40
+ import ctranslate2
41
+
42
+ return ctranslate2.get_cuda_device_count() > 0
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ class FasterWhisperBackend:
48
+ """faster-whisper (CTranslate2): CUDA on NVIDIA GPUs, int8 on CPU."""
49
+
50
+ name = "faster-whisper"
51
+
52
+ def _devices(self, options: Options) -> list[str]:
53
+ if options.device != "auto":
54
+ return [options.device]
55
+ return ["cuda", "cpu"] if cuda_available() else ["cpu"]
56
+
57
+ def transcribe(self, media: Path, options: Options, reporter: Reporter) -> Transcript:
58
+ """Try each device in turn; in `auto` mode a GPU failure falls back to the CPU.
59
+
60
+ CUDA problems (missing libraries, out of memory) can surface when the model loads
61
+ or only once decoding starts, so the whole run is retried.
62
+ """
63
+ devices = self._devices(options)
64
+ for i, device in enumerate(devices):
65
+ try:
66
+ return self._run(media, options, device, reporter)
67
+ except Exception as exc:
68
+ if i == len(devices) - 1:
69
+ raise RuntimeError(f"Transcription failed on {device}: {exc}") from exc
70
+ reporter.warning(f"GPU unavailable ({exc}); falling back to CPU, which is slower.")
71
+ raise AssertionError("unreachable")
72
+
73
+ def _run(self, media: Path, options: Options, device: str, reporter: Reporter) -> Transcript:
74
+ from faster_whisper import WhisperModel
75
+
76
+ compute_type = options.compute_type.get(device, "default")
77
+ model = WhisperModel(str(options.model_path), device=device, compute_type=compute_type)
78
+ segments_iter, info = model.transcribe(
79
+ str(media),
80
+ language=options.language,
81
+ beam_size=options.beam_size,
82
+ vad_filter=options.vad_filter,
83
+ word_timestamps=options.word_timestamps,
84
+ )
85
+ reporter.emit(
86
+ "start",
87
+ model=options.model,
88
+ device=device,
89
+ compute_type=compute_type,
90
+ language=info.language,
91
+ language_probability=round(info.language_probability, 4),
92
+ duration=round(info.duration, 2),
93
+ )
94
+ segments = []
95
+ for seg in segments_iter: # lazy: decoding happens while iterating
96
+ words = [
97
+ Word(
98
+ start=round(w.start, 3),
99
+ end=round(w.end, 3),
100
+ word=w.word,
101
+ probability=round(w.probability, 4),
102
+ )
103
+ for w in (seg.words or [])
104
+ ]
105
+ segments.append(
106
+ Segment(
107
+ start=round(seg.start, 3),
108
+ end=round(seg.end, 3),
109
+ text=seg.text.strip(),
110
+ words=words,
111
+ )
112
+ )
113
+ reporter.progress(seg.end, info.duration)
114
+ reporter.progress(info.duration, info.duration)
115
+ return Transcript(
116
+ source=media.name,
117
+ language=info.language,
118
+ language_probability=round(info.language_probability, 4),
119
+ duration=round(info.duration, 2),
120
+ model=options.model,
121
+ device=device,
122
+ segments=segments,
123
+ )
124
+
125
+
126
+ BACKENDS: dict[str, type] = {"faster-whisper": FasterWhisperBackend}
127
+
128
+
129
+ def get_backend(name: str = "faster-whisper") -> Backend:
130
+ """Instantiate a backend by name."""
131
+ if name not in BACKENDS:
132
+ raise KeyError(f"Unknown backend '{name}'. Available: {', '.join(BACKENDS)}")
133
+ return BACKENDS[name]()