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.
- legendaria-0.1.0.dev0/.gitignore +32 -0
- legendaria-0.1.0.dev0/.python-version +1 -0
- legendaria-0.1.0.dev0/LICENSE +21 -0
- legendaria-0.1.0.dev0/PKG-INFO +53 -0
- legendaria-0.1.0.dev0/README.md +25 -0
- legendaria-0.1.0.dev0/legendaria_engine/__init__.py +3 -0
- legendaria-0.1.0.dev0/legendaria_engine/__main__.py +5 -0
- legendaria-0.1.0.dev0/legendaria_engine/cli.py +194 -0
- legendaria-0.1.0.dev0/legendaria_engine/config.py +88 -0
- legendaria-0.1.0.dev0/legendaria_engine/cuda_libs.py +56 -0
- legendaria-0.1.0.dev0/legendaria_engine/defaults/config.json +16 -0
- legendaria-0.1.0.dev0/legendaria_engine/defaults/models.json +12 -0
- legendaria-0.1.0.dev0/legendaria_engine/events.py +111 -0
- legendaria-0.1.0.dev0/legendaria_engine/formats.py +37 -0
- legendaria-0.1.0.dev0/legendaria_engine/models.py +105 -0
- legendaria-0.1.0.dev0/legendaria_engine/paths.py +42 -0
- legendaria-0.1.0.dev0/legendaria_engine/schema.py +79 -0
- legendaria-0.1.0.dev0/legendaria_engine/transcriber.py +133 -0
- legendaria-0.1.0.dev0/pyproject.toml +48 -0
- legendaria-0.1.0.dev0/schemas/config.schema.json +30 -0
- legendaria-0.1.0.dev0/schemas/models.schema.json +27 -0
- legendaria-0.1.0.dev0/schemas/transcript.schema.json +45 -0
- legendaria-0.1.0.dev0/tests/conftest.py +27 -0
- legendaria-0.1.0.dev0/tests/test_cli.py +72 -0
- legendaria-0.1.0.dev0/tests/test_config.py +75 -0
- legendaria-0.1.0.dev0/tests/test_formats.py +36 -0
- legendaria-0.1.0.dev0/tests/test_schema.py +26 -0
- legendaria-0.1.0.dev0/tests/test_transcriber.py +63 -0
- 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,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}
|