srtforge 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {srtforge-0.1.2/src/srtforge.egg-info → srtforge-0.2.0}/PKG-INFO +24 -2
- {srtforge-0.1.2 → srtforge-0.2.0}/README.md +21 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/pyproject.toml +5 -1
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/__init__.py +1 -1
- srtforge-0.2.0/src/srtforge/_types.py +24 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/cli.py +60 -2
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/segment.py +37 -22
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/srt.py +21 -4
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/transcribe.py +3 -2
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/transcript.py +24 -4
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/translate.py +6 -3
- {srtforge-0.1.2 → srtforge-0.2.0/src/srtforge.egg-info}/PKG-INFO +24 -2
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/SOURCES.txt +3 -1
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/requires.txt +2 -0
- srtforge-0.2.0/tests/test_cli.py +192 -0
- srtforge-0.2.0/tests/test_merge.py +39 -0
- srtforge-0.2.0/tests/test_segment.py +74 -0
- srtforge-0.2.0/tests/test_srt.py +50 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/tests/test_transcript.py +28 -0
- srtforge-0.2.0/tests/test_translate.py +116 -0
- srtforge-0.1.2/tests/test_cli.py +0 -64
- srtforge-0.1.2/tests/test_merge.py +0 -16
- srtforge-0.1.2/tests/test_segment.py +0 -33
- srtforge-0.1.2/tests/test_srt.py +0 -19
- {srtforge-0.1.2 → srtforge-0.2.0}/LICENSE +0 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/setup.cfg +0 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/audio.py +0 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/merge.py +1 -1
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/dependency_links.txt +0 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/entry_points.txt +0 -0
- {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: srtforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Generate, translate, and burn SRT subtitles locally with MLX Whisper.
|
|
5
5
|
Author: rromanv
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,8 @@ Requires-Python: <3.13,>=3.10
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Requires-Dist: mlx-whisper>=0.4
|
|
25
|
-
|
|
25
|
+
Provides-Extra: translate
|
|
26
|
+
Requires-Dist: mlx-lm>=0.20; extra == "translate"
|
|
26
27
|
Dynamic: license-file
|
|
27
28
|
|
|
28
29
|
# srtforge
|
|
@@ -76,6 +77,12 @@ From PyPI:
|
|
|
76
77
|
pipx install --python python3.11 srtforge
|
|
77
78
|
```
|
|
78
79
|
|
|
80
|
+
If you want local LLM translation support, install the translation extra:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pipx install --python python3.11 'srtforge[translate]'
|
|
84
|
+
```
|
|
85
|
+
|
|
79
86
|
`srtforge` currently supports Python 3.10-3.12. If your default Python is newer
|
|
80
87
|
than that, such as Python 3.14, pass a supported interpreter explicitly with
|
|
81
88
|
`--python`.
|
|
@@ -86,6 +93,12 @@ From GitHub:
|
|
|
86
93
|
pipx install --python python3.11 git+https://github.com/rromanv/srtforge.git
|
|
87
94
|
```
|
|
88
95
|
|
|
96
|
+
With translation support from GitHub:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pipx install --python python3.11 'srtforge[translate] @ git+https://github.com/rromanv/srtforge.git'
|
|
100
|
+
```
|
|
101
|
+
|
|
89
102
|
For local development:
|
|
90
103
|
|
|
91
104
|
```bash
|
|
@@ -94,6 +107,7 @@ cd srtforge
|
|
|
94
107
|
python3.11 -m venv .venv
|
|
95
108
|
source .venv/bin/activate
|
|
96
109
|
pip install -e .
|
|
110
|
+
pip install -e '.[translate]' # optional, for `srtforge -t ...`
|
|
97
111
|
```
|
|
98
112
|
|
|
99
113
|
Or into a virtual environment:
|
|
@@ -102,6 +116,7 @@ Or into a virtual environment:
|
|
|
102
116
|
python3.11 -m venv .venv
|
|
103
117
|
source .venv/bin/activate
|
|
104
118
|
pip install srtforge
|
|
119
|
+
pip install 'srtforge[translate]' # optional, for `srtforge -t ...`
|
|
105
120
|
```
|
|
106
121
|
|
|
107
122
|
## Troubleshooting
|
|
@@ -177,6 +192,8 @@ srtforge video.mp4 -t "Brazilian Portuguese"
|
|
|
177
192
|
srtforge video.mp4 -t ja --translate-model mlx-community/Qwen3.5-9B-OptiQ-4bit
|
|
178
193
|
```
|
|
179
194
|
|
|
195
|
+
Translation requires installing `srtforge[translate]`.
|
|
196
|
+
|
|
180
197
|
Burn subtitles into a video:
|
|
181
198
|
|
|
182
199
|
```bash
|
|
@@ -184,6 +201,9 @@ srtforge merge video.mp4 video.srt -o final.mp4
|
|
|
184
201
|
srtforge merge video.mp4 video.es.srt --crf 16
|
|
185
202
|
```
|
|
186
203
|
|
|
204
|
+
Existing outputs are not overwritten by default. Pass `--force` when you intend
|
|
205
|
+
to replace an existing `.srt` or merged video output.
|
|
206
|
+
|
|
187
207
|
Run:
|
|
188
208
|
|
|
189
209
|
```bash
|
|
@@ -225,6 +245,7 @@ src/srtforge/
|
|
|
225
245
|
segment.py # sentence-aware re-cueing, wrapping, pacing
|
|
226
246
|
translate.py # context-aware local-LLM translation
|
|
227
247
|
transcript.py # reference transcript reading + correction
|
|
248
|
+
_types.py # shared typed subtitle data shapes
|
|
228
249
|
merge.py # burn subtitles into video with ffmpeg/libass
|
|
229
250
|
srt.py # SRT rendering
|
|
230
251
|
cli.py # argparse CLI
|
|
@@ -234,6 +255,7 @@ tests/
|
|
|
234
255
|
test_segment.py
|
|
235
256
|
test_srt.py
|
|
236
257
|
test_transcript.py
|
|
258
|
+
test_translate.py
|
|
237
259
|
```
|
|
238
260
|
|
|
239
261
|
## Development
|
|
@@ -49,6 +49,12 @@ From PyPI:
|
|
|
49
49
|
pipx install --python python3.11 srtforge
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
If you want local LLM translation support, install the translation extra:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install --python python3.11 'srtforge[translate]'
|
|
56
|
+
```
|
|
57
|
+
|
|
52
58
|
`srtforge` currently supports Python 3.10-3.12. If your default Python is newer
|
|
53
59
|
than that, such as Python 3.14, pass a supported interpreter explicitly with
|
|
54
60
|
`--python`.
|
|
@@ -59,6 +65,12 @@ From GitHub:
|
|
|
59
65
|
pipx install --python python3.11 git+https://github.com/rromanv/srtforge.git
|
|
60
66
|
```
|
|
61
67
|
|
|
68
|
+
With translation support from GitHub:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pipx install --python python3.11 'srtforge[translate] @ git+https://github.com/rromanv/srtforge.git'
|
|
72
|
+
```
|
|
73
|
+
|
|
62
74
|
For local development:
|
|
63
75
|
|
|
64
76
|
```bash
|
|
@@ -67,6 +79,7 @@ cd srtforge
|
|
|
67
79
|
python3.11 -m venv .venv
|
|
68
80
|
source .venv/bin/activate
|
|
69
81
|
pip install -e .
|
|
82
|
+
pip install -e '.[translate]' # optional, for `srtforge -t ...`
|
|
70
83
|
```
|
|
71
84
|
|
|
72
85
|
Or into a virtual environment:
|
|
@@ -75,6 +88,7 @@ Or into a virtual environment:
|
|
|
75
88
|
python3.11 -m venv .venv
|
|
76
89
|
source .venv/bin/activate
|
|
77
90
|
pip install srtforge
|
|
91
|
+
pip install 'srtforge[translate]' # optional, for `srtforge -t ...`
|
|
78
92
|
```
|
|
79
93
|
|
|
80
94
|
## Troubleshooting
|
|
@@ -150,6 +164,8 @@ srtforge video.mp4 -t "Brazilian Portuguese"
|
|
|
150
164
|
srtforge video.mp4 -t ja --translate-model mlx-community/Qwen3.5-9B-OptiQ-4bit
|
|
151
165
|
```
|
|
152
166
|
|
|
167
|
+
Translation requires installing `srtforge[translate]`.
|
|
168
|
+
|
|
153
169
|
Burn subtitles into a video:
|
|
154
170
|
|
|
155
171
|
```bash
|
|
@@ -157,6 +173,9 @@ srtforge merge video.mp4 video.srt -o final.mp4
|
|
|
157
173
|
srtforge merge video.mp4 video.es.srt --crf 16
|
|
158
174
|
```
|
|
159
175
|
|
|
176
|
+
Existing outputs are not overwritten by default. Pass `--force` when you intend
|
|
177
|
+
to replace an existing `.srt` or merged video output.
|
|
178
|
+
|
|
160
179
|
Run:
|
|
161
180
|
|
|
162
181
|
```bash
|
|
@@ -198,6 +217,7 @@ src/srtforge/
|
|
|
198
217
|
segment.py # sentence-aware re-cueing, wrapping, pacing
|
|
199
218
|
translate.py # context-aware local-LLM translation
|
|
200
219
|
transcript.py # reference transcript reading + correction
|
|
220
|
+
_types.py # shared typed subtitle data shapes
|
|
201
221
|
merge.py # burn subtitles into video with ffmpeg/libass
|
|
202
222
|
srt.py # SRT rendering
|
|
203
223
|
cli.py # argparse CLI
|
|
@@ -207,6 +227,7 @@ tests/
|
|
|
207
227
|
test_segment.py
|
|
208
228
|
test_srt.py
|
|
209
229
|
test_transcript.py
|
|
230
|
+
test_translate.py
|
|
210
231
|
```
|
|
211
232
|
|
|
212
233
|
## Development
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "srtforge"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Generate, translate, and burn SRT subtitles locally with MLX Whisper."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10,<3.13"
|
|
@@ -23,6 +23,10 @@ classifiers = [
|
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
25
|
"mlx-whisper>=0.4",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
translate = [
|
|
26
30
|
"mlx-lm>=0.20",
|
|
27
31
|
]
|
|
28
32
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Shared subtitle data shapes used across the pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WhisperWord(TypedDict, total=False):
|
|
9
|
+
word: str
|
|
10
|
+
start: float
|
|
11
|
+
end: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Segment(TypedDict, total=False):
|
|
15
|
+
start: float
|
|
16
|
+
end: float
|
|
17
|
+
text: str
|
|
18
|
+
words: list[WhisperWord]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cue(TypedDict, total=False):
|
|
22
|
+
start: float
|
|
23
|
+
end: float
|
|
24
|
+
text: str
|
|
@@ -17,6 +17,10 @@ from .transcript import correct_segments, initial_prompt_from_transcript, read_t
|
|
|
17
17
|
from .translate import DEFAULT_TRANSLATE_MODEL, TranslationError, translate_segments
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
class CliError(RuntimeError):
|
|
21
|
+
"""Raised for invalid CLI arguments that argparse cannot express alone."""
|
|
22
|
+
|
|
23
|
+
|
|
20
24
|
def _log(msg: str) -> None:
|
|
21
25
|
print(msg, file=sys.stderr, flush=True)
|
|
22
26
|
|
|
@@ -49,6 +53,29 @@ def _translated_path(output: Path, language: str) -> Path:
|
|
|
49
53
|
return output.with_suffix(f".{tag}{output.suffix}")
|
|
50
54
|
|
|
51
55
|
|
|
56
|
+
def _same_path(a: Path, b: Path) -> bool:
|
|
57
|
+
return a.resolve(strict=False) == b.resolve(strict=False)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ensure_srt_output(path: Path) -> None:
|
|
61
|
+
if path.suffix.lower() != ".srt":
|
|
62
|
+
raise CliError(f"Subtitle output must end in .srt: {path}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ensure_can_write_output(
|
|
66
|
+
output: Path,
|
|
67
|
+
*,
|
|
68
|
+
force: bool,
|
|
69
|
+
protected: list[Path],
|
|
70
|
+
kind: str,
|
|
71
|
+
) -> None:
|
|
72
|
+
for path in protected:
|
|
73
|
+
if _same_path(output, path):
|
|
74
|
+
raise CliError(f"Refusing to overwrite input {kind}: {output}")
|
|
75
|
+
if output.exists() and not force:
|
|
76
|
+
raise CliError(f"Output already exists: {output}. Pass --force to overwrite.")
|
|
77
|
+
|
|
78
|
+
|
|
52
79
|
def build_parser() -> argparse.ArgumentParser:
|
|
53
80
|
p = argparse.ArgumentParser(
|
|
54
81
|
prog="srtforge",
|
|
@@ -61,6 +88,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
61
88
|
"-o", "--output", type=Path, default=None,
|
|
62
89
|
help="Output .srt path (default: alongside the video).",
|
|
63
90
|
)
|
|
91
|
+
p.add_argument(
|
|
92
|
+
"--force", action="store_true",
|
|
93
|
+
help="Overwrite existing output files.",
|
|
94
|
+
)
|
|
64
95
|
p.add_argument(
|
|
65
96
|
"-m", "--model", default=DEFAULT_MODEL,
|
|
66
97
|
help=f"Whisper model / HF repo (default: {DEFAULT_MODEL}).",
|
|
@@ -122,6 +153,23 @@ def _gen_main(argv: list[str] | None = None) -> int:
|
|
|
122
153
|
need_words = args.word_timestamps or args.resegment
|
|
123
154
|
wav_path = None
|
|
124
155
|
try:
|
|
156
|
+
_ensure_srt_output(output)
|
|
157
|
+
_ensure_can_write_output(
|
|
158
|
+
output,
|
|
159
|
+
force=args.force,
|
|
160
|
+
protected=[args.video],
|
|
161
|
+
kind="video",
|
|
162
|
+
)
|
|
163
|
+
if translating:
|
|
164
|
+
t_out = _translated_path(output, args.translate)
|
|
165
|
+
_ensure_srt_output(t_out)
|
|
166
|
+
_ensure_can_write_output(
|
|
167
|
+
t_out,
|
|
168
|
+
force=args.force,
|
|
169
|
+
protected=[args.video, output],
|
|
170
|
+
kind="video or subtitle output",
|
|
171
|
+
)
|
|
172
|
+
|
|
125
173
|
transcript_text = read_transcript(args.transcript) if args.transcript else ""
|
|
126
174
|
_log(f"[1/{total}] Extracting audio from {args.video} ...")
|
|
127
175
|
wav_path = extract_audio(args.video)
|
|
@@ -172,7 +220,7 @@ def _gen_main(argv: list[str] | None = None) -> int:
|
|
|
172
220
|
_log("Done.")
|
|
173
221
|
return 0
|
|
174
222
|
|
|
175
|
-
except (FFmpegError, FileNotFoundError, TranslationError) as e:
|
|
223
|
+
except (CliError, FFmpegError, FileNotFoundError, TranslationError) as e:
|
|
176
224
|
_log(f"Error: {e}")
|
|
177
225
|
return 1
|
|
178
226
|
except KeyboardInterrupt:
|
|
@@ -202,6 +250,10 @@ def build_merge_parser() -> argparse.ArgumentParser:
|
|
|
202
250
|
"--preset", default="slow",
|
|
203
251
|
help="x264 speed/efficiency preset (default: slow).",
|
|
204
252
|
)
|
|
253
|
+
p.add_argument(
|
|
254
|
+
"--force", action="store_true",
|
|
255
|
+
help="Overwrite the output video if it already exists.",
|
|
256
|
+
)
|
|
205
257
|
return p
|
|
206
258
|
|
|
207
259
|
|
|
@@ -209,6 +261,12 @@ def _merge_main(argv: list[str]) -> int:
|
|
|
209
261
|
args = build_merge_parser().parse_args(argv)
|
|
210
262
|
output = args.output or args.video.with_suffix(f".subbed{args.video.suffix}")
|
|
211
263
|
try:
|
|
264
|
+
_ensure_can_write_output(
|
|
265
|
+
output,
|
|
266
|
+
force=args.force,
|
|
267
|
+
protected=[args.video, args.subtitles],
|
|
268
|
+
kind="video or subtitle file",
|
|
269
|
+
)
|
|
212
270
|
_log(f"Burning {args.subtitles} into {args.video} (crf={args.crf}, preset={args.preset}) ...")
|
|
213
271
|
burn_subtitles(
|
|
214
272
|
args.video,
|
|
@@ -219,7 +277,7 @@ def _merge_main(argv: list[str]) -> int:
|
|
|
219
277
|
)
|
|
220
278
|
_log(f"Done. Wrote {output}")
|
|
221
279
|
return 0
|
|
222
|
-
except (FFmpegError, FileNotFoundError) as e:
|
|
280
|
+
except (CliError, FFmpegError, FileNotFoundError) as e:
|
|
223
281
|
_log(f"Error: {e}")
|
|
224
282
|
return 1
|
|
225
283
|
except KeyboardInterrupt:
|
|
@@ -12,7 +12,8 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import re
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
from ._types import Cue, Segment
|
|
16
17
|
|
|
17
18
|
# --- Netflix-style defaults ------------------------------------------------- #
|
|
18
19
|
MAX_CPL = 42 # max characters per line
|
|
@@ -71,7 +72,7 @@ class _Sentence:
|
|
|
71
72
|
# --------------------------------------------------------------------------- #
|
|
72
73
|
# Word flattening & sentence splitting
|
|
73
74
|
# --------------------------------------------------------------------------- #
|
|
74
|
-
def _flatten_words(segments: list[
|
|
75
|
+
def _flatten_words(segments: list[Segment]) -> list[_Word]:
|
|
75
76
|
words: list[_Word] = []
|
|
76
77
|
for seg in segments:
|
|
77
78
|
for w in seg.get("words") or []:
|
|
@@ -197,7 +198,7 @@ def _split_long_sentence(sent: _Sentence, opts: Opts) -> list[_Sentence]:
|
|
|
197
198
|
return chunks
|
|
198
199
|
|
|
199
200
|
|
|
200
|
-
def _build_cues(sentences: list[_Sentence], opts: Opts) -> list[
|
|
201
|
+
def _build_cues(sentences: list[_Sentence], opts: Opts) -> list[Cue]:
|
|
201
202
|
# 1) Ensure every unit fits within the line limits: split over-long ones.
|
|
202
203
|
units: list[_Sentence] = []
|
|
203
204
|
for s in sentences:
|
|
@@ -207,7 +208,7 @@ def _build_cues(sentences: list[_Sentence], opts: Opts) -> list[dict[str, Any]]:
|
|
|
207
208
|
units.extend(_split_long_sentence(s, opts))
|
|
208
209
|
|
|
209
210
|
# 2) Mostly one sentence per cue; merge a SHORT trailing unit if both fit.
|
|
210
|
-
cues: list[
|
|
211
|
+
cues: list[Cue] = []
|
|
211
212
|
i = 0
|
|
212
213
|
while i < len(units):
|
|
213
214
|
cur = units[i]
|
|
@@ -234,7 +235,7 @@ def _build_cues(sentences: list[_Sentence], opts: Opts) -> list[dict[str, Any]]:
|
|
|
234
235
|
# --------------------------------------------------------------------------- #
|
|
235
236
|
# Pacing / timing pass
|
|
236
237
|
# --------------------------------------------------------------------------- #
|
|
237
|
-
def _apply_pacing(cues: list[
|
|
238
|
+
def _apply_pacing(cues: list[Cue], opts: Opts) -> list[Cue]:
|
|
238
239
|
n = len(cues)
|
|
239
240
|
for k, cue in enumerate(cues):
|
|
240
241
|
chars = len(cue["text"].replace("\n", " "))
|
|
@@ -267,7 +268,7 @@ def _apply_pacing(cues: list[dict[str, Any]], opts: Opts) -> list[dict[str, Any]
|
|
|
267
268
|
# --------------------------------------------------------------------------- #
|
|
268
269
|
# Public API
|
|
269
270
|
# --------------------------------------------------------------------------- #
|
|
270
|
-
def fit_cues(cues: list[
|
|
271
|
+
def fit_cues(cues: list[Segment], opts: Opts | None = None) -> list[Cue]:
|
|
271
272
|
"""Fit each cue's text within the line limits, splitting over-capacity cues.
|
|
272
273
|
|
|
273
274
|
Used for translated text, whose length differs from the source. A cue whose
|
|
@@ -275,7 +276,7 @@ def fit_cues(cues: list[dict[str, Any]], opts: Opts | None = None) -> list[dict[
|
|
|
275
276
|
boundaries, with the original time span apportioned by character count.
|
|
276
277
|
"""
|
|
277
278
|
opts = opts or Opts()
|
|
278
|
-
out: list[
|
|
279
|
+
out: list[Cue] = []
|
|
279
280
|
for c in cues:
|
|
280
281
|
text = " ".join((c.get("text") or "").split())
|
|
281
282
|
if not text:
|
|
@@ -308,24 +309,38 @@ def fit_cues(cues: list[dict[str, Any]], opts: Opts | None = None) -> list[dict[
|
|
|
308
309
|
return _apply_pacing(out, opts)
|
|
309
310
|
|
|
310
311
|
|
|
311
|
-
def
|
|
312
|
-
"""Rebuild Whisper segments into readable, sentence-aligned cues.
|
|
313
|
-
|
|
314
|
-
Requires word-level timestamps (segment['words']). Segments lacking words
|
|
315
|
-
fall back to char-based splitting of their own text.
|
|
316
|
-
"""
|
|
317
|
-
opts = opts or Opts()
|
|
318
|
-
has_words = any(seg.get("words") for seg in segments)
|
|
319
|
-
if not has_words:
|
|
320
|
-
return segments
|
|
321
|
-
|
|
322
|
-
words = _flatten_words(segments)
|
|
323
|
-
if not words:
|
|
324
|
-
return []
|
|
312
|
+
def _resegment_words(words: list[_Word], opts: Opts) -> list[Cue]:
|
|
325
313
|
sentences = _split_sentences(words)
|
|
326
314
|
cues = _build_cues(sentences, opts)
|
|
327
315
|
cues = _apply_pacing(cues, opts)
|
|
328
|
-
# Wrap text into <=max_lines lines.
|
|
329
316
|
for cue in cues:
|
|
330
317
|
cue["text"] = wrap_lines(cue["text"], opts.max_cpl, opts.max_lines)
|
|
331
318
|
return cues
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def resegment(segments: list[Segment], opts: Opts | None = None) -> list[Cue]:
|
|
322
|
+
"""Rebuild Whisper segments into readable, sentence-aligned cues.
|
|
323
|
+
|
|
324
|
+
Uses word-level timestamps (segment['words']) where available. Segments
|
|
325
|
+
lacking words fall back to char-based splitting of their own text.
|
|
326
|
+
"""
|
|
327
|
+
opts = opts or Opts()
|
|
328
|
+
out: list[Cue] = []
|
|
329
|
+
pending_words: list[_Word] = []
|
|
330
|
+
|
|
331
|
+
def flush_words() -> None:
|
|
332
|
+
if pending_words:
|
|
333
|
+
out.extend(_resegment_words(pending_words, opts))
|
|
334
|
+
pending_words.clear()
|
|
335
|
+
|
|
336
|
+
for seg in segments:
|
|
337
|
+
words = _flatten_words([seg])
|
|
338
|
+
if words:
|
|
339
|
+
pending_words.extend(words)
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
flush_words()
|
|
343
|
+
out.extend(fit_cues([seg], opts))
|
|
344
|
+
|
|
345
|
+
flush_words()
|
|
346
|
+
return out
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from ._types import Segment
|
|
8
|
+
|
|
9
|
+
MIN_CUE_DURATION = 0.001
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
def _format_timestamp(seconds: float) -> str:
|
|
@@ -16,16 +20,29 @@ def _format_timestamp(seconds: float) -> str:
|
|
|
16
20
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
|
17
21
|
|
|
18
22
|
|
|
19
|
-
def
|
|
23
|
+
def _normalize_timing(start: float, end: float, previous_end: float) -> tuple[float, float]:
|
|
24
|
+
start = max(0.0, start, previous_end)
|
|
25
|
+
end = max(start + MIN_CUE_DURATION, end)
|
|
26
|
+
return start, end
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def segments_to_srt(segments: Iterable[Segment]) -> str:
|
|
20
30
|
"""Render Whisper segments to an SRT document string."""
|
|
21
31
|
blocks: list[str] = []
|
|
22
32
|
index = 1
|
|
33
|
+
previous_end = 0.0
|
|
23
34
|
for seg in segments:
|
|
24
35
|
text = (seg.get("text") or "").strip()
|
|
25
36
|
if not text:
|
|
26
37
|
continue
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
start_seconds, end_seconds = _normalize_timing(
|
|
39
|
+
float(seg.get("start", 0.0)),
|
|
40
|
+
float(seg.get("end", 0.0)),
|
|
41
|
+
previous_end,
|
|
42
|
+
)
|
|
43
|
+
previous_end = end_seconds
|
|
44
|
+
start = _format_timestamp(start_seconds)
|
|
45
|
+
end = _format_timestamp(end_seconds)
|
|
29
46
|
blocks.append(f"{index}\n{start} --> {end}\n{text}\n")
|
|
30
47
|
index += 1
|
|
31
48
|
return "\n".join(blocks)
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
from ._types import Segment
|
|
7
8
|
|
|
8
9
|
DEFAULT_MODEL = "mlx-community/whisper-large-v3-turbo"
|
|
9
10
|
|
|
@@ -14,7 +15,7 @@ def transcribe(
|
|
|
14
15
|
language: str | None = None,
|
|
15
16
|
word_timestamps: bool = False,
|
|
16
17
|
initial_prompt: str | None = None,
|
|
17
|
-
) -> list[
|
|
18
|
+
) -> list[Segment]:
|
|
18
19
|
"""Transcribe audio and return Whisper segments.
|
|
19
20
|
|
|
20
21
|
Each segment is a dict with at least 'start', 'end', 'text'.
|
|
@@ -5,11 +5,15 @@ import re
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from ._types import Segment
|
|
9
|
+
|
|
8
10
|
MIN_GLOBAL_SIMILARITY = 0.30
|
|
9
11
|
MIN_BLOCK_SIMILARITY = 0.60
|
|
12
|
+
MIN_TOKEN_SIMILARITY = 0.72
|
|
10
13
|
MAX_INITIAL_PROMPT_CHARS = 1_000
|
|
11
14
|
|
|
12
15
|
_TRAILING_PUNCT_RE = re.compile(r"([^\w]+)$")
|
|
16
|
+
_EDGE_PUNCT_RE = re.compile(r"^\W+|\W+$")
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
def read_transcript(path: Path) -> str:
|
|
@@ -41,6 +45,22 @@ def _transfer_trailing_punct(whisper_token: str, replacement: str) -> str:
|
|
|
41
45
|
return stripped + punct
|
|
42
46
|
|
|
43
47
|
|
|
48
|
+
def _normalize_token(token: str) -> str:
|
|
49
|
+
return _EDGE_PUNCT_RE.sub("", token.casefold())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tokens_close(whisper_token: str, reference_token: str) -> bool:
|
|
53
|
+
whisper = _normalize_token(whisper_token)
|
|
54
|
+
reference = _normalize_token(reference_token)
|
|
55
|
+
if not whisper or not reference or whisper == reference:
|
|
56
|
+
return False
|
|
57
|
+
max_len = max(len(whisper), len(reference))
|
|
58
|
+
if abs(len(whisper) - len(reference)) > max(2, max_len // 2):
|
|
59
|
+
return False
|
|
60
|
+
similarity = difflib.SequenceMatcher(None, whisper, reference).ratio()
|
|
61
|
+
return similarity >= MIN_TOKEN_SIMILARITY
|
|
62
|
+
|
|
63
|
+
|
|
44
64
|
def initial_prompt_from_transcript(text: str, max_chars: int = MAX_INITIAL_PROMPT_CHARS) -> str:
|
|
45
65
|
"""Return a short transcript prefix suitable for Whisper's initial prompt."""
|
|
46
66
|
text = " ".join(text.split())
|
|
@@ -51,7 +71,7 @@ def initial_prompt_from_transcript(text: str, max_chars: int = MAX_INITIAL_PROMP
|
|
|
51
71
|
return truncated or text[:max_chars].strip()
|
|
52
72
|
|
|
53
73
|
|
|
54
|
-
def correct_segments(segments: list[
|
|
74
|
+
def correct_segments(segments: list[Segment], reference_text: str) -> list[Segment]:
|
|
55
75
|
if not reference_text or not reference_text.strip():
|
|
56
76
|
return segments
|
|
57
77
|
|
|
@@ -98,7 +118,7 @@ def correct_segments(segments: list[dict], reference_text: str) -> list[dict]:
|
|
|
98
118
|
w_slice = whisper_lower[i1:i2]
|
|
99
119
|
r_slice = ref_lower[j1:j2]
|
|
100
120
|
if len(w_slice) == 1 and len(r_slice) == 1:
|
|
101
|
-
if
|
|
121
|
+
if _tokens_close(whisper_tokens[i1], ref_tokens[j1]):
|
|
102
122
|
corrected[i1] = _transfer_trailing_punct(
|
|
103
123
|
whisper_tokens[i1], ref_tokens[j1]
|
|
104
124
|
)
|
|
@@ -109,12 +129,12 @@ def correct_segments(segments: list[dict], reference_text: str) -> list[dict]:
|
|
|
109
129
|
if block_sm.ratio() >= MIN_BLOCK_SIMILARITY:
|
|
110
130
|
for k in range(len(w_slice)):
|
|
111
131
|
if k < len(r_slice):
|
|
112
|
-
if
|
|
132
|
+
if _tokens_close(whisper_tokens[i1 + k], ref_tokens[j1 + k]):
|
|
113
133
|
corrected[i1 + k] = _transfer_trailing_punct(
|
|
114
134
|
whisper_tokens[i1 + k], ref_tokens[j1 + k]
|
|
115
135
|
)
|
|
116
136
|
|
|
117
|
-
new_segments: list[
|
|
137
|
+
new_segments: list[Segment] = []
|
|
118
138
|
for idx, seg in enumerate(segments):
|
|
119
139
|
start_idx, end_idx, source = seg_offsets[idx]
|
|
120
140
|
seg_tokens = corrected[start_idx:end_idx]
|
|
@@ -10,7 +10,8 @@ Uses ``mlx_lm`` text models.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import re
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
from ._types import Segment
|
|
14
15
|
|
|
15
16
|
DEFAULT_TRANSLATE_MODEL = "mlx-community/Qwen3.5-9B-OptiQ-4bit"
|
|
16
17
|
|
|
@@ -111,6 +112,8 @@ def _parse_numbered(text: str, expected: int) -> dict[int, str] | None:
|
|
|
111
112
|
continue
|
|
112
113
|
idx = int(m.group(1))
|
|
113
114
|
if 1 <= idx <= expected:
|
|
115
|
+
if idx in out:
|
|
116
|
+
return None
|
|
114
117
|
out[idx] = m.group(2).strip()
|
|
115
118
|
if len(out) == expected and all(i in out for i in range(1, expected + 1)):
|
|
116
119
|
return out
|
|
@@ -121,13 +124,13 @@ def _parse_numbered(text: str, expected: int) -> dict[int, str] | None:
|
|
|
121
124
|
# Public API
|
|
122
125
|
# --------------------------------------------------------------------------- #
|
|
123
126
|
def translate_segments(
|
|
124
|
-
segments: list[
|
|
127
|
+
segments: list[Segment],
|
|
125
128
|
target_language: str,
|
|
126
129
|
model: str = DEFAULT_TRANSLATE_MODEL,
|
|
127
130
|
window: int = 40,
|
|
128
131
|
context_cues: int = 6,
|
|
129
132
|
backend: _Backend | None = None,
|
|
130
|
-
) -> list[
|
|
133
|
+
) -> list[Segment]:
|
|
131
134
|
"""Return new segments with text translated into ``target_language``.
|
|
132
135
|
|
|
133
136
|
Timestamps are preserved. Cue count is preserved (1:1 mapping). Translated
|