subtitlemaker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ """Turn Whisper segments into formatted subtitle cues.
2
+
3
+ Pure logic: no printing, no file access, no model.
4
+ """
5
+
6
+ import re
7
+
8
+
9
+ def clean_text(text):
10
+ """Clean up Whisper text."""
11
+
12
+ text = text.strip()
13
+
14
+ # Collapse multiple whitespace characters
15
+ text = re.sub(r"\s+", " ", text)
16
+
17
+ # Remove spaces before punctuation
18
+ text = re.sub(r"\s+([,.!?;:])", r"\1", text)
19
+
20
+ # Clean brackets
21
+ text = re.sub(r"([(\[])\s+", r"\1", text)
22
+ text = re.sub(r"\s+([)\]])", r"\1", text)
23
+
24
+ return text.strip()
25
+
26
+
27
+ def split_into_lines(text, style):
28
+ """
29
+ Split subtitle into a maximum of `style.max_lines` lines.
30
+
31
+ Attempts to:
32
+ - keep lines below `style.max_line_length`
33
+ - balance line lengths
34
+ - prefer punctuation boundaries
35
+ """
36
+
37
+ words = text.split()
38
+
39
+ if not words:
40
+ return []
41
+
42
+ # Fits on one line
43
+ if len(text) <= style.max_line_length:
44
+ return [text]
45
+
46
+ # Find the best two-line split
47
+ best_split = None
48
+ best_score = float("inf")
49
+
50
+ for i in range(1, len(words)):
51
+ left = " ".join(words[:i])
52
+ right = " ".join(words[i:])
53
+
54
+ if len(left) > style.max_line_length:
55
+ continue
56
+
57
+ if len(right) > style.max_line_length:
58
+ continue
59
+
60
+ # Prefer balanced lines
61
+ score = abs(len(left) - len(right))
62
+
63
+ # Prefer punctuation at the end of line 1
64
+ if left.endswith((".", ",", "!", "?", ";", ":")):
65
+ score -= 15
66
+
67
+ if score < best_score:
68
+ best_score = score
69
+ best_split = (left, right)
70
+
71
+ if best_split:
72
+ return list(best_split)
73
+
74
+ # Fallback: split approximately in half.
75
+ midpoint = len(words) // 2
76
+
77
+ return [
78
+ " ".join(words[:midpoint]),
79
+ " ".join(words[midpoint:]),
80
+ ]
81
+
82
+
83
+ # A cue is never shown for less than this, whatever the neighbours are doing.
84
+ _MIN_VISIBLE = 0.1
85
+
86
+
87
+ def _fits(text, style):
88
+ """True if `text` can be laid out within the line and length limits."""
89
+
90
+ if len(text) <= style.max_line_length:
91
+ return True
92
+
93
+ if style.max_lines < 2:
94
+ return False
95
+
96
+ words = text.split()
97
+
98
+ return any(
99
+ len(" ".join(words[:i])) <= style.max_line_length
100
+ and len(" ".join(words[i:])) <= style.max_line_length
101
+ for i in range(1, len(words))
102
+ )
103
+
104
+
105
+ def _join(tokens):
106
+ return clean_text("".join(token[0] for token in tokens))
107
+
108
+
109
+ def _best_break(tokens):
110
+ """
111
+ Where to end the cue when `tokens` plus one more word no longer fit.
112
+
113
+ Prefers the end of a sentence, then a clause (comma, semicolon, colon)
114
+ in the second half of the text. Returns the number of tokens to keep;
115
+ `len(tokens)` means no good break was found.
116
+ """
117
+
118
+ sentence = clause = None
119
+ total = len(_join(tokens))
120
+
121
+ for i in range(1, len(tokens)):
122
+
123
+ word = tokens[i - 1][0].strip()
124
+
125
+ if word.endswith((".", "!", "?")):
126
+ sentence = i
127
+
128
+ elif word.endswith((",", ";", ":")) and len(_join(tokens[:i])) * 2 >= total:
129
+ clause = i
130
+
131
+ if sentence is not None:
132
+ return sentence
133
+
134
+ return clause if clause is not None else len(tokens)
135
+
136
+
137
+ def _chunk(tokens, style):
138
+ """
139
+ Split a segment's tokens into runs that each fit one cue.
140
+
141
+ A token is `(text, start, end)` where the text carries its own leading
142
+ space and the times may be None. A run only grows while it can still be
143
+ laid out in valid lines, so every chunk (except a single over-long word)
144
+ splits cleanly in `split_into_lines`.
145
+ """
146
+
147
+ chunks = []
148
+ current = []
149
+
150
+ for token in tokens:
151
+
152
+ if not current or _fits(_join(current + [token]), style):
153
+ current.append(token)
154
+ continue
155
+
156
+ cut = _best_break(current)
157
+
158
+ # The leftover plus the new word must still fit; if not, break here.
159
+ if not _fits(_join(current[cut:] + [token]), style):
160
+ cut = len(current)
161
+
162
+ chunks.append(current[:cut])
163
+ current = current[cut:] + [token]
164
+
165
+ if current:
166
+ chunks.append(current)
167
+
168
+ return chunks
169
+
170
+
171
+ def _segment_tokens(segment, text):
172
+ """
173
+ The words of a segment as `(text, start, end)` tokens.
174
+
175
+ Uses Whisper's word timestamps when it provided them and they agree with
176
+ the segment text. Otherwise the tokens carry no timing and the caller
177
+ spreads the segment's duration over them.
178
+ """
179
+
180
+ words = getattr(segment, "words", None) or []
181
+
182
+ tokens = [
183
+ (word.word, word.start, word.end)
184
+ for word in words
185
+ if word.word.strip()
186
+ ]
187
+
188
+ usable = (
189
+ tokens
190
+ and all(start is not None and end is not None for _, start, end in tokens)
191
+ and "".join(word for word, _, _ in tokens).split() == text.split()
192
+ )
193
+
194
+ if usable:
195
+ return tokens
196
+
197
+ return [(" " + word, None, None) for word in text.split()]
198
+
199
+
200
+ def _segment_cues(segment, style):
201
+ """Cues for one Whisper segment, with their speech timing and no padding."""
202
+
203
+ text = clean_text(segment.text)
204
+
205
+ if not text:
206
+ return []
207
+
208
+ chunks = _chunk(_segment_tokens(segment, text), style)
209
+
210
+ if all(token[1] is not None for chunk in chunks for token in chunk):
211
+ return [
212
+ {
213
+ "start": float(chunk[0][1]),
214
+ "end": max(float(chunk[-1][2]), float(chunk[0][1])),
215
+ "text": _join(chunk),
216
+ }
217
+ for chunk in chunks
218
+ ]
219
+
220
+ # No word timing: share the segment's duration out by text length.
221
+ start = float(segment.start)
222
+ end = float(segment.end)
223
+
224
+ total_chars = sum(len(_join(chunk)) for chunk in chunks)
225
+
226
+ cues = []
227
+ cursor = start
228
+
229
+ for chunk in chunks:
230
+
231
+ chunk_text = _join(chunk)
232
+ chunk_end = cursor + (end - start) * len(chunk_text) / total_chars
233
+
234
+ cues.append({"start": cursor, "end": chunk_end, "text": chunk_text})
235
+
236
+ cursor = chunk_end
237
+
238
+ return cues
239
+
240
+
241
+ def _apply_timing(cues, style):
242
+ """
243
+ Make cue timing readable without moving speech around.
244
+
245
+ In order, for every cue:
246
+ 1. It starts when its speech starts, unless the previous cue is still
247
+ up (only possible when source segments overlap).
248
+ 2. It is held at least `min_duration` and long enough to read at
249
+ `max_cps`, but never longer than `max_duration`.
250
+ 3. It must end `min_gap` before the next cue's speech starts. This wins
251
+ over rule 2: readability padding never delays or overlaps the next
252
+ cue, so a very short gap can leave a cue under `min_duration`.
253
+ """
254
+
255
+ timed = []
256
+ previous_end = None
257
+
258
+ for i, cue in enumerate(cues):
259
+
260
+ start = cue["start"]
261
+
262
+ if previous_end is not None:
263
+ start = max(start, previous_end + style.min_gap)
264
+
265
+ plain = cue["text"].replace("\n", " ")
266
+
267
+ wanted = max(
268
+ cue["end"],
269
+ start + style.min_duration,
270
+ start + len(plain) / style.max_cps,
271
+ )
272
+
273
+ end = min(wanted, start + style.max_duration)
274
+
275
+ if i + 1 < len(cues):
276
+ end = min(end, cues[i + 1]["start"] - style.min_gap)
277
+
278
+ end = max(end, start + _MIN_VISIBLE)
279
+
280
+ timed.append({"start": start, "end": end, "text": cue["text"]})
281
+
282
+ previous_end = end
283
+
284
+ return timed
285
+
286
+
287
+ def build_subtitles(segments, style):
288
+ """Convert Whisper segments into formatted subtitles."""
289
+
290
+ cues = []
291
+
292
+ for segment in segments:
293
+
294
+ for cue in _segment_cues(segment, style):
295
+
296
+ lines = split_into_lines(cue["text"], style)
297
+
298
+ if not lines:
299
+ continue
300
+
301
+ cue["text"] = "\n".join(lines)
302
+ cues.append(cue)
303
+
304
+ return _apply_timing(cues, style)
@@ -0,0 +1,253 @@
1
+ """Whisper model access and language handling."""
2
+
3
+ import logging
4
+ import re
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ import ctranslate2
9
+ import numpy as np
10
+ from faster_whisper import WhisperModel, available_models
11
+
12
+ from ._cuda import preload_cuda_libs
13
+
14
+ try:
15
+ from faster_whisper.tokenizer import _LANGUAGE_CODES
16
+ except ImportError:
17
+ _LANGUAGE_CODES = None
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # user/model, as used on the Hugging Face Hub
22
+ _REPO_ID = re.compile(r"[A-Za-z0-9][\w.-]*/[\w.-]+")
23
+
24
+ # Defaults used when the model or compute type is left on "auto".
25
+ DEFAULT_MODEL = {"cuda": "large-v3", "cpu": "small"}
26
+ DEFAULT_COMPUTE_TYPE = {"cuda": "float16", "cpu": "int8"}
27
+
28
+ CUDA_HINT = (
29
+ "To use the GPU, install the CUDA 12 libraries with "
30
+ "'pip install subtitlemaker[cuda]' (or use --device cpu)."
31
+ )
32
+
33
+
34
+ class ModelError(RuntimeError):
35
+ """A model could not be selected or loaded. The message is user-facing."""
36
+
37
+
38
+ def cuda_available():
39
+ """True if CTranslate2 can see at least one CUDA device."""
40
+
41
+ try:
42
+ return ctranslate2.get_cuda_device_count() > 0
43
+
44
+ except RuntimeError:
45
+ return False
46
+
47
+
48
+ def resolve_model_config(config, cuda=None):
49
+ """
50
+ Turn "auto" settings into concrete ones.
51
+
52
+ - device: cuda if a GPU is visible, otherwise cpu (with a warning)
53
+ - model: large-v3 on cuda, small on cpu
54
+ - compute type: float16 on cuda, int8 on cpu
55
+
56
+ `cuda` overrides GPU detection (used by tests). Raises `ModelError`
57
+ for choices that cannot work: --device cuda without a GPU, an unknown
58
+ model name, or a compute type the device does not support.
59
+ """
60
+
61
+ if cuda is None:
62
+ cuda = cuda_available()
63
+
64
+ if config.device == "auto":
65
+
66
+ if cuda:
67
+ device = "cuda"
68
+
69
+ else:
70
+ device = "cpu"
71
+ log.warning(
72
+ "no CUDA device found, using the CPU. "
73
+ "Transcription will be much slower."
74
+ )
75
+
76
+ elif config.device == "cuda" and not cuda:
77
+ raise ModelError(
78
+ "--device cuda was requested but no CUDA device was found. "
79
+ "Use --device auto or --device cpu."
80
+ )
81
+
82
+ else:
83
+ device = config.device
84
+
85
+ name = config.name
86
+
87
+ if name == "auto":
88
+ name = DEFAULT_MODEL[device]
89
+
90
+ elif name not in available_models() and not Path(name).exists():
91
+
92
+ if _REPO_ID.fullmatch(name):
93
+ pass # a Hugging Face repo id, downloaded on first use
94
+
95
+ elif "/" in name or name.startswith("."):
96
+ raise ModelError(f"model path not found: {name}")
97
+
98
+ else:
99
+ raise ModelError(
100
+ f"unknown model '{name}'. Use one of: "
101
+ f"{', '.join(available_models())}; a Hugging Face repo "
102
+ f"id such as 'user/model'; or a path to a converted model."
103
+ )
104
+
105
+ compute_type = config.compute_type
106
+
107
+ if compute_type == "auto":
108
+ compute_type = DEFAULT_COMPUTE_TYPE[device]
109
+
110
+ else:
111
+ supported = ctranslate2.get_supported_compute_types(device)
112
+
113
+ if compute_type not in supported:
114
+ raise ModelError(
115
+ f"compute type '{compute_type}' is not supported on "
116
+ f"{device}. Supported: {', '.join(sorted(supported))}."
117
+ )
118
+
119
+ return replace(
120
+ config,
121
+ name=name,
122
+ device=device,
123
+ compute_type=compute_type,
124
+ )
125
+
126
+
127
+ def load_model(config, allow_cpu_fallback=False):
128
+ """
129
+ Load the model described by a resolved `ModelConfig`.
130
+
131
+ Returns `(model, config_used)`. With `allow_cpu_fallback`, a CUDA
132
+ failure (typically missing CUDA libraries) falls back to the CPU with
133
+ a warning instead of failing; the returned config shows what was used.
134
+ """
135
+
136
+ if config.device == "cuda":
137
+ preload_cuda_libs()
138
+
139
+ try:
140
+ return _create(config), config
141
+
142
+ except RuntimeError as e:
143
+
144
+ if config.device != "cuda":
145
+ raise ModelError(f"couldn't load model '{config.name}': {e}") from e
146
+
147
+ if not allow_cpu_fallback:
148
+ raise ModelError(
149
+ f"couldn't start CUDA: {e}\n{CUDA_HINT}"
150
+ ) from e
151
+
152
+ log.warning("CUDA could not be used (%s)", e)
153
+ log.warning(CUDA_HINT)
154
+
155
+ # The CUDA defaults no longer fit: pick CPU ones unless the user
156
+ # chose the model or compute type themselves.
157
+ fallback = replace(
158
+ config,
159
+ device="cpu",
160
+ name=(
161
+ DEFAULT_MODEL["cpu"]
162
+ if config.name == DEFAULT_MODEL["cuda"]
163
+ else config.name
164
+ ),
165
+ compute_type=DEFAULT_COMPUTE_TYPE["cpu"],
166
+ )
167
+
168
+ log.warning(
169
+ "falling back to the CPU with model '%s'.", fallback.name
170
+ )
171
+
172
+ try:
173
+ return _create(fallback), fallback
174
+
175
+ except RuntimeError as e2:
176
+ raise ModelError(f"couldn't load model '{fallback.name}': {e2}") from e2
177
+
178
+
179
+ def _create(config):
180
+ model = WhisperModel(
181
+ config.name,
182
+ device=config.device,
183
+ compute_type=config.compute_type,
184
+ )
185
+
186
+ if config.device == "cuda":
187
+ _warm_up(model)
188
+
189
+ return model
190
+
191
+
192
+ def _warm_up(model):
193
+ """
194
+ Run one tiny encode on the GPU.
195
+
196
+ CTranslate2 loads cuBLAS and cuDNN lazily, so missing libraries (or too
197
+ little GPU memory) would otherwise only surface in the middle of the
198
+ first transcription. Raises RuntimeError in that case.
199
+ """
200
+
201
+ silence = np.zeros(
202
+ (1, model.model.n_mels, model.feature_extractor.nb_max_frames),
203
+ dtype=np.float32,
204
+ )
205
+
206
+ model.model.encode(
207
+ ctranslate2.StorageView.from_array(silence),
208
+ to_cpu=True,
209
+ )
210
+
211
+
212
+ def parse_language_argument(value):
213
+ """
214
+ Parse the -l/--language argument.
215
+
216
+ Accepts a single language code ("he") or a comma or
217
+ space separated list of codes ("he,en") for audio that
218
+ contains more than one language.
219
+
220
+ Returns (languages, multilingual), where `multilingual`
221
+ is True when more than one code was given.
222
+ """
223
+
224
+ if not value:
225
+ return [], False
226
+
227
+ languages = []
228
+
229
+ for code in re.split(r"[,\s]+", value.strip()):
230
+
231
+ code = code.lower()
232
+
233
+ if not code:
234
+ continue
235
+
236
+ if code not in languages:
237
+ languages.append(code)
238
+
239
+ if _LANGUAGE_CODES is not None:
240
+
241
+ unknown = [
242
+ code
243
+ for code in languages
244
+ if code not in _LANGUAGE_CODES
245
+ ]
246
+
247
+ if unknown:
248
+ raise ValueError(
249
+ f"unknown language code(s): "
250
+ f"{', '.join(unknown)}"
251
+ )
252
+
253
+ return languages, len(languages) > 1