feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8) (#411)

* feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8)

Builds on the shared core (#408) and metadata UI (#409). Chapter-level control,
the spec's PR 3.

Shared core:
- chapter_cache_key(spans, sr, engine_id, voice_sig) — deterministic content
  hash of a chapter's audio inputs. Same inputs → reuse; any change (text,
  voice, order, pauses, sr, engine, resolved-voice signature) → re-render.

Backend (audiobook router):
- Chapter WAVs are now content-addressed in OUTPUTS_DIR/audiobook_cache. A
  re-run after a failure/interruption reuses already-rendered chapters and only
  synthesizes the missing/changed ones (resume). Job emits `cached` per chapter
  and `cached_chapters`/`failed_chapters` on done.
- Per-chapter fault isolation: a chapter that throws emits `chapter_error` and
  the job continues; the m4b assembles from the successful chapters. Re-running
  retries only the failed (un-cached) chapters.
- POST /audiobook/preview — render a single chapter to audition it; shares the
  same cache so a preview warms the full run and a re-preview is instant.
- _build_synth now exposes resolve + engine_id; _prepare_synth unifies the
  omnivoice/generic paths for both the job and preview.

Frontend:
- Plan view: a ▶ preview button per chapter with inline playback.
- Done panel: "reused N chapters" + "N failed — click Create to retry" notes.

