feat(export): provenance tags and optional disclosure for exported audio

ID3 provenance tags (synthetic speech, generator, version, date, opt-in
voice name) on magazine MP3/ZIP/EPUB exports via a stream-copied temp copy;
verify endpoint and CLI; AudioSeal tier is a documented stub.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 17:02:41 +02:00
parent 8b51487a64
commit 5a7c73ea42
10 changed files with 258 additions and 13 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Provenance tags on exported audio.** Magazine MP3 downloads, `audio.zip` and the MP3s inside the EPUB get ID3 tags (`synthetic_speech=true`, `generator`, `generator_version`, `generated_date`, comment; voice name only on opt-in) via one ffmpeg stream copy of a temp copy; stored audio is untouched. Per-export checkboxes on the magazine export row ("Mark as synthetic speech", "include voice name"), default from new setting `export_provenance_default` (on). `POST /api/export/provenance` and `python -m core.export_provenance file.mp3` read the tags back. Inaudible AudioSeal watermarking is researched (MIT code and weights, torch needed, weak message recovery after MP3/AAC) but only a stub `watermark(path)` that reports unavailable. Spoken disclosure is not implemented yet. Tests: `tests/test_watermark_tags.py`, `tests/magazine.spec.js`. See `architecture/watermarking.md`.
- **Inline text tags for TTS (setting `inline_tags_enabled`, default OFF).** `[pause 500ms]`/`[pause 1.2s]` (max 10 s each, 60 s per text), `[spell]USA[/spell]` (German letter names for German text), `[slow]..[/slow]` / `[fast]..[/fast]` (0.85 / 1.15 via ffmpeg atempo, innermost wins, unclosed ends at paragraph). Known control tags (e.g. `[laugh]`) are stripped, never spoken; `[1]` and `[sic]` stay. Only `POST /api/tts-preview` renders them; other paths are listed in `architecture/inline-tags.md`. Tests: `tests/test_ssml_lite.py`. - **Inline text tags for TTS (setting `inline_tags_enabled`, default OFF).** `[pause 500ms]`/`[pause 1.2s]` (max 10 s each, 60 s per text), `[spell]USA[/spell]` (German letter names for German text), `[slow]..[/slow]` / `[fast]..[/fast]` (0.85 / 1.15 via ffmpeg atempo, innermost wins, unclosed ends at paragraph). Known control tags (e.g. `[laugh]`) are stripped, never spoken; `[1]` and `[sic]` stay. Only `POST /api/tts-preview` renders them; other paths are listed in `architecture/inline-tags.md`. Tests: `tests/test_ssml_lite.py`.
- **Optional vocal isolation for URL/YouTube imports (default OFF).** `POST /api/isolate-vocals {id}` runs the Demucs CLI (`htdemucs`, two stems) on a registered temp audio and registers the vocals as a new id; the original stays valid. Needs `VOCAL_ISOLATION=1`, a `demucs` binary (or `VOCAL_ISOLATION_CMD`) and the weights already on disk; the app never downloads anything and nothing is added to the main image. Limits: 15 min input, 15 min runtime (`VOCAL_ISOLATION_MAX_SECONDS`/`_TIMEOUT`), one job at a time, cancelled on client disconnect, temp files always removed. `GET /api/version` reports `features.vocal_isolation`; the "Remove background music" button (clone page URL card and voice library add-URL) appears only then, with an aria-live status and a play original / play without music toggle. Tests: `tests/test_vocal_isolation.py`, `tests/vocal-isolation.spec.js`. See `architecture/vocal-isolation.md`. - **Optional vocal isolation for URL/YouTube imports (default OFF).** `POST /api/isolate-vocals {id}` runs the Demucs CLI (`htdemucs`, two stems) on a registered temp audio and registers the vocals as a new id; the original stays valid. Needs `VOCAL_ISOLATION=1`, a `demucs` binary (or `VOCAL_ISOLATION_CMD`) and the weights already on disk; the app never downloads anything and nothing is added to the main image. Limits: 15 min input, 15 min runtime (`VOCAL_ISOLATION_MAX_SECONDS`/`_TIMEOUT`), one job at a time, cancelled on client disconnect, temp files always removed. `GET /api/version` reports `features.vocal_isolation`; the "Remove background music" button (clone page URL card and voice library add-URL) appears only then, with an aria-live status and a play original / play without music toggle. Tests: `tests/test_vocal_isolation.py`, `tests/vocal-isolation.spec.js`. See `architecture/vocal-isolation.md`.
- **Voice export/import bundles.** `GET /api/voices/{id}/export` streams a `<id>.voice.zip` (audio, `reference.txt`, `meta.json` with seed, design prompt, rating/tags, `provenance`, `audio_problems`; optional picture and consent recording; `manifest.json` with format version, app version and sha256 per file; no temp files). `POST /api/voices/import` validates at the boundary: 150 MB upload cap, fixed flat member names only (zip-slip/path traversal rejected), per-file, total and compression-ratio caps, hashes verified, audio decoded, meta and provenance re-validated; a taken name gets a `_2` suffix, an identical voice is a no-op, nothing is overwritten. Voice library toolbar: "Export selected" and "Import bundle" (44px). Tests: `tests/test_voice_bundle.py`, `tests/voice-bundle.spec.js`. See `architecture/voice-bundle.md`. - **Voice export/import bundles.** `GET /api/voices/{id}/export` streams a `<id>.voice.zip` (audio, `reference.txt`, `meta.json` with seed, design prompt, rating/tags, `provenance`, `audio_problems`; optional picture and consent recording; `manifest.json` with format version, app version and sha256 per file; no temp files). `POST /api/voices/import` validates at the boundary: 150 MB upload cap, fixed flat member names only (zip-slip/path traversal rejected), per-file, total and compression-ratio caps, hashes verified, audio decoded, meta and provenance re-validated; a taken name gets a `_2` suffix, an identical voice is a no-op, nothing is overwritten. Voice library toolbar: "Export selected" and "Import bundle" (44px). Tests: `tests/test_voice_bundle.py`, `tests/voice-bundle.spec.js`. See `architecture/voice-bundle.md`.

