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/fs.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Filesystem writes that fail with a sentence instead of a traceback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .errors import InputError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_directory(path: str, what: str = "output directory") -> None:
|
|
11
|
+
try:
|
|
12
|
+
os.makedirs(path, exist_ok=True)
|
|
13
|
+
except OSError as exc:
|
|
14
|
+
raise InputError(f"cannot create the {what} {path}: {exc}") from exc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_text(path: str, text: str) -> None:
|
|
18
|
+
try:
|
|
19
|
+
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
|
20
|
+
handle.write(text)
|
|
21
|
+
except OSError as exc:
|
|
22
|
+
raise InputError(f"cannot write {path}: {exc}") from exc
|
readalign/members.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""What an output archive member can be, and how to read one.
|
|
2
|
+
|
|
3
|
+
An audiobook is three orders of magnitude larger than everything else readalign handles, so
|
|
4
|
+
members it does not need to look at are carried as a reference and streamed straight through to
|
|
5
|
+
the output rather than held as bytes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import zipfile
|
|
12
|
+
from contextlib import AbstractContextManager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import BinaryIO
|
|
15
|
+
|
|
16
|
+
from .errors import InputError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SourceFile:
|
|
21
|
+
"""A member whose bytes are a file on disk, such as a prepared audio track."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ArchiveMember:
|
|
28
|
+
"""A member copied through from the input EPUB without being parsed or changed."""
|
|
29
|
+
|
|
30
|
+
epub_path: str
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: An output archive as member name to content.
|
|
35
|
+
Member = dict[str, "bytes | SourceFile | ArchiveMember"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def open_archive(path: str) -> zipfile.ZipFile:
|
|
39
|
+
"""Open an EPUB for reading, turning every unreadable-path case into a clear message."""
|
|
40
|
+
try:
|
|
41
|
+
return zipfile.ZipFile(path)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
raise InputError(f"no such file: {path}") from None
|
|
44
|
+
except (IsADirectoryError, PermissionError) as exc:
|
|
45
|
+
# Windows raises PermissionError for a directory, where POSIX raises IsADirectoryError.
|
|
46
|
+
if os.path.isdir(path):
|
|
47
|
+
raise InputError(f"not an .epub file but a directory: {path}") from None
|
|
48
|
+
raise InputError(f"cannot read {path}: {exc.strerror or exc}") from None
|
|
49
|
+
except zipfile.BadZipFile as exc:
|
|
50
|
+
raise InputError(f"{path} is not a readable EPUB (not a zip archive): {exc}") from None
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
raise InputError(f"cannot read {path}: {exc.strerror or exc}") from None
|
|
53
|
+
|
|
54
|
+
class MemberReader(AbstractContextManager):
|
|
55
|
+
"""Opens by-reference members, holding each source archive open only once."""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._archives: dict[str, zipfile.ZipFile] = {}
|
|
59
|
+
|
|
60
|
+
def open(self, member: SourceFile | ArchiveMember) -> BinaryIO:
|
|
61
|
+
if isinstance(member, SourceFile):
|
|
62
|
+
return open(member.path, "rb")
|
|
63
|
+
archive = self._archives.get(member.epub_path)
|
|
64
|
+
if archive is None:
|
|
65
|
+
archive = zipfile.ZipFile(member.epub_path)
|
|
66
|
+
self._archives[member.epub_path] = archive
|
|
67
|
+
return archive.open(member.name)
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *_) -> None:
|
|
70
|
+
while self._archives:
|
|
71
|
+
_, archive = self._archives.popitem()
|
|
72
|
+
archive.close()
|
readalign/package.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Rewrite the package document and write the finished EPUB.
|
|
2
|
+
|
|
3
|
+
The OPF gains the audio and overlay manifest items, the ``media-overlay`` attribute on every
|
|
4
|
+
narrated content document, and the ``media:`` metadata EPUB 3.3 section 9.3.5 requires. The
|
|
5
|
+
archive is written mimetype-first and uncompressed, as OCF demands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import posixpath
|
|
12
|
+
import shutil
|
|
13
|
+
import zipfile
|
|
14
|
+
from urllib.parse import unquote
|
|
15
|
+
|
|
16
|
+
from lxml import etree
|
|
17
|
+
|
|
18
|
+
from .align import TimedSentence
|
|
19
|
+
from .audio import AudioTrack
|
|
20
|
+
from .clock import format_clock
|
|
21
|
+
from .epub import OPF_NS, EpubPackage, local_name
|
|
22
|
+
from .errors import InputError
|
|
23
|
+
from .fs import ensure_directory
|
|
24
|
+
from .members import Member, MemberReader, SourceFile
|
|
25
|
+
from .smil import build_smil, overlay_duration
|
|
26
|
+
|
|
27
|
+
AUDIO_DIR = "audio"
|
|
28
|
+
OVERLAY_DIR = "media-overlays"
|
|
29
|
+
|
|
30
|
+
ACTIVE_CLASS = "-epub-media-overlay-active"
|
|
31
|
+
PLAYBACK_ACTIVE_CLASS = "-epub-media-overlay-playback-active"
|
|
32
|
+
|
|
33
|
+
#: Stylesheet injected so the highlight is visible in readers that honour media:active-class.
|
|
34
|
+
ACTIVE_CLASS_CSS = f""".{ACTIVE_CLASS} {{
|
|
35
|
+
\tbackground-color: rgba(255, 224, 96, 0.55);
|
|
36
|
+
\tcolor: inherit;
|
|
37
|
+
}}
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_MEDIA_TYPES_BY_SUFFIX = {
|
|
41
|
+
".mp3": "audio/mpeg",
|
|
42
|
+
".m4a": "audio/mp4",
|
|
43
|
+
".opus": "audio/ogg; codecs=opus",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _manifest_element(package: EpubPackage) -> etree._Element:
|
|
48
|
+
root = package.opf_tree.getroot()
|
|
49
|
+
manifest = root.find(f"{{{OPF_NS}}}manifest")
|
|
50
|
+
if manifest is None:
|
|
51
|
+
raise InputError("the package document has no <manifest>")
|
|
52
|
+
return manifest
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _metadata_element(package: EpubPackage) -> etree._Element:
|
|
56
|
+
root = package.opf_tree.getroot()
|
|
57
|
+
metadata = root.find(f"{{{OPF_NS}}}metadata")
|
|
58
|
+
if metadata is None:
|
|
59
|
+
raise InputError("the package document has no <metadata>")
|
|
60
|
+
return metadata
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _free_id(package: EpubPackage, base: str) -> str:
|
|
64
|
+
if base not in package.manifest:
|
|
65
|
+
return base
|
|
66
|
+
suffix = 2
|
|
67
|
+
while f"{base}-{suffix}" in package.manifest:
|
|
68
|
+
suffix += 1
|
|
69
|
+
return f"{base}-{suffix}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _add_manifest_item(
|
|
73
|
+
package: EpubPackage, manifest: etree._Element, item_id: str, href: str, media_type: str
|
|
74
|
+
) -> etree._Element:
|
|
75
|
+
element = etree.SubElement(manifest, f"{{{OPF_NS}}}item")
|
|
76
|
+
element.set("id", item_id)
|
|
77
|
+
element.set("href", href)
|
|
78
|
+
element.set("media-type", media_type)
|
|
79
|
+
return element
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _strip_existing_overlays(package: EpubPackage, manifest: etree._Element) -> set[str]:
|
|
83
|
+
"""Drop overlays from an EPUB that already has them, so a re-run replaces rather than stacks."""
|
|
84
|
+
removed: set[str] = set()
|
|
85
|
+
for item in list(package.manifest.values()):
|
|
86
|
+
if item.media_type == "application/smil+xml":
|
|
87
|
+
manifest.remove(item.element)
|
|
88
|
+
package.manifest.pop(item.id, None)
|
|
89
|
+
removed.add(package.resolve(item.href))
|
|
90
|
+
elif item.element.get("media-overlay"):
|
|
91
|
+
del item.element.attrib["media-overlay"]
|
|
92
|
+
return removed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _drop_existing_overlay_metadata(metadata: etree._Element) -> None:
|
|
96
|
+
for element in list(metadata):
|
|
97
|
+
if element.tag == f"{{{OPF_NS}}}meta" and (element.get("property") or "").startswith(
|
|
98
|
+
"media:"
|
|
99
|
+
):
|
|
100
|
+
metadata.remove(element)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _set_meta(metadata: etree._Element, prop: str, value: str, refines: str | None = None) -> None:
|
|
104
|
+
element = etree.SubElement(metadata, f"{{{OPF_NS}}}meta")
|
|
105
|
+
element.set("property", prop)
|
|
106
|
+
if refines:
|
|
107
|
+
element.set("refines", refines)
|
|
108
|
+
element.text = value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _links_to(doc, css_path: str) -> list[etree._Element]:
|
|
112
|
+
"""The <link> elements in one document that point at ``css_path``."""
|
|
113
|
+
found = []
|
|
114
|
+
for element in doc.tree.getroot().iter():
|
|
115
|
+
if local_name(element.tag) != "link":
|
|
116
|
+
continue
|
|
117
|
+
href = element.get("href")
|
|
118
|
+
if not href:
|
|
119
|
+
continue
|
|
120
|
+
resolved = posixpath.normpath(
|
|
121
|
+
posixpath.join(posixpath.dirname(doc.zip_path), unquote(href))
|
|
122
|
+
)
|
|
123
|
+
if resolved == css_path:
|
|
124
|
+
found.append(element)
|
|
125
|
+
return found
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _strip_previous_stylesheet(
|
|
129
|
+
package: EpubPackage, manifest: etree._Element, css_path: str
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Drop an earlier run's stylesheet, so re-running replaces the link rather than stacking."""
|
|
132
|
+
for item in list(package.manifest.values()):
|
|
133
|
+
if item.media_type == "text/css" and package.resolve(item.href) == css_path:
|
|
134
|
+
manifest.remove(item.element)
|
|
135
|
+
package.manifest.pop(item.id, None)
|
|
136
|
+
for doc in package.docs:
|
|
137
|
+
for link in _links_to(doc, css_path):
|
|
138
|
+
link.getparent().remove(link)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _inject_stylesheet(
|
|
142
|
+
package: EpubPackage, manifest: etree._Element, href: str, css_path: str
|
|
143
|
+
) -> None:
|
|
144
|
+
_strip_previous_stylesheet(package, manifest, css_path)
|
|
145
|
+
_add_manifest_item(package, manifest, _free_id(package, "readalign-css"), href, "text/css")
|
|
146
|
+
for doc in package.docs:
|
|
147
|
+
root = doc.tree.getroot()
|
|
148
|
+
head = next(
|
|
149
|
+
(element for element in root.iter() if local_name(element.tag) == "head"),
|
|
150
|
+
None,
|
|
151
|
+
)
|
|
152
|
+
if head is None:
|
|
153
|
+
continue
|
|
154
|
+
namespace = root.tag[: root.tag.index("}") + 1] if root.tag.startswith("{") else ""
|
|
155
|
+
link = etree.SubElement(head, f"{namespace}link")
|
|
156
|
+
link.set("rel", "stylesheet")
|
|
157
|
+
link.set("type", "text/css")
|
|
158
|
+
link.set("href", posixpath.relpath(css_path, posixpath.dirname(doc.zip_path)))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_output(
|
|
162
|
+
package: EpubPackage,
|
|
163
|
+
entries_by_doc: dict[str, list[TimedSentence]],
|
|
164
|
+
tracks: list[AudioTrack],
|
|
165
|
+
*,
|
|
166
|
+
narrator: str | None = None,
|
|
167
|
+
) -> tuple[Member, dict]:
|
|
168
|
+
"""Produce the member-name-to-bytes map of the output EPUB, and a summary of what changed."""
|
|
169
|
+
manifest = _manifest_element(package)
|
|
170
|
+
metadata = _metadata_element(package)
|
|
171
|
+
_drop_existing_overlay_metadata(metadata)
|
|
172
|
+
replaced = _strip_existing_overlays(package, manifest)
|
|
173
|
+
|
|
174
|
+
by_index = {track.index: track for track in tracks}
|
|
175
|
+
used_tracks = sorted(
|
|
176
|
+
{entry.track_index for entries in entries_by_doc.values() for entry in entries}
|
|
177
|
+
)
|
|
178
|
+
audio_paths: dict[int, str] = {}
|
|
179
|
+
audio_hrefs: dict[int, str] = {}
|
|
180
|
+
files: Member = {
|
|
181
|
+
name: data for name, data in package.files.items() if name not in replaced
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for index in used_tracks:
|
|
185
|
+
track = by_index[index]
|
|
186
|
+
suffix = os.path.splitext(track.path)[1].lower()
|
|
187
|
+
href = posixpath.join(AUDIO_DIR, f"part{index:03d}{suffix}")
|
|
188
|
+
zip_path = package.resolve(href)
|
|
189
|
+
audio_paths[index] = zip_path
|
|
190
|
+
audio_hrefs[index] = href
|
|
191
|
+
files[zip_path] = SourceFile(track.path)
|
|
192
|
+
_add_manifest_item(
|
|
193
|
+
package,
|
|
194
|
+
manifest,
|
|
195
|
+
_free_id(package, f"readalign-audio-{index:03d}"),
|
|
196
|
+
href,
|
|
197
|
+
_MEDIA_TYPES_BY_SUFFIX.get(suffix, track.media_type),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
css_href = posixpath.join("css", "readalign.css")
|
|
201
|
+
css_path = package.resolve(css_href)
|
|
202
|
+
files[css_path] = ACTIVE_CLASS_CSS.encode("utf-8")
|
|
203
|
+
_inject_stylesheet(package, manifest, css_href, css_path)
|
|
204
|
+
|
|
205
|
+
overlays: list[tuple[str, float]] = []
|
|
206
|
+
total = 0.0
|
|
207
|
+
for doc in package.docs:
|
|
208
|
+
entries = entries_by_doc.get(doc.manifest_id) or []
|
|
209
|
+
if not entries:
|
|
210
|
+
continue
|
|
211
|
+
stem = posixpath.splitext(posixpath.basename(doc.href))[0]
|
|
212
|
+
href = posixpath.join(OVERLAY_DIR, f"{stem}.smil")
|
|
213
|
+
smil_path = package.resolve(href)
|
|
214
|
+
files[smil_path] = build_smil(smil_path, doc.zip_path, entries, by_index, audio_paths)
|
|
215
|
+
overlay_id = _free_id(package, f"readalign-smil-{doc.manifest_id}")
|
|
216
|
+
_add_manifest_item(package, manifest, overlay_id, href, "application/smil+xml")
|
|
217
|
+
package.manifest[doc.manifest_id].element.set("media-overlay", overlay_id)
|
|
218
|
+
duration = overlay_duration(entries)
|
|
219
|
+
overlays.append((overlay_id, duration))
|
|
220
|
+
total += duration
|
|
221
|
+
|
|
222
|
+
for overlay_id, duration in overlays:
|
|
223
|
+
_set_meta(metadata, "media:duration", format_clock(duration), refines=f"#{overlay_id}")
|
|
224
|
+
_set_meta(metadata, "media:duration", format_clock(total))
|
|
225
|
+
_set_meta(metadata, "media:active-class", ACTIVE_CLASS)
|
|
226
|
+
_set_meta(metadata, "media:playback-active-class", PLAYBACK_ACTIVE_CLASS)
|
|
227
|
+
if narrator:
|
|
228
|
+
_set_meta(metadata, "media:narrator", narrator)
|
|
229
|
+
|
|
230
|
+
for doc in package.docs:
|
|
231
|
+
files[doc.zip_path] = doc.serialize()
|
|
232
|
+
files[package.opf_path] = etree.tostring(
|
|
233
|
+
package.opf_tree, xml_declaration=True, encoding="utf-8"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
summary = {
|
|
237
|
+
"overlays": len(overlays),
|
|
238
|
+
"total_duration": format_clock(total),
|
|
239
|
+
"audio_files": len(audio_paths),
|
|
240
|
+
"audio_hrefs": audio_hrefs,
|
|
241
|
+
"active_class": ACTIVE_CLASS,
|
|
242
|
+
}
|
|
243
|
+
return files, summary
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def write_epub(path: str, files: Member, original_order: list[str]) -> None:
|
|
247
|
+
"""Write an OCF archive: mimetype first and stored, everything else deflated."""
|
|
248
|
+
ordered: list[str] = []
|
|
249
|
+
seen = set()
|
|
250
|
+
for name in ["mimetype", "META-INF/container.xml", *original_order]:
|
|
251
|
+
if name in files and name not in seen:
|
|
252
|
+
ordered.append(name)
|
|
253
|
+
seen.add(name)
|
|
254
|
+
ordered.extend(sorted(name for name in files if name not in seen))
|
|
255
|
+
|
|
256
|
+
ensure_directory(os.path.dirname(os.path.abspath(path)))
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive, MemberReader() as read:
|
|
260
|
+
for name in ordered:
|
|
261
|
+
data = files[name]
|
|
262
|
+
if name == "mimetype":
|
|
263
|
+
# OCF requires the first entry to be an uncompressed "mimetype".
|
|
264
|
+
info = zipfile.ZipInfo("mimetype")
|
|
265
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
266
|
+
info.external_attr = 0o644 << 16
|
|
267
|
+
archive.writestr(info, data)
|
|
268
|
+
continue
|
|
269
|
+
info = zipfile.ZipInfo(name)
|
|
270
|
+
info.external_attr = 0o644 << 16
|
|
271
|
+
info.compress_type = (
|
|
272
|
+
zipfile.ZIP_STORED
|
|
273
|
+
if posixpath.splitext(name)[1].lower() in _MEDIA_TYPES_BY_SUFFIX
|
|
274
|
+
else zipfile.ZIP_DEFLATED
|
|
275
|
+
)
|
|
276
|
+
if isinstance(data, bytes):
|
|
277
|
+
archive.writestr(info, data)
|
|
278
|
+
else:
|
|
279
|
+
with read.open(data) as source, archive.open(info, "w") as target:
|
|
280
|
+
shutil.copyfileobj(source, target, 1 << 20)
|
|
281
|
+
except OSError as exc:
|
|
282
|
+
raise InputError(f"cannot write {path}: {exc}") from exc
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def ensure_mimetype(files: Member) -> None:
|
|
286
|
+
"""Set the OCF mimetype. It is fixed for EPUB, so the input's copy is never carried over."""
|
|
287
|
+
files["mimetype"] = b"application/epub+zip"
|
readalign/refine.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Optional CTC forced-alignment pass that sharpens sentence boundaries.
|
|
2
|
+
|
|
3
|
+
Whisper's word timestamps come from attention weights and are typically good to a few tenths of
|
|
4
|
+
a second. A frame-level CTC aligner does better, so ``--refine`` runs torchaudio's MMS_FA bundle
|
|
5
|
+
over each already-aligned stretch and replaces the boundaries it is confident about.
|
|
6
|
+
|
|
7
|
+
The MMS-300m aligner is licensed cc-by-nc-4.0, which is not a licence that belongs in a default
|
|
8
|
+
install path, so this module is never imported unless ``--refine`` is passed and the model is
|
|
9
|
+
never downloaded otherwise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .align import AlignmentResult, TimedSentence
|
|
15
|
+
from .audio import SAMPLE_RATE, AudioTrack, read_wav_segment
|
|
16
|
+
from .errors import DependencyError
|
|
17
|
+
from .textnorm import tokenize_words
|
|
18
|
+
|
|
19
|
+
#: Seconds of audio handed to the aligner at once. The CTC lattice is time x tokens, so this
|
|
20
|
+
#: bounds peak memory independently of how long the book is.
|
|
21
|
+
CHUNK_SECONDS = 40.0
|
|
22
|
+
|
|
23
|
+
#: Context added either side of a chunk so a boundary word is never cut in half.
|
|
24
|
+
PAD_SECONDS = 0.5
|
|
25
|
+
|
|
26
|
+
#: Boundaries are only replaced when the new value is within this many seconds of the old one.
|
|
27
|
+
MAX_SHIFT = 2.0
|
|
28
|
+
|
|
29
|
+
LICENCE_NOTE = (
|
|
30
|
+
"the MMS-300m forced aligner is licensed CC BY-NC 4.0 (non-commercial). "
|
|
31
|
+
"Output timings refined with it inherit that restriction."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_bundle(device: str):
|
|
36
|
+
try:
|
|
37
|
+
import torch
|
|
38
|
+
import torchaudio
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise DependencyError(
|
|
41
|
+
"--refine needs the optional extra: pip install 'readalign[refine]'"
|
|
42
|
+
) from exc
|
|
43
|
+
try:
|
|
44
|
+
bundle = torchaudio.pipelines.MMS_FA
|
|
45
|
+
model = bundle.get_model()
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
message = str(exc)
|
|
48
|
+
raise DependencyError(
|
|
49
|
+
f"could not load the MMS forced aligner. It is downloaded once from "
|
|
50
|
+
f"download.pytorch.org, so this needs network access. Underlying error: {message}"
|
|
51
|
+
) from exc
|
|
52
|
+
resolved = device
|
|
53
|
+
if device == "auto":
|
|
54
|
+
resolved = "cuda" if torch.cuda.is_available() else "cpu"
|
|
55
|
+
model = model.to(resolved).eval()
|
|
56
|
+
return torch, bundle, model, resolved
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _chunks(entries: list[TimedSentence]) -> list[list[TimedSentence]]:
|
|
60
|
+
chunks: list[list[TimedSentence]] = []
|
|
61
|
+
current: list[TimedSentence] = []
|
|
62
|
+
for entry in entries:
|
|
63
|
+
if current and entry.end - current[0].start > CHUNK_SECONDS:
|
|
64
|
+
chunks.append(current)
|
|
65
|
+
current = []
|
|
66
|
+
current.append(entry)
|
|
67
|
+
if current:
|
|
68
|
+
chunks.append(current)
|
|
69
|
+
return chunks
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _transcript(
|
|
73
|
+
entries: list[TimedSentence], vocabulary: set[str]
|
|
74
|
+
) -> tuple[list[str], list[tuple[int, int]]]:
|
|
75
|
+
"""Flatten the chunk's sentences into aligner words, keeping each sentence's word range."""
|
|
76
|
+
words: list[str] = []
|
|
77
|
+
spans: list[tuple[int, int]] = []
|
|
78
|
+
for entry in entries:
|
|
79
|
+
tokens, _ = tokenize_words(entry.sentence.words)
|
|
80
|
+
start = len(words)
|
|
81
|
+
for token in tokens:
|
|
82
|
+
cleaned = "".join(character for character in token if character in vocabulary)
|
|
83
|
+
if cleaned:
|
|
84
|
+
words.append(cleaned)
|
|
85
|
+
spans.append((start, len(words)))
|
|
86
|
+
return words, spans
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def refine(
|
|
90
|
+
result: AlignmentResult,
|
|
91
|
+
tracks: list[AudioTrack],
|
|
92
|
+
*,
|
|
93
|
+
device: str = "auto",
|
|
94
|
+
log=print,
|
|
95
|
+
) -> dict:
|
|
96
|
+
"""Replace Whisper's sentence boundaries with CTC ones wherever both agree roughly."""
|
|
97
|
+
torch, bundle, model, resolved = _load_bundle(device)
|
|
98
|
+
tokenizer = bundle.get_tokenizer()
|
|
99
|
+
aligner = bundle.get_aligner()
|
|
100
|
+
vocabulary = set(bundle.get_dict())
|
|
101
|
+
by_index = {track.index: track for track in tracks}
|
|
102
|
+
log(f" refining boundaries with MMS_FA on {resolved}")
|
|
103
|
+
|
|
104
|
+
adjusted = 0
|
|
105
|
+
failed = 0
|
|
106
|
+
total_shift = 0.0
|
|
107
|
+
for track_index, track in sorted(by_index.items()):
|
|
108
|
+
if track.wav_path is None:
|
|
109
|
+
continue
|
|
110
|
+
entries = sorted(
|
|
111
|
+
(entry for entry in result.timed if entry.track_index == track_index),
|
|
112
|
+
key=lambda entry: entry.start,
|
|
113
|
+
)
|
|
114
|
+
if not entries:
|
|
115
|
+
continue
|
|
116
|
+
for chunk in _chunks(entries):
|
|
117
|
+
words, spans = _transcript(chunk, vocabulary)
|
|
118
|
+
if not words:
|
|
119
|
+
continue
|
|
120
|
+
begin = max(0.0, chunk[0].start - track.offset - PAD_SECONDS)
|
|
121
|
+
finish = min(track.duration, chunk[-1].end - track.offset + PAD_SECONDS)
|
|
122
|
+
if finish - begin < 0.05:
|
|
123
|
+
continue
|
|
124
|
+
samples = read_wav_segment(track.wav_path, begin, finish)
|
|
125
|
+
if samples.size < SAMPLE_RATE // 10:
|
|
126
|
+
continue
|
|
127
|
+
waveform = torch.from_numpy(samples).unsqueeze(0).to(resolved)
|
|
128
|
+
try:
|
|
129
|
+
with torch.inference_mode():
|
|
130
|
+
emission, _ = model(waveform)
|
|
131
|
+
token_spans = aligner(emission[0], tokenizer(words))
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
failed += 1
|
|
134
|
+
if failed == 1:
|
|
135
|
+
log(f" warning: the refiner failed on one chunk and was skipped: {exc}")
|
|
136
|
+
continue
|
|
137
|
+
ratio = waveform.size(1) / emission.size(1)
|
|
138
|
+
origin = begin + track.offset
|
|
139
|
+
starts = [origin + span[0].start * ratio / SAMPLE_RATE for span in token_spans]
|
|
140
|
+
ends = [origin + span[-1].end * ratio / SAMPLE_RATE for span in token_spans]
|
|
141
|
+
for entry, (first, last) in zip(chunk, spans, strict=True):
|
|
142
|
+
if last <= first or last > len(starts):
|
|
143
|
+
continue
|
|
144
|
+
new_start, new_end = starts[first], ends[last - 1]
|
|
145
|
+
if new_end <= new_start:
|
|
146
|
+
continue
|
|
147
|
+
if abs(new_start - entry.start) > MAX_SHIFT or abs(new_end - entry.end) > MAX_SHIFT:
|
|
148
|
+
continue
|
|
149
|
+
total_shift += abs(new_start - entry.start)
|
|
150
|
+
entry.start, entry.end = new_start, new_end
|
|
151
|
+
entry.interpolated = False
|
|
152
|
+
adjusted += 1
|
|
153
|
+
|
|
154
|
+
_repair_order(result.timed, by_index)
|
|
155
|
+
skipped = f", {failed} chunk(s) skipped" if failed else ""
|
|
156
|
+
log(f" refined {adjusted} sentence boundaries{skipped}")
|
|
157
|
+
return {
|
|
158
|
+
"device": resolved,
|
|
159
|
+
"adjusted": adjusted,
|
|
160
|
+
"failed_chunks": failed,
|
|
161
|
+
"mean_shift": round(total_shift / adjusted, 3) if adjusted else 0.0,
|
|
162
|
+
"licence": LICENCE_NOTE,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _repair_order(timed: list[TimedSentence], tracks: dict[int, AudioTrack]) -> None:
|
|
167
|
+
"""Keep the sequence monotonic and inside each track after boundaries moved."""
|
|
168
|
+
previous: float | None = None
|
|
169
|
+
for entry in timed:
|
|
170
|
+
track = tracks[entry.track_index]
|
|
171
|
+
entry.start = min(max(entry.start, track.offset), track.end)
|
|
172
|
+
entry.end = min(max(entry.end, entry.start + 0.05), track.end)
|
|
173
|
+
if previous is not None and entry.start < previous:
|
|
174
|
+
entry.start = min(previous, entry.end - 0.05)
|
|
175
|
+
previous = entry.end
|