feat(longform): shared render core — loudness, metadata, cover art (PR 1/8) (#408)

First slice of the Stories+Audiobook convergence (spec:
docs/specs/2026-06-13-stories-audiobook-maturity.md). Both features will compile
to one server-side chapterized renderer; this lands the shared pure builders and
wires them behind Audiobook.

New `backend/services/longform_render.py` (all pure, unit-tested without
ffmpeg/torch):
- build_ffmetadata(chapters, global_meta) — FFMETADATA1 with an optional global
  tag block (title/author→artist/narrator→composer/year→date/genre/description→
  comment) + chapter table.
- build_loudnorm_filter(preset) — `-af loudnorm` for ACX (~-19 LUFS, -3 dBTP) or
  podcast (-16 LUFS); off/unknown → None. Opt-in, so default behavior stays
  platform-identical.
- validate_cover_image — jpg/png + 8 MB cap guard.
- build_render_cmd — generalizes the m4b mux: m4b|mp3, optional cover
  (attached_pic) + loudness, bitrate validated.
- build_concat_list — moved here.

`services/audiobook.py`: build_chapter_ffmetadata / build_m4b_cmd / build_concat_list
are now backward-compatible wrappers over the core (existing imports + tests
unchanged).

`POST /audiobook`: now accepts optional `format` (m4b|mp3), `loudness`,
`cover_path`, and `metadata` and passes them through — backend-complete; the UI
for these lands in PR 2.

Tests: tests/test_longform_render.py (28) + existing test_audiobook.py (11) green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-13 13:49:34 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent f86beb041c
commit e9481ef307
5 changed files with 579 additions and 56 deletions
+19 -6
View File
@@ -24,12 +24,14 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from services.audiobook import (
build_chapter_ffmetadata,
build_concat_list,
build_m4b_cmd,
parse_audiobook_script,
synthesize_chapter,
)
from services.longform_render import (
build_concat_list,
build_ffmetadata,
build_render_cmd,
)
router = APIRouter()
@@ -50,6 +52,12 @@ class AudiobookRequest(BaseModel):
text: str
default_voice: str | None = None # voice profile id; None = engine default
bitrate: str = "128k"
format: str = "m4b" # "m4b" | "mp3"
loudness: str | None = None # None/"off" | "acx" | "podcast" (opt-in)
cover_path: str | None = None # server-side path to a jpg/png cover
# Global tags embedded in the output: {title, author, narrator, year,
# genre, description}. Player-visible (Apple Books / Audible read these).
metadata: dict | None = None
def _resolve_voice(profile_id: str | None) -> dict:
@@ -201,14 +209,19 @@ async def audiobook_synthesize(req: AudiobookRequest):
yield _emit({"type": "assembling"})
meta_path = os.path.join(work, "chapters.ffmeta")
with open(meta_path, "w", encoding="utf-8") as f:
f.write(build_chapter_ffmetadata(chapters_meta))
f.write(build_ffmetadata(chapters_meta, global_meta=req.metadata))
concat_path = os.path.join(work, "concat.txt")
with open(concat_path, "w", encoding="utf-8") as f:
f.write(build_concat_list(chapter_files))
out_name = f"audiobook_{job_id}.m4b"
ext = "mp3" if (req.format or "").lower() == "mp3" else "m4b"
out_name = f"audiobook_{job_id}.{ext}"
out_path = os.path.join(OUTPUTS_DIR, out_name)
await run_ffmpeg(
build_m4b_cmd(ffmpeg, concat_path, meta_path, out_path, bitrate=req.bitrate),
build_render_cmd(
ffmpeg, concat_path, meta_path, out_path,
fmt=ext, bitrate=req.bitrate,
cover_path=req.cover_path, loudness=req.loudness,
),
job_id=job_id,
)
+19 -50
View File
@@ -40,7 +40,6 @@ _HEADING_RE = re.compile(r"^[ \t]*#[ \t]+(\S.*)$", re.MULTILINE)
# ``finditer`` (the source of the polynomial-time ReDoS). A voice name never
# contains a bracket; the value is stripped in code.
_VOICE_RE = re.compile(r"\[voice:([^\]\[]*)\]")
_BITRATE_RE = re.compile(r"^\d{2,3}k$")
@dataclass
@@ -178,44 +177,23 @@ def synthesize_chapter(
return audio, audio.shape[-1] / float(sample_rate)
def _escape_meta(value: str) -> str:
"""Escape an FFMETADATA value (``=``, ``;``, ``#``, ``\\``, newline)."""
return re.sub(r"([=;#\\\n])", r"\\\1", value or "")
# ── ffmpeg / metadata builders ──────────────────────────────────────────────
#
# These now live in the shared ``longform_render`` core (Stories + Audiobook
# converge on one mux). The thin wrappers below preserve the original
# audiobook-only call sites/signatures; new callers should use
# ``longform_render`` directly to reach global metadata, cover art, loudness,
# and mp3 output.
from services.longform_render import ( # noqa: E402
build_concat_list,
build_ffmetadata,
build_render_cmd,
)
def build_chapter_ffmetadata(chapters: list[tuple[str, int]]) -> str:
"""Build an FFMETADATA1 document with one ``[CHAPTER]`` per (title, ms).
``chapters`` is ordered ``(title, duration_ms)`` pairs; START/END are the
cumulative millisecond offsets ffmpeg writes into the m4b chapter table.
"""
lines = [";FFMETADATA1"]
start = 0
for title, dur_ms in chapters:
end = start + max(0, int(dur_ms))
lines += [
"[CHAPTER]",
"TIMEBASE=1/1000",
f"START={start}",
f"END={end}",
f"title={_escape_meta(title)}",
]
start = end
return "\n".join(lines) + "\n"
def build_concat_list(wav_paths: list[str]) -> str:
"""Build an ffmpeg concat-demuxer list for the chapter WAVs.
Each line is ``file '<path>'`` with single quotes escaped the ffmpeg way
(``'`` → ``'\\''``), so paths with spaces/quotes can't break the list or
inject arguments.
"""
lines = []
for p in wav_paths:
safe = str(p).replace("'", "'\\''")
lines.append(f"file '{safe}'")
return "\n".join(lines) + "\n"
"""Backward-compatible alias: chapters-only FFMETADATA (no global tags)."""
return build_ffmetadata(chapters)
def build_m4b_cmd(
@@ -226,17 +204,8 @@ def build_m4b_cmd(
*,
bitrate: str = "128k",
) -> list[str]:
"""Pure argv for muxing chapter WAVs + FFMETADATA into a faststart m4b.
Input 0 is the concat-demuxer list of chapter WAVs; input 1 is the
FFMETADATA file (``-map_metadata 1`` pulls the chapter table from it).
"""
if not _BITRATE_RE.match(bitrate or ""):
bitrate = "128k"
return [
ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(concat_list_path),
"-i", str(metadata_path), "-map_metadata", "1",
"-c:a", "aac", "-b:a", bitrate,
"-movflags", "+faststart", "-f", "mp4", str(out_path),
]
"""Backward-compatible alias: a chapterized faststart m4b, no cover/loudness."""
return build_render_cmd(
ffmpeg, concat_list_path, metadata_path, out_path,
fmt="m4b", bitrate=bitrate,
)
+201
View File
@@ -0,0 +1,201 @@
"""Shared long-form render core (Stories + Audiobook convergence).
Both the Audiobook tab and the Stories Editor produce the *same* artifact: a
chapter-marked audio file built from chapter WAVs. This module owns the pure,
engine-agnostic ffmpeg/metadata builders for that mux so neither feature has to
reimplement it:
* ``build_ffmetadata`` — FFMETADATA1 doc: an optional ``[global]`` tag block
(title / author / narrator / year / genre / description) followed by one
``[CHAPTER]`` per (title, duration_ms).
* ``build_concat_list`` — ffmpeg concat-demuxer list of chapter WAVs.
* ``build_loudnorm_filter`` — an ``-af loudnorm=…`` string for an ACX /
podcast loudness preset (off by default — opt-in, so the default-behavior
stays platform-identical).
* ``validate_cover_image`` — guard a cover path (type + size) before it
reaches ffmpeg.
* ``build_render_cmd`` — pure argv for the mux: chapter WAVs + FFMETADATA
(+ optional cover art, loudness filter), output as ``m4b`` or ``mp3``.
Every function here is pure (string/argv in, string/argv out) so it's unit
tested without ffmpeg, torch, or a GPU. The impure ffmpeg run lives in the
caller (the audiobook router today; the stories job tomorrow).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
_BITRATE_RE = re.compile(r"^\d{2,3}k$")
_COVER_EXTS = {".jpg", ".jpeg", ".png"}
_COVER_MAX_BYTES = 8 * 1024 * 1024 # 8 MB — a book cover, not a payload
#: Our metadata field → FFMETADATA tag key. Order is stable for deterministic
#: output (tested). ``author`` maps to ``artist`` and ``narrator`` to
#: ``composer`` — the tags audiobook players (Apple Books, Audible) read for
#: those roles.
_GLOBAL_TAG_KEYS: list[tuple[str, str]] = [
("title", "title"),
("author", "artist"),
("album", "album"),
("narrator", "composer"),
("year", "date"),
("genre", "genre"),
("description", "comment"),
]
def _escape_meta(value: str) -> str:
"""Escape an FFMETADATA value (``=``, ``;``, ``#``, ``\\``, newline)."""
return re.sub(r"([=;#\\\n])", r"\\\1", value or "")
# ── Loudness normalization ──────────────────────────────────────────────────
@dataclass(frozen=True)
class LoudnessPreset:
"""A loudnorm target. ``i`` = integrated LUFS, ``tp`` = true-peak ceiling
(dBTP), ``lra`` = loudness range."""
key: str
i: float
tp: float
lra: float
#: ``acx`` targets Audible/ACX submission (≈ -19 LUFS integrated, ≤ -3 dBTP
#: peak — inside ACX's -23…-18 dB RMS / -3 dB peak window). ``podcast`` targets
#: the -16 LUFS streaming norm.
LOUDNESS_PRESETS: dict[str, LoudnessPreset] = {
"acx": LoudnessPreset("acx", -19.0, -3.0, 11.0),
"podcast": LoudnessPreset("podcast", -16.0, -1.5, 11.0),
}
def build_loudnorm_filter(preset: Optional[str]) -> Optional[str]:
"""Return an ``-af`` loudnorm filter string for ``preset``, or ``None`` for
off / unknown (single-pass; two-pass measure→apply is a runner enhancement).
"""
if not preset:
return None
p = LOUDNESS_PRESETS.get(preset.lower())
if p is None: # "off", "none", or anything unrecognized → no filter
return None
return f"loudnorm=I={p.i}:TP={p.tp}:LRA={p.lra}"
# ── FFMETADATA ──────────────────────────────────────────────────────────────
def build_ffmetadata(
chapters: Iterable[tuple[str, int]],
global_meta: Optional[dict] = None,
) -> str:
"""Build an FFMETADATA1 doc: optional global tags + one ``[CHAPTER]`` per
``(title, duration_ms)``. START/END are cumulative millisecond offsets.
"""
lines = [";FFMETADATA1"]
if global_meta:
for field_key, meta_key in _GLOBAL_TAG_KEYS:
val = global_meta.get(field_key)
if val is not None and str(val).strip():
lines.append(f"{meta_key}={_escape_meta(str(val).strip())}")
start = 0
for title, dur_ms in chapters:
end = start + max(0, int(dur_ms))
lines += [
"[CHAPTER]",
"TIMEBASE=1/1000",
f"START={start}",
f"END={end}",
f"title={_escape_meta(title)}",
]
start = end
return "\n".join(lines) + "\n"
def build_concat_list(wav_paths: Iterable[str]) -> str:
"""Build an ffmpeg concat-demuxer list. Single quotes in paths are escaped
the ffmpeg way (``'`` → ``'\\''``) so paths can't break the list or inject
arguments."""
lines = []
for p in wav_paths:
safe = str(p).replace("'", "'\\''")
lines.append(f"file '{safe}'")
return "\n".join(lines) + "\n"
# ── Cover art ───────────────────────────────────────────────────────────────
def validate_cover_image(path: Optional[str]) -> bool:
"""True if ``path`` is a readable jpg/png within the size cap. Anything
dubious (missing, wrong type, too big, unreadable) → False, and the caller
simply omits the cover rather than failing the render."""
if not path:
return False
try:
p = Path(path)
return (
p.is_file()
and p.suffix.lower() in _COVER_EXTS
and 0 < p.stat().st_size <= _COVER_MAX_BYTES
)
except OSError:
return False
# ── Render command ──────────────────────────────────────────────────────────
def build_render_cmd(
ffmpeg: str,
concat_list_path: str,
metadata_path: str,
out_path: str,
*,
fmt: str = "m4b",
bitrate: str = "128k",
cover_path: Optional[str] = None,
loudness: Optional[str] = None,
) -> list[str]:
"""Pure argv for muxing chapter WAVs + FFMETADATA into a tagged,
chapter-marked audio file.
Inputs: 0 = concat-demuxer list of chapter WAVs, 1 = FFMETADATA (chapters +
global tags), 2 = cover image (only when present + valid). ``fmt`` is
``m4b`` (AAC in mp4, faststart) or ``mp3`` (libmp3lame). A loudness preset
adds an ``-af loudnorm`` pass; an invalid/oversized cover is silently
dropped (see :func:`validate_cover_image`).
"""
if not _BITRATE_RE.match(bitrate or ""):
bitrate = "128k"
is_mp3 = (fmt or "").lower() == "mp3"
have_cover = validate_cover_image(cover_path)
cmd = [
ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(concat_list_path),
"-i", str(metadata_path),
]
if have_cover:
cmd += ["-i", str(cover_path)]
cmd += ["-map", "0:a", "-map_metadata", "1"]
if have_cover:
cmd += ["-map", "2:v", "-disposition:v", "attached_pic"]
filt = build_loudnorm_filter(loudness)
if filt:
cmd += ["-af", filt]
if is_mp3:
cmd += ["-c:a", "libmp3lame", "-b:a", bitrate]
if have_cover:
cmd += ["-c:v", "copy", "-id3v2_version", "3"]
cmd += ["-f", "mp3", str(out_path)]
else: # m4b — AAC in an mp4 container
cmd += ["-c:a", "aac", "-b:a", bitrate]
if have_cover:
cmd += ["-c:v", "copy"]
cmd += ["-movflags", "+faststart", "-f", "mp4", str(out_path)]
return cmd
@@ -0,0 +1,164 @@
# Stories Editor & Audiobook — Maturity Spec
> Compiled 2026-06-13. Targets the v0.3.x continuous-to-main line. Grounds every
> work item in the two features' current implementation (file:line refs below).
## 0. TL;DR — the central decision
Stories Editor and Audiobook are **two authoring frontends over the same job**:
*chapterized long-form TTS → a chapter-marked audio file*. Today they're built
on divergent stacks, and that divergence is the root cause of most maturity gaps:
| | Stories Editor | Audiobook |
|---|---|---|
| Render | **Client-side** Web Audio stitch (`decodeAudioData` per chunk, concat in RAM) | **Server-side** GPU pool → ffmpeg `.m4b` |
| State | localStorage only (`omnivoice.app`) | best-effort `job_store`, discarded on exit |
| Strength | multi-character dialogue, per-line voice/speed, auto-cast | chapter markers, SSE progress, scales to long books |
| Ceiling | can't render a 10-hour book (browser RAM, no resume, no loudness norm) | single textarea, no cover/metadata, no preview/retry/resume |
**Recommendation: converge on one server-side chapterized render core; keep two
distinct authoring frontends.** Stories Editor's "Generate full" compiles its
cast/lines into the same `Chapter[]`/`Span[]` model the Audiobook backend already
uses and submits the same job. This kills the duplication, lifts the browser
scale ceiling off Stories, and gives *both* features resume, loudness
normalization, cover art, and metadata for free.
Stories keeps its fast **client-side single-line preview** (low latency, no job
overhead). Only the full export moves server-side.
> **Alternative considered:** keep them fully separate and mature each stack
> independently. Rejected — it doubles the work (two metadata systems, two
> normalization paths, two resume mechanisms) and leaves Stories permanently
> unable to render book-length output. If the owner wants them kept separate,
> only §2 + §3-frontend apply and §1 is dropped.
---
## 1. Shared render core (new) — backend
**New module `backend/services/longform_render.py`**, generalizing the audiobook
pipeline (which already has the right shape).
- **Canonical job model** (already exists in `backend/services/audiobook.py:47-86`):
`Project → Chapter(title, spans) → Span(voice_id, text, pause_ms_after)`.
Promote it here; Audiobook and Stories both compile to it.
- **Reuse** `synthesize_chapter` (audiobook.py:140), `build_chapter_ffmetadata`
(:186), `build_concat_list` (:207), `build_m4b_cmd` (:221). Generalize
`build_*_cmd` to take metadata + cover + format.
- **Add capabilities** (each is a discrete, testable pure builder + a wiring step):
1. **Global metadata** → FFMETADATA `[global]`: `title`, `artist` (author),
`album`, `composer`/`narrator`, `date` (year), `genre`, `comment`.
2. **Cover art** → ffmpeg `-i cover.jpg -map 2 -disposition:v attached_pic`
(MP4 `COVR`). Validate image (size/type) server-side.
3. **Loudness normalization** → two-pass ffmpeg `loudnorm` to an **ACX preset**
(target ≈ -19…-21 LUFS integrated, ≤ -3 dB true peak, noise floor < -60 dB).
Toggle + preset (`ACX` / `Podcast -16 LUFS` / `Off`).
4. **Per-chapter checkpoint / resume**: cache each rendered chapter WAV keyed
by `hash(span_texts + voice_ids + pauses + engine + voice refs)`. On retry,
skip chapters whose hash already has a cached WAV → resume a failed/long job
without re-rendering completed chapters.
5. **Parallel chapter synth**: submit chapters to the existing `_gpu_pool`
concurrently (bounded by pool size) instead of strictly sequential.
6. **Output formats**: `m4b` (chaptered AAC, current), `mp3` (chaptered via
ID3 CHAP frames), `per-chapter files` (zip), `stems` (one track per voice —
Stories' existing stems concept, server-side).
- **Job persistence / library**: new alembic table `longform_jobs`
(id, kind=`audiobook`|`story`, title, status, output_path, chapters json,
metadata json, created_at). A finished book/story reappears in Projects/Library
and is re-downloadable. Backward-compatible migration (additive table).
---
## 2. Audiobook tab — maturity (frontend + thin backend)
Anchors: `frontend/src/pages/AudiobookTab.jsx`, `frontend/src/api/audiobook.ts`,
`backend/api/routers/audiobook.py`, `backend/services/audiobook.py`.
**P0**
- **Metadata panel**: title, author, narrator, year, genre, description, **cover
image** picker. Pipes to §1.1/§1.2.
- **Per-chapter preview**: render a single chapter (new `POST /audiobook/preview`
reusing `synthesize_chapter`) so users audition before committing the full book.
- **Chapter-level retry**: a failed chapter doesn't kill the job; mark it, let the
user re-run just that chapter (uses §1.4 checkpoints).
- **Resume**: reconnect to / restart an interrupted job, skipping cached chapters.
- **Loudness-norm toggle** (ACX preset default-off; explicit opt-in).
**P1**
- **Import → auto-chapter**: `.txt` (split on `# H1` / `^Chapter \d+`), **EPUB**
(spine + TOC → chapters; local parse, no network), drag-drop file.
- **Pronunciation lexicon**: per-project word→phoneme/respelling overrides applied
pre-synthesis (shared with Stories).
- **ETA + richer progress** (chapters done / total, elapsed, est. remaining).
**P2**
- SSML-lite (`[emphasis]`, `[slow]`/`[fast]`, `[spell]`) mapped to engine instruct
+ chunk rate. Batch (queue multiple books).
---
## 3. Stories Editor — maturity (frontend + backend)
Anchors: `frontend/src/components/StoriesEditor.jsx` (749-line monolith),
`frontend/src/store/storiesSlice.ts`, `frontend/src/utils/storyExport.js`,
`frontend/src/utils/parseScript.js`.
**P0**
- **Move "Generate full" to the shared server-side job** (§1). Add a
`storyToSpans()` compiler: cast + lines + `[voice:]`/`[pause]` markers →
`Chapter[]`/`Span[]`. `storyExport.js` shrinks to **preview-only** (keep the
snappy single-line client playback at `StoriesEditor.jsx:271-322`).
- **Per-line regenerate** (currently only full export exists).
- **Abort/cancel** an in-progress export (today the loop has no abort signal).
- **Wire the emotion/tone field → `instruct`** (`storiesSlice.ts:17` "Phase 3"
stub; tone chips insert text markers but never reach synthesis).
**P1**
- **Component split**: `CastPanel.jsx` / `StoryLine.jsx` / `LineDrawer.jsx`
(already flagged in `docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md`).
- **Per-line waveform + duration** thumbnail.
- **Chaptered M4B export** with markers (reuse §1; Stories already detects
`# ` chapter lines at `StoriesEditor.jsx:46`).
- **Optional server-persisted projects** (DB) so large casts/long scripts survive
localStorage limits; lazy-migrate existing localStorage projects.
**P2**
- EPUB/screenplay import polish; richer auto-cast (gender/voice matching).
---
## 4. Cross-cutting
- **Loudness norm, metadata+cover, resume/checkpoint, parallel synth** all live in
§1 and are consumed by both features — built once.
- **Pronunciation lexicon** shared service (used by Audiobook P1 + Stories).
- **DB**: `longform_jobs` (+ optional `story_projects`) via alembic, additive,
tested upgrade path. Existing `omnivoice_data/` and localStorage untouched.
- **i18n**: new keys under `audiobook.*` and `stories.*`; no hardcoded CJK.
- **Tests**: extend `tests/test_audiobook.py` — loudnorm argv (two-pass shape),
global-metadata + cover ffmeta/argv, `storyToSpans()` compiler, resume-skip
(cached chapter hash → skipped), EPUB→chapter parse. Frontend: metadata panel,
per-line regen, abort.
## 5. Constraints honored
- **Cross-platform parity**: ffmpeg `loudnorm` / `COVR` / `aac` work identically on
mac/Win/Linux (ffmpeg already bundled). Loudness toggle ships **off by default**
→ no default-behavior divergence. EPUB parse is local.
- **Local-first**: no cloud, no accounts; all rendering/import on-device.
- **Backward-compat data**: alembic additive tables; localStorage projects migrate
lazily, never break.
- **Versioning**: ships as continuous-to-main patches on the v0.3.x line; no RCs,
no new minor unless the owner asks.
## 6. Suggested PR slicing (order, each independently shippable)
1. **Shared `longform_render` core** + loudnorm + global-metadata/cover builders
(backend only, wired behind Audiobook). Pure builders + tests first.
2. **Audiobook metadata + cover UI**.
3. **Per-chapter preview + retry + resume** (checkpoints).
4. **Text/EPUB import + auto-chapter**.
5. **Stories "Generate" → shared job**; `storyToSpans()`; per-line regen; emotion→instruct.
6. **Stories component split + per-line waveform**; chaptered M4B.
7. **DB job library** (Projects/Library shows finished books/stories).
8. (P2) SSML-lite, batch, pronunciation lexicon UI.
+176
View File
@@ -0,0 +1,176 @@
"""Shared long-form render core (Stories + Audiobook convergence).
Pure builders for the chapterized mux: FFMETADATA (global tags + chapters),
concat list, loudness filter, cover validation, and the ffmpeg render argv.
All unit-testable without ffmpeg/torch/GPU.
"""
from __future__ import annotations
import pytest
from services.longform_render import (
LOUDNESS_PRESETS,
build_concat_list,
build_ffmetadata,
build_loudnorm_filter,
build_render_cmd,
validate_cover_image,
)
# ── loudness ────────────────────────────────────────────────────────────────
def test_loudnorm_acx_filter():
f = build_loudnorm_filter("acx")
assert f == "loudnorm=I=-19.0:TP=-3.0:LRA=11.0"
def test_loudnorm_podcast_filter():
assert build_loudnorm_filter("podcast") == "loudnorm=I=-16.0:TP=-1.5:LRA=11.0"
def test_loudnorm_case_insensitive():
assert build_loudnorm_filter("ACX") == build_loudnorm_filter("acx")
@pytest.mark.parametrize("val", [None, "", "off", "none", "bogus"])
def test_loudnorm_off_or_unknown_is_none(val):
assert build_loudnorm_filter(val) is None
def test_loudness_presets_within_acx_window():
# ACX wants integrated near -19 LUFS and a -3 dB peak ceiling.
acx = LOUDNESS_PRESETS["acx"]
assert -23.0 <= acx.i <= -18.0
assert acx.tp == -3.0
# ── FFMETADATA ──────────────────────────────────────────────────────────────
def test_ffmetadata_chapters_only_matches_legacy_shape():
doc = build_ffmetadata([("One", 1000), ("Two", 500)])
assert doc.startswith(";FFMETADATA1\n")
assert "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\ntitle=One" in doc
assert "START=1000\nEND=1500\ntitle=Two" in doc
# No global tags when none supplied.
assert "artist=" not in doc
def test_ffmetadata_global_tags_mapped_and_ordered():
doc = build_ffmetadata(
[("Ch", 1000)],
global_meta={
"title": "My Book", "author": "Ada", "narrator": "Grace",
"year": "2026", "genre": "Sci-Fi", "description": "A tale",
},
)
# field → tag mapping (author→artist, narrator→composer, year→date,
# description→comment) and stable order (title before artist).
head = doc.split("[CHAPTER]")[0]
assert head.index("title=My Book") < head.index("artist=Ada")
assert "composer=Grace" in head
assert "date=2026" in head
assert "genre=Sci-Fi" in head
assert "comment=A tale" in head
def test_ffmetadata_skips_empty_global_values():
doc = build_ffmetadata([("Ch", 1)], global_meta={"title": "T", "author": " ", "genre": None})
head = doc.split("[CHAPTER]")[0]
assert "title=T" in head
assert "artist=" not in head # whitespace-only dropped
assert "genre=" not in head # None dropped
def test_ffmetadata_escapes_special_chars():
doc = build_ffmetadata([("a=b;c#d", 100)], global_meta={"title": "x=y"})
assert r"title=x\=y" in doc
assert r"title=a\=b\;c\#d" in doc
# ── concat list ─────────────────────────────────────────────────────────────
def test_concat_list_quotes_and_escapes():
out = build_concat_list(["/a/one.wav", "/weird/it's here.wav"])
assert "file '/a/one.wav'" in out
assert "file '/weird/it'\\''s here.wav'" in out
# ── cover validation ────────────────────────────────────────────────────────
def test_cover_valid(tmp_path):
p = tmp_path / "cover.jpg"
p.write_bytes(b"\xff\xd8\xff" + b"x" * 100)
assert validate_cover_image(str(p)) is True
def test_cover_rejects_missing_and_bad_type(tmp_path):
assert validate_cover_image(None) is False
assert validate_cover_image(str(tmp_path / "nope.jpg")) is False
txt = tmp_path / "c.txt"
txt.write_bytes(b"hi")
assert validate_cover_image(str(txt)) is False
def test_cover_rejects_oversize(tmp_path):
big = tmp_path / "big.png"
big.write_bytes(b"\x89PNG" + b"0" * (8 * 1024 * 1024 + 1))
assert validate_cover_image(str(big)) is False
def test_cover_rejects_empty(tmp_path):
empty = tmp_path / "empty.jpg"
empty.write_bytes(b"")
assert validate_cover_image(str(empty)) is False
# ── render command ──────────────────────────────────────────────────────────
def test_render_cmd_m4b_default():
cmd = build_render_cmd("ffmpeg", "concat.txt", "ch.ffmeta", "out.m4b")
assert cmd[0] == "ffmpeg"
assert "-f" in cmd and "concat" in cmd
assert cmd[-3:] == ["-f", "mp4", "out.m4b"]
assert "-c:a" in cmd and "aac" in cmd
assert "+faststart" in cmd
assert "-map_metadata" in cmd
# no cover, no loudnorm by default
assert "attached_pic" not in cmd
assert "-af" not in cmd
def test_render_cmd_mp3_format():
cmd = build_render_cmd("ffmpeg", "c.txt", "m.ffmeta", "out.mp3", fmt="mp3")
assert "libmp3lame" in cmd
assert cmd[-3:] == ["-f", "mp3", "out.mp3"]
assert "+faststart" not in cmd
def test_render_cmd_bitrate_validation():
ok = build_render_cmd("ffmpeg", "c", "m", "o", bitrate="192k")
assert "192k" in ok
bad = build_render_cmd("ffmpeg", "c", "m", "o", bitrate="; rm -rf /")
assert "128k" in bad # rejected → default
assert "; rm -rf /" not in bad
def test_render_cmd_loudnorm_adds_af():
cmd = build_render_cmd("ffmpeg", "c", "m", "o", loudness="acx")
assert "-af" in cmd
assert any(a.startswith("loudnorm=") for a in cmd)
def test_render_cmd_with_cover(tmp_path):
cover = tmp_path / "cover.jpg"
cover.write_bytes(b"\xff\xd8\xff" + b"x" * 50)
cmd = build_render_cmd("ffmpeg", "c", "m", "o.m4b", cover_path=str(cover))
# cover becomes input 2, mapped as attached_pic, copied
assert str(cover) in cmd
assert "-map" in cmd and "2:v" in cmd
assert "attached_pic" in cmd
assert "-c:v" in cmd and "copy" in cmd
def test_render_cmd_drops_invalid_cover(tmp_path):
cmd = build_render_cmd("ffmpeg", "c", "m", "o.m4b", cover_path=str(tmp_path / "missing.jpg"))
assert "attached_pic" not in cmd # silently dropped, render still proceeds