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>
72 lines
3.0 KiB
Python
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)
|