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