tts-voice-creator-clone-and.../core/export_provenance.py
mARTin-B78 5a7c73ea42 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>
2026-10-04 17:02:41 +02:00

72 lines
3.0 KiB
Python

"""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)