diff options
| author | historia <historiavg@proton.me> | 2026-09-07 06:47:47 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-07 06:47:47 -0400 |
| commit | 84dd2d068317998f6fb59400c534ef5be6b51b53 (patch) | |
| tree | 025293e9d9229e02960374771ae522d9de2628ce /lib/project/src/voiceforge/cli.py | |
| parent | 39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff) | |
| download | producer-84dd2d068317998f6fb59400c534ef5be6b51b53.tar.gz | |
Diffstat (limited to 'lib/project/src/voiceforge/cli.py')
| -rw-r--r-- | lib/project/src/voiceforge/cli.py | 193 |
1 files changed, 193 insertions, 0 deletions
diff --git a/lib/project/src/voiceforge/cli.py b/lib/project/src/voiceforge/cli.py new file mode 100644 index 0000000..52cd1bd --- /dev/null +++ b/lib/project/src/voiceforge/cli.py @@ -0,0 +1,193 @@ +"""Command-line entry point; configuration errors fail before model downloads.""" +import argparse +from dataclasses import asdict, fields +import json +import math +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import tomllib + +from rich.console import Console + +from . import __version__ +from .audio import ffmpeg_run, loudness, measure +from .config import PROFILES, Settings, resolve, toml +from .pipeline import check_target, process, targets_for +from .progress import Display +from .setup import data_dir, ensure_ffmpeg + + +def parser(): + app = argparse.ArgumentParser(prog="producer", description="Local speech cleanup and podcast mastering. Originals are never overwritten.") + app.add_argument("--version", action="version", version=__version__) + sub = app.add_subparsers(dest="command", required=True) + for name in ("process", "preview", "config", "analyze"): + cmd = sub.add_parser(name) + cmd.add_argument("--config", type=Path, help="TOML configuration file") + cmd.add_argument("--profile", choices=PROFILES, default=None) + cmd.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="Override any setting using TOML value syntax") + for field in fields(Settings): + if field.name == "profile": + continue + default = getattr(Settings(), field.name) + option = "--" + field.name.replace("_", "-") + if isinstance(default, bool): + cmd.add_argument(option, action=argparse.BooleanOptionalAction, default=None) + else: + cmd.add_argument(option, type=type(default), default=None) + cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none") + if name == "config": + cmd.add_argument("--json", action="store_true") + else: + cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1) + if name in ("process", "preview"): + cmd.add_argument("--output-dir", type=Path, default=None, + help="Directory for generated outputs (default: beside each input)") + cmd.add_argument("--overwrite", action="store_true", help="Replace generated outputs, never input files") + if name == "process": + cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)") + if name == "preview": + cmd.add_argument("--start", type=float, default=0) + cmd.add_argument("--duration", type=float, default=30) + cmd.add_argument("--profiles", nargs="+", choices=PROFILES, default=["natural", "narrator", "radio"]) + sub.add_parser("doctor", help="Check tools and driver without downloading AI") + setup = sub.add_parser("setup", help="Download and self-test the isolated AI backend") + setup.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto") + return app + + +def settings_for(args, profile=None): + overrides = {field.name: getattr(args, field.name) for field in fields(Settings) + if field.name != "profile" and getattr(args, field.name, None) is not None} + for item in args.set: + key, sep, value = item.partition("=") + if not sep: + raise ValueError("--set expects KEY=VALUE") + # Strings can be supplied without TOML quotes for CLI convenience. + try: + parsed = tomllib.loads("value = " + value)["value"] + except tomllib.TOMLDecodeError: + parsed = value + overrides[key.strip()] = parsed + return resolve(args.config, profile or args.profile, overrides) + + +def require_input(source: Path) -> None: + if not source.is_file() or source.suffix.lower() != ".wav": + raise ValueError(f"Input must be an existing WAV file: {source}") + + +def main(): + app = parser() + args = app.parse_args() + console = Console(stderr=True) + try: + if args.command == "doctor": + from .ai import _cuda_available + ffmpeg = ensure_ffmpeg() + version = subprocess.run([ffmpeg, "-version"], capture_output=True, text=True, check=True).stdout.splitlines()[0] + console.print(version, markup=False) + console.print(f"FFmpeg: {ffmpeg}\nData: {data_dir()}\nCUDA driver available: {_cuda_available()}\nAI is installed and self-tested by './producer.sh setup'.", markup=False) + return + if args.command == "setup": + from .ai import ensure_ai + with Display() as display: + ensure_ffmpeg() + python = ensure_ai(args.device, display.update, verify=True) + console.print(f"AI ready: {python}", markup=False) + return + settings = settings_for(args) + if args.command == "config": + print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "") + return + with Display() as display: + if args.command == "analyze": + stats = measure(args.inputs[0], display.update) + stats["loudness"] = loudness(ensure_ffmpeg(), args.inputs[0], settings, stats["duration_seconds"], display.update) + print(json.dumps(stats, indent=2, allow_nan=False)) + return + if args.command == "process": + if args.output and len(args.inputs) != 1: + raise ValueError("--output requires exactly one input") + if args.output and args.output_dir: + raise ValueError("--output and --output-dir cannot be combined") + # Without an explicit destination, outputs are written beside + # each input; --output-dir keeps every result in one directory. + outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav" + for p in args.inputs] + # Preflight the whole plan so a predictable conflict cannot + # fail a batch after earlier inputs have already been published. + sources = {p.resolve() for p in args.inputs} + claimed: set[Path] = set() + for source, output in zip(args.inputs, outputs): + require_input(source) + targets = targets_for(output.absolute(), settings) + resolved = {target.resolve() for target in targets} + if len(resolved) < len(targets) or resolved & claimed: + raise ValueError("Inputs have duplicate output names; process them separately with --output") + claimed |= resolved + if resolved & sources or any( + target.exists() and os.path.samefile(source, target) for target in targets): + raise ValueError("An output would overwrite an input recording") + for target in targets: + check_target(source, target, args.overwrite) + for source, output in zip(args.inputs, outputs): + report = process(source, output, settings, display.update, args.overwrite) + console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) + for warning in report["warnings"]: + console.print(f"Warning: {warning}", markup=False) + return + if not math.isfinite(args.start) or args.start < 0 or not 0 < args.duration <= 300: + raise ValueError("Preview start must be >= 0 and duration must be in (0, 300] seconds") + if len(set(args.profiles)) != len(args.profiles): + raise ValueError("Preview profiles must be unique") + if any(item.partition("=")[0].strip() == "profile" for item in args.set): + raise ValueError("Preview profiles come from --profiles; remove the profile override from --set") + source = args.inputs[0] + require_input(source) + original = source.resolve() + output_dir = args.output_dir or source.parent + # Preflight every preview destination before extracting the excerpt. + claimed: set[Path] = set() + for profile in args.profiles: + output = output_dir / f"{source.stem}.{profile}.preview.wav" + if output.resolve() == original: + raise ValueError("Preview output would overwrite the original") + targets = [output, output.with_suffix(".report.json")] + resolved = {target.resolve() for target in targets} + if len(resolved) < len(targets) or resolved & claimed: + raise ValueError("Preview outputs have duplicate names") + claimed |= resolved + for target in targets: + check_target(original, target, args.overwrite) + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp: + excerpt = Path(tmp) / "excerpt.wav" + ffmpeg_run(ensure_ffmpeg(), ["-ss", str(args.start), "-i", str(original), "-t", str(args.duration), + "-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update) + for profile in args.profiles: + configured = settings_for(args, profile) + # Equal loudness prevents a louder preset winning an unfair A/B. + configured.normalize = True + configured.limiter = True + output = output_dir / f"{source.stem}.{profile}.preview.wav" + report = process(excerpt, output, configured, display.update, args.overwrite, + provenance={"original": str(original), "preview_start_seconds": args.start, + "requested_duration_seconds": args.duration, + "normalization_forced": True}) + console.print(f"Preview: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False) + for warning in report["warnings"]: + console.print(f"Warning: {warning}", markup=False) + except KeyboardInterrupt: + console.print("Cancelled; temporary files removed. Completed outputs may remain.") + raise SystemExit(130) + except (ValueError, OSError, RuntimeError, subprocess.SubprocessError) as error: + console.print(f"Error: {error}", markup=False) + raise SystemExit(1) + + +if __name__ == "__main__": + main() |
