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/audio.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Enumerate the narration, measure it with ffprobe, and prepare what actually ships.
|
|
2
|
+
|
|
3
|
+
Original files are never modified. Everything that goes into the EPUB is either a stream copy or
|
|
4
|
+
a transcode written into the work directory, and every timestamp readalign produces is measured
|
|
5
|
+
against those prepared files, so the clock values always describe the audio the reader plays.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import wave
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .errors import DependencyError, DRMError, InputError
|
|
21
|
+
|
|
22
|
+
AUDIO_EXTENSIONS = {
|
|
23
|
+
".mp3", ".m4a", ".m4b", ".mp4", ".flac", ".ogg", ".oga", ".opus", ".wav", ".aiff", ".aif",
|
|
24
|
+
".wma", ".aac",
|
|
25
|
+
}
|
|
26
|
+
DRM_EXTENSIONS = {".aax", ".aa", ".acsm", ".aaxc"}
|
|
27
|
+
|
|
28
|
+
#: Codec tags Apple FairPlay uses; ffprobe reports these for a protected track.
|
|
29
|
+
_DRM_CODEC_TAGS = {"drmi", "drms", "enca", "encv"}
|
|
30
|
+
|
|
31
|
+
SAMPLE_RATE = 16000
|
|
32
|
+
|
|
33
|
+
_SILENCE_RE = re.compile(r"silence_(start|end): (-?[\d.]+)")
|
|
34
|
+
_NUMBER_RE = re.compile(r"(\d+)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Chapter:
|
|
39
|
+
start: float
|
|
40
|
+
end: float
|
|
41
|
+
title: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AudioTrack:
|
|
46
|
+
"""One prepared audio file, plus where it sits in the book's overall timeline."""
|
|
47
|
+
|
|
48
|
+
index: int
|
|
49
|
+
source: str
|
|
50
|
+
path: str
|
|
51
|
+
href: str
|
|
52
|
+
media_type: str
|
|
53
|
+
duration: float
|
|
54
|
+
offset: float
|
|
55
|
+
chapters: list[Chapter] = field(default_factory=list)
|
|
56
|
+
transcoded: bool = False
|
|
57
|
+
wav_path: str | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def end(self) -> float:
|
|
61
|
+
return self.offset + self.duration
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def natural_key(name: str) -> tuple:
|
|
65
|
+
"""Sort ``track2`` before ``track10``, the way a person names audiobook parts."""
|
|
66
|
+
parts = _NUMBER_RE.split(os.path.basename(name).casefold())
|
|
67
|
+
return tuple(int(part) if part.isdigit() else part for part in parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def require_ffmpeg() -> None:
|
|
71
|
+
missing = [tool for tool in ("ffmpeg", "ffprobe") if shutil.which(tool) is None]
|
|
72
|
+
if missing:
|
|
73
|
+
raise DependencyError(
|
|
74
|
+
f"{' and '.join(missing)} not found on PATH. readalign needs ffmpeg installed: "
|
|
75
|
+
"https://ffmpeg.org/download.html"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run(command: list[str], what: str) -> subprocess.CompletedProcess:
|
|
80
|
+
try:
|
|
81
|
+
return subprocess.run(command, capture_output=True, text=True, encoding="utf-8",
|
|
82
|
+
errors="replace", check=False)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
raise DependencyError(f"could not run {command[0]} ({what}): {exc}") from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ffprobe(path: str) -> dict:
|
|
88
|
+
"""Probe a media file, returning the parsed ffprobe JSON."""
|
|
89
|
+
result = _run(
|
|
90
|
+
[
|
|
91
|
+
"ffprobe", "-v", "error", "-print_format", "json",
|
|
92
|
+
"-show_format", "-show_streams", "-show_chapters", path,
|
|
93
|
+
],
|
|
94
|
+
f"probing {path}",
|
|
95
|
+
)
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
detail = (result.stderr or "").strip().splitlines()
|
|
98
|
+
message = detail[-1] if detail else f"exit code {result.returncode}"
|
|
99
|
+
raise InputError(f"ffprobe could not read {path}: {message}")
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(result.stdout)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise InputError(f"ffprobe returned unreadable output for {path}: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _audio_stream(probe: dict, path: str) -> dict:
|
|
107
|
+
streams = [s for s in probe.get("streams", []) if s.get("codec_type") == "audio"]
|
|
108
|
+
if not streams:
|
|
109
|
+
raise InputError(f"{path} contains no audio stream")
|
|
110
|
+
return streams[0]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _duration_of(probe: dict, path: str) -> float:
|
|
114
|
+
for source in (probe.get("format", {}), _audio_stream(probe, path)):
|
|
115
|
+
value = source.get("duration")
|
|
116
|
+
if value:
|
|
117
|
+
try:
|
|
118
|
+
duration = float(value)
|
|
119
|
+
except ValueError:
|
|
120
|
+
continue
|
|
121
|
+
if duration > 0:
|
|
122
|
+
return duration
|
|
123
|
+
raise InputError(f"{path} has no readable duration; is it a complete audio file?")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _reject_drm(path: str, probe: dict | None = None) -> None:
|
|
127
|
+
if os.path.splitext(path)[1].lower() in DRM_EXTENSIONS:
|
|
128
|
+
raise DRMError("readalign does not handle DRM-protected files")
|
|
129
|
+
if probe is None:
|
|
130
|
+
return
|
|
131
|
+
for stream in probe.get("streams", []):
|
|
132
|
+
if (stream.get("codec_tag_string") or "").lower() in _DRM_CODEC_TAGS:
|
|
133
|
+
raise DRMError("readalign does not handle DRM-protected files")
|
|
134
|
+
raw_tags = probe.get("format", {}).get("tags") or {}
|
|
135
|
+
tags = {key.lower(): value for key, value in raw_tags.items()}
|
|
136
|
+
if any("drm" in key for key in tags):
|
|
137
|
+
raise DRMError("readalign does not handle DRM-protected files")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def discover_inputs(path: str) -> list[str]:
|
|
141
|
+
"""Return the audio files to use, in playback order."""
|
|
142
|
+
if not os.path.exists(path):
|
|
143
|
+
raise InputError(f"audio not found: {path}")
|
|
144
|
+
if os.path.isfile(path):
|
|
145
|
+
_reject_drm(path)
|
|
146
|
+
if os.path.splitext(path)[1].lower() not in AUDIO_EXTENSIONS:
|
|
147
|
+
raise InputError(
|
|
148
|
+
f"{path} does not look like an audio file "
|
|
149
|
+
f"(expected one of {', '.join(sorted(AUDIO_EXTENSIONS))})"
|
|
150
|
+
)
|
|
151
|
+
return [path]
|
|
152
|
+
entries = [
|
|
153
|
+
os.path.join(path, name)
|
|
154
|
+
for name in os.listdir(path)
|
|
155
|
+
if os.path.splitext(name)[1].lower() in AUDIO_EXTENSIONS
|
|
156
|
+
and os.path.isfile(os.path.join(path, name))
|
|
157
|
+
]
|
|
158
|
+
for entry in os.listdir(path):
|
|
159
|
+
if os.path.splitext(entry)[1].lower() in DRM_EXTENSIONS:
|
|
160
|
+
raise DRMError("readalign does not handle DRM-protected files")
|
|
161
|
+
if not entries:
|
|
162
|
+
wanted = ", ".join(sorted(AUDIO_EXTENSIONS))
|
|
163
|
+
raise InputError(f"no audio files in {path} (looked for {wanted})")
|
|
164
|
+
return sorted(entries, key=natural_key)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _chapters_from(probe: dict) -> list[Chapter]:
|
|
168
|
+
chapters: list[Chapter] = []
|
|
169
|
+
for entry in probe.get("chapters", []):
|
|
170
|
+
try:
|
|
171
|
+
start = float(entry.get("start_time", 0.0))
|
|
172
|
+
end = float(entry.get("end_time", 0.0))
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
continue
|
|
175
|
+
if end <= start:
|
|
176
|
+
continue
|
|
177
|
+
title = (entry.get("tags") or {}).get("title") or f"Chapter {len(chapters) + 1}"
|
|
178
|
+
chapters.append(Chapter(start=start, end=end, title=title))
|
|
179
|
+
return chapters
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _output_plan(path: str, probe: dict) -> tuple[str, str, bool]:
|
|
183
|
+
"""Pick the extension, media type and whether a transcode is needed for a source file."""
|
|
184
|
+
stream = _audio_stream(probe, path)
|
|
185
|
+
codec = (stream.get("codec_name") or "").lower()
|
|
186
|
+
names = (probe.get("format", {}).get("format_name") or "").split(",")
|
|
187
|
+
container = {name.strip() for name in names}
|
|
188
|
+
if codec == "mp3":
|
|
189
|
+
return ".mp3", "audio/mpeg", False
|
|
190
|
+
if codec == "aac" and container & {"mov", "mp4", "m4a", "3gp", "3g2", "mj2"}:
|
|
191
|
+
return ".m4a", "audio/mp4", False
|
|
192
|
+
if codec == "opus" and container & {"ogg"}:
|
|
193
|
+
return ".opus", "audio/ogg; codecs=opus", False
|
|
194
|
+
return ".m4a", "audio/mp4", True
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def prepare_tracks(sources: list[str], workdir: str, bitrate: str, log=print) -> list[AudioTrack]:
|
|
198
|
+
"""Copy or transcode every source into an EPUB core media type and measure the result.
|
|
199
|
+
|
|
200
|
+
MP3, AAC-in-MP4 and Opus-in-Ogg are core media types in EPUB 3, so they are stream-copied.
|
|
201
|
+
Anything else (FLAC, WAV, ALAC, WMA) is transcoded to AAC, because a reading system is not
|
|
202
|
+
required to play it.
|
|
203
|
+
"""
|
|
204
|
+
audio_dir = os.path.join(workdir, "audio")
|
|
205
|
+
os.makedirs(audio_dir, exist_ok=True)
|
|
206
|
+
tracks: list[AudioTrack] = []
|
|
207
|
+
offset = 0.0
|
|
208
|
+
for index, source in enumerate(sources, start=1):
|
|
209
|
+
probe = ffprobe(source)
|
|
210
|
+
_reject_drm(source, probe)
|
|
211
|
+
suffix, media_type, transcode = _output_plan(source, probe)
|
|
212
|
+
target = os.path.join(audio_dir, f"part{index:03d}{suffix}")
|
|
213
|
+
label = f"[{index}/{len(sources)}] {os.path.basename(source)}"
|
|
214
|
+
log(f" {label} -> {'transcoding to AAC' if transcode else 'stream copy'}")
|
|
215
|
+
command = ["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", source,
|
|
216
|
+
"-map", "0:a:0", "-map_chapters", "0", "-vn"]
|
|
217
|
+
command += ["-c:a", "aac", "-b:a", bitrate] if transcode else ["-c:a", "copy"]
|
|
218
|
+
if suffix == ".m4a":
|
|
219
|
+
command += ["-movflags", "+faststart"]
|
|
220
|
+
result = _run([*command, target], f"preparing {source}")
|
|
221
|
+
if result.returncode != 0 or not os.path.exists(target):
|
|
222
|
+
detail = (result.stderr or "").strip().splitlines()
|
|
223
|
+
raise InputError(
|
|
224
|
+
f"ffmpeg could not prepare {source}: {detail[-1] if detail else 'unknown error'}"
|
|
225
|
+
)
|
|
226
|
+
prepared = ffprobe(target)
|
|
227
|
+
duration = _duration_of(prepared, target)
|
|
228
|
+
tracks.append(
|
|
229
|
+
AudioTrack(
|
|
230
|
+
index=index,
|
|
231
|
+
source=source,
|
|
232
|
+
path=target,
|
|
233
|
+
href=f"audio/part{index:03d}{suffix}",
|
|
234
|
+
media_type=media_type,
|
|
235
|
+
duration=duration,
|
|
236
|
+
offset=offset,
|
|
237
|
+
chapters=_chapters_from(prepared) or _chapters_from(probe),
|
|
238
|
+
transcoded=transcode,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
offset += duration
|
|
242
|
+
return tracks
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def decode_wav(track: AudioTrack, workdir: str) -> str:
|
|
246
|
+
"""Decode a prepared track to 16 kHz mono PCM for the recogniser."""
|
|
247
|
+
wav_dir = os.path.join(workdir, "wav")
|
|
248
|
+
os.makedirs(wav_dir, exist_ok=True)
|
|
249
|
+
target = os.path.join(wav_dir, f"part{track.index:03d}.wav")
|
|
250
|
+
result = _run(
|
|
251
|
+
[
|
|
252
|
+
"ffmpeg", "-nostdin", "-v", "error", "-y", "-i", track.path,
|
|
253
|
+
"-ac", "1", "-ar", str(SAMPLE_RATE), "-c:a", "pcm_s16le", "-f", "wav", target,
|
|
254
|
+
],
|
|
255
|
+
f"decoding {track.path}",
|
|
256
|
+
)
|
|
257
|
+
if result.returncode != 0 or not os.path.exists(target):
|
|
258
|
+
detail = (result.stderr or "").strip().splitlines()
|
|
259
|
+
raise InputError(
|
|
260
|
+
f"ffmpeg could not decode {track.source}: {detail[-1] if detail else 'unknown error'}"
|
|
261
|
+
)
|
|
262
|
+
track.wav_path = target
|
|
263
|
+
return target
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def read_wav_segment(wav_path: str, start: float, end: float) -> np.ndarray:
|
|
267
|
+
"""Read ``[start, end)`` seconds of a 16 kHz mono WAV as float32 in -1..1."""
|
|
268
|
+
with wave.open(wav_path, "rb") as handle:
|
|
269
|
+
frames = handle.getnframes()
|
|
270
|
+
first = max(0, min(frames, int(start * SAMPLE_RATE)))
|
|
271
|
+
last = max(first, min(frames, int(end * SAMPLE_RATE)))
|
|
272
|
+
handle.setpos(first)
|
|
273
|
+
raw = handle.readframes(last - first)
|
|
274
|
+
return np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def detect_silences(
|
|
278
|
+
wav_path: str, noise_db: int = -32, min_duration: float = 0.35
|
|
279
|
+
) -> list[tuple[float, float]]:
|
|
280
|
+
"""Find silent stretches, used to cut long files where no word is being spoken."""
|
|
281
|
+
result = _run(
|
|
282
|
+
[
|
|
283
|
+
"ffmpeg", "-nostdin", "-v", "info", "-i", wav_path,
|
|
284
|
+
"-af", f"silencedetect=noise={noise_db}dB:d={min_duration}", "-f", "null", "-",
|
|
285
|
+
],
|
|
286
|
+
f"scanning {wav_path} for silence",
|
|
287
|
+
)
|
|
288
|
+
silences: list[tuple[float, float]] = []
|
|
289
|
+
start: float | None = None
|
|
290
|
+
for kind, value in _SILENCE_RE.findall(result.stderr or ""):
|
|
291
|
+
moment = float(value)
|
|
292
|
+
if kind == "start":
|
|
293
|
+
start = moment
|
|
294
|
+
elif start is not None:
|
|
295
|
+
silences.append((start, moment))
|
|
296
|
+
start = None
|
|
297
|
+
return silences
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def plan_windows(track: AudioTrack, max_seconds: float) -> list[tuple[float, float]]:
|
|
301
|
+
"""Split a track into recogniser-sized windows that never cut through speech.
|
|
302
|
+
|
|
303
|
+
Chapter marks are used when the file has them; otherwise the cut lands in the middle of the
|
|
304
|
+
longest silence near the target boundary. This keeps peak memory flat for a 20-hour m4b.
|
|
305
|
+
"""
|
|
306
|
+
duration = track.duration
|
|
307
|
+
if duration <= max_seconds:
|
|
308
|
+
return [(0.0, duration)]
|
|
309
|
+
|
|
310
|
+
boundaries: list[float] = []
|
|
311
|
+
if track.chapters:
|
|
312
|
+
for chapter in track.chapters:
|
|
313
|
+
if 0.0 < chapter.start < duration:
|
|
314
|
+
boundaries.append(chapter.start)
|
|
315
|
+
if not _covers(boundaries, duration, max_seconds):
|
|
316
|
+
silences = detect_silences(track.wav_path or track.path)
|
|
317
|
+
boundaries = _merge_boundaries(boundaries, silences, duration, max_seconds)
|
|
318
|
+
|
|
319
|
+
windows: list[tuple[float, float]] = []
|
|
320
|
+
cursor = 0.0
|
|
321
|
+
for boundary in [*sorted(boundaries), duration]:
|
|
322
|
+
if boundary - cursor <= 0.01:
|
|
323
|
+
continue
|
|
324
|
+
windows.append((cursor, boundary))
|
|
325
|
+
cursor = boundary
|
|
326
|
+
return windows or [(0.0, duration)]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _covers(boundaries: list[float], duration: float, max_seconds: float) -> bool:
|
|
330
|
+
cursor = 0.0
|
|
331
|
+
for boundary in [*sorted(boundaries), duration]:
|
|
332
|
+
if boundary - cursor > max_seconds:
|
|
333
|
+
return False
|
|
334
|
+
cursor = boundary
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _merge_boundaries(
|
|
339
|
+
boundaries: list[float],
|
|
340
|
+
silences: list[tuple[float, float]],
|
|
341
|
+
duration: float,
|
|
342
|
+
max_seconds: float,
|
|
343
|
+
) -> list[float]:
|
|
344
|
+
chosen = sorted(boundaries)
|
|
345
|
+
cursor = 0.0
|
|
346
|
+
result: list[float] = []
|
|
347
|
+
pending = [*chosen, duration]
|
|
348
|
+
for boundary in pending:
|
|
349
|
+
while boundary - cursor > max_seconds:
|
|
350
|
+
target = cursor + max_seconds
|
|
351
|
+
candidates = [
|
|
352
|
+
(end - start, (start + end) / 2)
|
|
353
|
+
for start, end in silences
|
|
354
|
+
if cursor + max_seconds * 0.5 < (start + end) / 2 < min(target + 120.0, boundary)
|
|
355
|
+
]
|
|
356
|
+
cut = max(candidates)[1] if candidates else target
|
|
357
|
+
result.append(cut)
|
|
358
|
+
cursor = cut
|
|
359
|
+
if boundary < duration:
|
|
360
|
+
result.append(boundary)
|
|
361
|
+
cursor = boundary
|
|
362
|
+
return result
|