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/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