View File

@ -750,6 +750,15 @@ sent. Websites that redistribute commercial magazines without a licence are deli
**Mobile / accessibility:** data only (API fields). The index shows `display_title` with `page_label` in smaller text, and the full title stays available to screen readers. **Mobile / accessibility:** data only (API fields). The index shows `display_title` with `page_label` in smaller text, and the full title stays available to screen readers.
**Offline / privacy:** the heuristics run locally. LLM titles use only the LLM configured in Settings and send it at most 1,500 characters of the entry's text. **Offline / privacy:** the heuristics run locally. LLM titles use only the LLM configured in Settings and send it at most 1,500 characters of the entry's text.
### Marking exported audio as synthetic speech
- **Purpose:** exported MP3s (single download, "Download all (ZIP)", "EPUB with audio") carry tags saying the speech is AI-generated.
- **Prerequisites:** an article with generated audio. No extra software.
- **Steps:** in the export row tick or untick "Mark as synthetic speech (tags)" (default on, remembered as a setting) and optionally "include voice name". Then download as usual.
- **Result:** the copy you download has tags `synthetic_speech`, `generator`, `generator_version`, `generated_date` (and `voice` if chosen). The stored audio is unchanged. To check a file: `python -m core.export_provenance file.mp3` or `POST /api/export/provenance` with the file as body.
- **Errors:** if tagging fails the download answers "Could not add provenance tags"; untick the box to export untagged.
- **Mobile / accessibility:** plain labelled checkboxes. **Offline:** works offline. **Privacy:** no user names or paths; the voice name only if you tick it. Tags can be removed by anyone, so they are an honest label, not protection. An inaudible watermark and a spoken disclosure are not available yet.
### Errors ### Errors
| What you see | What happened | What to do | | What you see | What happened | What to do |

