legendaria 0.1.0.dev0__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 (29) hide show
  1. legendaria-0.1.0.dev0/.gitignore +32 -0
  2. legendaria-0.1.0.dev0/.python-version +1 -0
  3. legendaria-0.1.0.dev0/LICENSE +21 -0
  4. legendaria-0.1.0.dev0/PKG-INFO +53 -0
  5. legendaria-0.1.0.dev0/README.md +25 -0
  6. legendaria-0.1.0.dev0/legendaria_engine/__init__.py +3 -0
  7. legendaria-0.1.0.dev0/legendaria_engine/__main__.py +5 -0
  8. legendaria-0.1.0.dev0/legendaria_engine/cli.py +194 -0
  9. legendaria-0.1.0.dev0/legendaria_engine/config.py +88 -0
  10. legendaria-0.1.0.dev0/legendaria_engine/cuda_libs.py +56 -0
  11. legendaria-0.1.0.dev0/legendaria_engine/defaults/config.json +16 -0
  12. legendaria-0.1.0.dev0/legendaria_engine/defaults/models.json +12 -0
  13. legendaria-0.1.0.dev0/legendaria_engine/events.py +111 -0
  14. legendaria-0.1.0.dev0/legendaria_engine/formats.py +37 -0
  15. legendaria-0.1.0.dev0/legendaria_engine/models.py +105 -0
  16. legendaria-0.1.0.dev0/legendaria_engine/paths.py +42 -0
  17. legendaria-0.1.0.dev0/legendaria_engine/schema.py +79 -0
  18. legendaria-0.1.0.dev0/legendaria_engine/transcriber.py +133 -0
  19. legendaria-0.1.0.dev0/pyproject.toml +48 -0
  20. legendaria-0.1.0.dev0/schemas/config.schema.json +30 -0
  21. legendaria-0.1.0.dev0/schemas/models.schema.json +27 -0
  22. legendaria-0.1.0.dev0/schemas/transcript.schema.json +45 -0
  23. legendaria-0.1.0.dev0/tests/conftest.py +27 -0
  24. legendaria-0.1.0.dev0/tests/test_cli.py +72 -0
  25. legendaria-0.1.0.dev0/tests/test_config.py +75 -0
  26. legendaria-0.1.0.dev0/tests/test_formats.py +36 -0
  27. legendaria-0.1.0.dev0/tests/test_schema.py +26 -0
  28. legendaria-0.1.0.dev0/tests/test_transcriber.py +63 -0
  29. legendaria-0.1.0.dev0/uv.lock +1437 -0
@@ -0,0 +1,32 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+
10
+ # Node
11
+ node_modules/
12
+
13
+ # Environment
14
+ .env
15
+ .env.*
16
+
17
+ # Media / outputs
18
+ *.mp4
19
+ *.mov
20
+ *.mkv
21
+ *.wav
22
+ *.mp3
23
+ output/
24
+
25
+ # Editors / OS
26
+ .vscode/
27
+ .idea/
28
+ .DS_Store
29
+
30
+ # Local-only notes (contain private data, never pushed)
31
+ docs/whisper-blender-brainstorm.md
32
+ docs/local/
@@ -0,0 +1 @@
1
+ 3.11
@@ -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,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,25 @@
1
+ # Legendária engine (`flosub`)
2
+
3
+ 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:
4
+
5
+ ```bash
6
+ flosub transcribe video.mp4 # writes video.json, video.srt, video.vtt
7
+ flosub transcribe talk.mp3 --language pt --model medium --format srt
8
+ flosub export video.json --format vtt
9
+ flosub models list
10
+ flosub models download medium
11
+ flosub config show
12
+ ```
13
+
14
+ GPU acceleration works on NVIDIA cards (Linux and Windows) with the `cuda` extra; everything else runs on the CPU.
15
+
16
+ ## Development
17
+
18
+ ```bash
19
+ cd engine
20
+ uv sync --extra test # add --extra cuda for NVIDIA GPUs
21
+ uv run flosub --help
22
+ uv run pytest
23
+ ```
24
+
25
+ License: MIT.
@@ -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}