Tests: chapter_cache_key determinism + sensitivity (8); preview validation +
cache-hit-skips-synth (3). 55 backend + 326 frontend green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audiobook): mark cache-key SHA1 usedforsecurity=False (bandit B324)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-13 14:19:48 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 086ac08592
commit 7af5143fac
7 changed files with 350 additions and 43 deletions
+135 -32
View File
@@ -126,13 +126,14 @@ def _resolve_voice(profile_id: str | None) -> dict:
return out
def _build_synth(default_voice: str | None):
"""Return ``(synth, sample_rate)`` bound to the active TTS engine.
def _build_synth(default_voice: str | None) -> dict:
"""Describe how to synthesize for the active TTS engine.
``synth(text, voice_id)`` renders one span of text (already pause- and
chapter-split) in the given voice and returns a 1-D audio tensor. Voice
resolutions are cached per id. The default OmniVoice model takes the native
path; other engines go through the generic ``TTSBackend`` adapter.
Returns a dict with ``mode``, ``resolve`` (voice-id → resolved refs, cached
per id) and ``engine_id``. For OmniVoice it also carries the async
``get_model``; other engines carry a ready ``synth`` + ``sample_rate``.
:func:`_prepare_synth` turns this into a uniform ``(synth, sr, resolve,
engine_id)`` once the (async) model is in hand.
"""
from services.tts_backend import OmniVoiceBackend, active_backend_id, get_backend_class
@@ -144,11 +145,12 @@ def _build_synth(default_voice: str | None):
cache[key] = _resolve_voice(key)
return cache[key]
cls = get_backend_class(active_backend_id())
engine_id = active_backend_id()
cls = get_backend_class(engine_id)
if cls is OmniVoiceBackend:
from services.model_manager import get_model
# get_model() is async; the caller resolves it before threading.
return ("omnivoice", resolve, get_model)
return {"mode": "omnivoice", "resolve": resolve,
"engine_id": engine_id, "get_model": get_model}
backend = cls()
@@ -158,14 +160,109 @@ def _build_synth(default_voice: str | None):
text, language=None, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
)
return ("generic", synth, backend.sample_rate)
return {"mode": "generic", "resolve": resolve, "engine_id": engine_id,
"synth": synth, "sample_rate": backend.sample_rate}
async def _prepare_synth(default_voice: str | None):
"""Resolve :func:`_build_synth` into ``(synth, sample_rate, resolve,
engine_id)`` — awaiting the OmniVoice model load when needed. Shared by the
full job and the per-chapter preview."""
info = _build_synth(default_voice)
resolve, engine_id = info["resolve"], info["engine_id"]
if info["mode"] == "omnivoice":
model = await info["get_model"]()
sr = getattr(model, "sampling_rate", 24000)
def synth(text, voice_id):
v = resolve(voice_id)
return model.generate(
text=text, language=None, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
)[0]
return synth, sr, resolve, engine_id
return info["synth"], info["sample_rate"], resolve, engine_id
def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir):
"""Render one chapter, content-addressed so a re-run reuses it (resume).
Returns ``(wav_path, duration_s, was_cached)``. The WAV lives at
``cache_dir/<key>.wav`` where ``key`` is :func:`chapter_cache_key` over the
chapter's spans + sample rate + engine + each voice's resolved signature, so
an unchanged chapter is never re-synthesized. Runs in the GPU-pool executor.
"""
import wave
from services.audio_io import atomic_save_wav
from services.longform_render import chapter_cache_key
spans_tuples = [(s.voice_id, s.text, s.pause_ms_after) for s in chapter.spans]
sig: dict = {}
for s in chapter.spans:
k = s.voice_id or ""
if k not in sig:
v = resolve(s.voice_id)
sig[k] = f"{v.get('ref_audio')}|{v.get('instruct')}|{v.get('seed')}"
key = chapter_cache_key(spans_tuples, sample_rate=sr, engine_id=engine_id, voice_sig=sig)
wav_path = os.path.join(cache_dir, f"{key}.wav")
if os.path.exists(wav_path):
try:
with wave.open(wav_path, "rb") as w:
dur = w.getnframes() / float(w.getframerate() or sr)
return wav_path, dur, True
except Exception:
pass # corrupt cache entry — fall through and re-render
audio, dur = synthesize_chapter(chapter.spans, synth, sr)
atomic_save_wav(wav_path, audio, sr)
return wav_path, dur, False
class AudiobookPreviewRequest(BaseModel):
text: str
chapter_index: int = 0
default_voice: str | None = None
@router.post("/audiobook/preview")
async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
"""Render a single chapter so the user can audition it before the full run.
Reuses the same content-addressed cache as the job, so a preview warms the
cache (the later full render reuses it) and a re-preview is instant.
"""
from core.config import OUTPUTS_DIR
from services.model_manager import _gpu_pool
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
if not plan.chapters:
raise HTTPException(status_code=400, detail="no chapters parsed from the script")
n = len(plan.chapters)
if not (0 <= req.chapter_index < n):
raise HTTPException(status_code=400, detail=f"chapter_index out of range (0..{n - 1})")
chapter = plan.chapters[req.chapter_index]
cache_dir = os.path.join(OUTPUTS_DIR, "audiobook_cache")
os.makedirs(cache_dir, exist_ok=True)
synth, sr, resolve, engine_id = await _prepare_synth(req.default_voice)
loop = asyncio.get_running_loop()
wav_path, dur, was_cached = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
)
return {
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
"duration_s": round(dur, 2),
"cached": was_cached,
"title": chapter.title,
}
@router.post("/audiobook")
async def audiobook_synthesize(req: AudiobookRequest):
"""Synthesize a chapterized m4b audiobook, streaming SSE progress."""
from core.config import OUTPUTS_DIR
from services.audio_io import atomic_save_wav
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
from services.model_manager import _gpu_pool
@@ -198,39 +295,44 @@ async def audiobook_synthesize(req: AudiobookRequest):
work = os.path.join(OUTPUTS_DIR, f"audiobook_{job_id}")
os.makedirs(work, exist_ok=True)
# Chapter WAVs are content-addressed in a shared cache so a re-run
# (after a failure or interruption) reuses what already rendered — only
# the missing/changed chapters synthesize again (resume).
cache_dir = os.path.join(OUTPUTS_DIR, "audiobook_cache")
os.makedirs(cache_dir, exist_ok=True)
loop = asyncio.get_running_loop()
try:
mode, a, b = _build_synth(req.default_voice)
if mode == "omnivoice":
resolve, get_model = a, b
model = await get_model()
sr = getattr(model, "sampling_rate", 24000)
def synth(text, voice_id):
v = resolve(voice_id)
return model.generate(
text=text, language=None, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
)[0]
else:
synth, sr = a, b
synth, sr, resolve, engine_id = await _prepare_synth(req.default_voice)
total = len(plan.chapters)
chapter_files: list[str] = []
chapters_meta: list[tuple[str, int]] = []
cached_n = 0
failed: list[int] = []
yield _emit({"type": "started", "job_id": job_id, "chapters": total})
for i, chapter in enumerate(plan.chapters):
audio, dur = await loop.run_in_executor(
_gpu_pool, synthesize_chapter, chapter.spans, synth, sr,
)
wav_path = os.path.join(work, f"chapter_{i:03d}.wav")
atomic_save_wav(wav_path, audio, sr)
try:
wav_path, dur, was_cached = await loop.run_in_executor(
_gpu_pool, _render_chapter_cached,
chapter, synth, sr, engine_id, resolve, cache_dir,
)
except Exception as ce: # isolate a bad chapter — keep going
failed.append(i)
yield _emit({"type": "chapter_error", "index": i, "total": total,
"title": chapter.title, "error": str(ce)[:200]})
continue
chapter_files.append(wav_path)
chapters_meta.append((chapter.title, int(round(dur * 1000))))
cached_n += 1 if was_cached else 0
yield _emit({"type": "chapter", "index": i, "total": total,
"title": chapter.title, "duration_s": round(dur, 2)})
"title": chapter.title, "duration_s": round(dur, 2),
"cached": was_cached})
if not chapter_files:
yield _emit({"type": "error", "error": "all chapters failed to render"})
return
yield _emit({"type": "assembling"})
meta_path = os.path.join(work, "chapters.ffmeta")
@@ -258,7 +360,8 @@ async def audiobook_synthesize(req: AudiobookRequest):
pass
total_s = sum(d for _, d in chapters_meta) / 1000.0
yield _emit({"type": "done", "output": out_name,
"chapters": total, "duration_s": round(total_s, 2)})
"chapters": len(chapter_files), "duration_s": round(total_s, 2),
"cached_chapters": cached_n, "failed_chapters": failed})
except Exception as e: # surface, don't 500 the stream
if job_store is not None:
try:
+34
View File
@@ -16,6 +16,8 @@ reimplement it:
reaches ffmpeg.
* ``build_render_cmd`` — pure argv for the mux: chapter WAVs + FFMETADATA
(+ optional cover art, loudness filter), output as ``m4b`` or ``mp3``.
* ``chapter_cache_key`` — deterministic content hash so a re-run reuses
already-rendered chapters (resume) and re-renders only what changed.
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
@@ -24,6 +26,8 @@ caller (the audiobook router today; the stories job tomorrow).
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
@@ -53,6 +57,36 @@ def _escape_meta(value: str) -> str:
return re.sub(r"([=;#\\\n])", r"\\\1", value or "")
# ── Chapter cache key (resume) ──────────────────────────────────────────────
def chapter_cache_key(
spans: Iterable[tuple[Optional[str], str, int]],
*,
sample_rate: int,
engine_id: str,
voice_sig: Optional[dict] = None,
) -> str:
"""Deterministic content hash for a chapter's rendered audio.
``spans`` is an ordered list of ``(voice_id, text, pause_ms_after)``. Same
inputs → same key → reuse the cached chapter WAV on a re-run (resume); any
change (text, voice, order, pauses, sample rate, engine, or a voice's
resolved signature) → new key → re-render. ``voice_sig`` maps each voice id
to a stable signature string (e.g. ``ref_audio|instruct|seed``) so editing
the underlying profile also invalidates the cache.
"""
payload = {
"sr": int(sample_rate),
"engine": engine_id or "",
"spans": [[v, t, int(p)] for (v, t, p) in spans],
"voices": {k: voice_sig[k] for k in sorted(voice_sig)} if voice_sig else {},
}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
# Content-addressing only — not a security digest. usedforsecurity=False
# keeps bandit's B324 (weak-hash) check quiet.
return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:20]
# ── Loudness normalization ──────────────────────────────────────────────────
@dataclass(frozen=True)
+19
View File
@@ -28,6 +28,25 @@ export async function audiobookPlan(
return res.json();
}
export interface AudiobookPreview {
output: string; // path under OUTPUTS_DIR, served via /audio
duration_s: number;
cached: boolean;
title: string;
}
/** Render a single chapter to audition it (also warms the resume cache). */
export async function audiobookPreviewChapter(
body: { text: string; chapter_index: number; default_voice?: string | null },
): Promise<AudiobookPreview> {
const res = await apiFetch('/audiobook/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
/** Global tags embedded in the output file (player-visible). */
export interface AudiobookMetadata {
title?: string;
+4 -1
View File
@@ -140,7 +140,10 @@
"meta_narrator": "Narrator",
"meta_year": "Year",
"meta_genre": "Genre",
"meta_description": "Description"
"meta_description": "Description",
"preview_chapter": "Preview chapter: {{title}}",
"cached_note": "Reused {{count}} already-rendered chapter(s).",
"failed_note": "{{count}} chapter(s) failed — click Create again to retry just those."
},
"launchpad": {
"greeting": "hello there",
+60 -10
View File
@@ -1,8 +1,10 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BookMarked, Loader, Download, Image as ImageIcon, X } from 'lucide-react';
import { BookMarked, Loader, Download, Image as ImageIcon, X, Play } from 'lucide-react';
import { audiobookPlan, audiobookGenerate, audiobookUploadCover } from '../api/audiobook';
import {
audiobookPlan, audiobookGenerate, audiobookUploadCover, audiobookPreviewChapter,
} from '../api/audiobook';
import { audioUrl } from '../api/generate';
import { splitSSEBuffer, parseSSELine } from '../utils/sseParse';
@@ -23,6 +25,8 @@ export default function AudiobookTab({ profiles = [] }) {
const [progress, setProgress] = useState(null); // {current,total,title,assembling}
const [output, setOutput] = useState('');
const [error, setError] = useState('');
const [done, setDone] = useState(null); // {cached_chapters, failed_chapters}
const [chapterPrev, setChapterPrev] = useState({}); // index → {url, loading}
const abortRef = useRef(false);
// Output + metadata (embedded in the file; players show these).
@@ -59,9 +63,22 @@ export default function AudiobookTab({ profiles = [] }) {
}
}, [text, defaultVoice]);
const onPreviewChapter = useCallback(async (i) => {
setError('');
setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: true } }));
try {
const r = await audiobookPreviewChapter({ text, chapter_index: i, default_voice: defaultVoice || null });
setChapterPrev((p) => ({ ...p, [i]: { url: audioUrl(r.output), loading: false } }));
} catch (e) {
setChapterPrev((p) => ({ ...p, [i]: { ...(p[i] || {}), loading: false } }));
setError(e?.message || String(e));
}
}, [text, defaultVoice]);
const onCreate = useCallback(async () => {
setError('');
setOutput('');
setDone(null);
setProgress({ current: 0, total: 0 });
setGenerating(true);
abortRef.current = false;
@@ -100,8 +117,14 @@ export default function AudiobookTab({ profiles = [] }) {
setProgress({ current: evt.index + 1, total: evt.total, title: evt.title });
} else if (evt.type === 'assembling') {
setProgress((p) => ({ ...(p || {}), assembling: true }));
} else if (evt.type === 'chapter_error') {
setProgress({ current: evt.index + 1, total: evt.total, title: evt.title });
} else if (evt.type === 'done') {
setOutput(evt.output);
setDone({
cached_chapters: evt.cached_chapters || 0,
failed_chapters: evt.failed_chapters || [],
});
} else if (evt.type === 'error') {
setError(evt.error || 'synthesis failed');
}
@@ -258,6 +281,16 @@ export default function AudiobookTab({ profiles = [] }) {
{output && (
<div className="audiobook-done" style={{ margin: '16px 0' }}>
<div style={{ marginBottom: 8 }}> {t('audiobook.ready')}</div>
{done && done.failed_chapters.length > 0 && (
<div className="muted" style={{ marginBottom: 8 }}>
{t('audiobook.failed_note', { count: done.failed_chapters.length })}
</div>
)}
{done && done.cached_chapters > 0 && (
<div className="muted" style={{ marginBottom: 8 }}>
{t('audiobook.cached_note', { count: done.cached_chapters })}
</div>
)}
<audio controls src={audioUrl(output)} style={{ width: '100%' }} />
<div style={{ marginTop: 8 }}>
<a className="btn" href={audioUrl(output)} download={output}>
@@ -271,14 +304,31 @@ export default function AudiobookTab({ profiles = [] }) {
<div className="audiobook-plan" style={{ marginTop: 16 }}>
<h3>{t('audiobook.plan_heading', { count: plan.chapter_count })}</h3>
<ol>
{plan.chapters.map((c, i) => (
<li key={i} style={{ marginBottom: 4 }}>
<strong>{c.title}</strong>{' '}
<span className="muted">
{t('audiobook.chapter_meta', { spans: c.spans.length, chars: c.char_count })}
</span>
</li>
))}
{plan.chapters.map((c, i) => {
const prev = chapterPrev[i] || {};
return (
<li key={i} style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
className="btn"
onClick={() => onPreviewChapter(i)}
disabled={prev.loading || busy}
aria-label={t('audiobook.preview_chapter', { title: c.title })}
style={{ padding: '2px 6px' }}
>
{prev.loading ? <Loader size={12} className="spin" /> : <Play size={12} />}
</button>
<strong>{c.title}</strong>{' '}
<span className="muted">
{t('audiobook.chapter_meta', { spans: c.spans.length, chars: c.char_count })}
</span>
</div>
{prev.url && (
<audio controls src={prev.url} style={{ width: '100%', marginTop: 4 }} />
)}
</li>
);
})}
</ol>
</div>
)}
+60
View File
@@ -0,0 +1,60 @@
"""Per-chapter preview endpoint + the resume cache-hit path.
Validation cases call the handler directly (no synth reached). The cache-hit
test exercises ``_render_chapter_cached`` with a pre-seeded WAV so it returns
the cached chapter without ever invoking synth (no torch/GPU).
"""
from __future__ import annotations
import asyncio
import wave
import pytest
from fastapi import HTTPException
from api.routers.audiobook import (
AudiobookPreviewRequest,
_render_chapter_cached,
audiobook_preview,
)
from services.audiobook import Chapter, Span
from services.longform_render import chapter_cache_key
def test_preview_rejects_empty_script():
with pytest.raises(HTTPException) as ei:
asyncio.run(audiobook_preview(AudiobookPreviewRequest(text="", chapter_index=0)))
assert ei.value.status_code == 400
def test_preview_rejects_out_of_range_index():
with pytest.raises(HTTPException) as ei:
asyncio.run(audiobook_preview(AudiobookPreviewRequest(text="# A\nhello", chapter_index=5)))
assert ei.value.status_code == 400
def _write_wav(path, sr=24000, frames=2400):
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(b"\x00\x00" * frames)
def test_render_chapter_cache_hit_skips_synth(tmp_path):
sr = 24000
chapter = Chapter(title="C1", spans=[Span(voice_id=None, text="hi", pause_ms_after=0)])
resolve = lambda _vid: {"ref_audio": None, "instruct": None, "seed": None} # noqa: E731
# Pre-seed the cache at the exact key this chapter will hash to.
sig = {"": "None|None|None"}
key = chapter_cache_key([(None, "hi", 0)], sample_rate=sr, engine_id="eng", voice_sig=sig)
_write_wav(tmp_path / f"{key}.wav", sr=sr, frames=sr // 2) # 0.5 s
def boom(*_a, **_k):
raise AssertionError("synth must not be called on a cache hit")
wav_path, dur, cached = _render_chapter_cached(chapter, boom, sr, "eng", resolve, str(tmp_path))
assert cached is True
assert wav_path.endswith(f"{key}.wav")
assert abs(dur - 0.5) < 0.01
+38
View File
@@ -14,6 +14,7 @@ from services.longform_render import (
build_ffmetadata,
build_loudnorm_filter,
build_render_cmd,
chapter_cache_key,
validate_cover_image,
)
@@ -174,3 +175,40 @@ def test_render_cmd_with_cover(tmp_path):
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
# ── chapter cache key (resume) ──────────────────────────────────────────────
_SPANS = [(None, "Once upon a time.", 350), ("narrator", "The end.", 0)]
def test_cache_key_deterministic():
a = chapter_cache_key(_SPANS, sample_rate=24000, engine_id="omnivoice")
b = chapter_cache_key(list(_SPANS), sample_rate=24000, engine_id="omnivoice")
assert a == b and len(a) == 20
@pytest.mark.parametrize("mutate", [
lambda: chapter_cache_key([(None, "Different.", 350), ("narrator", "The end.", 0)],
sample_rate=24000, engine_id="omnivoice"), # text
lambda: chapter_cache_key([("x", "Once upon a time.", 350), ("narrator", "The end.", 0)],
sample_rate=24000, engine_id="omnivoice"), # voice
lambda: chapter_cache_key([(None, "Once upon a time.", 500), ("narrator", "The end.", 0)],
sample_rate=24000, engine_id="omnivoice"), # pause
lambda: chapter_cache_key(list(reversed(_SPANS)), sample_rate=24000, engine_id="omnivoice"), # order
lambda: chapter_cache_key(_SPANS, sample_rate=44100, engine_id="omnivoice"), # sr
lambda: chapter_cache_key(_SPANS, sample_rate=24000, engine_id="kokoro"), # engine
lambda: chapter_cache_key(_SPANS, sample_rate=24000, engine_id="omnivoice",
voice_sig={"narrator": "ref.wav|warm|7"}), # voice sig
])
def test_cache_key_changes_on_any_input(mutate):
base = chapter_cache_key(_SPANS, sample_rate=24000, engine_id="omnivoice")
assert mutate() != base
def test_cache_key_voice_sig_order_irrelevant():
a = chapter_cache_key(_SPANS, sample_rate=24000, engine_id="omnivoice",
voice_sig={"a": "1", "b": "2"})
b = chapter_cache_key(_SPANS, sample_rate=24000, engine_id="omnivoice",
voice_sig={"b": "2", "a": "1"})
assert a == b