Files
settled-reach/tooling/db/audio_post.py
T
jpmschweitzerandClaude Opus 4.6 bc226d8baf chore(db): move db/connectors/ to tooling/db/ (#274)
Consolidates all connector scripts under tooling/ per project
structure conventions. Symlink at db/connectors → tooling/db/
preserves backwards compatibility (remove after Sprint 22).

Updated references in CLAUDE.md, Makefile, DEVOPS.md, all skill
files, agent files, rules, schema comments, and Sprint 21
briefings. Python scripts updated with correct SCHEMA_PATH
(now relative to WORKTREE_ROOT/db/schema.sql).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:08:00 +01:00

172 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Audio post-processing wrapper around ffmpeg.
Subcommands:
convert — WAV to OGG (libvorbis, quality 6)
normalize — LUFS normalize to -16 LUFS (broadcast standard)
trim — Remove leading/trailing silence
pipeline — trim + normalize + convert (full post-processing chain)
All operations write to a new file (never overwrites input).
"""
import argparse
import os
import subprocess
import sys
import shutil
import json
def check_ffmpeg():
if not shutil.which("ffmpeg"):
print(json.dumps({"ok": False, "error": "ffmpeg not found in PATH"}))
sys.exit(1)
def run_ffmpeg(args, description):
"""Run ffmpeg, capture output, return success."""
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args
print(f" {description}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(json.dumps({
"ok": False,
"error": f"ffmpeg failed: {result.stderr.strip()}",
"command": " ".join(cmd)
}))
sys.exit(1)
return True
def cmd_convert(args):
"""Convert WAV to OGG (libvorbis)."""
output = args.output or args.input.rsplit(".", 1)[0] + ".ogg"
run_ffmpeg(
["-i", args.input, "-c:a", "libvorbis", "-q:a", str(args.quality), output],
f"converting {os.path.basename(args.input)}{os.path.basename(output)}"
)
size = os.path.getsize(output)
print(json.dumps({"ok": True, "output": output, "size_bytes": size}))
def cmd_normalize(args):
"""LUFS normalize audio file."""
output = args.output or _suffixed(args.input, "_norm")
run_ffmpeg(
["-i", args.input, "-af",
f"loudnorm=I={args.lufs}:LRA=11:TP=-1",
output],
f"normalizing to {args.lufs} LUFS"
)
print(json.dumps({"ok": True, "output": output}))
def cmd_trim(args):
"""Trim leading/trailing silence."""
output = args.output or _suffixed(args.input, "_trimmed")
af = (
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse,"
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse"
)
run_ffmpeg(
["-i", args.input, "-af", af, output],
f"trimming silence (threshold: {args.threshold}dB)"
)
print(json.dumps({"ok": True, "output": output}))
def cmd_pipeline(args):
"""Full post-processing: trim → normalize → convert to OGG."""
base = args.input.rsplit(".", 1)[0]
trimmed = base + "_trimmed.wav"
normalized = base + "_norm.wav"
output = args.output or base + ".ogg"
# Step 1: trim
af_trim = (
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse,"
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse"
)
run_ffmpeg(
["-i", args.input, "-af", af_trim, trimmed],
"step 1/3: trimming silence"
)
# Step 2: normalize
run_ffmpeg(
["-i", trimmed, "-af",
f"loudnorm=I={args.lufs}:LRA=11:TP=-1",
normalized],
f"step 2/3: normalizing to {args.lufs} LUFS"
)
# Step 3: convert
run_ffmpeg(
["-i", normalized, "-c:a", "libvorbis", "-q:a", str(args.quality), output],
f"step 3/3: converting to OGG"
)
# Clean up intermediates
os.remove(trimmed)
os.remove(normalized)
size = os.path.getsize(output)
print(json.dumps({"ok": True, "output": output, "size_bytes": size}))
def _suffixed(path, suffix):
base, ext = os.path.splitext(path)
return base + suffix + ext
def main():
parser = argparse.ArgumentParser(description="Audio post-processing (ffmpeg wrapper)")
sub = parser.add_subparsers(dest="command", required=True)
# convert
p = sub.add_parser("convert", help="WAV → OGG")
p.add_argument("input", help="Input WAV file")
p.add_argument("--output", "-o", help="Output file (default: same name .ogg)")
p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)")
p.set_defaults(func=cmd_convert)
# normalize
p = sub.add_parser("normalize", help="LUFS normalize")
p.add_argument("input", help="Input audio file")
p.add_argument("--output", "-o", help="Output file")
p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)")
p.set_defaults(func=cmd_normalize)
# trim
p = sub.add_parser("trim", help="Trim silence")
p.add_argument("input", help="Input audio file")
p.add_argument("--output", "-o", help="Output file")
p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)")
p.set_defaults(func=cmd_trim)
# pipeline
p = sub.add_parser("pipeline", help="Full post-processing: trim + normalize + convert")
p.add_argument("input", help="Input WAV file")
p.add_argument("--output", "-o", help="Output OGG file")
p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)")
p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)")
p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)")
p.set_defaults(func=cmd_pipeline)
args = parser.parse_args()
check_ffmpeg()
args.func(args)
if __name__ == "__main__":
main()