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 +3 -0
- readalign/__main__.py +8 -0
- readalign/align.py +340 -0
- readalign/asr.py +277 -0
- readalign/audio.py +362 -0
- readalign/cli.py +495 -0
- readalign/clock.py +42 -0
- readalign/epub.py +671 -0
- readalign/errors.py +27 -0
- readalign/fs.py +22 -0
- readalign/members.py +72 -0
- readalign/package.py +287 -0
- readalign/refine.py +175 -0
- readalign/report.py +153 -0
- readalign/sentences.py +78 -0
- readalign/sidecar.py +142 -0
- readalign/smil.py +77 -0
- readalign/textnorm.py +158 -0
- readalign-1.0.0.dist-info/METADATA +332 -0
- readalign-1.0.0.dist-info/RECORD +23 -0
- readalign-1.0.0.dist-info/WHEEL +4 -0
- readalign-1.0.0.dist-info/entry_points.txt +2 -0
- readalign-1.0.0.dist-info/licenses/LICENSE +21 -0
readalign/__init__.py
ADDED
readalign/__main__.py
ADDED
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}"
|