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:
co-authored by
Claude Opus 4.8
parent
086ac08592
commit
7af5143fac
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user