View File

@ -0,0 +1,26 @@
# Provenance tags and watermarking for exported audio
Status: tier 1 implemented (`core/export_provenance.py`); tier 2 (AudioSeal) stubbed, not installed.
## Research (checked 2026-10-04)
**AudioSeal (facebookresearch/audioseal)**
- Licence: README states code AND model weights are MIT ("full MIT license, including the license for the model weights", since April 2024). Commercial use allowed. Verdict: licence acceptable.
- Requirements: Python >= 3.8, PyTorch >= 1.13, OmegaConf, NumPy (einops for streaming). Weights are fetched from the Hugging Face hub on first use (network download, so must be baked in or mounted for an offline box).
- ARM64/CPU: the host has torch 2.10 (aarch64, cu130), but the app image has no torch (nothing in Dockerfile/requirements). The generator is a small conv/LSTM net at 16 kHz and runs on CPU; no CPU benchmark was found in the docs, so cost per hour of audio is unverified (measure before promising). Docs flag torchaudio backend quirks (needs `soundfile` or torchaudio 2.1).
- Robustness (paper "Proactive Detection of Voice Cloning with Localized Watermarking", arXiv 2401.17264, and third-party comparisons): detection (is-watermarked) stays high under MP3/AAC (mean AUC about 0.97; 128 kbps AAC detection 1.0), but exact recovery of the 16-bit message under AAC 128k falls to about 0 and newer comparisons report < 60% message accuracy under MP3/AAC. So: only "watermarked yes/no" is dependable after re-encode, not a voice/version id.
- What is detectable: per-sample (1/16000 s) localized probability that audio was watermarked, plus optional 16-bit message. Needs the AudioSeal detector (python+torch); no public third party checks it. Trained at 16 kHz, resampling needed for 22-44 kHz audio. Adversarial removal is possible.
**Tier 1 alternatives (no dependencies)**
- Provenance tags in the container (ID3v2.3 TXXX/COMM via ffmpeg `-metadata`): `synthetic_speech=true`, `generator`, `generator_version`, `generated_date`, optional `voice` (name only), `comment`. Honest, trivially strippable, survives only while the container is kept.
- Spoken disclosure ("Dieses Hörbuch wurde synthetisch erzeugt"): not strippable by metadata removal, but needs a TTS call plus a re-encoding concat; NOT implemented yet (see Open decisions).
## Design
1. Tier 1 (implemented): on export, one ffmpeg stream copy (`-c copy -map_metadata 0 -id3v2_version 3`) of the MP3 into a temp copy with the tags. The stored article audio is never changed. Applies to: single article download, `audio.zip`, and the MP3s inside the EPUB. Query `?provenance=1|0` and `&voice=1` per export; absent -> setting `export_provenance_default` (default true). No user names, paths or secrets are written; voice only when opted in, basename only; ffmpeg errors are not forwarded (they can contain paths).
2. Verify: `POST /api/export/provenance` (raw audio body, 100 MB cap) returns the tags and `synthetic`; CLI `python -m core.export_provenance file.mp3` (exit 0 if tagged).
3. Tier 2: `watermark(path)` raises `NotImplementedError`; `watermark_available()` is False. Enabling it later means: optional extra image/venv with torch + audioseal and pre-mounted weights, a feature flag, lazy import inside `watermark()`, and a decision on re-encode (watermark must be applied to PCM before the MP3 encode, i.e. at synthesis time, not on the finished MP3 copy).
## Open decisions
- Spoken disclosure: render once per language via the normal TTS path and concat at export (re-encode) - needs owner go-ahead.
- Tier 2: needs torch in a sidecar container (not the main image); recommended only if an inaudible mark is really required, given weak message recovery after AAC/MP3.

View File

