#!/usr/bin/env python3 """Produce bounded audio evidence and a reviewable podcast edit plan.""" from __future__ import annotations import argparse import hashlib import json import math import os import re import shutil import subprocess import sys from pathlib import Path from typing import Any class AudioError(Exception): def __init__(self, status: str, message: str, exit_code: int = 2) -> None: super().__init__(message) self.status = status self.message = message self.exit_code = exit_code def resolve_tool(name: str) -> str: expanded = os.path.expanduser(name) if os.path.sep in expanded: candidate = Path(expanded) if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate.resolve()) else: resolved = shutil.which(name) if resolved: return resolved raise AudioError("missing_tool", f"executable not found: {name}", 3) def run(argv: list[str], timeout: float) -> subprocess.CompletedProcess[str]: try: return subprocess.run( argv, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as exc: raise AudioError("timeout", f"command exceeded {timeout:g} seconds", 4) from exc except OSError as exc: raise AudioError("tool_start_failed", str(exc), 3) from exc def require_success(result: subprocess.CompletedProcess[str], status: str) -> None: if result.returncode: detail = result.stderr.strip()[-2000:] or result.stdout.strip()[-2000:] raise AudioError(status, detail or f"command exited {result.returncode}", 1) def finite(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) def ffmpeg_window(args: argparse.Namespace) -> list[str]: values: list[str] = [] if args.analysis_start: values.extend(["-ss", f"{args.analysis_start:g}"]) values.extend(["-t", f"{args.analysis_duration:g}"]) return values def inventory_filters(ffmpeg: str, timeout: float) -> set[str]: result = run([ffmpeg, "-hide_banner", "-filters"], timeout) require_success(result, "filter_inventory_failed") names: set[str] = set() for line in result.stdout.splitlines(): parts = line.split() if len(parts) >= 2 and set(parts[0]).issubset(set(".TSCAVN|->")): names.add(parts[1]) return names def parse_probe(stdout: str) -> dict[str, Any]: try: document = json.loads(stdout) except json.JSONDecodeError as exc: raise AudioError("invalid_json", f"invalid ffprobe JSON: {exc}", 1) from exc if not isinstance(document, dict): raise AudioError("invalid_json", "ffprobe JSON root must be an object", 1) streams = document.get("streams", []) if not isinstance(streams, list) or not any( isinstance(stream, dict) and stream.get("codec_type") == "audio" for stream in streams ): raise AudioError("audio_stream_missing", "probe did not report an audio stream", 1) return document def probe_duration(document: dict[str, Any]) -> float | None: raw = document.get("format", {}).get("duration") try: return float(raw) except (TypeError, ValueError): return None def parse_silence(log: str, analysis_end: float | None) -> list[dict[str, Any]]: starts = [float(value) for value in re.findall(r"silence_start:\s*([-+0-9.eE]+)", log)] ends = [ (float(end), float(duration)) for end, duration in re.findall( r"silence_end:\s*([-+0-9.eE]+)\s*\|\s*silence_duration:\s*([-+0-9.eE]+)", log, ) ] intervals: list[dict[str, Any]] = [] for index, start in enumerate(starts): if index < len(ends): end, measured_duration = ends[index] intervals.append({"start": start, "end": end, "duration": measured_duration}) else: intervals.append( { "start": start, "end": analysis_end, "duration": None if analysis_end is None else max(0.0, analysis_end - start), "open_ended": True, } ) return intervals def last_measure(pattern: str, log: str) -> float | None: values = re.findall(pattern, log, flags=re.MULTILINE) if not values: return None value = values[-1] if value.lower() in {"-inf", "inf", "+inf"}: return None return float(value) def measurement( ffmpeg: str, source: str, filter_name: str, filter_expression: str, filters: set[str], args: argparse.Namespace, ) -> tuple[dict[str, Any], str | None]: if filter_name not in filters: return { "status": "UNAVAILABLE", "filter": filter_name, "reason": f"local FFmpeg filter inventory does not contain {filter_name}", }, None command = [ ffmpeg, "-nostats", "-v", "info", *ffmpeg_window(args), "-i", source, "-map", "0:a:0", "-af", filter_expression, "-f", "null", "-", ] result = run(command, args.timeout) require_success(result, f"{filter_name}_failed") return { "status": "MEASURED", "filter": filter_name, "command": command, "analysis_window": { "start_seconds": args.analysis_start, "duration_seconds": args.analysis_duration, }, }, result.stderr def load_transcript(path: str | None, source_duration: float | None) -> dict[str, Any]: if path is None: return {"status": "NOT_PROVIDED", "candidates": []} try: document = json.loads(Path(path).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise AudioError("invalid_transcript", f"could not load transcript JSON: {exc}") from exc if not isinstance(document, dict) or not isinstance(document.get("segments"), list): raise AudioError("invalid_transcript", "transcript must contain a segments array") quality = document.get("quality") if not isinstance(quality, dict) or not quality: raise AudioError("invalid_transcript", "transcript must disclose timing/alignment quality") candidates: list[dict[str, Any]] = [] for index, segment in enumerate(document["segments"]): if not isinstance(segment, dict): raise AudioError("invalid_transcript", f"segment {index} must be an object") start, end, text = segment.get("start"), segment.get("end"), segment.get("text") if ( not finite(start) or not finite(end) or start < 0 or end <= start or not isinstance(text, str) ): raise AudioError("invalid_transcript", f"segment {index} has invalid timing or text") if source_duration is not None and end > source_duration: raise AudioError("invalid_transcript", f"segment {index} exceeds source duration") candidates.append( { "id": segment.get("id", f"transcript-{index + 1}"), "source_range": {"in": start, "out": end}, "action": segment.get("proposed_action", "review"), "reason": segment.get("reason", "transcript navigation candidate"), "evidence": { "type": "transcript", "segment_index": index, "text_sha256": hashlib.sha256(text.encode()).hexdigest(), "timing_quality": quality, }, "confidence": segment.get("confidence"), "review_status": "needs_listening_review", } ) return {"status": "CANDIDATES_ONLY", "quality": quality, "candidates": candidates} def write_exclusive(path: str, value: object) -> None: target = Path(path).expanduser() try: with target.open("x", encoding="utf-8") as handle: json.dump(value, handle, indent=2, sort_keys=True) handle.write("\n") except FileExistsError as exc: raise AudioError("output_exists", f"refusing to overwrite report: {target}") from exc except OSError as exc: raise AudioError("report_write_failed", str(exc)) from exc def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("input") parser.add_argument("--ffmpeg", default="ffmpeg") parser.add_argument("--ffprobe", default="ffprobe") parser.add_argument("--timeout", type=float, default=30.0) parser.add_argument("--analysis-start", type=float, default=0.0) parser.add_argument("--analysis-duration", type=float, default=300.0) parser.add_argument("--silence", action="store_true", help="alias for --measure-silence") parser.add_argument("--measure-silence", action="store_true") parser.add_argument("--measure-loudness", action="store_true") parser.add_argument("--measure-clipping", action="store_true") parser.add_argument("--silence-threshold", default="-40dB") parser.add_argument("--silence-duration", type=float, default=0.5) parser.add_argument("--clipping-threshold-db", type=float, default=-0.1) parser.add_argument("--transcript", help="timed transcript JSON with disclosed quality") parser.add_argument("--handles", type=float, default=0.05) parser.add_argument("--fade-duration", type=float, default=0.01) parser.add_argument("--target-lufs", type=float) parser.add_argument("--true-peak-limit", type=float) parser.add_argument("--output-codec") parser.add_argument("--output-sample-rate", type=int) parser.add_argument("--output-channel-layout") parser.add_argument("--report-output", help="write report to a new path; overwrite is refused") parser.add_argument("--json", action="store_true") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if not 0 < args.timeout <= 300: raise AudioError("invalid_limit", "--timeout must be greater than 0 and at most 300") if not args.analysis_start >= 0 or not 0 < args.analysis_duration <= 3600: raise AudioError("invalid_limit", "analysis window must be within 0-3600 seconds") if not 0 < args.silence_duration <= args.analysis_duration: raise AudioError( "invalid_threshold", "silence duration must fit inside the analysis window" ) if not -20 <= args.clipping_threshold_db <= 0: raise AudioError( "invalid_threshold", "clipping threshold must be between -20 and 0 dBFS" ) if args.handles < 0 or args.fade_duration < 0: raise AudioError("invalid_plan", "handles and fade duration must be non-negative") ffprobe = resolve_tool(args.ffprobe) source = str(Path(args.input).expanduser()) probe_command = [ ffprobe, "-v", "error", "-show_format", "-show_streams", "-of", "json", source, ] probe_result = run(probe_command, args.timeout) require_success(probe_result, "probe_failed") probe = parse_probe(probe_result.stdout) source_duration = probe_duration(probe) requested = ( args.silence or args.measure_silence or args.measure_loudness or args.measure_clipping ) ffmpeg: str | None = None filters: set[str] = set() ffmpeg_version: str | None = None if requested: ffmpeg = resolve_tool(args.ffmpeg) version_result = run([ffmpeg, "-version"], args.timeout) require_success(version_result, "version_failed") ffmpeg_version = version_result.stdout.splitlines()[0] filters = inventory_filters(ffmpeg, args.timeout) evidence: dict[str, Any] = {} if args.silence or args.measure_silence: result, log = measurement( ffmpeg or "ffmpeg", source, "silencedetect", f"silencedetect=noise={args.silence_threshold}:d={args.silence_duration:g}", filters, args, ) result.update( { "evidence_class": "threshold_candidate", "threshold": args.silence_threshold, "minimum_duration_seconds": args.silence_duration, "editorial_status": "needs_listening_review", } ) if log is not None: analysis_end = args.analysis_start + min( args.analysis_duration, source_duration or args.analysis_duration, ) result["intervals"] = parse_silence(log, analysis_end) evidence["silence"] = result if args.measure_loudness: result, log = measurement( ffmpeg or "ffmpeg", source, "ebur128", "ebur128=peak=true", filters, args, ) if log is not None: result["integrated_lufs"] = last_measure(r"^\s*I:\s+(-?inf|[-+0-9.]+)\s+LUFS", log) result["true_peak_dbfs"] = last_measure( r"^\s*Peak:\s+(-?inf|[-+0-9.]+)\s+dBFS", log ) result["target_lufs"] = args.target_lufs result["true_peak_limit_dbfs"] = args.true_peak_limit evidence["loudness"] = result if args.measure_clipping: result, log = measurement( ffmpeg or "ffmpeg", source, "astats", "astats=metadata=1:reset=0", filters, args, ) if log is not None: peak = last_measure(r"Peak level dB:\s+(-?inf|[-+0-9.]+)", log) result.update( { "peak_level_dbfs": peak, "candidate_threshold_dbfs": args.clipping_threshold_db, "clipping_candidate": peak is not None and peak >= args.clipping_threshold_db, "editorial_status": "needs_listening_review", } ) evidence["clipping"] = result transcript = load_transcript(args.transcript, source_duration) plan = { "schema_version": 1, "source": source, "source_preservation": "original_untouched", "overwrite_policy": "refuse", "output_contract": { "target_lufs": args.target_lufs, "true_peak_limit_dbfs": args.true_peak_limit, "codec": args.output_codec, "sample_rate": args.output_sample_rate, "channel_layout": args.output_channel_layout, }, "candidates": [ { **candidate, "handles_seconds": args.handles, "fade_seconds": args.fade_duration, } for candidate in transcript["candidates"] ], "assembly_options": { "voice_music_separation": "UNPLANNED", "ducking": "UNPLANNED", "intro_outro": "UNPLANNED", "chapters": "UNPLANNED", "metadata": "UNPLANNED", }, "approval_gate": "no candidate becomes an edit without listening review", } report = { "ok": True, "status": "MEASURED_WITH_BOUNDARIES" if evidence else "PROBED", "input": source, "tool_context": {"ffprobe_command": probe_command, "ffmpeg_version": ffmpeg_version}, "probe": probe, "analysis": evidence, "transcript": transcript, "podcast_edit_plan": plan, "unverified": [ "speaker identity", "transcript semantic accuracy", "editorial suitability of silence or clipping candidates", "listening quality", "downstream compatibility", ], } if args.report_output: write_exclusive(args.report_output, report) print( json.dumps(report, sort_keys=True, separators=(",", ":")) if args.json else json.dumps(report, indent=2, sort_keys=True) ) return 0 except AudioError as exc: print(json.dumps({"ok": False, "status": exc.status, "error": exc.message}, sort_keys=True)) return exc.exit_code if __name__ == "__main__": sys.exit(main())