readalign 1.0.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.
readalign/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """readalign: build an EPUB 3 with Media Overlays from an audiobook and the matching ebook."""
2
+
3
+ __version__ = "1.0.0"
readalign/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m readalign``, equivalent to the ``readalign`` command."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main(sys.argv[1:]))
readalign/align.py ADDED
@@ -0,0 +1,340 @@
1
+ """Match the ebook's words to the narration's words and time every sentence.
2
+
3
+ The book and the transcript are both reduced to normalised tokens, then aligned with a two-stage
4
+ scheme: unique shared n-grams pin the two streams together at thousands of anchor points, and
5
+ only the small regions between anchors go through a real diff. That is what keeps a 20-hour book
6
+ inside a few hundred megabytes; a single global dynamic-programming matrix over 200k x 200k
7
+ tokens is what makes the older tools in this space fall over.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import bisect
13
+ import difflib
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass, field
16
+
17
+ from .asr import Word
18
+ from .audio import AudioTrack
19
+ from .epub import Sentence
20
+ from .errors import AlignmentError
21
+ from .textnorm import tokenize_words
22
+
23
+ #: n-gram lengths tried when pinning two regions together, longest (most reliable) first.
24
+ ANCHOR_SIZES = (10, 7, 5, 4, 3)
25
+
26
+ #: Regions at or below this size go straight to the diff instead of being anchored further.
27
+ SMALL_REGION = 3000
28
+
29
+ #: Above this many cells a diff is refused; the region is reported as unaligned instead.
30
+ MAX_DIFF_CELLS = 40_000_000
31
+
32
+ #: Minimum share of a sentence's tokens that must match for it to count as confidently timed.
33
+ CONFIDENT = 0.5
34
+
35
+
36
+ @dataclass
37
+ class TimedSentence:
38
+ sentence: Sentence
39
+ start: float
40
+ end: float
41
+ confidence: float
42
+ track_index: int
43
+ interpolated: bool = False
44
+ #: Seconds the start could be out by: the width of the gap an unmatched sentence was
45
+ #: placed inside, or how far a start had to be pushed to keep the order. A sentence
46
+ #: anchored on its own narrated words has a drift of zero.
47
+ drift: float = 0.0
48
+ clamped: bool = False
49
+
50
+
51
+ @dataclass
52
+ class AlignmentResult:
53
+ timed: list[TimedSentence] = field(default_factory=list)
54
+ skipped: list[Sentence] = field(default_factory=list)
55
+ trimmed_head: int = 0
56
+ trimmed_tail: int = 0
57
+ unaligned_regions: list[dict] = field(default_factory=list)
58
+ matched_tokens: int = 0
59
+ book_tokens: int = 0
60
+ asr_tokens: int = 0
61
+
62
+
63
+ def _unique_ngram_anchors(
64
+ book: list[str], asr: list[str], region: tuple[int, int, int, int], size: int
65
+ ) -> list[tuple[int, int]]:
66
+ """Positions where one n-gram occurs exactly once on each side of the region."""
67
+ book_start, book_end, asr_start, asr_end = region
68
+ if book_end - book_start < size or asr_end - asr_start < size:
69
+ return []
70
+ book_index: dict[tuple[str, ...], list[int]] = defaultdict(list)
71
+ for position in range(book_start, book_end - size + 1):
72
+ book_index[tuple(book[position: position + size])].append(position)
73
+ asr_index: dict[tuple[str, ...], list[int]] = defaultdict(list)
74
+ for position in range(asr_start, asr_end - size + 1):
75
+ asr_index[tuple(asr[position: position + size])].append(position)
76
+ pairs = [
77
+ (positions[0], asr_index[key][0])
78
+ for key, positions in book_index.items()
79
+ if len(positions) == 1 and len(asr_index.get(key, ())) == 1
80
+ ]
81
+ pairs.sort()
82
+ return pairs
83
+
84
+
85
+ def _longest_increasing(pairs: list[tuple[int, int]], size: int) -> list[tuple[int, int]]:
86
+ """Longest chain of anchors that is strictly increasing and non-overlapping on both sides."""
87
+ tails: list[int] = []
88
+ chain_index: list[int] = []
89
+ parents: list[int] = [-1] * len(pairs)
90
+ for position, (_, asr_position) in enumerate(pairs):
91
+ slot = bisect.bisect_left(tails, asr_position)
92
+ if slot == len(tails):
93
+ tails.append(asr_position)
94
+ chain_index.append(position)
95
+ else:
96
+ tails[slot] = asr_position
97
+ chain_index[slot] = position
98
+ parents[position] = chain_index[slot - 1] if slot else -1
99
+ chain: list[tuple[int, int]] = []
100
+ cursor = chain_index[-1] if chain_index else -1
101
+ while cursor >= 0:
102
+ chain.append(pairs[cursor])
103
+ cursor = parents[cursor]
104
+ chain.reverse()
105
+ kept: list[tuple[int, int]] = []
106
+ for book_position, asr_position in chain:
107
+ if kept and (book_position < kept[-1][0] + size or asr_position < kept[-1][1] + size):
108
+ continue
109
+ kept.append((book_position, asr_position))
110
+ return kept
111
+
112
+
113
+ def _diff_pairs(
114
+ book: list[str], asr: list[str], region: tuple[int, int, int, int]
115
+ ) -> list[tuple[int, int]]:
116
+ book_start, book_end, asr_start, asr_end = region
117
+ matcher = difflib.SequenceMatcher(
118
+ None, book[book_start:book_end], asr[asr_start:asr_end], autojunk=False
119
+ )
120
+ pairs: list[tuple[int, int]] = []
121
+ for book_offset, asr_offset, length in matcher.get_matching_blocks():
122
+ pairs.extend(
123
+ (book_start + book_offset + step, asr_start + asr_offset + step)
124
+ for step in range(length)
125
+ )
126
+ return pairs
127
+
128
+
129
+ def align_tokens(
130
+ book: list[str], asr: list[str]
131
+ ) -> tuple[list[tuple[int, int]], list[tuple[int, int, int, int]]]:
132
+ """Align two token streams, returning matched pairs and the regions left unaligned."""
133
+ pairs: list[tuple[int, int]] = []
134
+ unaligned: list[tuple[int, int, int, int]] = []
135
+ stack = [(0, len(book), 0, len(asr))]
136
+ while stack:
137
+ region = stack.pop()
138
+ book_start, book_end, asr_start, asr_end = region
139
+ book_size, asr_size = book_end - book_start, asr_end - asr_start
140
+ if book_size <= 0 or asr_size <= 0:
141
+ continue
142
+ if book_size <= SMALL_REGION and asr_size <= SMALL_REGION:
143
+ pairs.extend(_diff_pairs(book, asr, region))
144
+ continue
145
+ chain: list[tuple[int, int]] = []
146
+ used_size = 0
147
+ for size in ANCHOR_SIZES:
148
+ candidates = _unique_ngram_anchors(book, asr, region, size)
149
+ chain = _longest_increasing(candidates, size)
150
+ if chain:
151
+ used_size = size
152
+ break
153
+ if not chain:
154
+ if book_size * asr_size <= MAX_DIFF_CELLS:
155
+ pairs.extend(_diff_pairs(book, asr, region))
156
+ else:
157
+ unaligned.append(region)
158
+ continue
159
+ cursor_book, cursor_asr = book_start, asr_start
160
+ for anchor_book, anchor_asr in chain:
161
+ stack.append((cursor_book, anchor_book, cursor_asr, anchor_asr))
162
+ pairs.extend(
163
+ (anchor_book + step, anchor_asr + step) for step in range(used_size)
164
+ )
165
+ cursor_book, cursor_asr = anchor_book + used_size, anchor_asr + used_size
166
+ stack.append((cursor_book, book_end, cursor_asr, asr_end))
167
+ pairs.sort()
168
+ return pairs, unaligned
169
+
170
+
171
+ def _track_for(tracks: list[AudioTrack], moment: float) -> AudioTrack:
172
+ for track in tracks:
173
+ if moment < track.end:
174
+ return track
175
+ return tracks[-1]
176
+
177
+
178
+ def align(
179
+ sentences: list[Sentence],
180
+ words: list[Word],
181
+ tracks: list[AudioTrack],
182
+ *,
183
+ log=print,
184
+ ) -> AlignmentResult:
185
+ """Time every sentence in the book against the narration."""
186
+ if not sentences:
187
+ raise AlignmentError("the ebook contains no sentences to align")
188
+ if not words:
189
+ raise AlignmentError(
190
+ "the narration produced no words. Check that the audio actually contains speech "
191
+ "and that --language matches it."
192
+ )
193
+
194
+ book_tokens: list[str] = []
195
+ sentence_spans: list[tuple[int, int]] = []
196
+ for sentence in sentences:
197
+ tokens, _ = tokenize_words(sentence.words)
198
+ sentence_spans.append((len(book_tokens), len(book_tokens) + len(tokens)))
199
+ book_tokens.extend(tokens)
200
+
201
+ asr_tokens, asr_origin = tokenize_words([word.text for word in words])
202
+ if not book_tokens:
203
+ raise AlignmentError("the ebook contains no words to align")
204
+
205
+ log(f" aligning {len(book_tokens)} book tokens against {len(asr_tokens)} spoken tokens")
206
+ pairs, unaligned = align_tokens(book_tokens, asr_tokens)
207
+ if not pairs:
208
+ raise AlignmentError(
209
+ "no part of the narration matched the ebook. Are the audiobook and the ebook the "
210
+ "same work, and is --language correct?"
211
+ )
212
+
213
+ mapping = [-1] * len(book_tokens)
214
+ for book_position, asr_position in pairs:
215
+ mapping[book_position] = asr_position
216
+
217
+ first_match = pairs[0][0]
218
+ last_match = pairs[-1][0]
219
+
220
+ result = AlignmentResult(
221
+ matched_tokens=len(pairs),
222
+ book_tokens=len(book_tokens),
223
+ asr_tokens=len(asr_tokens),
224
+ unaligned_regions=[
225
+ {
226
+ "book_tokens": [region[0], region[1]],
227
+ "audio_seconds": [
228
+ round(words[asr_origin[min(region[2], len(asr_origin) - 1)]].start, 2),
229
+ round(words[asr_origin[min(region[3] - 1, len(asr_origin) - 1)]].end, 2),
230
+ ] if asr_origin else [0.0, 0.0],
231
+ }
232
+ for region in unaligned
233
+ ],
234
+ )
235
+
236
+ timed: list[TimedSentence | None] = [None] * len(sentences)
237
+ for index, sentence in enumerate(sentences):
238
+ start_token, end_token = sentence_spans[index]
239
+ if end_token <= start_token or end_token <= first_match or start_token > last_match:
240
+ continue
241
+ matched = [
242
+ mapping[position]
243
+ for position in range(start_token, end_token)
244
+ if mapping[position] >= 0
245
+ ]
246
+ if not matched:
247
+ continue
248
+ first_word = words[asr_origin[matched[0]]]
249
+ last_word = words[asr_origin[matched[-1]]]
250
+ confidence = len(matched) / (end_token - start_token)
251
+ track = _track_for(tracks, first_word.start)
252
+ timed[index] = TimedSentence(
253
+ sentence=sentence,
254
+ start=first_word.start,
255
+ end=max(last_word.end, first_word.start + 0.05),
256
+ confidence=round(min(1.0, confidence), 4),
257
+ track_index=track.index,
258
+ )
259
+
260
+ _interpolate_gaps(sentences, sentence_spans, timed, tracks, first_match, last_match)
261
+ _enforce_monotonic(timed, tracks)
262
+
263
+ result.timed = [entry for entry in timed if entry is not None]
264
+ result.skipped = [
265
+ sentence for index, sentence in enumerate(sentences) if timed[index] is None
266
+ ]
267
+ result.trimmed_head = sum(
268
+ 1 for index, _ in enumerate(sentences) if sentence_spans[index][1] <= first_match
269
+ )
270
+ result.trimmed_tail = sum(
271
+ 1 for index, _ in enumerate(sentences) if sentence_spans[index][0] > last_match
272
+ )
273
+ return result
274
+
275
+
276
+ def _interpolate_gaps(
277
+ sentences: list[Sentence],
278
+ spans: list[tuple[int, int]],
279
+ timed: list[TimedSentence | None],
280
+ tracks: list[AudioTrack],
281
+ first_match: int,
282
+ last_match: int,
283
+ ) -> None:
284
+ """Give unmatched sentences inside the narrated body a time from their neighbours."""
285
+ anchored = [index for index, entry in enumerate(timed) if entry is not None]
286
+ if not anchored:
287
+ return
288
+ for index, sentence in enumerate(sentences):
289
+ if timed[index] is not None:
290
+ continue
291
+ start_token, end_token = spans[index]
292
+ if end_token <= start_token or end_token <= first_match or start_token > last_match:
293
+ continue
294
+ position = bisect.bisect_left(anchored, index)
295
+ before = anchored[position - 1] if position else None
296
+ after = anchored[position] if position < len(anchored) else None
297
+ if before is None or after is None:
298
+ continue
299
+ previous, following = timed[before], timed[after]
300
+ span_start, span_end = spans[before][1], spans[after][0]
301
+ total = max(1, span_end - span_start)
302
+ window = max(0.0, following.start - previous.end)
303
+ start = previous.end + window * (start_token - span_start) / total
304
+ end = previous.end + window * (end_token - span_start) / total
305
+ track = _track_for(tracks, start)
306
+ timed[index] = TimedSentence(
307
+ sentence=sentence,
308
+ start=start,
309
+ end=max(end, start + 0.05),
310
+ confidence=0.0,
311
+ track_index=track.index,
312
+ interpolated=True,
313
+ drift=round(window, 3),
314
+ )
315
+
316
+
317
+ def _enforce_monotonic(timed: list[TimedSentence | None], tracks: list[AudioTrack]) -> None:
318
+ """Keep clips ordered, inside their own file, and never past the file's real duration.
319
+
320
+ How far a start has to be pushed is recorded as drift: a sentence matched to the wrong
321
+ occurrence of its own words is what makes the order go backwards in the first place.
322
+ """
323
+ by_track = {track.index: track for track in tracks}
324
+ previous_end = 0.0
325
+ for entry in timed:
326
+ if entry is None:
327
+ continue
328
+ track = by_track[entry.track_index]
329
+ anchored_start = entry.start
330
+ entry.start = max(entry.start, previous_end)
331
+ entry.drift = round(max(entry.drift, entry.start - anchored_start), 3)
332
+ if entry.start >= track.end:
333
+ entry.start = max(track.offset, track.end - 0.05)
334
+ entry.end = max(entry.end, entry.start + 0.05)
335
+ if entry.end > track.end:
336
+ entry.end = track.end
337
+ entry.clamped = True
338
+ if entry.end <= entry.start:
339
+ entry.start = max(track.offset, entry.end - 0.05)
340
+ previous_end = entry.end
readalign/asr.py ADDED
@@ -0,0 +1,277 @@
1
+ """Transcribe the narration to words with timestamps, in absolute book time.
2
+
3
+ The result is a flat, ordered list of words spanning every track, which is what the aligner
4
+ wants: it never has to know which file a word came from.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import glob
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import site
15
+ import sys
16
+ import time
17
+ from dataclasses import asdict, dataclass
18
+
19
+ from .audio import AudioTrack, read_wav_segment
20
+ from .errors import DependencyError
21
+
22
+ _CUDA_READY = False
23
+
24
+
25
+ def enable_cuda_libraries() -> None:
26
+ """Put the pip-installed NVIDIA runtime DLLs where CTranslate2 can find them.
27
+
28
+ CTranslate2 loads cuBLAS with a plain ``LoadLibrary`` that consults ``PATH``, so on Windows
29
+ ``os.add_dll_directory`` alone is not enough: without this, faster-whisper silently falls back
30
+ to the CPU while ``get_cuda_device_count()`` still reports a device.
31
+ """
32
+ global _CUDA_READY
33
+ if _CUDA_READY:
34
+ return
35
+ _CUDA_READY = True
36
+ roots = [*site.getsitepackages(), site.getusersitepackages()]
37
+ directories = []
38
+ for root in roots:
39
+ directories.extend(glob.glob(os.path.join(root, "nvidia", "*", "bin")))
40
+ directories.extend(glob.glob(os.path.join(root, "nvidia", "*", "lib")))
41
+ for directory in directories:
42
+ if os.path.isdir(directory):
43
+ if hasattr(os, "add_dll_directory"):
44
+ with contextlib.suppress(OSError):
45
+ os.add_dll_directory(directory)
46
+ if directory not in os.environ.get("PATH", "").split(os.pathsep):
47
+ os.environ["PATH"] = directory + os.pathsep + os.environ.get("PATH", "")
48
+
49
+
50
+ @dataclass
51
+ class Word:
52
+ """One recognised word, timed against the whole book rather than one file."""
53
+
54
+ text: str
55
+ start: float
56
+ end: float
57
+ probability: float
58
+
59
+
60
+ def pick_device(requested: str) -> tuple[str, str]:
61
+ """Resolve ``auto`` into a concrete CTranslate2 device and compute type."""
62
+ if requested in {"cuda", "cpu"}:
63
+ device = requested
64
+ else:
65
+ enable_cuda_libraries()
66
+ try:
67
+ import ctranslate2
68
+
69
+ device = "cuda" if ctranslate2.get_cuda_device_count() > 0 else "cpu"
70
+ except Exception:
71
+ device = "cpu"
72
+ if device == "cuda":
73
+ enable_cuda_libraries()
74
+ return device, ("float16" if device == "cuda" else "int8")
75
+
76
+
77
+ def load_model(name: str, device: str, compute_type: str):
78
+ try:
79
+ from faster_whisper import WhisperModel
80
+ except ImportError as exc: # pragma: no cover - dependency is declared, but be explicit
81
+ raise DependencyError(f"faster-whisper is not installed: {exc}") from exc
82
+ try:
83
+ return WhisperModel(name, device=device, compute_type=compute_type)
84
+ except Exception as exc:
85
+ message = str(exc)
86
+ if any(token in message.lower() for token in ("connection", "resolve", "network", "proxy")):
87
+ raise DependencyError(
88
+ f"could not download the '{name}' model. readalign needs network access once to "
89
+ f"fetch it, then works offline. Underlying error: {message}"
90
+ ) from exc
91
+ raise DependencyError(f"could not load the '{name}' model: {message}") from exc
92
+
93
+
94
+ CUDA_HINT = (
95
+ "install the CUDA runtime wheels with "
96
+ "'pip install nvidia-cublas-cu12 nvidia-cudnn-cu12', or use --device cpu"
97
+ )
98
+
99
+
100
+ def _is_cuda_failure(exc: Exception) -> bool:
101
+ message = str(exc).lower()
102
+ return any(token in message for token in ("cuda", "cublas", "cudnn", "gpu"))
103
+
104
+
105
+ @dataclass
106
+ class _Settings:
107
+ """The recogniser knobs, carried together so the per-track call stays readable."""
108
+
109
+ model_name: str
110
+ language: str | None
111
+ vad: bool
112
+ beam_size: int
113
+ window_seconds: float
114
+
115
+
116
+ def _transcribe_track(model, track, count, settings, *, before, total, started, log) -> list[Word]:
117
+ """Transcribe one track, in windows, returning words relative to the start of that track."""
118
+ from .audio import plan_windows
119
+
120
+ words: list[Word] = []
121
+ windows = plan_windows(track, settings.window_seconds)
122
+ for window_index, (start, end) in enumerate(windows, start=1):
123
+ audio = read_wav_segment(track.wav_path, start, end)
124
+ suffix = f" window {window_index}/{len(windows)}" if len(windows) > 1 else ""
125
+ log(f" [{track.index}/{count}] {os.path.basename(track.source)}{suffix}: "
126
+ f"transcribing {_hms(end - start)}")
127
+ segments, detected = model.transcribe(
128
+ audio,
129
+ language=settings.language,
130
+ word_timestamps=True,
131
+ vad_filter=settings.vad,
132
+ beam_size=settings.beam_size,
133
+ condition_on_previous_text=False,
134
+ )
135
+ for segment in segments:
136
+ for word in segment.words or []:
137
+ text = word.word.strip()
138
+ if text:
139
+ # float() because CTranslate2 hands back numpy scalars, which would
140
+ # otherwise travel all the way into the JSON report and fail to encode.
141
+ words.append(
142
+ Word(text, start + float(word.start), start + float(word.end),
143
+ float(word.probability))
144
+ )
145
+ settings.language = settings.language or detected.language
146
+ elapsed = time.monotonic() - started
147
+ log(f" {len(words)} words so far, {_hms(before + end)}/{_hms(total)} "
148
+ f"audio in {_hms(elapsed)} ({(before + end) / max(elapsed, 0.001):.1f}x realtime)")
149
+ return words
150
+
151
+
152
+ def _cache_key(track: AudioTrack, model: str, language: str | None, vad: bool) -> str:
153
+ stat = os.stat(track.path)
154
+ payload = "|".join(
155
+ [os.path.basename(track.source), str(stat.st_size), f"{track.duration:.3f}",
156
+ model, language or "auto", str(vad)]
157
+ )
158
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
159
+
160
+
161
+ def _load_cached(cache_dir: str | None, key: str) -> tuple[list[Word], str | None] | None:
162
+ """The words and the language of a cached track, or None if there is nothing usable."""
163
+ if not cache_dir:
164
+ return None
165
+ path = os.path.join(cache_dir, f"{key}.json")
166
+ try:
167
+ with open(path, encoding="utf-8") as handle:
168
+ data = json.load(handle)
169
+ except (OSError, json.JSONDecodeError):
170
+ return None
171
+ try:
172
+ # Caches written before the language was recorded are a bare list of words.
173
+ if isinstance(data, list):
174
+ return [Word(**entry) for entry in data], None
175
+ return [Word(**entry) for entry in data["words"]], data.get("language")
176
+ except (KeyError, TypeError):
177
+ return None
178
+
179
+
180
+ def _store_cached(
181
+ cache_dir: str | None, key: str, words: list[Word], language: str | None
182
+ ) -> None:
183
+ if not cache_dir:
184
+ return
185
+ try:
186
+ os.makedirs(cache_dir, exist_ok=True)
187
+ path = os.path.join(cache_dir, f"{key}.json")
188
+ with open(path, "w", encoding="utf-8") as handle:
189
+ json.dump({"language": language, "words": [asdict(w) for w in words]}, handle)
190
+ except OSError as exc:
191
+ print(f" warning: could not write the transcript cache: {exc}", file=sys.stderr)
192
+
193
+
194
+ def transcribe_tracks(
195
+ tracks: list[AudioTrack],
196
+ *,
197
+ model_name: str,
198
+ device: str = "auto",
199
+ language: str | None = None,
200
+ vad: bool = True,
201
+ beam_size: int = 5,
202
+ window_seconds: float = 3600.0,
203
+ cache_dir: str | None = None,
204
+ log=print,
205
+ ) -> tuple[list[Word], dict]:
206
+ """Transcribe every track and return the words in absolute book time."""
207
+ resolved_device, compute_type = pick_device(device)
208
+ can_fall_back = resolved_device == "cuda" and device != "cuda"
209
+ settings = _Settings(model_name, language, vad, beam_size, window_seconds)
210
+ words: list[Word] = []
211
+ model = None
212
+ info = {"device": resolved_device, "compute_type": compute_type, "model": model_name,
213
+ "cached_tracks": 0, "language": language}
214
+ started = time.monotonic()
215
+ audio_total = sum(track.duration for track in tracks) or 1.0
216
+ done = 0.0
217
+
218
+ for track in tracks:
219
+ key = _cache_key(track, model_name, language, vad) if cache_dir else ""
220
+ cached = _load_cached(cache_dir, key)
221
+ if cached is not None:
222
+ cached_words, cached_language = cached
223
+ log(f" [{track.index}/{len(tracks)}] {os.path.basename(track.source)}: "
224
+ f"{len(cached_words)} words from cache")
225
+ settings.language = settings.language or cached_language
226
+ words.extend(
227
+ Word(w.text, w.start + track.offset, w.end + track.offset, w.probability)
228
+ for w in cached_words
229
+ )
230
+ info["cached_tracks"] += 1
231
+ done += track.duration
232
+ continue
233
+
234
+ while True:
235
+ try:
236
+ if model is None:
237
+ log(f" loading whisper model '{model_name}' on "
238
+ f"{resolved_device} ({compute_type})")
239
+ model = load_model(model_name, resolved_device, compute_type)
240
+ track_words = _transcribe_track(
241
+ model, track, len(tracks), settings,
242
+ before=done, total=audio_total, started=started, log=log,
243
+ )
244
+ break
245
+ except (DependencyError, RuntimeError) as exc:
246
+ # CTranslate2 only touches the GPU on the first encode, so a broken CUDA
247
+ # install surfaces here and not when the model was constructed.
248
+ if not _is_cuda_failure(exc):
249
+ raise
250
+ if not can_fall_back:
251
+ raise DependencyError(f"the GPU could not be used: {exc}. {CUDA_HINT}") from exc
252
+ log(f" warning: the GPU could not be used ({exc}), falling back to the CPU. "
253
+ f"To use the GPU, {CUDA_HINT}.")
254
+ can_fall_back = False
255
+ model = None
256
+ resolved_device, compute_type = "cpu", "int8"
257
+ info["device"], info["compute_type"] = resolved_device, compute_type
258
+ info["cuda_fallback"] = str(exc)
259
+ _store_cached(cache_dir, key, track_words, settings.language)
260
+ words.extend(
261
+ Word(w.text, w.start + track.offset, w.end + track.offset, w.probability)
262
+ for w in track_words
263
+ )
264
+ done += track.duration
265
+
266
+ words.sort(key=lambda word: (word.start, word.end))
267
+ info["language"] = settings.language
268
+ info["words"] = len(words)
269
+ info["seconds"] = round(time.monotonic() - started, 1)
270
+ return words, info
271
+
272
+
273
+ def _hms(seconds: float) -> str:
274
+ seconds = max(0.0, seconds)
275
+ hours, rest = divmod(int(seconds), 3600)
276
+ minutes, secs = divmod(rest, 60)
277
+ return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"