@ -38,7 +38,7 @@ _SETTINGS_KEYS = {
"stt_language", "stt_preferred_backend", "stt_language", "stt_preferred_backend",
"auto_refine", "refine_model", "auto_refine", "refine_model",
"refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation", "refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation",
"captures_default_voice", "captures_default_voice", "export_provenance_default",
"client_voice_bindings", "client_voice_bindings",
"llm_url", "llm_model", "llm_api_key", "llm_url", "llm_model", "llm_api_key",
# Magazine layout is a vision task. Kept separate from the general text # Magazine layout is a vision task. Kept separate from the general text

71
core/export_provenance.py Normal file
View File

@ -0,0 +1,71 @@
"""Provenance tags for exported generated audio (tier 1) and the AudioSeal stub (tier 2).
Tags are written with one ffmpeg stream copy (no re-encode) into a COPY of the MP3; the stored
article audio is never modified. Tags never contain paths, user names or secrets; the voice is
included only when the caller opts in. See architecture/watermarking.md.
"""
from __future__ import annotations
import json
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path
GENERATOR = "tts-voice-creator-clone-and-design-2"
_KEYS = ("synthetic_speech", "generator", "generator_version", "generated_date", "voice")
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
def build_tags(app_version: str, voice: str = "", include_voice: bool = False, today: str = "") -> dict[str, str]:
tags = {
"synthetic_speech": "true",
"generator": GENERATOR,
"generator_version": _CTRL.sub("", str(app_version))[:32],
"generated_date": today or datetime.now(timezone.utc).strftime("%Y-%m-%d"),
}
if include_voice and voice:
tags["voice"] = _CTRL.sub("", Path(str(voice)).name)[:80] # name only, never a path
tags["comment"] = f"Synthetic speech (AI-generated) by {GENERATOR} {tags['generator_version']}"
return tags
def apply_tags(src: Path, dst: Path, tags: dict[str, str]) -> Path:
"""Copy src to dst (stream copy) with the tags; raises RuntimeError on ffmpeg failure."""
cmd = ["ffmpeg", "-y", "-v", "error", "-i", str(src), "-map", "0:a", "-map_metadata", "0",
"-c", "copy", "-id3v2_version", "3"]
for k, v in tags.items():
cmd += ["-metadata", f"{k}={v}"]
cmd.append(str(dst))
r = subprocess.run(cmd, capture_output=True, timeout=120)
if r.returncode != 0:
raise RuntimeError("tagging failed") # ffmpeg stderr may contain paths: not forwarded
return dst
def read_tags(path: Path) -> dict[str, str]:
"""Provenance tags found in an audio file ({} if none). Never raises."""
try:
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format_tags", "-of", "json", str(path)],
capture_output=True, timeout=30)
found = (json.loads(r.stdout or b"{}").get("format") or {}).get("tags") or {}
except (OSError, ValueError, subprocess.SubprocessError):
return {}
low = {str(k).lower(): str(v) for k, v in found.items()}
return {k: low[k] for k in _KEYS if k in low}
def watermark(path: Path) -> Path:
"""Tier 2 (AudioSeal) is not installed in this image: always reports unavailable."""
raise NotImplementedError("Inaudible watermarking (AudioSeal) is not available in this build")
def watermark_available() -> bool:
return False
if __name__ == "__main__": # verify CLI: python -m core.export_provenance file.mp3
import sys
found = read_tags(Path(sys.argv[1])) if len(sys.argv) == 2 else {}
print(json.dumps(found, indent=2))
sys.exit(0 if found.get("synthetic_speech") == "true" else 1)

View File

