POST /v1/audio/speech returned synthetic audio without the AudioSeal provenance watermark while /generate marked the same text — and the audit behind #1169 found the same class of gap in five more producers. EU AI Act Art. 50(2) (applicable 2026-08-02, expressly carved out of the open-source exemption in Art. 2(12)) makes machine-readable marking of synthetic audio a provider obligation, so per-door coverage gaps are compliance bugs. Root cause: coverage grew call-site-by-call-site (three separate embed_watermark calls) with nothing forcing a new producer to opt in. The fix, per the whole-class rule: * services/watermark.py grows `mark_synthetic(wav, sr, *, context, force=False)` — THE named chokepoint (delegates to embed_watermark: pref-gated, no-op without AudioSeal, never raises / degrades to unmarked) — plus `will_mark()` for cache-key derivation. * Gaps closed at the tensor stage, before any encoding: - openai_compat `_run_tts` (the reported gap; all response_formats) - tts_stream /ws/tts (per-sentence, before PCM16 framing) - generation stream=true preview chunks (marked streamed copy; the saved take keeps its single whole-take mark in finalize) - batch dub pipeline (assembled track, before WAV write / aac mux) - longform chapter render — covers /audiobook, /longform/render (Stories), /audiobook/preview and /audiobook/resume; the chapter cache key now carries a watermark tag so stale unmarked cache entries can never be served for a marked-on render - dub preview-segment (docstring had declared it exempt) - archetype render (served preview + materialized profile reference) * Already-covered paths (generate finalize, dub segments, persona bundles) migrated onto the same chokepoint. * Documented non-producers/gaps instead of fake coverage: /stories/encode is a pure transcoder of user uploads (must NOT mark); the opt-in SoniTranslate sidecar synthesizes+muxes externally and is a documented provenance gap at its route. * Regression tests: tests/test_synthetic_audio_watermark_1169.py runs detect_watermark() on the actual response audio of every producing route (real watermark service, fake AudioSeal nets, fake engine; verified fail-before on the pre-fix tree — 11 of 13 fail). * Recurrence-proofing: tests/test_watermark_route_coverage.py structurally asserts every synthesis call site references mark_synthetic (justified allowlist), producers keep their call, and embed_watermark is never called outside the chokepoint. Settings toggle semantics are unchanged (the issue's two legal questions stay flagged for a lawyer, deliberately unanswered here).
69 lines
2.6 KiB
Python
69 lines
2.6 KiB
Python
"""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. The voice
|
|
# signature is ref_audio|ref_text|instruct|seed (all None here). Since
|
|
# #1169 the key also carries a watermark tag whenever marking is active
|
|
# (pref on + AudioSeal importable), so a pre-#1169 unmarked cache entry
|
|
# can never satisfy a marked-on render; mirror that derivation here.
|
|
from services.watermark import will_mark
|
|
sig = {"": "None|None|None|None"}
|
|
if will_mark():
|
|
sig["\x00watermark"] = "1"
|
|
key = chapter_cache_key([(None, "hi", 0, None)], 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, seg_stats = _render_chapter_cached(chapter, boom, sr, "eng", resolve, str(tmp_path))
|
|
assert cached is True
|
|
assert seg_stats is None # chapter-level hit — the segment layer untouched
|
|
assert wav_path.endswith(f"{key}.wav")
|
|
assert abs(dur - 0.5) < 0.01
|