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.
Files changed (31) hide show
  1. {srtforge-0.1.2/src/srtforge.egg-info → srtforge-0.2.0}/PKG-INFO +24 -2
  2. {srtforge-0.1.2 → srtforge-0.2.0}/README.md +21 -0
  3. {srtforge-0.1.2 → srtforge-0.2.0}/pyproject.toml +5 -1
  4. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/__init__.py +1 -1
  5. srtforge-0.2.0/src/srtforge/_types.py +24 -0
  6. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/cli.py +60 -2
  7. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/segment.py +37 -22
  8. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/srt.py +21 -4
  9. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/transcribe.py +3 -2
  10. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/transcript.py +24 -4
  11. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/translate.py +6 -3
  12. {srtforge-0.1.2 → srtforge-0.2.0/src/srtforge.egg-info}/PKG-INFO +24 -2
  13. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/SOURCES.txt +3 -1
  14. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/requires.txt +2 -0
  15. srtforge-0.2.0/tests/test_cli.py +192 -0
  16. srtforge-0.2.0/tests/test_merge.py +39 -0
  17. srtforge-0.2.0/tests/test_segment.py +74 -0
  18. srtforge-0.2.0/tests/test_srt.py +50 -0
  19. {srtforge-0.1.2 → srtforge-0.2.0}/tests/test_transcript.py +28 -0
  20. srtforge-0.2.0/tests/test_translate.py +116 -0
  21. srtforge-0.1.2/tests/test_cli.py +0 -64
  22. srtforge-0.1.2/tests/test_merge.py +0 -16
  23. srtforge-0.1.2/tests/test_segment.py +0 -33
  24. srtforge-0.1.2/tests/test_srt.py +0 -19
  25. {srtforge-0.1.2 → srtforge-0.2.0}/LICENSE +0 -0
  26. {srtforge-0.1.2 → srtforge-0.2.0}/setup.cfg +0 -0
  27. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/audio.py +0 -0
  28. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge/merge.py +1 -1
  29. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/dependency_links.txt +0 -0
  30. {srtforge-0.1.2 → srtforge-0.2.0}/src/srtforge.egg-info/entry_points.txt +0 -0
  31. {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.1.2
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
- Requires-Dist: mlx-lm>=0.20
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.1.2"
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
 
@@ -1,3 +1,3 @@
1
1
  """srtforge: generate SRT subtitles from video using local Whisper (MLX)."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -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
- from typing import Any
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[dict[str, Any]]) -> list[_Word]:
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[dict[str, Any]]:
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[dict[str, Any]] = []
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[dict[str, Any]], opts: Opts) -> list[dict[str, Any]]:
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[dict[str, Any]], opts: Opts | None = None) -> list[dict[str, Any]]:
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[dict[str, Any]] = []
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 resegment(segments: list[dict[str, Any]], opts: Opts | None = None) -> list[dict[str, Any]]:
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 Any, Iterable
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 segments_to_srt(segments: Iterable[dict[str, Any]]) -> str:
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
- start = _format_timestamp(float(seg.get("start", 0.0)))
28
- end = _format_timestamp(float(seg.get("end", 0.0)))
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
- from typing import Any
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[dict[str, Any]]:
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[dict], reference_text: str) -> list[dict]:
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 w_slice[0] != r_slice[0]:
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 w_slice[k] != r_slice[k]:
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[dict] = []
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
- from typing import Any
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[dict[str, Any]],
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[dict[str, Any]]:
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