@ -24,6 +24,7 @@ import io
import json import json
import logging import logging
import re import re
import shutil
import statistics import statistics
import time import time
import uuid import uuid
@ -3296,8 +3297,41 @@ def _article_download_filename(meta: dict, article: dict) -> str:
return (name[:180] or article.get("id") or "article") + ".mp3" return (name[:180] or article.get("id") or "article") + ".mp3"
def _export_mp3(mp3: Path, tmpdir: Path, provenance: str, voice: str) -> Path:
"""The MP3 to export: a tagged copy when provenance tags are on (query param, else the setting)."""
from core import export_provenance as ep
from core.constants import __version__
on = bool(_load_settings().get("export_provenance_default", True)) if provenance == "" else provenance == "1"
if not on:
return mp3
name = ""
if voice == "1":
segs = (mag.read_audio_timings(mp3) or {}).get("segments") or []
name = next((x.get("voice") for x in segs if isinstance(x, dict) and x.get("voice")), "") or ""
try:
return ep.apply_tags(mp3, tmpdir / mp3.name, ep.build_tags(__version__, name, voice == "1"))
except (RuntimeError, OSError, __import__("subprocess").SubprocessError):
raise HTTPException(500, "Could not add provenance tags to the export")
@router.post("/api/export/provenance")
async def export_provenance_verify(request: Request):
"""Verify: read the provenance tags back from an uploaded audio file (raw body, 100 MB cap)."""
import tempfile
from core import export_provenance as ep
body = await request.body()
if not body or len(body) > 100 * 1024 * 1024:
raise HTTPException(400, "Send an audio file of up to 100 MB as the request body")
with tempfile.TemporaryDirectory(prefix="prov-") as t:
f = Path(t) / "in.bin"
f.write_bytes(body)
tags = await asyncio.to_thread(ep.read_tags, f)
return {"tags": tags, "synthetic": tags.get("synthetic_speech") == "true",
"watermark_available": ep.watermark_available()}
@router.get("/api/magazine/{doc_id}/articles/{article_id}/audio") @router.get("/api/magazine/{doc_id}/articles/{article_id}/audio")
async def magazine_article_audio_file(doc_id: str, article_id: str): async def magazine_article_audio_file(doc_id: str, article_id: str, provenance: str = "", voice: str = ""):
d, meta = _require_magazine(doc_id) d, meta = _require_magazine(doc_id)
article_id = _article_id(article_id) article_id = _article_id(article_id)
path = d / "audio" / f"{article_id}.mp3" path = d / "audio" / f"{article_id}.mp3"
@ -3305,7 +3339,16 @@ async def magazine_article_audio_file(doc_id: str, article_id: str):
raise HTTPException(404, "No audio generated yet") raise HTTPException(404, "No audio generated yet")
articles = mag.load_articles(d) articles = mag.load_articles(d)
article = next((a for a in articles if a.get("id") == article_id), {"id": article_id}) article = next((a for a in articles if a.get("id") == article_id), {"id": article_id})
return FileResponse(path, media_type="audio/mpeg", filename=_article_download_filename(meta, article)) from starlette.background import BackgroundTask
import tempfile
tmp = Path(tempfile.mkdtemp(prefix="mag-prov-"))
try:
out = await asyncio.to_thread(_export_mp3, path, tmp, provenance, voice)
except BaseException:
shutil.rmtree(tmp, ignore_errors=True)
raise
return FileResponse(out, media_type="audio/mpeg", filename=_article_download_filename(meta, article),
background=BackgroundTask(shutil.rmtree, tmp, ignore_errors=True))
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str, def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str,
@ -3359,7 +3402,7 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
@router.get("/api/magazine/{doc_id}/epub") @router.get("/api/magazine/{doc_id}/epub")
async def magazine_epub(doc_id: str, article: str = ""): async def magazine_epub(doc_id: str, article: str = "", provenance: str = "", voice: str = ""):
"""The issue (or one article) as an EPUB 3 with Media Overlays: text that highlights as the MP3 plays. """The issue (or one article) as an EPUB 3 with Media Overlays: text that highlights as the MP3 plays.
Only approved articles that already have audio are included, in listing order. Sentence timings are Only approved articles that already have audio are included, in listing order. Sentence timings are
@ -3378,6 +3421,8 @@ async def magazine_epub(doc_id: str, article: str = ""):
if not articles: if not articles:
raise HTTPException(404, "Article not found") raise HTTPException(404, "Article not found")
parts = [] parts = []
import tempfile
tagdir = Path(tempfile.mkdtemp(prefix="mag-prov-"))
for a in articles: for a in articles:
mp3 = d / "audio" / f"{a['id']}.mp3" mp3 = d / "audio" / f"{a['id']}.mp3"
if a.get("status") != "approved" or not mp3.is_file(): if a.get("status") != "approved" or not mp3.is_file():
@ -3391,12 +3436,13 @@ async def magazine_epub(doc_id: str, article: str = ""):
continue # an unreadable MP3 cannot be given timings at all continue # an unreadable MP3 cannot be given timings at all
duration_ms = int(length * 1000) duration_ms = int(length * 1000)
parts.append({"title": a.get("title") or "", "author": a.get("author") or "", "blocks": blocks, parts.append({"title": a.get("title") or "", "author": a.get("author") or "", "blocks": blocks,
"mp3": mp3, "duration_ms": duration_ms, "timings": timings, "mp3": await asyncio.to_thread(_export_mp3, mp3, tagdir, provenance, voice),
"duration_ms": duration_ms, "timings": timings,
"spoken_chars": mag.spoken_chars_for(blocks, timings)}) "spoken_chars": mag.spoken_chars_for(blocks, timings)})
if not parts: if not parts:
shutil.rmtree(tagdir, ignore_errors=True)
raise HTTPException(404, "No approved article has audio yet") raise HTTPException(404, "No approved article has audio yet")
language = mag.detect_article_language(b["text"] for p in parts for b in p["blocks"]) language = mag.detect_article_language(b["text"] for p in parts for b in p["blocks"])
import tempfile
tmp = tempfile.NamedTemporaryFile(prefix="mag-epub-", suffix=".epub", delete=False) tmp = tempfile.NamedTemporaryFile(prefix="mag-epub-", suffix=".epub", delete=False)
tmp.close() tmp.close()
out = Path(tmp.name) out = Path(tmp.name)
@ -3406,17 +3452,19 @@ async def magazine_epub(doc_id: str, article: str = ""):
articles=parts, identifier=f"urn:uuid:{doc_id[:8]}-{doc_id[8:12]}-{doc_id[12:16]}-{doc_id[16:20]}-{doc_id[20:32]}") articles=parts, identifier=f"urn:uuid:{doc_id[:8]}-{doc_id[8:12]}-{doc_id[12:16]}-{doc_id[16:20]}-{doc_id[20:32]}")
except ValueError as exc: except ValueError as exc:
out.unlink(missing_ok=True) out.unlink(missing_ok=True)
shutil.rmtree(tagdir, ignore_errors=True)
raise HTTPException(404, str(exc)) raise HTTPException(404, str(exc))
except BaseException: except BaseException:
out.unlink(missing_ok=True) out.unlink(missing_ok=True)
shutil.rmtree(tagdir, ignore_errors=True)
raise raise
filename = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(meta.get("title") or "magazine")).strip(" ._")[:80] or "magazine" filename = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(meta.get("title") or "magazine")).strip(" ._")[:80] or "magazine"
return FileResponse(out, media_type="application/epub+zip", filename=f"{filename}.epub", return FileResponse(out, media_type="application/epub+zip", filename=f"{filename}.epub",
background=BackgroundTask(lambda: out.unlink(missing_ok=True))) background=BackgroundTask(lambda: (out.unlink(missing_ok=True), shutil.rmtree(tagdir, ignore_errors=True))))
@router.get("/api/magazine/{doc_id}/audio.zip") @router.get("/api/magazine/{doc_id}/audio.zip")
async def magazine_audio_zip(doc_id: str): async def magazine_audio_zip(doc_id: str, provenance: str = "", voice: str = ""):
d, meta = _require_magazine(doc_id) d, meta = _require_magazine(doc_id)
audio_dir = d / "audio" audio_dir = d / "audio"
if not audio_dir.is_dir(): if not audio_dir.is_dir():
@ -3431,7 +3479,8 @@ async def magazine_audio_zip(doc_id: str):
if not ordered: if not ordered:
raise HTTPException(404, "No audio generated yet") raise HTTPException(404, "No audio generated yet")
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf: import tempfile
with tempfile.TemporaryDirectory(prefix="mag-prov-") as tagdir, zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
for i, a in enumerate(ordered, start=1): for i, a in enumerate(ordered, start=1):
page = (a.get("pages") or [0])[0] page = (a.get("pages") or [0])[0]
name = _article_download_filename(meta, a) name = _article_download_filename(meta, a)
@ -3439,7 +3488,8 @@ async def magazine_audio_zip(doc_id: str):
# by filename gives the reading order even where two articles # by filename gives the reading order even where two articles
# start on the same page. # start on the same page.
arcname = f"{i:03d} - " + name arcname = f"{i:03d} - " + name
zf.write(audio_dir / f"{a['id']}.mp3", arcname=arcname) zf.write(await asyncio.to_thread(_export_mp3, audio_dir / f"{a['id']}.mp3", Path(tagdir), provenance, voice),
arcname=arcname)
buf.seek(0) buf.seek(0)
safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine" safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine"
return Response(content=buf.getvalue(), media_type="application/zip", return Response(content=buf.getvalue(), media_type="application/zip",

View File

@ -1543,18 +1543,42 @@ async function magGenerateAllAudio(force = false) {
if (!$('mag-playlist')?.hidden) renderPlaylistTracks(); if (!$('mag-playlist')?.hidden) renderPlaylistTracks();
} }
// Provenance tags on exported audio: per-export checkboxes, default from the saved setting.
let _magProvInit = false;
function magProvQuery() {
const on = $('mag-prov-tags')?.checked !== false;
return '?provenance=' + (on ? '1' : '0') + ($('mag-prov-voice')?.checked ? '&voice=1' : '');
}
function magProvChanged() {
magUpdateAudioZipLink();
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ export_provenance_default: $('mag-prov-tags')?.checked !== false }) }).catch(() => {});
}
async function magProvInit() {
if (_magProvInit) return;
_magProvInit = true;
try {
const s = await fetch('/api/settings').then((r) => r.json());
const box = $('mag-prov-tags');
if (box) box.checked = s.export_provenance_default !== false;
} catch (e) { /* default stays on */ }
magUpdateAudioZipLink();
}
function magUpdateAudioZipLink() { function magUpdateAudioZipLink() {
magProvInit();
const link = $('mag-audio-zip-link'); const link = $('mag-audio-zip-link');
if (!link) return; if (!link) return;
const q = magProvQuery();
const any = state.articles.some((a) => a.has_audio); const any = state.articles.some((a) => a.has_audio);
link.hidden = !any; link.hidden = !any;
if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip'; if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip' + q;
// The EPUB carries only approved articles that have audio. // The EPUB carries only approved articles that have audio.
const epub = $('mag-epub-link'); const epub = $('mag-epub-link');
if (epub) { if (epub) {
const ready = state.articles.some((a) => a.has_audio && a.status === 'approved'); const ready = state.articles.some((a) => a.has_audio && a.status === 'approved');
epub.hidden = !ready; epub.hidden = !ready;
if (ready) epub.href = '/api/magazine/' + state.doc.id + '/epub'; if (ready) epub.href = '/api/magazine/' + state.doc.id + '/epub' + q;
} }
} }
@ -6965,6 +6989,7 @@ root.magChecksToggled = magChecksToggled;
root.magAcrSave = magAcrSave; root.magAcrSave = magAcrSave;
root.magAcrResolve = magAcrResolve; root.magAcrResolve = magAcrResolve;
root.magInit = magInit; root.magInit = magInit;
root.magProvChanged = magProvChanged;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages }; MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };
if (document.readyState === 'loading') { if (document.readyState === 'loading') {

View File

@ -159,6 +159,12 @@
title="An EPUB 3 book whose text highlights while the audio plays (Media Overlays). Approved articles that already have audio."> title="An EPUB 3 book whose text highlights while the audio plays (Media Overlays). Approved articles that already have audio.">
<span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> EPUB with audio <span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> EPUB with audio
</a> </a>
<label class="muted" for="mag-prov-tags" title="Writes tags into the exported copy (stored audio is unchanged): synthetic speech, generator, version, date. No names or paths.">
<input type="checkbox" id="mag-prov-tags" checked onchange="magProvChanged()"> Mark as synthetic speech (tags)
</label>
<label class="muted" for="mag-prov-voice" title="Also write the voice name into the tags.">
<input type="checkbox" id="mag-prov-voice" onchange="magProvChanged()"> include voice name
</label>
<span class="mag-audio-export-progress-wrap" hidden id="mag-audio-export-progress-wrap"> <span class="mag-audio-export-progress-wrap" hidden id="mag-audio-export-progress-wrap">
<progress id="mag-audio-export-progress" max="1" value="0"></progress> <progress id="mag-audio-export-progress" max="1" value="0"></progress>
<span id="mag-audio-export-count" class="muted"></span> <span id="mag-audio-export-count" class="muted"></span>

View File

@ -1610,7 +1610,13 @@ test('the EPUB link appears only when an approved article has audio', async ({ p
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
const link = page.getByRole('link', { name: /EPUB with audio/ }); const link = page.getByRole('link', { name: /EPUB with audio/ });
await expect(link).toBeVisible(); await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub`); await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub?provenance=1`);
// Provenance tags: on by default, voice opt-in, choice is carried by the export links and saved as the default.
await expect(page.locator('#mag-prov-tags')).toBeChecked();
await page.locator('#mag-prov-voice').evaluate(el => el.click());
await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub?provenance=1&voice=1`);
await page.locator('#mag-prov-tags').evaluate(el => el.click());
await expect(page.locator('#mag-audio-zip-link')).toHaveAttribute('href', `/api/magazine/${DOC_ID}/audio.zip?provenance=0&voice=1`);
}); });
test('no EPUB link for an approved article without audio', async ({ page }) => { test('no EPUB link for an approved article without audio', async ({ page }) => {

View File

@ -0,0 +1,51 @@
import subprocess
import tempfile
import unittest
from pathlib import Path
from core import export_provenance as ep
class ProvenanceTags(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = Path(self.tmp.name)
self.src = self.dir / "secret_user_dir_tone.mp3"
subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
str(self.src)], check=True)
def tearDown(self):
self.tmp.cleanup()
def test_tags_present_and_original_untouched(self):
out = ep.apply_tags(self.src, self.dir / "out.mp3", ep.build_tags("1.2.3", "narrator", False))
tags = ep.read_tags(out)
self.assertEqual(tags["synthetic_speech"], "true")
self.assertEqual(tags["generator_version"], "1.2.3")
self.assertNotIn("voice", tags)
self.assertEqual(ep.read_tags(self.src), {})
def test_voice_only_on_opt_in_and_name_only(self):
out = ep.apply_tags(self.src, self.dir / "v.mp3", ep.build_tags("1", "/voices/me/narrator.wav", True))
self.assertEqual(ep.read_tags(out)["voice"], "narrator.wav")
def test_no_path_leakage(self):
out = ep.apply_tags(self.src, self.dir / "o.mp3", ep.build_tags("1", "x", True))
blob = out.read_bytes()
self.assertNotIn(self.tmp.name.encode(), blob)
self.assertNotIn(b"secret_user_dir", blob)
def test_bad_input_raises_without_path(self):
with self.assertRaises(RuntimeError) as cm:
ep.apply_tags(self.dir / "missing.mp3", self.dir / "x.mp3", {})
self.assertNotIn(self.tmp.name, str(cm.exception))
self.assertEqual(ep.read_tags(self.dir / "missing.mp3"), {})
def test_watermark_stub_unavailable(self):
self.assertFalse(ep.watermark_available())
with self.assertRaises(NotImplementedError):
ep.watermark(self.src)
if __name__ == "__main__":
unittest.main()