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.
- subtitlemaker/__init__.py +3 -0
- subtitlemaker/__main__.py +5 -0
- subtitlemaker/_cuda.py +91 -0
- subtitlemaker/cli.py +771 -0
- subtitlemaker/config.py +158 -0
- subtitlemaker/formats/__init__.py +80 -0
- subtitlemaker/formats/_timestamp.py +18 -0
- subtitlemaker/formats/srt.py +27 -0
- subtitlemaker/formats/txt.py +10 -0
- subtitlemaker/formats/vtt.py +33 -0
- subtitlemaker/log.py +88 -0
- subtitlemaker/media.py +55 -0
- subtitlemaker/progress.py +97 -0
- subtitlemaker/segmenter.py +304 -0
- subtitlemaker/transcribe.py +253 -0
- subtitlemaker-0.1.0.dist-info/METADATA +207 -0
- subtitlemaker-0.1.0.dist-info/RECORD +20 -0
- subtitlemaker-0.1.0.dist-info/WHEEL +4 -0
- subtitlemaker-0.1.0.dist-info/entry_points.txt +2 -0
- subtitlemaker-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|