126 lines
4.3 KiB
Python
126 lines
4.3 KiB
Python
# 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
|