- make test-tooling: planet-gen determinism guard + import_economics
--dry-run, wired into pre-push on TOOLING_CHANGED; ruff widened to
E4/E7/E9/F/W (90 safe auto-fixes applied; E402/E702/F841 ignored with
documented counts)
- one-generator reality fixed in DEVOPS.md, asset-pipeline rule, CLAUDE.md
(import_economics sole generator since #951/D-223); dead check-protocol
target deleted; DEVOPS hook/config sections rewritten from the actual
hook sources; team-patterns gate description updated (client+tooling)
- project.yaml: 0.2.0 → 0.4.0 per the 0.{phase}.{n} scheme, description
refreshed from the v0.1 Sova narration to cascade reality
- stale comment sweep: voxel.rs stub claims (all 8 families implemented),
cascade.rs TODO recited to T-1044, main.rs D-192 handshake claim,
relationships.rs/chunk_streaming.rs version targets → phase language
- gitignore: client/settings.db* e2e-run artifacts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 lines
5.5 KiB
Python
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],
|
|
"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()
|