# Copyright 2026 Umbra Tools (umbratools.com) # SPDX-License-Identifier: Apache-2.0 """The m4b must ship the same audio spec as the AR MP3 package. Previously the m4b path had no sample-rate flag, no channel layout, and no loudness handling at all, so it produced 24 kHz / ~96 kbps AAC while the MP3 masters were 44.1 kHz / 192 kbps CBR mono. Measured on a real build, the delivered m4b peaked at -2.0 dB and failed the -3 dB ceiling that Author's Republic and Spotify both require. """ from __future__ import annotations import shutil import subprocess from typing import TYPE_CHECKING import numpy as np import pytest import soundfile as sf if TYPE_CHECKING: from pathlib import Path from umbraaudio.assembly.ar_export import ( PEAK_MAX_DB, RMS_HI_DB, RMS_LO_DB, TARGET_SAMPLE_RATE, ) from umbraaudio.assembly.export import AudiobookExporter SR = 24000 needs_ffmpeg = pytest.mark.skipif( shutil.which("ffmpeg") is None, reason="ffmpeg required" ) def _chapter_wav(path: Path, seconds: float = 2.0, amp: float = 0.2) -> Path: sf.write(str(path), (amp * np.random.randn(int(SR * seconds))).astype(np.float32), SR) return path def _built_m4b(tmp_path: Path, chapters: int = 2) -> Path: """Concatenate real chapter WAVs and run the real m4b encode path.""" audio = tmp_path / "chapter_audio" audio.mkdir(parents=True, exist_ok=True) files = [_chapter_wav(audio / f"ch{i}_full.wav") for i in range(1, chapters + 1)] exporter = AudiobookExporter() concat = tmp_path / "concat.wav" exporter._concat_wavs_with_silence(files, concat, gap_ms=2000) out = tmp_path / "book.m4b" exporter._encode_aac_m4b(concat, out, work_dir=tmp_path) return out @needs_ffmpeg def test_m4b_is_44100_mono_not_the_tts_native_rate(tmp_path: Path) -> None: """The TTS renders at 24 kHz; the deliverable must not ship at that rate.""" m4b = _built_m4b(tmp_path) proc = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate,channels", "-of", "csv=p=0", str(m4b)], capture_output=True, text=True, check=True, ) rate, channels = proc.stdout.strip().split(",")[:2] assert int(rate) == TARGET_SAMPLE_RATE == 44100 assert int(channels) == 1 @needs_ffmpeg def test_m4b_meets_the_peak_ceiling(tmp_path: Path) -> None: """AAC overshoots the float ceiling, so the FILE is what gets enforced.""" m4b = _built_m4b(tmp_path) report = AudiobookExporter().m4b_loudness_report(m4b) assert report["peak_db"] is not None assert report["peak_db"] <= PEAK_MAX_DB, report assert report["peak_ok"] is True, report @needs_ffmpeg def test_m4b_rms_sits_in_the_distributor_band(tmp_path: Path) -> None: m4b = _built_m4b(tmp_path) report = AudiobookExporter().m4b_loudness_report(m4b) assert report["rms_db"] is not None assert RMS_LO_DB <= report["rms_db"] <= RMS_HI_DB, report assert report["loudness_ok"] is True, report @needs_ffmpeg def test_aac_overshoot_margin_is_applied_so_trims_converge() -> None: """Trimming by the exact excess oscillates; the margin guarantees progress. Measured overshoot across successive passes was +0.8, +0.9, +1.6 dB, so an exact-excess loop ran out of passes still breaching. """ from umbraaudio.assembly.export import AAC_ENFORCE_PASSES, AAC_OVERSHOOT_MARGIN_DB assert AAC_OVERSHOOT_MARGIN_DB > 0.0 assert AAC_ENFORCE_PASSES >= 3 def test_stitcher_gap_matches_the_approved_path() -> None: """Spec 22 gap 1: the m4b stitched chapters with different silence. AudioStitcher's defaults (500 / 4000) are what ChapterBuilder - the reviewed path - uses. The m4b used 0 between chapters, so the packaged book differed from the audio a reviewer approved. """ from umbraaudio.assembly import AudioStitcher defaults = AudioStitcher() assert defaults.silence_utt == 500 assert defaults.silence_chap == 4000 @needs_ffmpeg def test_concat_resamples_to_the_delivery_rate(tmp_path: Path) -> None: files = [_chapter_wav(tmp_path / "a.wav"), _chapter_wav(tmp_path / "b.wav")] exporter = AudiobookExporter() out = tmp_path / "concat.wav" exporter._concat_wavs_with_silence(files, out, gap_ms=100) info = sf.info(str(out)) assert info.samplerate == TARGET_SAMPLE_RATE