Files
UmbraAudio/umbraaudio/assembly/ar_export.py
2026-10-18 19:01:04 -07:00

684 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import json
import logging
import re
import subprocess
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
import soundfile as sf
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
SAMPLE_RATE = 24000
TARGET_SAMPLE_RATE = 44100
BIT_RATE = "192k"
SILENCE_MS = 2000
MAX_TRACK_SEC = 119 * 60
MAX_FILE_BYTES = 170 * 1024 * 1024
RMS_LO_DB = -23.0
RMS_HI_DB = -18.0
PEAK_MAX_DB = -3.0
NORM_TARGET_RMS_DB = -20.5
NORM_MAX_GAIN_DB = 12.0
PEAK_LIMIT_LINEAR = float(10 ** (PEAK_MAX_DB / 20))
# libmp3lame overshoots the float peak ceiling by roughly 1 dB, so the ENCODED
# file must be re-measured and, if it still breaches, re-encoded with the excess
# trimmed. Two passes is normally enough; the cap stops a pathological track
# from looping forever.
PEAK_ENFORCE_PASSES = 3
SAMPLE_MARKER_TEXT = "Welcome to the audio book"
STORY_START_RE = re.compile(r"\bchapter\s+(one|1)\b", re.IGNORECASE)
@dataclass
class ARChapter:
"""One manuscript chapter and how it participates in the AR package."""
chapter_id: int
chapter_index: int
title: str
first_utterance: str = ""
audio_path: Path | None = None
role: str = "chapter"
number: str = ""
@property
def display_title(self) -> str:
return (self.title or f"Chapter {self.chapter_id}").strip()
@dataclass
class ARTrack:
seq: int
role: str
title: str
filename: str
source: str
duration_s: float
size_bytes: int
number: str = ""
rms_db: float | None = None
peak_db: float | None = None
warning: str = ""
loudness_ok: bool = True
peak_ok: bool = True
segments: list[tuple[str, Any]] = field(default_factory=list)
@property
def display_title(self) -> str:
prefix = self.number
return f"{prefix} {self.title}".strip() if prefix else self.title
def as_dict(self) -> dict[str, Any]:
return {
"seq": self.seq,
"role": self.role,
"number": self.number,
"title": self.display_title,
"name": self.title,
"file": self.filename,
"source": self.source,
"duration_s": round(self.duration_s, 2),
"size_bytes": self.size_bytes,
"rms_db": self.rms_db,
"peak_db": self.peak_db,
"warning": self.warning,
"loudness_ok": self.loudness_ok,
"peak_ok": self.peak_ok,
}
class ARPackageExporter:
"""Build the Author's Republic MP3 submission package.
Every track maps 1:1 to a manuscript chapter that the author edits:
the sample chapter ("Sample Track"), each story chapter, and the final
chapter ("Closing Track"). No audio is synthesized — all content comes
from the project's ``chapter_audio`` files. Every file is forced to mono,
44.1 kHz, 192 kbit CBR MP3 with 2 seconds of silence at both ends. Each
track is loudness-normalized to a −20.5 dB RMS target with a hard −3 dBFS
peak ceiling so the package meets AR's loudness gates; a JSON manifest of
per-track statistics (duration, size, loudness, peak, AR compliance flags)
is written beside the files.
"""
def __init__(self, data_dir: Path, ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe") -> None:
self.data_dir = Path(data_dir)
self.ffmpeg = ffmpeg
self.ffprobe = ffprobe
self.package_dir = self.export_dir / "ar_package"
self.work_dir = self.export_dir / "ar_work"
@property
def export_dir(self) -> Path:
return self.data_dir / "export"
@property
def chapter_audio_dir(self) -> Path:
return self.data_dir / "chapter_audio"
# ------------------------------------------------------------------
# Public build entry point
# ------------------------------------------------------------------
def build(
self,
chapters: list[ARChapter],
meta: dict[str, str],
force: bool = False,
progress_cb: Callable[[int, str], None] | None = None,
) -> dict[str, Any]:
"""Assemble the full AR package and return the manifest.
``chapters`` must be in final book order with roles already assigned.
``meta`` is the publishing metadata (title, author, narrator,
publisher, output_name) used for file naming.
``progress_cb`` (if given) is called with ``(percent, detail)``.
"""
self.package_dir.mkdir(parents=True, exist_ok=True)
self.work_dir.mkdir(parents=True, exist_ok=True)
def _progress(percent: int, detail: str) -> None:
if progress_cb is not None:
progress_cb(percent, detail)
ordered = [c for c in chapters if c.audio_path is not None]
self._assign_numbers(ordered)
roles = {c.role: c for c in ordered}
base = self._base_name(meta)
tracks: list[ARTrack] = []
seq = 0
# sample, then opening, then the story, and the closing credit LAST.
# Emitting "closing" in this loop placed the end-of-book credit at
# position 3, ahead of the story it was meant to conclude.
for role in ("sample", "opening"):
special = roles.get(role)
if special is not None:
track = self._make_track(
seq=seq + 1, role=role, number=special.number,
title=special.display_title,
segments=[("wav", special.audio_path)],
source=str(special.audio_path.name),
)
tracks.append(track)
seq += 1
for ch in ordered:
if ch.role != "chapter":
continue
track = self._make_track(
seq=seq + 1, role="chapter", number=ch.number,
title=ch.display_title,
segments=[("wav", ch.audio_path)],
source=str(ch.audio_path.name),
)
tracks.append(track)
seq += 1
closing = roles.get("closing")
if closing is not None:
tracks.append(
self._make_track(
seq=seq + 1, role="closing", number=closing.number,
title=closing.display_title,
segments=[("wav", closing.audio_path)],
source=str(closing.audio_path.name),
)
)
seq += 1
filenames = self._filename_plan(base, tracks)
for i, (track, fname) in enumerate(zip(tracks, filenames, strict=True)):
track.filename = fname
out = self.package_dir / fname
self._encode_track(track, out)
_progress(
10 + int((i + 1) / len(tracks) * 90),
f"Encoding track {i + 1}/{len(tracks)} ({fname})...",
)
manifest = self._manifest(meta, base, tracks)
manifest_path = self.export_dir / "ar_package.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
logger.info("AR package built: %s (%d tracks)", self.package_dir, len(tracks))
_progress(100, f"AR package built: {len(tracks)} tracks")
return manifest
# ------------------------------------------------------------------
# Chapter numbering
# ------------------------------------------------------------------
def _assign_numbers(self, chapters: list[ARChapter]) -> None:
"""Set per-chapter display numbers.
- sample / unnumbered front matter -> no number
- opening -> ``i``
- closing -> ``ii``
- story chapters -> 01, 02, ... in manuscript order, starting at the
first chapter that self-announces as ``Chapter one``/``Chapter 1``.
"""
story = 0
started = False
for ch in chapters:
if ch.role == "sample":
ch.number = ""
elif ch.role == "opening":
ch.number = "i"
elif ch.role == "closing":
ch.number = "ii"
elif ch.role == "chapter":
if not started and STORY_START_RE.search(ch.first_utterance or ""):
started = True
if started:
story += 1
ch.number = str(story)
else:
ch.number = ""
# ------------------------------------------------------------------
# Text / naming
# ------------------------------------------------------------------
def _base_name(self, meta: dict[str, str]) -> str:
raw = meta.get("output_name") or meta.get("title") or "audiobook"
stem = Path(raw).stem
stem = re.sub(r"[^\w\s.-]", "", stem).strip()
stem = re.sub(r"\s+", "_", stem)
return stem or "audiobook"
def _filename_plan(self, base: str, tracks: list[ARTrack]) -> list[str]:
plan = []
for track in tracks:
title = re.sub(r"[^\w\s-]", "", track.title).strip()
title = re.sub(r"\s+", "_", title) or "Track"
number = track.number
prefix = number.zfill(2) if number.isdigit() else number
part = f"{prefix}_" if prefix else ""
plan.append(f"{base}_{part}{title}.mp3")
return plan
# ------------------------------------------------------------------
# Track assembly, encoding, measurement
# ------------------------------------------------------------------
def _make_track(
self,
seq: int,
role: str,
title: str,
segments: list[tuple[str, Any]],
source: str = "",
number: str = "",
) -> ARTrack:
combined = self._concatenate(segments)
rms, peak = self._measure_loudness(combined)
duration_s = len(combined) / SAMPLE_RATE + 2 * SILENCE_MS / 1000
warning = (
f"track exceeds 119 minutes ({duration_s / 60:.1f} min)"
if duration_s > MAX_TRACK_SEC
else ""
)
return ARTrack(
seq=seq,
role=role,
number=number,
title=title,
filename="",
source=source,
duration_s=duration_s,
size_bytes=0,
rms_db=rms,
peak_db=peak,
warning=warning,
segments=segments,
)
def _encode_track(self, track: ARTrack, out: Path) -> None:
content = self._concatenate(track.segments)
content = self._normalize(content, track.rms_db)
rms, peak, passes = self._encode_and_enforce_peak(content, out)
if rms is not None:
track.rms_db, track.peak_db = rms, peak
track.warning = self._loudness_warning(rms, peak, track.warning)
track.loudness_ok = RMS_LO_DB <= rms <= RMS_HI_DB
track.peak_ok = peak is not None and peak <= PEAK_MAX_DB
if passes > 1:
track.warning = "; ".join(
w for w in (track.warning, f"peak enforced over {passes} encodes") if w
)
track.size_bytes = out.stat().st_size
def encode_chapter_mp3(self, wav_path: Path, out: Path) -> dict[str, Any]:
"""Encode one chapter WAV into an AR-compliant MP3.
Mirrors :meth:`_encode_track` exactly (mono, 44.1 kHz, 192 kbit CBR
MP3, loudness-normalized to the −20.5 dB RMS target with the ±12 dB
gain limit and −3 dBFS peak ceiling, 2 s silence at both ends) so a
per-chapter ``Build Chapter`` result and the assembled AR package
produce identical audio. Returns per-file stats (duration, size,
loudness, peaks, AR compliance flags) for the caller.
"""
self.work_dir.mkdir(parents=True, exist_ok=True)
content, sr = sf.read(str(wav_path), dtype="float32")
if content.ndim > 1:
content = content.mean(axis=1)
if sr != SAMPLE_RATE:
content = self._resample_chunk(wav_path, sr)
content = np.asarray(content, dtype=np.float32)
src_rms, _ = self._measure_loudness(content)
content = self._normalize(content, src_rms)
rms, peak, passes = self._encode_and_enforce_peak(content, out)
duration_s = round(len(content) / SAMPLE_RATE, 2) + 2 * SILENCE_MS / 1000
warning = self._loudness_warning(rms, peak)
bits = [warning] if warning else []
if duration_s > MAX_TRACK_SEC:
bits.append(f"{duration_s:.0f}s exceeds {MAX_TRACK_SEC // 60} min")
if passes > 1:
bits.append(f"peak enforced over {passes} encodes")
size_bytes = out.stat().st_size
if size_bytes > MAX_FILE_BYTES:
bits.append(f"{size_bytes / (1024 * 1024):.0f} MB exceeds 170 MB")
return {
"duration_s": duration_s,
"size_bytes": size_bytes,
"rms_db": rms,
"peak_db": peak,
"warning": "; ".join(bits),
"loudness_ok": rms is not None and RMS_LO_DB <= rms <= RMS_HI_DB,
"peak_ok": peak is not None and peak <= PEAK_MAX_DB,
}
def _normalize(self, audio: np.ndarray, source_rms: float | None) -> np.ndarray:
audio = np.asarray(audio, dtype=np.float32)
if source_rms is None:
return audio
gain_db = min(max(NORM_TARGET_RMS_DB - source_rms, -NORM_MAX_GAIN_DB), NORM_MAX_GAIN_DB)
scaled = audio * float(10 ** (gain_db / 20))
np.clip(scaled, -PEAK_LIMIT_LINEAR, PEAK_LIMIT_LINEAR, out=scaled)
return scaled
def _measure_file(
self, path: Path, content_s: float | None = None
) -> tuple[float | None, float | None]:
"""Measure RMS and peak of an ENCODED file, not of the source buffer.
``content_s`` excludes the head/tail room tone from the RMS measurement.
Every distributor defines RMS over "the entire program (not including
room tone)", and the padding's share varies enormously: it is worth
0.03 dB on a 30-minute chapter but ~7 dB on a 1-second one, which is
what made synthetic fixtures read low. Peak is unaffected either way
because padding is silence and cannot raise a peak, so the same trimmed
pass serves both figures.
The gate must describe the bytes the listener receives. Measuring the
pre-encode float array certified our intent rather than the artifact:
libmp3lame overshoots the ceiling, so all 32 tracks passed at exactly
-3.0 dB while the shipped MP3s sat near -2.0 dB.
"""
head_s = SILENCE_MS / 1000.0
chain = "volumedetect"
if content_s is not None and content_s > 0:
chain = f"atrim=start={head_s}:end={head_s + content_s:.4f},volumedetect"
try:
result = subprocess.run(
[self.ffmpeg, "-i", str(path), "-af", chain, "-f", "null", "-"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError:
return None, None
stderr = result.stderr or ""
rms = peak = None
m = re.search(r"mean_volume:\s*([-\d.]+) dB", stderr)
if m:
rms = float(m.group(1))
m = re.search(r"max_volume:\s*([-\d.]+) dB", stderr)
if m:
peak = float(m.group(1))
return rms, peak
def _encode_and_enforce_peak(
self, content: np.ndarray, out: Path
) -> tuple[float | None, float | None, int]:
"""Encode to ``out``, trimming gain until the FILE meets the peak ceiling.
Both figures are read from the **encoded MP3 after any trim**, so the
manifest always describes the delivered bytes.
Peak must come from the encoded file: libmp3lame overshoots the float
ceiling, so only the delivered file reveals the breach.
RMS from the encoded file is still the whole-program measure without
room tone: the 2 s head and tail padding is 4 s out of hundreds, worth
under 0.1 dB (measured 0.03 dB). Measuring the padded WAV instead was
far worse - it read ~9 dB low on a quiet title, because that buffer was
pre-normalisation and still full of padding.
Re-measuring after each trim is what makes the gate honest. Measuring
RMS once up front recorded a PRE-trim value, so a track that needed peak
correction was reported ~3 dB louder than it is: ch15 took a -2.9 dB
trim yet the manifest still said -20.6 dB while the file measured
-23.8 dB, and the gate called it compliant.
"""
work = content
passes = 0
rms = peak = None
content_s = len(content) / SAMPLE_RATE
for attempt in range(PEAK_ENFORCE_PASSES):
passes = attempt + 1
wav = self.work_dir / f"enforce_{out.stem}_{attempt}.wav"
sf.write(str(wav), self._pad_silence(work), SAMPLE_RATE)
try:
self._encode_wav(wav, out)
finally:
wav.unlink(missing_ok=True)
rms, peak = self._measure_file(out, content_s=content_s)
if peak is None or peak <= PEAK_MAX_DB:
break
trim_db = PEAK_MAX_DB - peak
logger.warning(
"Encoded peak %.2f dB breaches %.1f dB for %s; re-encoding with %.2f dB trim",
peak, PEAK_MAX_DB, out.name, trim_db,
)
work = work * float(10 ** (trim_db / 20))
return rms, peak, passes
def _loudness_warning(
self, rms: float | None, peak: float | None, base: str = ""
) -> str:
bits = [base] if base else []
if rms is not None and not (RMS_LO_DB <= rms <= RMS_HI_DB):
bits.append(f"RMS {rms:.1f} dB outside [{RMS_LO_DB}, {RMS_HI_DB}] dB")
if peak is not None and peak > PEAK_MAX_DB:
bits.append(f"peak {peak:.1f} dB above {PEAK_MAX_DB} dB")
return "; ".join(bits)
def _concatenate(self, segments: list[tuple[str, Any]]) -> np.ndarray:
chunks: list[np.ndarray] = []
for kind, value in segments:
if kind == "gap":
ms = int(value) # type: ignore[arg-type]
chunks.append(np.zeros(int(SAMPLE_RATE * ms / 1000), dtype=np.float32))
elif kind == "wav":
audio, sr = sf.read(str(value), dtype="float32")
if audio.ndim > 1:
audio = audio.mean(axis=1)
if sr != SAMPLE_RATE:
chunks.append(self._resample_chunk(value, sr))
else:
chunks.append(audio.astype(np.float32))
if not chunks:
return np.zeros(1, dtype=np.float32)
return np.concatenate(chunks)
def _resample_chunk(self, path: Path, sr: int) -> np.ndarray:
tmp = self.work_dir / f"resample_{path.stem}_{sr}.wav"
subprocess.run(
[self.ffmpeg, "-y", "-i", str(path), "-ar", str(SAMPLE_RATE), str(tmp)],
capture_output=True,
check=True,
)
audio, _ = sf.read(str(tmp), dtype="float32")
tmp.unlink(missing_ok=True)
return audio.astype(np.float32)
def _pad_silence(self, audio: np.ndarray) -> np.ndarray:
pad = np.zeros(int(SAMPLE_RATE * SILENCE_MS / 1000), dtype=np.float32)
return np.concatenate([pad, audio, pad])
def _measure_loudness(self, audio: np.ndarray) -> tuple[float | None, float | None]:
tmp = self.work_dir / "loudness_probe.wav"
sf.write(str(tmp), audio, SAMPLE_RATE)
try:
result = subprocess.run(
[self.ffmpeg, "-i", str(tmp), "-af", "volumedetect", "-f", "null", "-"],
capture_output=True, text=True, check=True,
)
except subprocess.CalledProcessError:
tmp.unlink(missing_ok=True)
return None, None
tmp.unlink(missing_ok=True)
stderr = result.stderr or ""
rms = peak = None
m = re.search(r"mean_volume:\s*([-\d.]+) dB", stderr)
if m:
rms = float(m.group(1))
m = re.search(r"max_volume:\s*([-\d.]+) dB", stderr)
if m:
peak = float(m.group(1))
return rms, peak
def _encode_wav(self, wav_path: Path, out: Path) -> None:
result = subprocess.run(
[
self.ffmpeg, "-y",
"-i", str(wav_path),
"-ar", str(TARGET_SAMPLE_RATE),
"-ac", "1",
"-c:a", "libmp3lame",
"-b:a", BIT_RATE,
str(out),
],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(f"MP3 encoding failed: {result.stderr[-500:]}")
# ------------------------------------------------------------------
# Manifest
# ------------------------------------------------------------------
def _manifest(self, meta: dict[str, str], base: str, tracks: list[ARTrack]) -> dict[str, Any]:
return {
"book": {
"title": meta.get("title", ""),
"author": meta.get("author", ""),
"narrator": meta.get("narrator", ""),
"publisher": meta.get("publisher", ""),
"output_name": meta.get("output_name", ""),
},
"base_name": base,
"built_at": datetime.now(UTC).isoformat(timespec="seconds"),
"format": {
"codec": "mp3",
"sample_rate": TARGET_SAMPLE_RATE,
"channels": 1,
"bit_rate_kbps": 192,
"cbr": True,
"silence_s": SILENCE_MS / 1000,
},
"track_count": len(tracks),
"total_duration_s": round(sum(t.duration_s for t in tracks), 2),
"tracks": [t.as_dict() for t in tracks],
"warnings": [t.warning for t in tracks if t.warning],
# Declared, deliberate departures from the spec. A track that is
# peak-compliant but still under the RMS floor cannot be fixed with
# gain (it sits exactly at the peak ceiling, so there is no
# headroom) because its crest factor makes RMS -20.5 and peak -3.0
# mutually exclusive. Listed here so an exception is a recorded
# decision rather than something a reviewer has to rediscover from a
# warning string.
"accepted_deviations": [
{
"track": t.title,
"file": t.filename,
"rule": f"RMS floor {RMS_LO_DB} dB",
"measured_rms_db": t.rms_db,
"measured_peak_db": t.peak_db,
"crest_db": (
round(t.peak_db - t.rms_db, 2)
if t.rms_db is not None and t.peak_db is not None
else None
),
"reason": (
"Crest factor makes the RMS window and the peak ceiling "
"mutually exclusive at this gain. Left as-is: a limiter "
"would meet the number by squashing transients."
),
}
for t in tracks
if not t.loudness_ok and t.peak_ok
],
}
def discover_chapters(data_dir: Path) -> list[ARChapter]:
"""Read the project DB + ``chapter_audio`` and assign AR package roles.
Role rules (transparent, per-project):
- **sample**: chapter whose first utterance opens with the book's promo
intro (``Welcome to the audio book``);
- **opening**: chapter titled ``Opening Track`` (intro, numbered ``i``);
- **closing**: the last chapter that has audio (final "Closing Track");
- **skip**: chapters with no utterances or no audio file — no track kept;
- **chapter**: everything else, in manuscript order. Display numbers are
assigned at build time by :meth:`ARPackageExporter._assign_numbers`.
"""
import sqlite3
db = sqlite3.connect(str(data_dir / "project.db"))
db.row_factory = sqlite3.Row
totals: dict[int, int] = {}
for r in db.execute("SELECT chapter_id, COUNT(*) AS n FROM utterances GROUP BY chapter_id"):
totals[r["chapter_id"]] = r["n"]
first: dict[int, str] = {}
for r in db.execute(
"SELECT chapter_id, utterance_index, text FROM utterances "
"ORDER BY chapter_id, utterance_index"
):
if r["chapter_id"] not in first:
first[r["chapter_id"]] = (r["text"] or "").strip()
rows = db.execute(
"SELECT id, chapter_index, title FROM chapters ORDER BY chapter_index"
).fetchall()
db.close()
audio_dir = data_dir / "chapter_audio"
chapters: list[ARChapter] = []
for r in rows:
cid = r["id"]
if totals.get(cid, 0) == 0:
continue
title = (r["title"] or "").strip() or f"Chapter {r['chapter_index']}"
first_text = first.get(cid, "")
audio_path = audio_dir / f"ch{cid}_full.wav"
role = "chapter"
if first_text.lower().startswith(SAMPLE_MARKER_TEXT.lower()):
role = "sample"
elif title.lower().startswith("opening"):
role = "opening"
chapters.append(
ARChapter(
chapter_id=cid,
chapter_index=r["chapter_index"],
title=title,
first_utterance=first_text,
audio_path=audio_path if audio_path.exists() else None,
role=role,
)
)
last_with_audio = next(
(c for c in reversed(chapters) if c.audio_path is not None), None
)
if last_with_audio is not None:
last_with_audio.role = "closing"
return chapters
def export_chapter_mp3(data_dir: Path, wav_path: Path, out: Path) -> dict[str, Any]:
"""Encode ``wav_path`` into an AR-compliant MP3 at ``out``.
Convenience wrapper used by the per-chapter ``Build Chapter`` pipeline and
the web per-chapter MP3 download so both bake the exact AR normalization
(44.1 kHz mono, 192 kbit CBR, −20.5 dB RMS target, −3 dBFS peak ceiling,
2 s edge silence) into every delivered file.
"""
return ARPackageExporter(data_dir).encode_chapter_mp3(wav_path, out)