aboutsummaryrefslogtreecommitdiff
path: root/lib/src
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-07 06:47:47 -0400
committerhistoria <historiavg@proton.me>2026-09-07 06:47:47 -0400
commit84dd2d068317998f6fb59400c534ef5be6b51b53 (patch)
tree025293e9d9229e02960374771ae522d9de2628ce /lib/src
parent39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff)
downloadproducer-main.tar.gz
slop rewriteHEADmain
Diffstat (limited to 'lib/src')
-rw-r--r--lib/src/__main__.py4
-rw-r--r--lib/src/producer/__init__.py1
-rw-r--r--lib/src/producer/cli.py321
-rw-r--r--lib/src/producer/config.py286
-rw-r--r--lib/src/producer/doctor.py95
-rw-r--r--lib/src/producer/dsp.py283
-rw-r--r--lib/src/producer/engines/__init__.py0
-rw-r--r--lib/src/producer/engines/base.py43
-rw-r--r--lib/src/producer/engines/chunking.py166
-rw-r--r--lib/src/producer/engines/denoise_dfn.py140
-rw-r--r--lib/src/producer/engines/denoise_spectral.py186
-rw-r--r--lib/src/producer/engines/denoise_zip.py68
-rw-r--r--lib/src/producer/engines/enhance_mossformer.py70
-rw-r--r--lib/src/producer/engines/enhance_resemble.py95
-rw-r--r--lib/src/producer/engines/resemble_worker.py68
-rw-r--r--lib/src/producer/io.py87
-rw-r--r--lib/src/producer/lazy.py257
-rw-r--r--lib/src/producer/loudness.py30
-rw-r--r--lib/src/producer/meters.py112
-rw-r--r--lib/src/producer/pipeline.py269
-rw-r--r--lib/src/producer/report.py56
-rw-r--r--lib/src/producer/ui.py319
-rw-r--r--lib/src/producer/updates.py273
23 files changed, 0 insertions, 3229 deletions
diff --git a/lib/src/__main__.py b/lib/src/__main__.py
deleted file mode 100644
index cfe0046..0000000
--- a/lib/src/__main__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from producer.cli import main
-
-if __name__ == "__main__":
- main()
diff --git a/lib/src/producer/__init__.py b/lib/src/producer/__init__.py
deleted file mode 100644
index 3dc1f76..0000000
--- a/lib/src/producer/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = "0.1.0"
diff --git a/lib/src/producer/cli.py b/lib/src/producer/cli.py
deleted file mode 100644
index 779de96..0000000
--- a/lib/src/producer/cli.py
+++ /dev/null
@@ -1,321 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import itertools
-import os
-import sys
-from pathlib import Path
-
-from . import __version__, pipeline, ui
-from . import config as cfgmod
-from . import dsp as pdsp
-from . import io as pio
-from . import report as repmod
-from .config import DSP_KEYS, PROFILES, Options
-
-DATA_DIR = Path(__file__).resolve().parents[2]
-EXTS = {".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".wma"}
-
-
-def build_parser() -> argparse.ArgumentParser:
- p = argparse.ArgumentParser(
- prog="producer",
- description="One-click narration/podcast mastering: denoise, enhance, voice DSP, loudness.",
- )
- p.add_argument("inputs", nargs="*", help="audio file(s) to process")
- p.add_argument("doctor", nargs="?", help=argparse.SUPPRESS)
- p.add_argument("-o", "--output", help="output file, or directory for batch")
- p.add_argument("--profile", choices=tuple(PROFILES), help="audiobook (default) or podcast")
- p.add_argument(
- "--denoise",
- choices=["dfn3", "zipenhancer", "spectral", "off"],
- help="denoise engine (default dfn3)",
- )
- p.add_argument("--denoise-strength", type=float, help="0-1 blend of denoised signal")
- p.add_argument(
- "--denoise-pf",
- action="store_true",
- dest="denoise_pf",
- default=None,
- help="enable the DeepFilterNet post filter (extra noise reduction, "
- "may eat soft speech on clean recordings)",
- )
- p.add_argument(
- "--enhance", choices=["off", "mossformer2", "resemble"], help="speech enhancement engine"
- )
- p.add_argument("--enhance-strength", type=float, help="0-1 blend of enhanced signal")
- p.add_argument(
- "--no-dsp", action="store_false", dest="dsp", help="skip EQ/compression/de-ess chain"
- )
- p.add_argument(
- "--no-levelling", action="store_false", dest="levelling", help="skip loudness stage"
- )
- for key in DSP_KEYS:
- p.add_argument(f"--{key}", type=float, metavar="0-1", help=f"strength for {key} stage")
- p.add_argument("--hpf-hz", type=float, help="high-pass corner (default 80)")
- p.add_argument("--target", type=float, help="loudness target (RMS dB or LUFS per profile)")
- p.add_argument("--ceiling", type=float, dest="ceiling_db", help="true-peak ceiling in dB")
- p.add_argument("--sample-rate", type=int, help="output sample rate (44100/48000)")
- p.add_argument(
- "--bit-depth", type=int, choices=[16, 24, 32], default=None, help="wav/flac bit depth"
- )
- p.add_argument(
- "--format", choices=["wav", "flac", "mp3"], dest="out_format", help="output format"
- )
- p.add_argument("--device", choices=["auto", "cuda", "cpu"], help="compute device")
- p.add_argument(
- "--engine-chunk",
- type=float,
- dest="engine_chunk_s",
- metavar="SEC",
- help="GPU engine chunk length in seconds (default 30, 0 = whole file)",
- )
- p.add_argument(
- "--engine-overlap",
- type=float,
- dest="engine_overlap_s",
- metavar="SEC",
- help="GPU engine chunk overlap in seconds (default 0.5)",
- )
- p.add_argument("--batch", action="store_true", help="inputs are directories/globs to expand")
- p.add_argument("--report", action="store_true", help="write <output>.report.json")
- p.add_argument("--report-path", help="explicit report file path")
- p.add_argument("--dry-run", action="store_true", help="show the processing chain and exit")
- p.add_argument(
- "--no-update-check", action="store_true", help="skip the dependency update check"
- )
- p.add_argument("--config", help="config file (default config.toml in the project root)")
- p.add_argument("-v", "--verbose", action="count", default=0)
- p.add_argument("--version", action="store_true")
- return p
-
-
-def _apply_args(opts: Options, args: argparse.Namespace) -> None:
- m = {
- "profile": "profile",
- "denoise": "denoise",
- "denoise_strength": "denoise_strength",
- "denoise_pf": "denoise_pf",
- "enhance": "enhance",
- "enhance_strength": "enhance_strength",
- "output": "output",
- "hpf_hz": "hpf_hz",
- "target": "target",
- "ceiling_db": "ceiling_db",
- "sample_rate": "sample_rate",
- "bit_depth": "bit_depth",
- "out_format": "out_format",
- "device": "device",
- "engine_chunk_s": "engine_chunk_s",
- "engine_overlap_s": "engine_overlap_s",
- "report": "report",
- "report_path": "report_path",
- "dry_run": "dry_run",
- "verbose": "verbose",
- }
- for arg_name, field_name in m.items():
- v = getattr(args, arg_name, None)
- if v is not None:
- setattr(opts, field_name, v)
- if args.dsp is False:
- opts.dsp = False
- if args.levelling is False:
- opts.levelling = False
- for key in DSP_KEYS:
- v = getattr(args, key, None)
- if v is not None:
- opts.strengths[key] = float(v)
- if opts.profile not in PROFILES:
- raise SystemExit(f"unknown profile: {opts.profile}")
- if not 0.0 <= opts.denoise_strength <= 1.0 or not 0.0 <= opts.enhance_strength <= 1.0:
- raise SystemExit("engine strengths must be within 0-1")
- if opts.engine_chunk_s < 0.0 or opts.engine_overlap_s < 0.0:
- raise SystemExit("--engine-chunk/--engine-overlap must be >= 0")
- if opts.engine_chunk_s > 0.0 and opts.engine_overlap_s >= opts.engine_chunk_s:
- raise SystemExit("--engine-overlap must be smaller than --engine-chunk")
- for k in DSP_KEYS:
- v = opts.strengths[k]
- if v is not None and not 0.0 <= float(v) <= 1.0:
- raise SystemExit(f"--{k} must be within 0-1")
-
-
-def _expand_inputs(args: argparse.Namespace) -> list[Path]:
- inputs: list[Path] = []
- for raw in args.inputs:
- p = Path(raw)
- if args.batch and p.is_dir():
- inputs.extend(sorted(q for q in p.iterdir() if q.suffix.lower() in EXTS))
- elif args.batch and not p.exists():
- import glob
-
- for q in sorted(glob.glob(raw)):
- q = Path(q)
- if q.suffix.lower() in EXTS:
- inputs.append(q)
- else:
- inputs.append(p)
- return inputs
-
-
-def _resolve_output(inp: Path, opts: Options, single: bool) -> Path:
- ext = opts.out_format.lower()
- if opts.output:
- o = Path(opts.output)
- if single:
- return o if o.suffix else o.with_suffix("." + ext)
- o.mkdir(parents=True, exist_ok=True)
- return o / f"{inp.stem}_processed.{ext}"
- return inp.with_name(f"{inp.stem}_processed.{ext}")
-
-
-def _next_free(path: Path) -> Path:
- for i in itertools.count(1):
- cand = path.with_name(f"{path.stem}_{i}{path.suffix}")
- if not cand.exists():
- return cand
-
-
-def _resolve_output_conflict(out_path: Path, interactive: bool) -> Path | None:
- """Returns the path to write, or None when the user cancels."""
- if not out_path.exists():
- return out_path
- alt = _next_free(out_path)
- if not interactive:
- ui.log(f"[producer] {out_path} exists, writing {alt.name} instead")
- return alt
- while True:
- try:
- ans = (
- input(
- f"[producer] {out_path} exists "
- f"([o]verwrite / [r]ename to {alt.name} / [c]ancel): "
- )
- .strip()
- .lower()
- )
- except EOFError:
- print()
- return None
- if ans in ("o", "overwrite"):
- return out_path
- if ans in ("r", "rename"):
- return alt
- if ans in ("c", "cancel"):
- return None
- print("[producer] please answer o, r, or c")
-
-
-def process_one(
- inp: Path,
- opts: Options,
- single: bool,
- quiet: bool = False,
- pos: tuple[int, int] | None = None,
-) -> Path | None:
- out_path = _resolve_output(inp, opts, single)
- out_path = _resolve_output_conflict(out_path, sys.stdin.isatty())
- if out_path is None:
- ui.log(f"[producer] skipped {inp}")
- return None
- x, sr = pio.decode(inp)
- target_sr = opts.out_sample_rate()
- pos_s = f"[{pos[0]}/{pos[1]}] " if pos else ""
- ui.log(f"[producer] {pos_s}{inp} ({sr} Hz, {len(x) / sr:.1f}s)")
- status = ui.Status(prefix=f"[producer] {pos_s}{inp.name} — ")
- res = pipeline.run_pipeline(x, sr, opts, reporter=status)
- status.finish()
- y = res.audio
- if target_sr != sr:
- y = pdsp.resample(y, sr, target_sr)
- pio.encode(y, target_sr, out_path, opts.out_format, opts.bit_depth)
- rep = repmod.build(str(inp), str(out_path), opts, res.before, res.after, res.timings, res.notes)
- if opts.report or opts.report_path:
- rp = (
- Path(opts.report_path)
- if opts.report_path
- else out_path.with_name(out_path.stem + ".report.json")
- )
- repmod.save(rep, rp)
- if not quiet:
- repmod.print_human(rep)
- ui.log(f"[producer] {pos_s}wrote {out_path}")
- return out_path
-
-
-def main(argv: list[str] | None = None) -> int:
- argv = list(sys.argv[1:] if argv is None else argv)
- if argv and argv[0] == "doctor":
- from . import doctor
-
- return doctor.main(argv[1:])
- if argv and argv[0] == "update":
- from . import updates
-
- return updates.run_update_command(argv[1:])
- parser = build_parser()
- args = parser.parse_args(argv)
- if args.version:
- print(f"producer {__version__}")
- return 0
- if (
- not args.no_update_check
- and os.environ.get("PRODUCER_NO_UPDATE_CHECK") != "1"
- and not args.dry_run
- ):
- try:
- from . import updates
-
- updates.check_and_prompt()
- except Exception:
- pass
- opts = Options()
- cfg_path = Path(args.config) if args.config else DATA_DIR.parent / "config.toml"
- if not args.config:
- cfgmod.write_default_config(cfg_path)
- cfgmod.apply_config(opts, cfgmod.load_config(cfg_path))
- _apply_args(opts, args)
- inputs = _expand_inputs(args)
- if not inputs:
- parser.error("no input files given")
- if len(inputs) > 1 and opts.output:
- o = Path(opts.output)
- if o.suffix and not o.is_dir():
- parser.error(
- "-o/--output must be a directory when processing multiple files "
- "(one <stem>_processed.<ext> file is written per input)"
- )
- if opts.dry_run:
- stages = pipeline.build_stages(opts)
- print(f"producer dry run (profile={opts.profile}, format={opts.out_format})")
- for st in stages:
- state = "on " if st.enabled else "off"
- print(f" [{state}] {st.name:<10} {st.detail}")
- print(f" output: {opts.out_format} @ {opts.out_sample_rate()} Hz, {opts.bit_depth}-bit")
- return 0
- (DATA_DIR / "models").mkdir(parents=True, exist_ok=True)
- (DATA_DIR / "cache").mkdir(parents=True, exist_ok=True)
- failed = 0
- single = len(inputs) == 1
- total = len(inputs)
- for idx, inp in enumerate(inputs, start=1):
- try:
- process_one(
- inp,
- opts,
- single,
- quiet=total > 1,
- pos=(idx, total) if total > 1 else None,
- )
- except Exception as e:
- ui.finish_live()
- failed += 1
- print(f"[producer] ERROR {inp}: {e}", file=sys.stderr)
- if opts.verbose:
- import traceback
-
- traceback.print_exc()
- return 1 if failed else 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/lib/src/producer/config.py b/lib/src/producer/config.py
deleted file mode 100644
index 968df4a..0000000
--- a/lib/src/producer/config.py
+++ /dev/null
@@ -1,286 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any
-
-import tomllib
-
-DSP_KEYS = (
- "hpf",
- "mud",
- "warmth",
- "soothe",
- "compress",
- "tape",
- "deess",
- "presence",
- "air",
- "breath",
-)
-
-
-@dataclass
-class Profile:
- name: str
- loudness_mode: str = "rms"
- target: float = -20.0
- ceiling_db: float = -3.0
- sample_rate: int = 44100
- # RMS level the DSP chain is gain-staged to before compressors bite; the
- # pre-gain lets the absolute EQ/comp thresholds stay meaningful regardless
- # of how hot or quiet the (denoised) input arrives.
- dsp_ref_db: float = -20.0
- hpf_hz: float = 80.0
- mud: tuple[float, float, float] = (300.0, -2.5, 1.0)
- warmth: tuple[float, float] = (150.0, 1.5)
- comp1: tuple[float, float, float, float] = (-20.0, 2.0, 15.0, 150.0)
- comp2: tuple[float, float, float, float] = (-18.0, 3.0, 5.0, 100.0)
- deess: tuple[float, float, float] = (5500.0, 8000.0, 5.0)
- presence: tuple[float, float] = (3000.0, 1.5)
- air: tuple[float, float] = (10000.0, 1.5)
- strengths: dict[str, float] = field(
- default_factory=lambda: {
- "hpf": 1.0,
- "mud": 0.8,
- "warmth": 0.8,
- "soothe": 0.3,
- "compress": 0.8,
- "tape": 0.0,
- "deess": 0.6,
- "presence": 0.8,
- "air": 0.6,
- "breath": 0.35,
- }
- )
-
-
-AUDIOBOOK = Profile("audiobook")
-PODCAST = Profile(
- "podcast",
- loudness_mode="lufs",
- target=-16.0,
- ceiling_db=-1.5,
- sample_rate=48000,
- dsp_ref_db=-18.0,
- mud=(300.0, -1.5, 1.0),
- warmth=(150.0, 1.0),
- comp1=(-18.0, 2.0, 15.0, 150.0),
- comp2=(-16.0, 3.0, 5.0, 100.0),
- deess=(5500.0, 8000.0, 6.0),
- presence=(3000.0, 2.5),
- air=(10000.0, 2.0),
- strengths={
- "hpf": 1.0,
- "mud": 0.6,
- "warmth": 0.7,
- "soothe": 0.5,
- "compress": 0.9,
- "tape": 0.2,
- "deess": 0.7,
- "presence": 1.0,
- "air": 0.8,
- "breath": 0.2,
- },
-)
-RADIO = Profile(
- "radio",
- loudness_mode="lufs",
- target=-16.0,
- ceiling_db=-1.5,
- sample_rate=48000,
- dsp_ref_db=-17.0,
- hpf_hz=70.0,
- mud=(280.0, -3.5, 1.3),
- warmth=(100.0, 3.0),
- comp1=(-16.0, 3.0, 10.0, 120.0),
- comp2=(-14.0, 4.0, 3.0, 90.0),
- deess=(5000.0, 7500.0, 4.0),
- presence=(3500.0, 1.0),
- air=(9000.0, 1.0),
- strengths={
- "hpf": 1.0,
- "mud": 0.9,
- "warmth": 1.0,
- "soothe": 0.7,
- "compress": 1.0,
- "tape": 0.55,
- "deess": 0.5,
- "presence": 0.7,
- "air": 0.5,
- "breath": 0.4,
- },
-)
-PROFILES = {"audiobook": AUDIOBOOK, "podcast": PODCAST, "radio": RADIO}
-
-
-@dataclass
-class Options:
- profile: str = "audiobook"
- denoise: str = "dfn3"
- # 0.9 flattens the residual gain wobble mask-based denoisers leave on
- # speech; the dry blend trades a whisper of noise back for stability.
- denoise_strength: float = 0.9
- denoise_pf: bool = False
- enhance: str = "off"
- enhance_strength: float = 1.0
- dsp: bool = True
- levelling: bool = True
- target: float | None = None
- ceiling_db: float | None = None
- sample_rate: int | None = None
- bit_depth: int = 32
- out_format: str = "wav"
- device: str = "auto"
- strengths: dict[str, float | None] = field(default_factory=lambda: {k: None for k in DSP_KEYS})
- hpf_hz: float | None = None
- # 0 = feed engines the whole file (one warm model pass, no seam artifacts);
- # on OOM the run automatically falls back to large chunks.
- engine_chunk_s: float = 0.0
- engine_overlap_s: float = 0.5
- output: str | None = None
- report: bool = False
- report_path: str | None = None
- dry_run: bool = False
- verbose: int = 0
-
- def prof(self) -> Profile:
- return PROFILES[self.profile]
-
- def eff(self, key: str) -> float:
- v = self.strengths.get(key)
- if v is None:
- return float(self.prof().strengths[key])
- return float(v)
-
- def target_value(self) -> float:
- return self.prof().target if self.target is None else float(self.target)
-
- def ceiling(self) -> float:
- return self.prof().ceiling_db if self.ceiling_db is None else float(self.ceiling_db)
-
- def out_sample_rate(self) -> int:
- return self.prof().sample_rate if self.sample_rate is None else int(self.sample_rate)
-
- def loudness_mode(self) -> str:
- return self.prof().loudness_mode
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "profile": self.profile,
- "denoise": self.denoise,
- "denoise_strength": self.denoise_strength,
- "denoise_pf": self.denoise_pf,
- "enhance": self.enhance,
- "enhance_strength": self.enhance_strength,
- "dsp": self.dsp,
- "levelling": self.levelling,
- "target": self.target_value(),
- "ceiling_db": self.ceiling(),
- "sample_rate": self.out_sample_rate(),
- "bit_depth": self.bit_depth,
- "out_format": self.out_format,
- "device": self.device,
- "strengths": {k: self.eff(k) for k in DSP_KEYS},
- "hpf_hz": self.hpf_hz if self.hpf_hz is not None else self.prof().hpf_hz,
- "engine_chunk_s": self.engine_chunk_s,
- "engine_overlap_s": self.engine_overlap_s,
- }
-
-
-_CFG_FIELDS = {
- "profile": "profile",
- "denoise_strength": "denoise_strength",
- "enhance_strength": "enhance_strength",
- "format": "out_format",
- "bit_depth": "bit_depth",
- "device": "device",
- "target": "target",
- "ceiling": "ceiling_db",
- "sample_rate": "sample_rate",
- "engine_chunk": "engine_chunk_s",
- "engine_overlap": "engine_overlap_s",
-}
-
-
-def _engine_cfg(value: Any) -> tuple[Any, Any]:
- if isinstance(value, dict):
- return value.get("engine"), value.get("strength")
- return value, None
-
-
-def load_config(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- with path.open("rb") as f:
- return tomllib.load(f)
-
-
-def apply_config(opts: Options, cfg: dict[str, Any]) -> None:
- for key, field_name in _CFG_FIELDS.items():
- if key in cfg:
- setattr(opts, field_name, cfg[key])
- if "denoise" in cfg:
- eng, strength = _engine_cfg(cfg["denoise"])
- if eng:
- opts.denoise = eng
- if strength is not None:
- opts.denoise_strength = float(strength)
- if isinstance(cfg["denoise"], dict) and "pf" in cfg["denoise"]:
- opts.denoise_pf = bool(cfg["denoise"]["pf"])
- if "enhance" in cfg:
- eng, strength = _engine_cfg(cfg["enhance"])
- if eng:
- opts.enhance = eng
- if strength is not None:
- opts.enhance_strength = float(strength)
- prof_cfg = cfg.get(opts.profile)
- if isinstance(prof_cfg, dict):
- for k in DSP_KEYS:
- if k in prof_cfg:
- opts.strengths[k] = float(prof_cfg[k])
- if "hpf_hz" in prof_cfg:
- opts.hpf_hz = float(prof_cfg["hpf_hz"])
-
-
-def write_default_config(path: Path) -> None:
- if path.exists():
- return
- lines = [
- 'profile = "audiobook"',
- "",
- "# GPU engine processing: engines receive the whole file by default so",
- "# models run one warm pass (no seam artifacts). engine_chunk > 0 forces",
- "# chunked processing in seconds for very long files / low VRAM.",
- "# engine_chunk = 0.0 # 0 = whole file (automatic fallback on OOM)",
- "# engine_overlap = 0.5 # crossfade between chunks in seconds",
- "",
- "[denoise]",
- 'engine = "dfn3"',
- "# 0.9 flattens residual denoiser gain wobble; 1.0 = full suppression",
- "strength = 0.9",
- "# pf = false # DFN post filter: extra noise reduction, may eat soft speech",
- "",
- "[enhance]",
- 'engine = "off"',
- "",
- "[audiobook]",
- "mud = 0.8",
- "warmth = 0.8",
- "compress = 0.8",
- "deess = 0.6",
- "presence = 0.8",
- "air = 0.6",
- "breath = 0.35",
- "",
- "[podcast]",
- "mud = 0.6",
- "warmth = 0.7",
- "compress = 0.9",
- "deess = 0.7",
- "presence = 1.0",
- "air = 0.8",
- "breath = 0.2",
- "",
- ]
- path.write_text("\n".join(lines))
diff --git a/lib/src/producer/doctor.py b/lib/src/producer/doctor.py
deleted file mode 100644
index 2d8997f..0000000
--- a/lib/src/producer/doctor.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from __future__ import annotations
-
-import importlib.util
-import shutil
-import sys
-from pathlib import Path
-
-from . import __version__
-
-DATA_DIR = Path(__file__).resolve().parents[2]
-
-
-def _check(name: str, ok: bool, detail: str = "") -> bool:
- mark = "ok " if ok else "MISS"
- line = f" [{mark}] {name}"
- if detail:
- line += f": {detail}"
- print(line)
- return ok
-
-
-def _import(name: str):
- try:
- return importlib.import_module(name)
- except Exception:
- return None
-
-
-def _chain_error(name: str) -> str:
- """Import failure text for a module whose deeper import chain doesn't load."""
- try:
- importlib.import_module(name)
- except Exception as e:
- return str(e) or type(e).__name__
- return ""
-
-
-def main(_argv: list[str] | None = None) -> int:
- print(f"producer {__version__} doctor")
- print(f" data dir: {DATA_DIR}")
- critical_ok = True
-
- v = sys.version_info
- critical_ok &= _check("python", v >= (3, 10), f"{v.major}.{v.minor}.{v.micro}")
-
- for mod in ("numpy", "scipy", "soundfile", "pyloudnorm"):
- m = _import(mod)
- ok = m is not None
- critical_ok &= ok
- _check(mod, ok, getattr(m, "__version__", "") if ok else "not installed")
-
- torch = _import("torch")
- if torch is None:
- _check("torch", False, "not installed (installs on first denoise/enhance run)")
- else:
- ver = getattr(torch, "__version__", "?")
- cuda = bool(torch.cuda.is_available())
- dev = torch.cuda.get_device_name(0) if cuda else ""
- _check("torch", True, f"{ver}, cuda={cuda}" + (f" ({dev})" if dev else ""))
-
- ffmpeg = shutil.which("ffmpeg")
- _check("ffmpeg", ffmpeg is not None, ffmpeg or "missing (needed for mp3 + odd formats)")
-
- for mod in ("deepfilternet", "zipenhancer", "clearvoice"):
- m = _import(mod) or _import(mod.replace("-", "_"))
- if m is None:
- _check(mod, False, "lazy (installed on first use)")
- continue
- detail = "installed"
- if mod == "zipenhancer":
- # zipenhancer loads modelscope lazily on first use; probe the
- # config module whose extras-only deps have broken it in the wild
- broken = _chain_error("modelscope.utils.config")
- if broken and "modelscope" not in broken:
- detail += f"; modelscope chain broken: {broken} (will install on first use)"
- _check(mod, True, detail)
-
- dfn3 = DATA_DIR / "models" / "DeepFilterNet3" / "config.ini"
- _check("dfn3 weights", dfn3.is_file(), str(dfn3))
-
- res_venv = DATA_DIR / "venvs" / "resemble" / "bin" / "python"
- _check("resemble venv", res_venv.is_file(), str(res_venv))
-
- uv = DATA_DIR / "bin" / "uv"
- _check("uv", uv.is_file() or shutil.which("uv") is not None, str(uv))
-
- cfg = DATA_DIR.parent / "config.toml"
- _check("config.toml", cfg.exists(), str(cfg))
-
- print("doctor:", "core OK" if critical_ok else "core problems detected")
- return 0 if critical_ok else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/lib/src/producer/dsp.py b/lib/src/producer/dsp.py
deleted file mode 100644
index e65e595..0000000
--- a/lib/src/producer/dsp.py
+++ /dev/null
@@ -1,283 +0,0 @@
-from __future__ import annotations
-
-import numpy as np
-from scipy import ndimage, signal
-
-from .meters import SILENCE_DB
-
-_EPS = np.float32(1e-12)
-
-
-def _coef(time_ms: float, sr: int) -> float:
- return float(np.exp(-1000.0 / (max(time_ms, 1e-4) * sr)))
-
-
-def _onepole(x: np.ndarray, a: float) -> np.ndarray:
- a = np.float32(a)
- return signal.lfilter(
- np.array([1.0 - a], dtype=np.float32), np.array([1.0, -a], dtype=np.float32), x
- )
-
-
-def rbj(kind: str, sr: int, freq: float, gain_db: float = 0.0, q: float = 0.7071) -> np.ndarray:
- a = 10.0 ** (gain_db / 40.0)
- w0 = 2.0 * np.pi * freq / sr
- cs = np.cos(w0)
- sn = np.sin(w0)
- alpha = sn / (2.0 * q)
- if kind == "highpass":
- b = [(1 + cs) / 2, -(1 + cs), (1 + cs) / 2]
- aa = [1 + alpha, -2 * cs, 1 - alpha]
- elif kind == "lowpass":
- b = [(1 - cs) / 2, 1 - cs, (1 - cs) / 2]
- aa = [1 + alpha, -2 * cs, 1 - alpha]
- elif kind == "peaking":
- b = [1 + alpha * a, -2 * cs, 1 - alpha * a]
- aa = [1 + alpha / a, -2 * cs, 1 - alpha / a]
- elif kind == "lowshelf":
- sq = 2 * np.sqrt(a) * alpha
- b = [
- a * ((a + 1) - (a - 1) * cs + sq),
- 2 * a * ((a - 1) - (a + 1) * cs),
- a * ((a + 1) - (a - 1) * cs - sq),
- ]
- aa = [
- (a + 1) + (a - 1) * cs + sq,
- -2 * ((a - 1) + (a + 1) * cs),
- (a + 1) + (a - 1) * cs - sq,
- ]
- elif kind == "highshelf":
- sq = 2 * np.sqrt(a) * alpha
- b = [
- a * ((a + 1) + (a - 1) * cs + sq),
- -2 * a * ((a - 1) + (a + 1) * cs),
- a * ((a + 1) + (a - 1) * cs - sq),
- ]
- aa = [
- (a + 1) - (a - 1) * cs + sq,
- 2 * ((a - 1) - (a + 1) * cs),
- (a + 1) - (a - 1) * cs - sq,
- ]
- else:
- raise ValueError(f"unknown filter kind: {kind}")
- arr = np.array(b + aa, dtype=np.float64)
- return arr / arr[3]
-
-
-def _as_sos(sos: np.ndarray) -> np.ndarray:
- sos = sos[None, :] if sos.ndim == 1 else sos
- return sos.astype(np.float32, copy=False)
-
-
-def biquad(x: np.ndarray, sos: np.ndarray) -> np.ndarray:
- x32 = x.astype(np.float32, copy=False)
- y = signal.sosfilt(_as_sos(sos), x32)
- return y.astype(np.float32, copy=False)
-
-
-def hpf(x: np.ndarray, sr: int, hz: float) -> np.ndarray:
- return biquad(x, rbj("highpass", sr, hz))
-
-
-def shelf(x: np.ndarray, sr: int, freq: float, gain_db: float, low: bool = True) -> np.ndarray:
- if abs(gain_db) < 0.01:
- return x
- return biquad(x, rbj("lowshelf" if low else "highshelf", sr, freq, gain_db))
-
-
-def peak_eq(x: np.ndarray, sr: int, freq: float, gain_db: float, q: float = 1.0) -> np.ndarray:
- if abs(gain_db) < 0.01:
- return x
- return biquad(x, rbj("peaking", sr, freq, gain_db, q))
-
-
-def compressor(
- x: np.ndarray,
- sr: int,
- threshold_db: float,
- ratio: float,
- attack_ms: float,
- release_ms: float,
- knee_db: float = 6.0,
-) -> np.ndarray:
- x32 = x.astype(np.float32, copy=False)
- a_att = _coef(attack_ms, sr)
- a_rel = _coef(release_ms, sr)
- env = np.sqrt(np.clip(_onepole(np.square(x32), a_att), 0.0, None))
- level_db = 20.0 * np.log10(env + _EPS)
- over = level_db - np.float32(threshold_db)
- k = np.float32(knee_db)
- gr = np.where(
- over <= -k / 2,
- np.float32(0.0),
- np.where(
- over < k / 2,
- (1.0 - 1.0 / ratio) * np.square(over + k / 2) / (2.0 * k),
- (1.0 - 1.0 / ratio) * over,
- ),
- )
- gr = _onepole(np.clip(gr, 0.0, None), a_rel)
- gain = 10.0 ** (-gr / 20.0)
- return (x32 * gain).astype(np.float32, copy=False)
-
-
-def deesser(
- x: np.ndarray, sr: int, lo_hz: float, hi_hz: float, max_reduction_db: float
-) -> np.ndarray:
- if max_reduction_db < 0.05:
- return x
- x32 = x.astype(np.float32, copy=False)
- low = signal.sosfiltfilt(
- signal.butter(4, lo_hz, btype="lowpass", fs=sr, output="sos").astype(np.float32), x32
- )
- high = x32 - low
- mid = signal.sosfiltfilt(
- signal.butter(4, [lo_hz, hi_hz], btype="bandpass", fs=sr, output="sos").astype(np.float32),
- high,
- )
- win = max(3, int(0.005 * sr) | 1)
- kernel = (np.hanning(win) / np.sum(np.hanning(win))).astype(np.float32)
- env = signal.convolve(np.abs(mid), kernel, mode="same")
- act = env[env > _EPS]
- if act.size == 0:
- return x
- thr = np.float32(float(np.percentile(act, 95)) * 10.0 ** (-3.0 / 20.0))
- over = np.clip(20.0 * np.log10((env + _EPS) / thr), 0.0, None)
- gr = np.clip(over * np.float32(0.6), 0.0, np.float32(max_reduction_db))
- gr = _onepole(_onepole(gr, _coef(1.0, sr)), _coef(30.0, sr))
- gain = 10.0 ** (-gr / 20.0)
- high_out = high - mid + mid * gain
- return (low + high_out).astype(np.float32, copy=False)
-
-
-def expander(
- x: np.ndarray,
- sr: int,
- max_drop_db: float = 6.0,
- ratio: float = 2.0,
- attack_ms: float = 10.0,
- release_ms: float = 120.0,
-) -> np.ndarray:
- if max_drop_db < 0.05:
- return x
- n = x.size
- frame = max(1, int(0.020 * sr))
- nf = n // frame
- if nf < 2:
- return x
- frames = x[: nf * frame].reshape(nf, frame).astype(np.float32, copy=False)
- frms = np.sqrt(np.mean(np.square(frames), axis=1, dtype=np.float64))
- fdb = 20.0 * np.log10(frms + 1e-12)
- active = fdb[fdb > SILENCE_DB + 1.0]
- if active.size == 0:
- return x
- speech_ref = float(np.percentile(active, 90))
- floor_ref = float(np.percentile(active, 5))
- thr = 0.5 * (speech_ref + floor_ref)
- drop = np.where(fdb < thr, np.minimum((thr - fdb) * (1.0 - 1.0 / ratio), max_drop_db), 0.0)
- centers = (np.arange(nf) + 0.5) * frame
- drop_s = np.interp(np.arange(n, dtype=np.float32), centers, drop).astype(np.float32)
- drop_s = np.maximum(
- _onepole(drop_s, _coef(release_ms, sr)), _onepole(drop_s, _coef(attack_ms, sr))
- )
- gain = 10.0 ** (-np.clip(drop_s, 0.0, np.float32(max_drop_db)) / 20.0)
- return (x.astype(np.float32, copy=False) * gain).astype(np.float32, copy=False)
-
-
-def limit(
- x: np.ndarray,
- sr: int,
- ceiling_db: float,
- release_ms: float = 60.0,
- lookahead_ms: float = 1.0,
-) -> np.ndarray:
- x32 = x.astype(np.float32, copy=False)
- n = x32.size
- if n == 0:
- return x
- lin = 10.0 ** (ceiling_db / 20.0)
- win = max(1, int(lookahead_ms * sr / 1000.0) | 1)
- env = ndimage.maximum_filter1d(np.abs(x32), size=win, mode="nearest")
- over = np.clip(20.0 * np.log10(env + _EPS) - ceiling_db, 0.0, None)
- block = max(1, int(0.005 * sr))
- nb = (n + block - 1) // block
- padded = np.zeros(nb * block, dtype=np.float32)
- padded[:n] = over
- block_over = padded.reshape(nb, block).max(axis=1)
- decay = float(np.exp(-1000.0 * 0.005 / max(release_ms, 0.1)))
- held = np.empty(nb, dtype=np.float64)
- prev = 0.0
- for i in range(nb):
- prev = max(block_over[i], prev * decay)
- held[i] = prev
- held_s = np.interp(np.arange(n, dtype=np.float32), (np.arange(nb) + 0.5) * block, held)
- held_s = _onepole(held_s.astype(np.float32), _coef(1.0, sr))
- gain = 10.0 ** (-held_s / 20.0)
- y = x32 * gain
- bad = np.abs(y) > lin
- if np.any(bad):
- y[bad] = np.float32(lin) * np.sign(y[bad])
- return y.astype(np.float32, copy=False)
-
-
-def resample(x: np.ndarray, sr_in: int, sr_out: int) -> np.ndarray:
- if sr_in == sr_out or x.size == 0:
- return x
- from math import gcd
-
- g = gcd(int(sr_in), int(sr_out))
- y = signal.resample_poly(x, int(sr_out) // g, int(sr_in) // g, window=("kaiser", 10.0))
- return y.astype(np.float32, copy=False)
-
-
-def tape(x: np.ndarray, sr: int, amount: float) -> np.ndarray:
- """Gentle asymmetric soft-clip saturation — analog-style even-harmonic warmth."""
- if amount < 0.02:
- return x
- s = np.float32(np.clip(amount, 0.0, 1.0))
- drive = np.float32(1.0 + 2.5 * float(s))
- x32 = x.astype(np.float32, copy=False)
- curve = np.where(x32 < 0.0, x32 * (1.0 + 0.4 * s), x32)
- y = np.tanh(curve * drive) / np.tanh(drive)
- rms_in = float(np.sqrt(np.mean(np.square(x32))))
- rms_out = float(np.sqrt(np.mean(np.square(y))))
- y *= np.float32(rms_in / max(rms_out, float(_EPS)))
- return (x32 * (1.0 - s) + y * s).astype(np.float32, copy=False)
-
-
-def soothe(x: np.ndarray, sr: int, amount: float) -> np.ndarray:
- """Dynamic reducer that digs into resonances only while they stick out.
-
- Targets boxy 200-450 Hz and harsh 2.5-6 kHz bands; static-only when turned
- to zero. Deesser-style: band split -> smoothed envelope -> percentile
- threshold -> gain reduce -> subtractive recombination.
- """
- if amount < 0.02:
- return x
- s = np.float32(np.clip(amount, 0.0, 1.0))
- y = x.astype(np.float32, copy=False)
- bands = ((200.0, 450.0, 3.5), (2500.0, 6000.0, 5.0))
- win = max(3, int(0.010 * sr) | 1)
- kernel = (np.hanning(win) / np.sum(np.hanning(win))).astype(np.float32)
- changed = False
- for lo, hi, max_db in bands:
- if max_db * float(s) < 0.1:
- continue
- band = signal.sosfiltfilt(
- signal.butter(4, [lo, hi], btype="bandpass", fs=sr, output="sos").astype(np.float32),
- y,
- )
- env = signal.convolve(np.abs(band), kernel, mode="same")
- act = env[env > _EPS]
- if act.size == 0:
- continue
- thr = np.float32(float(np.percentile(act, 88)) * 10.0 ** (-6.0 / 20.0))
- over = np.clip(20.0 * np.log10((env + _EPS) / thr), 0.0, None)
- gr = np.clip(over * np.float32(0.7), 0.0, np.float32(max_db)) * s
- gr = _onepole(_onepole(gr, _coef(5.0, sr)), _coef(60.0, sr))
- gain = 10.0 ** (-gr / 20.0)
- y = y - band + band * gain
- changed = True
- if not changed or np.array_equal(y, x.astype(np.float32, copy=False)):
- return x
- return y.astype(np.float32, copy=False)
diff --git a/lib/src/producer/engines/__init__.py b/lib/src/producer/engines/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/lib/src/producer/engines/__init__.py
+++ /dev/null
diff --git a/lib/src/producer/engines/base.py b/lib/src/producer/engines/base.py
deleted file mode 100644
index 9192a17..0000000
--- a/lib/src/producer/engines/base.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from __future__ import annotations
-
-import numpy as np
-
-
-def pick_device(pref: str = "auto") -> str:
- if pref == "cpu":
- return "cpu"
- try:
- import torch
- except ImportError:
- if pref == "cuda":
- print("[producer] warning: torch not installed; using cpu", flush=True)
- return "cpu"
- if torch.cuda.is_available():
- return "cuda"
- if pref == "cuda":
- print("[producer] warning: CUDA requested but unavailable; using cpu", flush=True)
- return "cpu"
-
-
-def device_name(device: str) -> str:
- if device == "cuda":
- import torch
-
- return torch.cuda.get_device_name(0)
- return "cpu"
-
-
-def blend(x: np.ndarray, y: np.ndarray, strength: float) -> np.ndarray:
- if y.size != x.size:
- # a resample round trip can drift by a sample or two (resample_poly
- # emits ceil(n * up/down) per hop); realign before the elementwise math
- y = y[: x.size] if y.size > x.size else np.pad(y, (0, x.size - y.size))
- s = float(np.clip(strength, 0.0, 1.0))
- if s >= 0.999:
- return y.astype(np.float32, copy=False)
- if s <= 0.001:
- return x.astype(np.float32, copy=False)
- out = np.asarray(y, dtype=np.float32) - np.asarray(x, dtype=np.float32)
- out *= s
- out += x
- return out
diff --git a/lib/src/producer/engines/chunking.py b/lib/src/producer/engines/chunking.py
deleted file mode 100644
index 627e3b8..0000000
--- a/lib/src/producer/engines/chunking.py
+++ /dev/null
@@ -1,166 +0,0 @@
-from __future__ import annotations
-
-import gc
-from collections.abc import Callable, Iterator
-
-import numpy as np
-
-from .. import ui
-
-MIN_CHUNK_S = 2.0
-
-_OOM_ERRORS: tuple[type[BaseException], ...] | None = None
-
-
-def _oom_errors() -> tuple[type[BaseException], ...]:
- global _OOM_ERRORS
- if _OOM_ERRORS is None:
- errs: list[type[BaseException]] = [MemoryError]
- try:
- import torch
-
- errs.append(torch.cuda.OutOfMemoryError)
- except Exception:
- pass
- _OOM_ERRORS = tuple(errs)
- return _OOM_ERRORS
-
-
-def free_vram() -> None:
- gc.collect()
- try:
- import torch
-
- if torch.cuda.is_available():
- torch.cuda.empty_cache()
- except Exception:
- pass
-
-
-def plan_chunks(n: int, chunk: int, overlap: int) -> list[tuple[int, int]]:
- """Split n samples into spans of at most `chunk` samples sharing `overlap`.
-
- Consecutive spans advance by chunk - overlap; the last span always covers
- the tail (its length is at least overlap + 1).
- """
- n = int(n)
- if n <= 0:
- return []
- chunk = int(max(1, chunk))
- overlap = int(max(0, overlap))
- if overlap >= chunk:
- raise ValueError("overlap must be smaller than chunk")
- if chunk >= n:
- return [(0, n)]
- spans: list[tuple[int, int]] = []
- start = 0
- while True:
- end = min(start + chunk, n)
- spans.append((start, end))
- if end >= n:
- return spans
- start += chunk - overlap
-
-
-def stitch(spans: list[tuple[int, int]], n: int, pieces) -> np.ndarray:
- """Overlap-add engine outputs (aligned with spans) into one n-sample array.
-
- The first `overlap` samples of each piece crossfade linearly against the
- tail already written by the previous piece.
- """
- out = np.zeros(n, dtype=np.float32)
- prev_end = -1
- for (start, end), piece in zip(spans, pieces, strict=True):
- seg = np.asarray(piece, dtype=np.float32).reshape(-1)
- want = end - start
- if seg.size < want:
- seg = np.pad(seg, (0, want - seg.size), mode="edge")
- seg = seg[:want]
- if start < 0 or end > n:
- raise ValueError(f"span ({start}, {end}) outside output of length {n}")
- ov = max(0, prev_end - start)
- if ov > 0:
- ramp = (np.arange(ov, dtype=np.float32) + 0.5) / float(ov)
- out[start : start + ov] *= 1.0 - ramp
- out[start : start + ov] += seg[:ov] * ramp
- out[start + ov : end] = seg[ov:]
- else:
- out[start:end] = seg
- prev_end = end
- return out
-
-
-def _tracked(
- x: np.ndarray,
- spans: list[tuple[int, int]],
- ctx: int,
- fn,
- on_progress: Callable[[int, int], None] | None,
-) -> Iterator[np.ndarray]:
- total = len(spans)
- if on_progress is not None:
- on_progress(0, total)
- for i, (a, b) in enumerate(spans):
- lo = max(0, a - ctx)
- hi = min(x.size, b + ctx)
- piece = fn(x[lo:hi])
- if on_progress is not None:
- on_progress(i + 1, total)
- seg = np.asarray(piece, dtype=np.float32).reshape(-1)
- want_full = hi - lo
- if seg.size < want_full:
- seg = np.pad(seg, (0, want_full - seg.size), mode="edge")
- seg = seg[:want_full]
- yield seg[a - lo : a - lo + (b - a)]
-
-
-def apply_chunked(
- x: np.ndarray,
- sr: int,
- chunk_s: float,
- overlap_s: float,
- fn,
- min_chunk_s: float = MIN_CHUNK_S,
- on_progress: Callable[[int, int], None] | None = None,
- context_s: float = 0.0,
-) -> np.ndarray:
- """Run fn on the signal in overlapping chunks and stitch the results.
-
- fn receives a 1-D chunk and must return a sample-aligned array of the same
- length. chunk_s <= 0 disables chunking (single whole-signal call). With
- context_s > 0 each chunk is widened by up to that many seconds of
- neighbouring audio on both sides before fn runs, and the extra context in
- fn's output is trimmed away again — recurrent/stateful models then process
- the region that survives stitching with warmed-up state instead of a cold
- start. On CUDA or host OOM the whole run is retried with halved chunk
- length down to min_chunk_s. on_progress(done, total_chunks) fires as chunks
- complete.
- """
- n = int(x.size)
- chunk_s = float(chunk_s or 0.0)
- overlap_s = max(0.0, float(overlap_s or 0.0))
- ctx = max(0, round(float(context_s or 0.0) * sr))
- if chunk_s <= 0.0:
- try:
- return fn(x)
- except _oom_errors():
- # Whole-file passes can OOM on multi-hour files; fall back to
- # large chunks and let the halving loop take it from there.
- free_vram()
- whole = 120.0
- ui.log(
- f"[producer] whole-file pass ran out of memory; retrying with {whole:.0f}s chunks"
- )
- return apply_chunked(x, sr, whole, overlap_s, fn, min_chunk_s, on_progress, context_s)
- try:
- spans = plan_chunks(n, round(chunk_s * sr), round(overlap_s * sr))
- return stitch(spans, n, _tracked(x, spans, ctx, fn, on_progress))
- except _oom_errors():
- free_vram()
- smaller = chunk_s / 2.0
- if smaller < min_chunk_s:
- raise
- ui.log(
- f"[producer] engine ran out of memory; retrying with {smaller:.0f}s chunks",
- )
- return apply_chunked(x, sr, smaller, overlap_s, fn, min_chunk_s, on_progress, context_s)
diff --git a/lib/src/producer/engines/denoise_dfn.py b/lib/src/producer/engines/denoise_dfn.py
deleted file mode 100644
index 186ffd2..0000000
--- a/lib/src/producer/engines/denoise_dfn.py
+++ /dev/null
@@ -1,140 +0,0 @@
-from __future__ import annotations
-
-import sys
-import types
-import warnings
-import zipfile
-from collections.abc import Callable
-from pathlib import Path
-
-import numpy as np
-
-from .. import dsp, lazy, ui
-from .base import blend, device_name, pick_device
-from .chunking import apply_chunked, free_vram
-
-MODELS_DIR = lazy.DATA_DIR / "models"
-TAG = "v0.5.6"
-# Neighbouring audio fed to each chunk so the recurrent model and its feature
-# normalizers run warm at chunk seams; trimmed away before stitching.
-CONTEXT_S = 2.0
-MODEL_ZIPS = {
- "DeepFilterNet3": "models/DeepFilterNet3.zip",
- "DeepFilterNet2": "models/DeepFilterNet2.zip",
-}
-BASE_URL = f"https://raw.githubusercontent.com/Rikorose/DeepFilterNet/{TAG}"
-
-
-def _shim_torchaudio_backend() -> None:
- warnings.filterwarnings(
- "ignore",
- message=r".*AudioMetaData.*has been moved.*",
- category=UserWarning,
- module=r"df[./]io",
- )
- try:
- import torchaudio.backend # noqa: F401
- except Exception:
- pkg = sys.modules.get("torchaudio")
- if pkg is not None and "torchaudio.backend" not in sys.modules:
- stub = types.ModuleType("torchaudio.backend")
- stub.__path__ = []
- sys.modules["torchaudio.backend"] = stub
- pkg.backend = stub
-
-
-def _shim_df_git() -> None:
- import df.io
- import df.logger
- import df.utils
-
- for mod in (df.utils, df.logger, df.io):
- for name in ("get_git_root", "get_commit_hash", "get_branch_name"):
- if hasattr(mod, name):
- setattr(mod, name, lambda: None)
-
-
-def ensure_model(model: str) -> Path:
- target = MODELS_DIR / model
- if (target / "config.ini").is_file():
- return target
- url = f"{BASE_URL}/{MODEL_ZIPS[model]}"
- MODELS_DIR.mkdir(parents=True, exist_ok=True)
- zpath = MODELS_DIR / f"{model}.zip"
- ui.download(url, zpath, f"downloading {model} weights")
- ui.log(f"[producer] extracting {model} weights...")
- with zipfile.ZipFile(zpath) as z:
- z.extractall(target)
- zpath.unlink(missing_ok=True)
- if not (target / "config.ini").is_file():
- inner = list(target.glob(f"**/{model}/config.ini"))
- if inner:
- src = inner[0].parent
- for f in src.iterdir():
- f.rename(target / f.name)
- if not (target / "config.ini").is_file():
- raise RuntimeError(f"{model} weights download failed")
- return target
-
-
-def denoise(
- x: np.ndarray,
- sr: int,
- strength: float,
- device_pref: str = "auto",
- chunk_s: float = 30.0,
- overlap_s: float = 0.5,
- on_progress: Callable[[int, int], None] | None = None,
- post_filter: bool = False,
-) -> tuple[np.ndarray, str, str]:
- """Denoise with DeepFilterNet.
-
- post_filter opts into DFN's extra noise-reduction post filter; it
- over-attenuates and can eat soft speech on clean recordings, so it stays
- off unless requested.
- """
- # torch first: deepfilternet's declared torch dependency would otherwise
- # resolve to the newest (multi-GB CUDA) build before we pin our tested one.
- lazy.ensure_torch()
- lazy.ensure(["deepfilternet==0.5.6"], purpose="DeepFilterNet")
- _shim_torchaudio_backend()
- _shim_df_git()
- model_name = "DeepFilterNet3"
- try:
- model_dir = ensure_model(model_name)
- except Exception:
- model_name = "DeepFilterNet2"
- model_dir = ensure_model(model_name)
- from df.enhance import enhance as df_enhance
- from df.enhance import init_df
-
- device = pick_device(device_pref)
- model, df_state, _ = init_df(
- model_base_dir=str(model_dir),
- post_filter=post_filter,
- log_level="error",
- log_file=None,
- )
- try:
- model = model.to(device)
- dev = device
- except Exception:
- dev = "cpu"
- sr_df = int(df_state.sr())
- xin = dsp.resample(x, sr, sr_df)
- import torch
-
- def run(chunk: np.ndarray) -> np.ndarray:
- t = torch.from_numpy(np.ascontiguousarray(chunk, dtype=np.float32)).unsqueeze(0)
- y = df_enhance(model, df_state, t)
- if isinstance(y, torch.Tensor):
- y = y.detach().cpu().numpy()
- return np.asarray(y, dtype=np.float32).reshape(-1)
-
- y = apply_chunked(
- xin, sr_df, chunk_s, overlap_s, run, on_progress=on_progress, context_s=CONTEXT_S
- )
- free_vram()
- y = dsp.resample(y, sr_df, sr)
- y = blend(x, y, strength)
- return y, f"dfn ({model_name})", f"{device_name(dev)} ({dev})"
diff --git a/lib/src/producer/engines/denoise_spectral.py b/lib/src/producer/engines/denoise_spectral.py
deleted file mode 100644
index e1bb256..0000000
--- a/lib/src/producer/engines/denoise_spectral.py
+++ /dev/null
@@ -1,186 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Callable
-
-import numpy as np
-from scipy import signal
-
-from .. import ui
-from .base import blend
-from .chunking import apply_chunked
-
-N_FFT = 2048
-HOP = 512
-DD_ALPHA = 0.97
-PROFILE_PCT = 35.0
-GATE_PCT = 30.0
-GAIN_SMOOTH_MS = 20.0
-PROFILE_BLOCK_S = 60.0
-RESERVOIR_ROWS = 4096
-MIN_CHUNK_S = 8.0
-
-
-def _frame_rms(x: np.ndarray, frame: int) -> np.ndarray:
- nf = x.size // frame
- if nf < 1:
- return np.ones(1, dtype=np.float64)
- return np.sqrt(
- np.mean(np.square(x[: nf * frame].reshape(nf, frame).astype(np.float64)), axis=1)
- )
-
-
-def _noise_profile(x: np.ndarray, sr: int) -> np.ndarray:
- """File-global per-bin noise PSD estimate.
-
- Two cheap passes: a time-domain pass finds the quietest fraction of
- frames (speech gaps), then an STFT pass subsamples those frames' PSDs
- into a fixed-size per-bin reservoir. Because the profile comes from the
- whole file instead of a per-chunk percentile, speech-heavy regions can
- no longer leak into the estimate — the failure that made the old engine
- under-suppress tape hiss between sentences.
- """
- hop_t = _frame_rms(x, HOP)
- thr = float(np.percentile(hop_t, GATE_PCT))
- nb_bins = N_FFT // 2 + 1
- reservoir = np.zeros((nb_bins, RESERVOIR_ROWS), dtype=np.float32)
- count = 0
- rng = np.random.default_rng(0)
-
- def add_rows(rows: np.ndarray) -> None:
- nonlocal count
- k = rows.shape[0]
- if k == 0:
- return
- if count < RESERVOIR_ROWS:
- take = min(k, RESERVOIR_ROWS - count)
- reservoir[:, count : count + take] = rows[:take].T
- count += take
- rows = rows[take:]
- k = rows.shape[0]
- if k > 0:
- idx = rng.integers(0, RESERVOIR_ROWS, size=k)
- reservoir[:, idx] = rows.T
-
- block = int(PROFILE_BLOCK_S * sr)
- collected = 0
- for start in range(0, x.size, block):
- seg = x[start : min(start + block, x.size)]
- if seg.size < N_FFT * 2:
- break
- _f, _t, S = signal.stft(
- seg, window="hann", nperseg=N_FFT, noverlap=N_FFT - HOP, boundary="zeros", padded=True
- )
- psd = (S.real * S.real + S.imag * S.imag).astype(np.float32)
- # STFT frames sit ~head/HOP before the block start due to zero padding
- offset = start // HOP - (N_FFT // 2) // HOP
- quiet = np.zeros(psd.shape[1], dtype=bool)
- vals = hop_t[max(0, offset) : max(0, offset) + psd.shape[1]]
- quiet[: vals.size] = vals < thr
- add_rows(psd[:, quiet].T)
- collected += int(quiet.sum())
- if collected == 0:
- # no quiet frames at all (continuous dense speech): blind subsample
- for start in range(0, x.size, block):
- seg = x[start : min(start + block, x.size)]
- if seg.size < N_FFT * 2:
- break
- _f, _t, S = signal.stft(
- seg,
- window="hann",
- nperseg=N_FFT,
- noverlap=N_FFT - HOP,
- boundary="zeros",
- padded=True,
- )
- psd = (S.real * S.real + S.imag * S.imag).astype(np.float32)
- step = max(1, psd.shape[1] // (RESERVOIR_ROWS // 8))
- add_rows(psd[:, ::step].T)
- if count == 0:
- return np.zeros(nb_bins, dtype=np.float32)
- return np.percentile(reservoir[:, :count], PROFILE_PCT, axis=1).astype(np.float32)
-
-
-def _denoise_chunk(chunk: np.ndarray, sr: int, strength: float, noise: np.ndarray) -> np.ndarray:
- n = chunk.size
- if n < N_FFT * 2:
- return chunk
- head = N_FFT // 2
- tail = head + (-(n - N_FFT)) % HOP
- padded = np.pad(chunk, (head, tail), mode="edge")
- _f, _t, S = signal.stft(
- padded, window="hann", nperseg=N_FFT, noverlap=N_FFT - HOP, boundary="zeros", padded=True
- )
- psd = (S.real * S.real + S.imag * S.imag).astype(np.float32)
- if not np.any(noise > 0.0):
- return chunk[:n].astype(np.float32)
- eps = np.float32(1e-20)
- over = 1.0 + 3.0 * float(strength)
- snr_post = psd / (noise[:, None] * np.float32(over) + eps)
- floor_gain = np.float32(10.0 ** (-(10.0 + 28.0 * float(strength)) / 20.0))
- gains = np.empty_like(psd)
- prev_g2 = np.ones(psd.shape[0], dtype=np.float32)
- prev_post = snr_post[:, 0]
- for i in range(psd.shape[1]):
- post = snr_post[:, i]
- snr_prio = (
- DD_ALPHA * prev_g2 * prev_post
- + (1.0 - DD_ALPHA) * np.maximum(post - 1.0, np.float32(0.0))
- ).astype(np.float32)
- g = snr_prio / (1.0 + snr_prio)
- np.maximum(g, floor_gain, out=g)
- gains[:, i] = g
- prev_g2 = g * g
- prev_post = post
- a = np.float32(np.exp(-1000.0 * HOP / sr / GAIN_SMOOTH_MS))
- gains = signal.lfilter([1.0 - a], [1.0, -a], gains, axis=1)
- _t2, rec = signal.istft(
- S * gains, window="hann", nperseg=N_FFT, noverlap=N_FFT - HOP, boundary=True
- )
- out = rec[head : head + n]
- if out.size < n:
- out = np.pad(out, (0, n - out.size), mode="edge")
- return out.astype(np.float32)
-
-
-def denoise(
- x: np.ndarray,
- sr: int,
- strength: float,
- device_pref: str = "auto",
- chunk_s: float = 30.0,
- overlap_s: float = 0.5,
- on_progress: Callable[[int, int], None] | None = None,
-) -> tuple[np.ndarray, str, str]:
- """Denoise with decision-directed spectral subtraction on a global noise
- profile.
-
- Pure DSP: one pass measures the per-bin noise PSD across the whole file
- (from its quietest frames), then a Wiener-style gain with decision-
- directed smoothing is applied in overlapping blocks. The global profile
- keeps suppression uniform everywhere — steady tape hiss and breath noise
- between sentences go down by ~20+ dB instead of the old capped-at-24 dB
- per-chunk estimate. Deterministic, no model download,
- no time-varying gain wobble.
- """
- strength = float(np.clip(strength, 0.0, 1.0))
- if strength <= 0.001:
- return x.copy(), "spectral", "cpu"
- chunk_s = float(chunk_s or 0.0)
- if chunk_s <= 0.0:
- # a whole-file STFT would need ~10 GB per hour of audio; block instead
- ui.log("[producer] spectral denoiser processes in blocks; clamping --engine-chunk")
- chunk_s = 30.0
- chunk_s = max(chunk_s, MIN_CHUNK_S)
- overlap_s = max(0.0, min(float(overlap_s or 0.0), chunk_s / 2.0))
- noise = _noise_profile(x, sr)
- y = apply_chunked(
- x,
- sr,
- chunk_s,
- overlap_s,
- lambda chunk: _denoise_chunk(chunk, sr, strength, noise),
- on_progress=on_progress,
- context_s=2.0,
- )
- y = blend(x, y, strength)
- return y, "spectral (dd-wiener, global profile)", "cpu"
diff --git a/lib/src/producer/engines/denoise_zip.py b/lib/src/producer/engines/denoise_zip.py
deleted file mode 100644
index 3f37f61..0000000
--- a/lib/src/producer/engines/denoise_zip.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Callable
-
-import numpy as np
-
-from .. import dsp, lazy
-from .base import blend
-from .chunking import apply_chunked
-
-
-def _peak_normalize(y: np.ndarray) -> np.ndarray:
- peak = float(np.max(np.abs(y))) if y.size else 0.0
- if peak > 1e-10:
- y *= 10.0 ** (-3.0 / 20.0) / peak
- if y.size:
- peak = float(np.max(np.abs(y)))
- if peak > 0.99:
- y *= 0.95 / peak
- return y
-
-
-def denoise(
- x: np.ndarray,
- sr: int,
- strength: float,
- device_pref: str = "auto",
- chunk_s: float = 30.0,
- overlap_s: float = 0.5,
- on_progress: Callable[[int, int], None] | None = None,
-) -> tuple[np.ndarray, str, str]:
- # torch first: zipenhancer's declared torch>=2.0 dependency would otherwise
- # resolve to the newest (multi-GB CUDA) build before we pin our tested one.
- lazy.ensure_torch()
- # zipenhancer's weight loader imports modelscope even for the default
- # model, but doesn't declare it (it is only an optional extra on PyPI).
- lazy.ensure(["zipenhancer==0.3.2", "modelscope"], purpose="ZipEnhancer")
- # the loader runs lazily on the first denoise() call, and modelscope's
- # config/hub code imports extras-only helpers (addict, simplejson, ...)
- # on the way; the import and call probes install whatever trips over.
- lazy.ensure_import("zipenhancer", purpose="ZipEnhancer")
- # the library API takes the full modelscope repo id; its short-name
- # mapping lives only in its CLI, and anything unrecognized is treated
- # as a repo id (modelscope E3021)
- from zipenhancer import MODEL_ZIPENHANCER as Z_MODEL_REPO
- from zipenhancer import denoise as z_denoise
-
- sr_z = 16000
- strength = float(np.clip(strength, 0.0, 1.0))
- x16 = dsp.resample(x, sr, sr_z)
-
- def run(chunk: np.ndarray) -> np.ndarray:
- result = z_denoise(chunk, sr_z, model=Z_MODEL_REPO, normalize=False, strength=strength)
- y = result[0] if isinstance(result, tuple) else result
- return np.asarray(y, dtype=np.float32).reshape(-1)
-
- y = apply_chunked(
- x16,
- sr_z,
- chunk_s,
- overlap_s,
- lambda chunk: lazy.ensure_call(lambda: run(chunk), purpose="ZipEnhancer"),
- on_progress=on_progress,
- )
- _peak_normalize(y)
- y = dsp.resample(y, sr_z, sr)
- y = blend(x, y, strength)
- return y, "zipenhancer (16 kHz SOTA, bandwidth restored)", device_pref
diff --git a/lib/src/producer/engines/enhance_mossformer.py b/lib/src/producer/engines/enhance_mossformer.py
deleted file mode 100644
index 3298c09..0000000
--- a/lib/src/producer/engines/enhance_mossformer.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Callable
-
-import numpy as np
-
-from .. import dsp, io, lazy
-from .base import blend, device_name, pick_device
-from .chunking import apply_chunked, free_vram
-
-# ClearVoice's batch decode path crashes on inputs longer than
-# one_time_decode_length (20 s); keep chunks below it.
-MAX_CHUNK_S = 20.0
-
-
-def enhance(
- x: np.ndarray,
- sr: int,
- strength: float,
- device_pref: str = "auto",
- chunk_s: float = 30.0,
- overlap_s: float = 0.5,
- on_progress: Callable[[int, int], None] | None = None,
-) -> tuple[np.ndarray, str, str]:
- # torch first: clearvoice's declared torch dependency would otherwise
- # resolve to the newest (multi-GB CUDA) build before we pin our tested one.
- lazy.ensure_torch()
- lazy.ensure(["clearvoice==0.1.2"], purpose="MossFormer2 (ClearVoice)")
- # clearvoice is modelscope-based and imports undeclared helpers; the probe
- # installs whatever the import chain actually trips over.
- lazy.ensure_import("clearvoice", purpose="MossFormer2 (ClearVoice)")
- from clearvoice import ClearVoice
-
- device = pick_device(device_pref)
- sr_target = 48000
- xin = dsp.resample(x, sr, sr_target)
- cv = ClearVoice(task="speech_enhancement", model_name="MossFormer2_SE_48K")
- if chunk_s <= 0:
- import tempfile
- from pathlib import Path
-
- with tempfile.TemporaryDirectory(prefix="producer_mf2_") as td:
- inp = Path(td) / "in.wav"
- outp = Path(td) / "out.wav"
- io.encode(xin, sr_target, inp, "wav", 16)
- result = cv(input_path=str(inp), output_name=str(outp))
- if isinstance(result, tuple):
- y, fs = result[0], int(result[1])
- else:
- y, fs = io.decode(outp)
- else:
- effective = min(float(chunk_s), MAX_CHUNK_S)
-
- def run(chunk: np.ndarray) -> np.ndarray:
- out = cv(chunk[None, :].astype(np.float32))
- if isinstance(out, tuple):
- out = out[0]
- return np.asarray(out, dtype=np.float32).reshape(-1)
-
- y = apply_chunked(xin, sr_target, effective, overlap_s, run, on_progress=on_progress)
- fs = sr_target
- free_vram()
- y = np.asarray(y, dtype=np.float32)
- if y.ndim > 1:
- y = y.mean(axis=-1) if y.shape[-1] <= 2 else y.reshape(-1)
- if fs != sr_target:
- y = dsp.resample(y, fs, sr_target)
- y = dsp.resample(y, sr_target, sr)
- y = blend(x, y, strength)
- return y, "mossformer2 (MossFormer2_SE_48K)", device_name(device)
diff --git a/lib/src/producer/engines/enhance_resemble.py b/lib/src/producer/engines/enhance_resemble.py
deleted file mode 100644
index 8042215..0000000
--- a/lib/src/producer/engines/enhance_resemble.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from __future__ import annotations
-
-import contextlib
-import os
-import subprocess
-import sys
-from collections.abc import Callable
-from pathlib import Path
-
-import numpy as np
-
-from .. import dsp, io, lazy, ui
-from .base import blend
-
-VENV_DIR = lazy.DATA_DIR / "venvs" / "resemble"
-WORKER = Path(__file__).resolve().parent / "resemble_worker.py"
-REQ_HASH_FILE = VENV_DIR / ".req-hash"
-REQS = ["resemble-enhance==0.0.1", "librosa", "soundfile"]
-
-
-def _ensure_venv() -> Path:
- marker = REQ_HASH_FILE.read_text() if REQ_HASH_FILE.exists() else None
- want = "|".join(REQS)
- py = VENV_DIR / "bin" / "python"
- if py.is_file() and marker == want:
- return py
- uv = lazy.find_uv()
- if not py.is_file():
- ui.log("[producer] creating resemble venv (CPython 3.11)...")
- ui.run([uv, "venv", str(VENV_DIR), "--python", "3.11"], "creating resemble venv")
- ui.log("[producer] installing resemble-enhance into isolated venv (one-time, large)...")
- ui.run(
- [uv, "pip", "install", "--python", str(py), *REQS],
- "installing resemble-enhance dependencies",
- )
- REQ_HASH_FILE.write_text(want)
- return py
-
-
-def enhance(
- x: np.ndarray,
- sr: int,
- strength: float,
- device_pref: str = "auto",
- chunk_s: float = 30.0,
- overlap_s: float = 0.5,
- on_progress: Callable[[int, int], None] | None = None,
-) -> tuple[np.ndarray, str, str]:
- import tempfile
-
- py = _ensure_venv()
- from .base import device_name as _dname
- from .base import pick_device as _pick
-
- device = _pick(device_pref)
- with tempfile.TemporaryDirectory(prefix="producer_res_") as td:
- inp = Path(td) / "in.wav"
- outp = Path(td) / "out.wav"
- io.encode(x, sr, inp, "wav", 16)
- env = dict(os.environ)
- env["HF_HOME"] = str(lazy.DATA_DIR / "models" / "hf")
- env["TORCH_HOME"] = str(lazy.DATA_DIR / "models" / "torch")
- cmd = [
- str(py),
- str(WORKER),
- str(inp),
- str(outp),
- device,
- str(float(np.clip(strength, 0.0, 1.0))),
- str(float(chunk_s)),
- str(float(overlap_s)),
- ]
- proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, text=True)
- assert proc.stdout is not None
- for line in proc.stdout:
- parts = line.split()
- if len(parts) == 4 and parts[0] == "PROGRESS":
- if on_progress is not None:
- with contextlib.suppress(ValueError):
- on_progress(int(parts[2]), int(parts[3]))
- continue
- if line.strip():
- sys.stderr.write(line if line.endswith("\n") else line + "\n")
- rc = proc.wait()
- if rc != 0:
- raise subprocess.CalledProcessError(rc, cmd)
- y, fs = io.decode(outp)
- if fs != sr:
- y = dsp.resample(y, fs, sr)
- y = blend(x, y, strength)
- return y, "resemble-enhance (generative, may alter timbre)", _dname(device)
-
-
-if __name__ == "__main__":
- sys.exit(0)
diff --git a/lib/src/producer/engines/resemble_worker.py b/lib/src/producer/engines/resemble_worker.py
deleted file mode 100644
index 493ceb3..0000000
--- a/lib/src/producer/engines/resemble_worker.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from __future__ import annotations
-
-import contextlib
-import sys
-from pathlib import Path
-
-import numpy as np
-import soundfile as sf
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
-
-from producer.engines.chunking import apply_chunked
-
-
-def load(path: str) -> tuple[np.ndarray, int]:
- data, sr = sf.read(path, dtype="float32", always_2d=True)
- return data.mean(axis=1).astype(np.float32), int(sr)
-
-
-def _report(stage: str):
- def cb(done: int, total: int) -> None:
- print(f"PROGRESS {stage} {done} {total}", flush=True)
-
- return cb
-
-
-def main() -> int:
- import torch
- from resemble_enhance.enhancer.inference import denoise as r_denoise
- from resemble_enhance.enhancer.inference import enhance as r_enhance
-
- inp, outp, device = sys.argv[1], sys.argv[2], sys.argv[3]
- strength = float(sys.argv[4])
- chunk_s = float(sys.argv[5]) if len(sys.argv) > 5 else 30.0
- overlap_s = float(sys.argv[6]) if len(sys.argv) > 6 else 0.5
- x, sr = load(inp)
- state = {"sr": sr}
-
- def denoise_fn(chunk: np.ndarray) -> np.ndarray:
- t = torch.from_numpy(np.ascontiguousarray(chunk))
- with contextlib.suppress(Exception):
- t, state["sr"] = r_denoise(t, state["sr"], device)
- if isinstance(t, torch.Tensor):
- t = t.detach().cpu().numpy()
- return np.asarray(t, dtype=np.float32).reshape(-1)
-
- def enhance_fn(chunk: np.ndarray) -> np.ndarray:
- t = torch.from_numpy(np.ascontiguousarray(chunk))
- try:
- y, _ = r_enhance(
- t, state["sr"], device, nfe=64, solver="midpoint", lambd=1.0 - 0.1 * strength
- )
- except TypeError:
- y, _ = r_enhance(t, state["sr"], device)
- if isinstance(y, torch.Tensor):
- y = y.detach().cpu().numpy()
- return np.asarray(y, dtype=np.float32).reshape(-1)
-
- x = apply_chunked(x, sr, chunk_s, overlap_s, denoise_fn, on_progress=_report("denoise"))
- y = apply_chunked(
- x, state["sr"], chunk_s, overlap_s, enhance_fn, on_progress=_report("enhance")
- )
- sf.write(outp, y, int(state["sr"]), subtype="FLOAT")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/lib/src/producer/io.py b/lib/src/producer/io.py
deleted file mode 100644
index b6d38b6..0000000
--- a/lib/src/producer/io.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-import shutil
-import subprocess
-import tempfile
-from pathlib import Path
-
-import numpy as np
-import soundfile as sf
-
-SUBTYPES = {16: "PCM_16", 24: "PCM_24", 32: "FLOAT"}
-
-
-def ffmpeg_available() -> bool:
- return shutil.which("ffmpeg") is not None
-
-
-def decode(path: str | Path) -> tuple[np.ndarray, int]:
- path = Path(path)
- try:
- data, sr = sf.read(str(path), dtype="float32", always_2d=True)
- except RuntimeError:
- if not ffmpeg_available():
- raise
- with tempfile.TemporaryDirectory(prefix="producer_dec_") as tmpdir:
- tmp = Path(tmpdir) / "dec.wav"
- subprocess.run(
- ["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vn", "-ac", "1", str(tmp)],
- check=True,
- )
- data, sr = sf.read(str(tmp), dtype="float32", always_2d=True)
- mono = data.mean(axis=1).astype(np.float32)
- return mono, int(sr)
-
-
-def encode(
- x: np.ndarray,
- sr: int,
- path: str | Path,
- out_format: str = "wav",
- bit_depth: int = 32,
-) -> Path:
- path = Path(path)
- fmt = out_format.lower()
- if fmt in ("wav", "flac"):
- subtype = SUBTYPES.get(bit_depth, "PCM_16")
- sf.write(str(path), x, sr, subtype=subtype, format=fmt.upper())
- return path
- if fmt == "mp3":
- if not ffmpeg_available():
- raise RuntimeError("mp3 output requires ffmpeg on PATH")
- with tempfile.TemporaryDirectory(prefix="producer_enc_") as td:
- tmp_wav = Path(td) / "enc.wav"
- sf.write(str(tmp_wav), x, sr, subtype="PCM_16")
- subprocess.run(
- [
- "ffmpeg",
- "-v",
- "error",
- "-y",
- "-i",
- str(tmp_wav),
- "-codec:a",
- "libmp3lame",
- "-b:a",
- "192k",
- str(path),
- ],
- check=True,
- )
- return path
- raise ValueError(f"unsupported output format: {out_format}")
-
-
-def supported_globs() -> list[str]:
- return (
- "*.wav",
- "*.flac",
- "*.mp3",
- "*.m4a",
- "*.aac",
- "*.ogg",
- "*.opus",
- "*.aif",
- "*.aiff",
- "*.wma",
- )
diff --git a/lib/src/producer/lazy.py b/lib/src/producer/lazy.py
deleted file mode 100644
index 55bbf6c..0000000
--- a/lib/src/producer/lazy.py
+++ /dev/null
@@ -1,257 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import importlib.util
-import json
-import platform
-import re
-import shutil
-import sys
-import urllib.request
-from collections.abc import Callable
-from importlib import metadata
-from pathlib import Path
-from typing import TypeVar
-from urllib.parse import unquote
-
-from . import ui
-
-_T = TypeVar("_T")
-
-DATA_DIR = Path(__file__).resolve().parents[2]
-TORCH_VERSION = "2.7.1"
-TORCH_GPU_INDEX = "https://download.pytorch.org/whl/cu126"
-WHEEL_CACHE = DATA_DIR / "cache" / "wheels"
-DIST_ALIASES = {
- "deepfilternet": "df",
- "deepfilterlib": "libdf",
-}
-# the probe installs by the failing import name, but some import names differ
-# from their pip name; only entries actually tripped by a chain get used
-IMPORT_ALIASES = {
- "PIL": "pillow",
- "yaml": "pyyaml",
- "dateutil": "python-dateutil",
- "dotenv": "python-dotenv",
- "cv2": "opencv-python",
- "sklearn": "scikit-learn",
- "skimage": "scikit-image",
-}
-
-
-class EngineUnavailable(RuntimeError):
- pass
-
-
-def find_uv() -> str:
- uv = shutil.which("uv")
- if uv:
- return uv
- local = DATA_DIR / "bin" / "uv"
- if local.is_file():
- return str(local)
- raise EngineUnavailable("uv not found; run the producer script to bootstrap")
-
-
-def has_module(name: str) -> bool:
- return importlib.util.find_spec(name) is not None
-
-
-def dist_name(spec: str) -> str:
- return spec.split("==")[0].split(">=")[0].split("<")[0].split("[")[0]
-
-
-def is_installed(spec: str) -> bool:
- dist = dist_name(spec)
- try:
- metadata.distribution(dist)
- return True
- except metadata.PackageNotFoundError:
- return has_module(DIST_ALIASES.get(dist, dist))
-
-
-def gpu_present() -> bool:
- return shutil.which("nvidia-smi") is not None
-
-
-def _py_tag() -> str:
- return f"cp{sys.version_info.major}{sys.version_info.minor}"
-
-
-def _arches() -> tuple[str, ...]:
- m = platform.machine().lower()
- if m in ("amd64", "x86_64"):
- return ("x86_64", "amd64")
- if m in ("aarch64", "arm64"):
- return ("aarch64", "arm64")
- return (m,)
-
-
-def _get_text(url: str) -> str:
- req = urllib.request.Request(url, headers={"User-Agent": ui.UA})
- with urllib.request.urlopen(req, timeout=30) as resp:
- return resp.read().decode("utf-8", "replace")
-
-
-def _choose_pypi(data: dict) -> tuple[str, str, str | None, int | None] | None:
- """Pick the linux wheel for this interpreter from a PyPI JSON release."""
- tag = _py_tag()
- arches = _arches()
- for u in data.get("urls", []):
- fn = u.get("filename", "")
- if fn.endswith(".whl") and tag in fn and "linux" in fn and any(a in fn for a in arches):
- return fn, u["url"], (u.get("digests") or {}).get("sha256"), u.get("size")
- return None
-
-
-def _choose_gpu(
- html: str, pkg: str, version: str
-) -> tuple[str, str, str | None, int | None] | None:
- """Pick a cu126 wheel from a download.pytorch.org index page.
-
- Hrefs may be relative or absolute and carry a #sha256=<hex> fragment, e.g.
- https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp311-...whl#sha256=...
- """
- prefix = f"{pkg}-{version}%2Bcu126-{_py_tag()}-"
- arches = _arches()
- for raw in re.findall(r'href="([^"]+\.whl[^"]*)"', html):
- href, _, frag = raw.partition("#")
- sha = frag[len("sha256=") :] if frag.startswith("sha256=") else None
- encoded = href.rsplit("/", 1)[-1]
- fname = unquote(encoded)
- if not encoded.startswith(prefix) or not any(a in fname for a in arches):
- continue
- url = href if href.startswith("http") else f"{TORCH_GPU_INDEX}/{pkg}/{href}"
- return fname, url, sha, None
- return None
-
-
-def _pypi_wheel(pkg: str, version: str) -> tuple[str, str, str | None, int | None] | None:
- data = json.loads(_get_text(f"https://pypi.org/pypi/{pkg}/{version}/json"))
- return _choose_pypi(data)
-
-
-def _gpu_wheel(pkg: str, version: str) -> tuple[str, str, str | None, int | None] | None:
- return _choose_gpu(_get_text(f"{TORCH_GPU_INDEX}/{pkg}/"), pkg, version)
-
-
-def _resolve_torch_wheels(gpu: bool) -> list[tuple[str, str, str | None, int | None]]:
- """(filename, url, sha256, size) for torch + torchaudio; [] when unresolvable."""
- pick = _gpu_wheel if gpu else _pypi_wheel
- wheels = []
- for pkg in ("torch", "torchaudio"):
- found = pick(pkg, TORCH_VERSION)
- if found is None:
- return []
- wheels.append(found)
- return wheels
-
-
-def ensure_torch() -> None:
- if has_module("torch"):
- return
- gpu = gpu_present()
- flavor = "CUDA" if gpu else "CPU"
- ui.log(
- f"[producer] installing torch {TORCH_VERSION} ({flavor})..."
- + (" ~2.5 GB download" if gpu else "")
- )
- wheels: list[tuple[str, str, str | None, int | None]] = []
- try:
- wheels = _resolve_torch_wheels(gpu)
- except Exception as e:
- ui.log(f"[producer] wheel index lookup failed ({e}); using uv directly")
- if wheels:
- paths = [
- ui.download(
- url,
- WHEEL_CACHE / name,
- f"downloading {name}",
- expected_size=size,
- sha256=sha,
- )
- for name, url, sha, size in wheels
- ]
- ui.run(
- [find_uv(), "pip", "install", "--python", sys.executable, *(str(p) for p in paths)],
- "installing torch",
- )
- else:
- cmd = [find_uv(), "pip", "install", "--python", sys.executable]
- if gpu:
- cmd += [
- f"torch=={TORCH_VERSION}+cu126",
- f"torchaudio=={TORCH_VERSION}+cu126",
- "--index-url",
- TORCH_GPU_INDEX,
- ]
- else:
- cmd += [f"torch=={TORCH_VERSION}", f"torchaudio=={TORCH_VERSION}"]
- ui.run(cmd, "installing torch")
- importlib.invalidate_caches()
-
-
-def ensure(packages: list[str], purpose: str) -> None:
- missing = [p for p in packages if not is_installed(p)]
- if not missing:
- return
- if any(m.startswith("torch") for m in missing):
- ensure_torch()
- missing = [m for m in missing if not m.startswith("torch") and not is_installed(m)]
- if not missing:
- return
- ui.log(f"[producer] installing {purpose} dependencies (one-time)...")
- ui.run(
- [find_uv(), "pip", "install", "--python", sys.executable, *missing],
- f"installing {purpose} dependencies",
- )
- importlib.invalidate_caches()
- for spec in missing:
- if not is_installed(spec):
- raise EngineUnavailable(f"failed to install {dist_name(spec)} for {purpose}")
-
-
-def ensure_import(module: str, purpose: str, max_rounds: int = 4) -> None:
- """Import `module`, installing any undeclared dependency it trips over.
-
- Some model loaders import modules their wheels don't declare (ModelScope's
- config/hub code needs addict/simplejson/sortedcontainers, for example).
- The failing import name usually doubles as the pip spec; the ones where
- it doesn't are translated via IMPORT_ALIASES. Runs until the import
- succeeds; gives up after max_rounds with a clear error.
- """
- missing = module
- for _ in range(max_rounds):
- try:
- importlib.import_module(module)
- return
- except ModuleNotFoundError as e:
- missing = e.name or module
- if "." in missing:
- # a submodule of an installed package is broken; installing
- # "pkg.sub" from pip would be nonsense — surface the real error
- raise
- ensure([IMPORT_ALIASES.get(missing, missing)], purpose=purpose)
- raise EngineUnavailable(f"cannot import {module} for {purpose} (still missing: {missing})")
-
-
-def ensure_call(fn: Callable[[], _T], purpose: str, max_rounds: int = 8) -> _T:
- """Run fn(), installing any undeclared dependency its import chain trips over.
-
- Engine weight loaders import lazily on the first call rather than at
- import time, and those imports reach modules whose wheels don't declare
- their deps (ModelScope's config/hub code needs addict/simplejson/
- sortedcontainers, for example, which are extras-only on PyPI). Same
- recovery loop as ensure_import, around a call instead of an import; only
- the first invocation can trip, later ones find the chain importable.
- """
- missing = ""
- for _ in range(max_rounds):
- try:
- return fn()
- except ModuleNotFoundError as e:
- missing = e.name or ""
- if not missing or "." in missing:
- raise
- ensure([IMPORT_ALIASES.get(missing, missing)], purpose=purpose)
- raise EngineUnavailable(f"cannot run {purpose} (still missing: {missing})")
diff --git a/lib/src/producer/loudness.py b/lib/src/producer/loudness.py
deleted file mode 100644
index 8ceae97..0000000
--- a/lib/src/producer/loudness.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from __future__ import annotations
-
-import numpy as np
-
-from . import dsp
-from .meters import meter, true_peak_db
-
-
-def normalize(
- x: np.ndarray,
- sr: int,
- mode: str,
- target: float,
- ceiling_db: float,
- max_iters: int = 4,
-) -> np.ndarray:
- if not np.any(x):
- return x
- y = x
- for _ in range(max_iters):
- cur = meter(y, sr, mode)
- gain = target - cur
- if abs(gain) < 0.05:
- break
- y = y * (10.0 ** (gain / 20.0))
- y = dsp.limit(y, sr, ceiling_db)
- tp = true_peak_db(y, sr)
- if tp > ceiling_db + 0.05:
- y = y * (10.0 ** ((ceiling_db - tp) / 20.0))
- return y.astype(np.float32)
diff --git a/lib/src/producer/meters.py b/lib/src/producer/meters.py
deleted file mode 100644
index dc4c566..0000000
--- a/lib/src/producer/meters.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-import numpy as np
-from scipy import signal
-
-SILENCE_DB = -120.0
-_METER_BLOCK = 1 << 20
-
-
-def _db(v: float) -> float:
- if v <= 0.0 or not np.isfinite(v):
- return SILENCE_DB
- return max(20.0 * np.log10(v), SILENCE_DB)
-
-
-def rms_db(x: np.ndarray) -> float:
- total = 0.0
- for i in range(0, x.size, _METER_BLOCK):
- blk = x[i : i + _METER_BLOCK]
- total += float(np.square(blk.astype(np.float64)).sum())
- mean = total / x.size if x.size else 0.0
- return _db(float(np.sqrt(mean)))
-
-
-def sample_peak_db(x: np.ndarray) -> float:
- peak = 0.0
- for i in range(0, x.size, _METER_BLOCK):
- blk = x[i : i + _METER_BLOCK]
- peak = max(peak, float(np.max(np.abs(blk))) if blk.size else 0.0)
- return _db(peak)
-
-
-def true_peak_db(x: np.ndarray, sr: int, oversample: int = 4) -> float:
- if x.size < 2:
- return sample_peak_db(x)
- half = 10 * oversample
- h = signal.firwin(2 * half + 1, 1.0 / oversample, window=("kaiser", 8.0)).astype(np.float32)
- pad = 4 * half + oversample
- block = max(1, int(30.0 * sr))
- peak = 0.0
- start = 0
- while start < x.size:
- end = min(start + block, x.size)
- a, b = max(0, start - pad), min(x.size, end + pad)
- y = signal.resample_poly(x[a:b].astype(np.float32, copy=False), oversample, 1, window=h)
- lo = (start - a) * oversample
- hi = (end - a) * oversample
- peak = max(peak, float(np.max(np.abs(y[lo:hi]))))
- start = end
- return _db(peak)
-
-
-def block_pct_db(x: np.ndarray, sr: int, block_ms: float = 50.0, pct: float = 5.0) -> float:
- n = max(1, int(sr * block_ms / 1000.0))
- nb = x.size // n
- if nb < 1:
- return sample_peak_db(x) if x.size else SILENCE_DB
- view = x[: nb * n].reshape(nb, n)
- rows = max(1, _METER_BLOCK // n)
- block_rms = np.empty(nb, dtype=np.float64)
- for i in range(0, nb, rows):
- sl = view[i : i + rows]
- block_rms[i : i + rows] = np.sqrt(np.mean(np.square(sl.astype(np.float64)), axis=1))
- active = block_rms[block_rms > 0.0]
- if active.size == 0:
- return SILENCE_DB
- return _db(float(np.percentile(active, pct)))
-
-
-def noise_floor_db(x: np.ndarray, sr: int) -> float:
- return block_pct_db(x, sr, block_ms=50.0, pct=5.0)
-
-
-def speech_level_db(x: np.ndarray, sr: int) -> float:
- """Robust loudness of the actual speech in x (95th pct of 50 ms frame RMS).
-
- Unlike plain RMS this barely moves with long pauses, so it is a stable
- anchor for gain staging the DSP chain.
- """
- return block_pct_db(x, sr, block_ms=50.0, pct=95.0)
-
-
-def lufs(x: np.ndarray, sr: int) -> float:
- import pyloudnorm as pyln
-
- if not np.any(x):
- return SILENCE_DB
- if x.size < int(sr * 0.2):
- return SILENCE_DB
- meter = pyln.Meter(sr)
- try:
- val = meter.integrated_loudness(x)
- except ValueError:
- return SILENCE_DB
- if not np.isfinite(val):
- return SILENCE_DB
- return float(val)
-
-
-def meter(x: np.ndarray, sr: int, mode: str) -> float:
- return lufs(x, sr) if mode == "lufs" else rms_db(x)
-
-
-def all_meters(x: np.ndarray, sr: int) -> dict[str, float]:
- return {
- "rms_db": rms_db(x),
- "sample_peak_db": sample_peak_db(x),
- "true_peak_db": true_peak_db(x, sr),
- "lufs": lufs(x, sr),
- "noise_floor_db": noise_floor_db(x, sr),
- "duration_s": round(x.size / sr, 3) if sr else 0.0,
- }
diff --git a/lib/src/producer/pipeline.py b/lib/src/producer/pipeline.py
deleted file mode 100644
index e7aa4ae..0000000
--- a/lib/src/producer/pipeline.py
+++ /dev/null
@@ -1,269 +0,0 @@
-from __future__ import annotations
-
-import math
-import time
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from typing import Protocol
-
-import numpy as np
-
-from . import dsp, loudness
-from .config import Options
-from .meters import all_meters, speech_level_db
-
-
-class Reporter(Protocol):
- """Status sink for the pipeline; see producer.ui.Status."""
-
- def stage(self, name: str) -> None: ...
-
- def tick(self, done: int, total: int) -> None: ...
-
- def stage_done(self, name: str, elapsed: float | None = None) -> None: ...
-
-
-@dataclass
-class Stage:
- name: str
- detail: str
- enabled: bool = True
- fn: Callable[[np.ndarray, int], np.ndarray] | None = None
-
-
-@dataclass
-class RunResult:
- audio: np.ndarray
- sr: int
- stages: list[Stage]
- timings: dict[str, float] = field(default_factory=dict)
- notes: list[str] = field(default_factory=list)
- before: dict[str, float] = field(default_factory=dict)
- after: dict[str, float] = field(default_factory=dict)
-
-
-def _chunk_note(opts: Options, notes: list[str], x: np.ndarray, sr: int) -> None:
- if opts.engine_chunk_s <= 0 or x.size <= opts.engine_chunk_s * sr:
- return
- eff = max(0.01, opts.engine_chunk_s - opts.engine_overlap_s)
- n_chunks = math.ceil(x.size / sr / eff)
- notes.append(
- f"engine chunking: {n_chunks} chunks x {opts.engine_chunk_s:g}s"
- f" ({opts.engine_overlap_s:g}s overlap)"
- )
-
-
-def _denoise_fn(opts: Options, notes: list[str], reporter: Reporter | None = None):
- engine = opts.denoise
- prog = reporter.tick if reporter is not None else None
-
- def fn(x: np.ndarray, sr: int) -> np.ndarray:
- if engine == "off":
- return x
- _chunk_note(opts, notes, x, sr)
- if engine == "dfn3":
- from .engines import denoise_dfn
-
- y, eng, dev = denoise_dfn.denoise(
- x,
- sr,
- opts.denoise_strength,
- opts.device,
- chunk_s=opts.engine_chunk_s,
- overlap_s=opts.engine_overlap_s,
- on_progress=prog,
- post_filter=opts.denoise_pf,
- )
- elif engine == "zipenhancer":
- from .engines import denoise_zip
-
- y, eng, dev = denoise_zip.denoise(
- x,
- sr,
- opts.denoise_strength,
- opts.device,
- chunk_s=opts.engine_chunk_s,
- overlap_s=opts.engine_overlap_s,
- on_progress=prog,
- )
- elif engine == "spectral":
- from .engines import denoise_spectral
-
- y, eng, dev = denoise_spectral.denoise(
- x,
- sr,
- opts.denoise_strength,
- opts.device,
- chunk_s=opts.engine_chunk_s,
- overlap_s=opts.engine_overlap_s,
- on_progress=prog,
- )
- else:
- raise ValueError(f"unknown denoise engine: {engine}")
- notes.append(f"denoise: {eng} on {dev}")
- return y
-
- return fn
-
-
-def _enhance_fn(opts: Options, notes: list[str], reporter: Reporter | None = None):
- engine = opts.enhance
- prog = reporter.tick if reporter is not None else None
-
- def fn(x: np.ndarray, sr: int) -> np.ndarray:
- if engine == "off":
- return x
- _chunk_note(opts, notes, x, sr)
- if engine == "mossformer2":
- from .engines import enhance_mossformer
-
- y, eng, dev = enhance_mossformer.enhance(
- x,
- sr,
- opts.enhance_strength,
- opts.device,
- chunk_s=opts.engine_chunk_s,
- overlap_s=opts.engine_overlap_s,
- on_progress=prog,
- )
- elif engine == "resemble":
- from .engines import enhance_resemble
-
- y, eng, dev = enhance_resemble.enhance(
- x,
- sr,
- opts.enhance_strength,
- opts.device,
- chunk_s=opts.engine_chunk_s,
- overlap_s=opts.engine_overlap_s,
- on_progress=prog,
- )
- else:
- raise ValueError(f"unknown enhance engine: {engine}")
- notes.append(f"enhance: {eng} on {dev}")
- return y
-
- return fn
-
-
-def _dsp_fn(opts: Options, notes: list[str]):
- p = opts.prof()
-
- def fn(x: np.ndarray, sr: int) -> np.ndarray:
- # Gain-stage the (denoised) signal to the level the voice chain was
- # designed around. Denoisers shift the level distribution, and the
- # absolute comp thresholds only make sense at a known input level;
- # anchoring on measured speech level keeps the chain predictable and
- # stops the compressors over-riding sparse denoised speech.
- ref = speech_level_db(x, sr)
- pregain_db = float(np.clip(p.dsp_ref_db - ref, -24.0, 24.0))
- notes.append(f"dsp pregain: {pregain_db:+.1f} dB (speech level {ref:.1f} dB)")
- x = x * np.float32(10.0 ** (pregain_db / 20.0))
- hpf_hz = opts.hpf_hz if opts.hpf_hz is not None else p.hpf_hz
- if opts.eff("hpf") > 0.001:
- x = dsp.hpf(x, sr, hpf_hz)
- f, g, q = p.mud
- x = dsp.peak_eq(x, sr, f, g * opts.eff("mud"), q)
- wf, wg = p.warmth
- x = dsp.shelf(x, sr, wf, wg * opts.eff("warmth"), low=True)
- x = dsp.soothe(x, sr, opts.eff("soothe"))
- c1 = p.comp1
- y = dsp.compressor(x, sr, c1[0], c1[1], c1[2], c1[3])
- x = _blend(x, y, opts.eff("compress") * 0.7)
- c2 = p.comp2
- y = dsp.compressor(x, sr, c2[0], c2[1], c2[2], c2[3])
- x = _blend(x, y, opts.eff("compress") * 0.5)
- x = dsp.tape(x, sr, opts.eff("tape"))
- d = p.deess
- x = dsp.deesser(x, sr, d[0], d[1], d[2] * opts.eff("deess"))
- pf, pg = p.presence
- x = dsp.peak_eq(x, sr, pf, pg * opts.eff("presence"))
- af, ag = p.air
- x = dsp.shelf(x, sr, af, ag * opts.eff("air"), low=False)
- # Between sentences a good denoiser leaves near-silence but tape hiss
- # and breaths survive; duck them for real instead of the old 6 dB cap.
- breath = opts.eff("breath")
- max_drop = (12.0 + 12.0 * breath) if breath > 0.02 else 0.0
- x = dsp.expander(x, sr, max_drop_db=max_drop)
- return x
-
- return fn
-
-
-def _blend(x: np.ndarray, y: np.ndarray, s: float) -> np.ndarray:
- s = float(np.clip(s, 0.0, 1.0))
- if s >= 0.999:
- return y.astype(np.float32, copy=False)
- if s <= 0.001:
- return x.astype(np.float32, copy=False)
- out = np.asarray(y, dtype=np.float32) - np.asarray(x, dtype=np.float32)
- out *= s
- out += x
- return out
-
-
-def _level_fn(opts: Options, notes: list[str]):
- def fn(x: np.ndarray, sr: int) -> np.ndarray:
- return loudness.normalize(x, sr, opts.loudness_mode(), opts.target_value(), opts.ceiling())
-
- return fn
-
-
-def build_stages(
- opts: Options, notes: list[str] | None = None, reporter: Reporter | None = None
-) -> list[Stage]:
- notes = notes if notes is not None else []
- stages: list[Stage] = []
- stages.append(
- Stage(
- "denoise",
- f"engine={opts.denoise} strength={opts.denoise_strength:.2f}"
- f" pf={'on' if opts.denoise_pf else 'off'}",
- opts.denoise != "off",
- _denoise_fn(opts, notes, reporter),
- )
- )
- stages.append(
- Stage(
- "enhance",
- f"engine={opts.enhance} strength={opts.enhance_strength:.2f}",
- opts.enhance != "off",
- _enhance_fn(opts, notes, reporter),
- )
- )
- dsp_desc = "hpf→mud→warmth→soothe→comp2x→tape→deess→presence→air→breath"
- stages.append(Stage("dsp", dsp_desc, opts.dsp, _dsp_fn(opts, notes) if opts.dsp else None))
- stages.append(
- Stage(
- "levelling",
- f"mode={opts.loudness_mode()} target={opts.target_value()} ceiling={opts.ceiling()}",
- opts.levelling,
- _level_fn(opts, notes) if opts.levelling else None,
- )
- )
- return stages
-
-
-def run_pipeline(
- x: np.ndarray, sr: int, opts: Options, reporter: Reporter | None = None
-) -> RunResult:
- notes: list[str] = []
- stages = build_stages(opts, notes)
- before = all_meters(x, sr)
- y = x
- timings: dict[str, float] = {}
- for st in stages:
- if not st.enabled or st.fn is None:
- continue
- if reporter is not None:
- reporter.stage(st.name)
- t0 = time.perf_counter()
- y = st.fn(y, sr)
- elapsed = time.perf_counter() - t0
- timings[st.name] = round(elapsed, 3)
- if reporter is not None:
- reporter.stage_done(st.name, elapsed)
- after = all_meters(y, sr)
- return RunResult(
- audio=y, sr=sr, stages=stages, timings=timings, notes=notes, before=before, after=after
- )
diff --git a/lib/src/producer/report.py b/lib/src/producer/report.py
deleted file mode 100644
index 27fbb8f..0000000
--- a/lib/src/producer/report.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-from . import __version__
-from .config import Options
-
-
-def build(
- input_path: str,
- out_path: str,
- opts: Options,
- before: dict[str, float],
- after: dict[str, float],
- timings: dict[str, float],
- notes: list[str],
-) -> dict:
- return {
- "tool": "producer",
- "version": __version__,
- "input": str(input_path),
- "output": str(out_path),
- "settings": opts.to_dict(),
- "before": before,
- "after": after,
- "stage_seconds": timings,
- "engine_notes": notes,
- }
-
-
-def print_human(rep: dict) -> None:
- b = rep["before"]
- a = rep["after"]
- print(" before -> after:")
- for key, label in (
- ("rms_db", "RMS"),
- ("true_peak_db", "true peak"),
- ("lufs", "LUFS"),
- ("noise_floor_db", "noise floor"),
- ):
- print(f" {label:<12} {b[key]:>8.1f} dB -> {a[key]:>8.1f} dB")
- if rep["engine_notes"]:
- for note in rep["engine_notes"]:
- print(f" {note}")
- times = rep["stage_seconds"]
- if times:
- total = sum(times.values())
- detail = ", ".join(f"{k} {v:.1f}s" for k, v in times.items())
- print(f" stages: {detail} (total {total:.1f}s)")
-
-
-def save(rep: dict, path: str | Path) -> Path:
- path = Path(path)
- path.write_text(json.dumps(rep, indent=2))
- return path
diff --git a/lib/src/producer/ui.py b/lib/src/producer/ui.py
deleted file mode 100644
index bc08756..0000000
--- a/lib/src/producer/ui.py
+++ /dev/null
@@ -1,319 +0,0 @@
-"""Terminal progress primitives: live status lines, download bars, subprocess runners.
-
-All live rendering shares one line on stdout: renderers erase whatever line is
-currently live before drawing their own, and `log()` erases it before printing
-a permanent line, so engine prints, downloads, and stage ticks never garble
-each other. Without a TTY nothing is drawn in place; instead milestones are
-logged periodically so piped output still shows regular progress.
-"""
-
-from __future__ import annotations
-
-import hashlib
-import shutil
-import subprocess
-import sys
-import threading
-import time
-import urllib.request
-from pathlib import Path
-
-_lock = threading.Lock()
-_live = "" # contents of the in-place line, "" when none
-
-UA = "producer/0.1.0"
-
-
-def is_tty() -> bool:
- try:
- return bool(sys.stdout and sys.stdout.isatty())
- except Exception:
- return False
-
-
-def _cols() -> int:
- try:
- return max(20, shutil.get_terminal_size().columns)
- except Exception:
- return 80
-
-
-def _erase() -> None:
- global _live
- if _live:
- sys.stdout.write("\r" + " " * len(_live) + "\r")
- _live = ""
-
-
-def _render_live(text: str) -> None:
- global _live
- if not is_tty():
- return
- with _lock:
- text = text[: _cols() - 1]
- _erase()
- sys.stdout.write("\r" + text)
- sys.stdout.flush()
- _live = text
-
-
-def finish_live() -> None:
- """Clear the in-place line, if any."""
- with _lock:
- if is_tty():
- _erase()
- sys.stdout.flush()
-
-
-def log(msg: str) -> None:
- """Print a permanent line, clearing any in-place progress line first."""
- with _lock:
- if is_tty():
- _erase()
- print(msg, flush=True)
-
-
-def fmt_bytes(n: float | None) -> str:
- if n is None:
- return "?"
- n = float(n)
- for unit in ("B", "KB", "MB", "GB", "TB"):
- if n < 1024.0 or unit == "TB":
- return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
- n /= 1024.0
- return f"{n:.1f} TB"
-
-
-def fmt_secs(s: float | None) -> str:
- if s is None or s < 0 or s != s: # None, negative, NaN
- return "?"
- s = int(s)
- h, rem = divmod(s, 3600)
- m, sec = divmod(rem, 60)
- return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"
-
-
-def _bar(frac: float, width: int = 22) -> str:
- frac = min(1.0, max(0.0, frac))
- fill = round(frac * width)
- return "[" + "#" * fill + "-" * (width - fill) + "]"
-
-
-class Progress:
- """Byte progress for one download: live bar on a TTY, milestones otherwise."""
-
- def __init__(self, label: str, total: int | None = None) -> None:
- self.label = label
- self.total = total
- self.done = 0
- self._t0 = time.monotonic()
- self._last_draw = 0.0
- self._last_ms_t = 0.0
- self._last_ms_pct = -100
-
- def update(self, done: int, total: int | None = None) -> None:
- if total is not None:
- self.total = total
- self.done = done
- now = time.monotonic()
- if is_tty():
- if now - self._last_draw >= 0.1 or (self.total and done >= self.total):
- self._draw(now)
- else:
- self._milestone(now)
-
- def _draw(self, now: float) -> None:
- self._last_draw = now
- el = max(1e-6, now - self._t0)
- speed = self.done / el
- text = f"[producer] {self.label} {_bar(0)}"
- if self.total:
- frac = self.done / self.total
- rem = max(0.0, el * (self.total - self.done) / max(1, self.done))
- text = (
- f"[producer] {self.label} {_bar(frac)} {fmt_bytes(self.done)}/"
- f"{fmt_bytes(self.total)} ({frac * 100:.0f}%) {fmt_bytes(speed)}/s"
- f" ETA {fmt_secs(rem)}"
- )
- else:
- text = f"[producer] {self.label} {fmt_bytes(self.done)} {fmt_bytes(speed)}/s"
- _render_live(text)
-
- def _milestone(self, now: float) -> None:
- pct = 100.0 * self.done / self.total if self.total else 0.0
- if (
- now - self._last_ms_t >= 30.0
- or (self.total and pct - self._last_ms_pct >= 10.0)
- or (self.total and self.done >= self.total)
- ):
- self._last_ms_t = now
- self._last_ms_pct = pct
- log(f"[producer] {self.label} {fmt_bytes(self.done)}/{fmt_bytes(self.total)}")
-
- def close(self, final: str | None = None) -> None:
- el = time.monotonic() - self._t0
- msg = final or (f"[producer] {self.label} done ({fmt_bytes(self.done)} in {fmt_secs(el)})")
- log(msg)
-
-
-def _sha256_of(path: Path) -> str:
- h = hashlib.sha256()
- with path.open("rb") as f:
- for chunk in iter(lambda: f.read(1 << 20), b""):
- h.update(chunk)
- return h.hexdigest()
-
-
-def _cache_ok(dest: Path, expected_size: int | None, sha256: str | None) -> bool:
- if not dest.is_file():
- return False
- if sha256:
- if expected_size is not None and dest.stat().st_size != expected_size:
- return False
- return _sha256_of(dest) == sha256
- if expected_size is not None:
- return dest.stat().st_size == expected_size
- return True
-
-
-def download(
- url: str,
- dest: Path,
- label: str | None = None,
- expected_size: int | None = None,
- sha256: str | None = None,
- timeout: float = 60.0,
-) -> Path:
- """Download `url` to `dest` with a progress bar; skips if already cached."""
- dest = Path(dest)
- label = label or f"downloading {dest.name}"
- if _cache_ok(dest, expected_size, sha256):
- log(f"[producer] {label}: already cached ({fmt_bytes(dest.stat().st_size)})")
- return dest
- dest.parent.mkdir(parents=True, exist_ok=True)
- tmp = dest.with_name(dest.name + ".part")
- req = urllib.request.Request(url, headers={"User-Agent": UA})
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- total = expected_size
- if total is None:
- try:
- total = int(resp.headers.get("Content-Length") or 0) or None
- except (TypeError, ValueError):
- total = None
- prog = Progress(label, total)
- hasher = hashlib.sha256() if sha256 else None
- tmp.parent.mkdir(parents=True, exist_ok=True)
- with tmp.open("wb") as f:
- while True:
- chunk = resp.read(1 << 20)
- if not chunk:
- break
- f.write(chunk)
- if hasher is not None:
- hasher.update(chunk)
- prog.update(f.tell())
- prog.close()
- if hasher is not None and hasher.hexdigest() != sha256:
- tmp.unlink(missing_ok=True)
- raise RuntimeError(f"checksum mismatch for {dest.name} ({url})")
- tmp.replace(dest)
- return dest
-
-
-def run(cmd: list[str], label: str, check: bool = True) -> int:
- """Run a subprocess, keeping output visible and adding heartbeats when piped.
-
- On a TTY the child inherits the terminal (uv draws its own progress bars);
- when output is piped, the child's lines are forwarded and a heartbeat with
- elapsed time is logged every 30 s so long installs never look frozen.
- """
- if is_tty():
- proc = subprocess.run(cmd, check=check)
- return proc.returncode
- start = time.monotonic()
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
-
- def beat() -> None:
- while p.poll() is None:
- time.sleep(30.0)
- if p.poll() is None:
- log(f"[producer] {label}... {int(time.monotonic() - start)}s elapsed")
-
- threading.Thread(target=beat, daemon=True).start()
- assert p.stdout is not None
- for line in p.stdout:
- line = line.rstrip()
- if line:
- log(line)
- rc = p.wait()
- if check and rc != 0:
- raise subprocess.CalledProcessError(rc, cmd)
- return rc
-
-
-class Status:
- """Per-file pipeline reporter: one live line for the current stage."""
-
- def __init__(self, prefix: str = "") -> None:
- self.prefix = prefix
- self.stage_name: str | None = None
- self.total: int | None = None
- self.done = 0
- self._t0 = 0.0
- self._last_draw = 0.0
- self._last_ms_t = 0.0
- self._last_ms_pct = -100
-
- def stage(self, name: str) -> None:
- self.stage_name = name
- self.total = None
- self.done = 0
- self._t0 = time.monotonic()
- self._last_ms_t = self._t0
- self._last_ms_pct = -100
- self._draw(force=True)
-
- def tick(self, done: int, total: int) -> None:
- """Progress within the current stage (engine chunks)."""
- self.total = total
- self.done = done
- now = time.monotonic()
- if is_tty():
- if now - self._last_draw >= 0.1 or done >= total:
- self._draw(now)
- elif total and (
- done >= total
- or now - self._last_ms_t >= 30.0
- or 100.0 * done / total - self._last_ms_pct >= 20.0
- ):
- self._last_ms_t = now
- self._last_ms_pct = 100.0 * done / total
- log(f"{self.prefix}{self.stage_name} {done}/{total} chunks ({self._last_ms_pct:.0f}%)")
-
- def stage_done(self, name: str, elapsed: float | None = None) -> None:
- el = time.monotonic() - self._t0 if elapsed is None else elapsed
- chunks = f" ({self.done}/{self.total} chunks)" if self.total else ""
- log(f"{self.prefix}{name} done in {el:.1f}s{chunks}")
- self.stage_name = None
- finish_live()
-
- def _draw(self, force: bool = False) -> None:
- if self.stage_name is None:
- return
- now = time.monotonic()
- if not force and now - self._last_draw < 0.1:
- return
- self._last_draw = now
- el = now - self._t0
- text = f"{self.prefix}{self.stage_name}"
- if self.total:
- pct = 100.0 * self.done / self.total
- eta = el * (self.total - self.done) / self.done if self.done else None
- text += f" {_bar(self.done / self.total, 18)} {self.done}/{self.total}"
- text += f" chunks ({pct:.0f}%) ETA {fmt_secs(eta)}"
- else:
- text += f"... {el:.0f}s"
- _render_live(text)
-
- def finish(self) -> None:
- finish_live()
diff --git a/lib/src/producer/updates.py b/lib/src/producer/updates.py
deleted file mode 100644
index 690e955..0000000
--- a/lib/src/producer/updates.py
+++ /dev/null
@@ -1,273 +0,0 @@
-"""Dependency update checks: probe uv for newer allowed versions, prompt, apply.
-
-Probes are `uv pip install --dry-run --upgrade` resolutions against the live
-environment, so "newer" always respects requirements-core.txt pins. Engine
-probes pin `torch==<installed base>` and include the core requirements file so
-an engine upgrade can never re-resolve a CUDA torch onto a CPU wheel or drag
-core pins. Everything here is best-effort: uv/network failures are skipped
-silently and never block a run.
-"""
-
-from __future__ import annotations
-
-import importlib
-import re
-import subprocess
-import sys
-from concurrent.futures import ThreadPoolExecutor
-from importlib import metadata
-from pathlib import Path
-from typing import NamedTuple
-
-from . import lazy, ui
-
-CORE_FILE = lazy.DATA_DIR / "requirements-core.txt"
-RESEMBLE_PY = lazy.DATA_DIR / "venvs" / "resemble" / "bin" / "python"
-ENGINE_DISTS = ("deepfilternet", "zipenhancer", "clearvoice")
-PROBE_TIMEOUT = 8.0
-
-
-class Update(NamedTuple):
- """One promptable upgrade; `cmd` is the uv invocation that applies it."""
-
- label: str
- old: str | None
- new: str
- note: str
- cmd: list[str]
-
-
-def _installed(dist: str) -> str | None:
- try:
- return metadata.version(dist)
- except metadata.PackageNotFoundError:
- return None
-
-
-def _installed_in(python: Path, dist: str) -> str | None:
- try:
- proc = subprocess.run(
- [
- str(python),
- "-c",
- "import sys; from importlib.metadata import version; print(version(sys.argv[1]))",
- dist,
- ],
- capture_output=True,
- text=True,
- timeout=15.0,
- )
- except (OSError, subprocess.SubprocessError):
- return None
- if proc.returncode != 0:
- return None
- return proc.stdout.strip() or None
-
-
-def _core_names() -> list[str]:
- names = []
- for line in CORE_FILE.read_text(encoding="utf-8").splitlines():
- line = line.strip()
- if line and not line.startswith("#"):
- names.append(re.split(r"[<>=!~;\[\s]", line)[0])
- return names
-
-
-def _parse_would_install(out: str) -> dict[str, str]:
- found: dict[str, str] = {}
- for line in out.splitlines():
- line = line.strip()
- if line.startswith(("+ ", "~ ")):
- name, _, ver = line[2:].partition("==")
- if name and ver:
- found[name.strip()] = ver.strip()
- return found
-
-
-def _vkey(version: str) -> tuple:
- """Orderable key for simple version comparison ("1.2.10" > "1.2.9")."""
- parts = []
- for chunk in re.split(r"[._\-+!]", version):
- if chunk:
- parts.append((1, int(chunk), "") if chunk.isdigit() else (0, 0, chunk))
- return tuple(parts)
-
-
-def _probe(args: list[str], python: str | None, keep: set[str]) -> dict[str, str]:
- """`uv pip install --dry-run --upgrade <args>` -> {name: version} for `keep`.
-
- Best-effort: any uv failure (offline, unsatisfiable resolution, timeout)
- yields {} so the update check silently skips that group.
- """
- try:
- cmd = [
- lazy.find_uv(),
- "pip",
- "install",
- "--dry-run",
- "--upgrade",
- "--python",
- python or sys.executable,
- *args,
- ]
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=PROBE_TIMEOUT)
- except Exception:
- return {}
- if proc.returncode != 0:
- return {}
- got = _parse_would_install(proc.stdout)
- return {name: ver for name, ver in got.items() if name in keep}
-
-
-def _jobs() -> list[tuple[str, list[str], str | None, set[str], str]]:
- """(key, probe args, venv python or None, keep-set, display note) per probe."""
- jobs: list[tuple[str, list[str], str | None, set[str], str]] = [
- ("core", ["-U", "-r", str(CORE_FILE)], None, set(_core_names()), "")
- ]
- torch = _installed("torch")
- gpu = lazy.gpu_present()
- if torch and _installed("torchaudio"):
- args = ["-U", "torch", "torchaudio"]
- note = "CUDA, ~2.5 GB download" if gpu else "CPU build"
- if gpu:
- args += ["--index-url", lazy.TORCH_GPU_INDEX]
- jobs.append(("torch+torchaudio", args, None, {"torch", "torchaudio"}, note))
- for name in ENGINE_DISTS:
- if torch and _installed(name):
- jobs.append(
- (
- name,
- ["-U", "-r", str(CORE_FILE), f"torch=={torch.split('+')[0]}", name],
- None,
- {name},
- "",
- )
- )
- if RESEMBLE_PY.is_file() and _installed_in(RESEMBLE_PY, "resemble-enhance"):
- jobs.append(
- (
- "resemble-enhance",
- ["-U", "resemble-enhance"],
- str(RESEMBLE_PY),
- {"resemble-enhance"},
- "isolated venv",
- )
- )
- return jobs
-
-
-def collect() -> list[Update]:
- """Best-effort list of available updates (empty when up to date or offline)."""
- jobs = _jobs()
- if not jobs:
- return []
- with ThreadPoolExecutor(max_workers=len(jobs)) as ex:
- probes = list(ex.map(lambda j: (j[0], j[1], j[4], _probe(j[1], j[2], j[3])), jobs))
- updates: list[Update] = []
- uv = lazy.find_uv()
- for key, _args, note, got in probes:
- if not got:
- continue
- if key == "core":
- for name in _core_names():
- new, old = got.get(name), _installed(name)
- if new and old and _vkey(new) > _vkey(old):
- updates.append(
- Update(
- name,
- old,
- new,
- "",
- [
- uv,
- "pip",
- "install",
- "--python",
- sys.executable,
- "-U",
- "-r",
- str(CORE_FILE),
- ],
- )
- )
- elif key == "torch+torchaudio":
- new, ta_new, old = got.get("torch"), got.get("torchaudio"), _installed("torch")
- if new and ta_new and old and _vkey(new) > _vkey(old):
- cmd = [
- uv,
- "pip",
- "install",
- "--python",
- sys.executable,
- f"torch=={new}",
- f"torchaudio=={ta_new}",
- ]
- if lazy.gpu_present():
- cmd += ["--index-url", lazy.TORCH_GPU_INDEX]
- updates.append(Update("torch + torchaudio", old, new, note, cmd))
- else:
- new = got.get(key)
- old = _installed_in(RESEMBLE_PY, key) if key == "resemble-enhance" else _installed(key)
- if new and old and _vkey(new) > _vkey(old):
- if key == "resemble-enhance":
- cmd = [uv, "pip", "install", "--python", str(RESEMBLE_PY), f"{key}=={new}"]
- else:
- torch = _installed("torch")
- cmd = [
- uv,
- "pip",
- "install",
- "--python",
- sys.executable,
- "-r",
- str(CORE_FILE),
- f"torch=={torch.split('+')[0]}",
- f"{key}=={new}",
- ]
- updates.append(Update(key, old, new, note, cmd))
- return updates
-
-
-def _confirm(prompt: str) -> bool:
- try:
- ans = input(f"[producer] {prompt} [y/N]: ").strip().lower()
- except EOFError:
- print()
- return False
- return ans in ("y", "yes")
-
-
-def check_and_prompt(force: bool = False, assume_yes: bool = False) -> bool:
- """Check once; prompt on interactive TTYs. Returns True when updates applied."""
- updates = collect()
- if not updates:
- if force:
- ui.log("[producer] everything is up to date")
- return False
- ui.log("[producer] updates available:")
- for u in updates:
- line = f" {u.label} {u.old} -> {u.new}"
- if u.note:
- line += f" ({u.note})"
- ui.log(line)
- if not assume_yes:
- if not sys.stdin.isatty():
- if force:
- raise SystemExit("producer update: stdin is not interactive; use --yes")
- ui.log("[producer] run ./producer update to install these")
- return False
- if not _confirm("install updates?"):
- ui.log("[producer] skipped updates")
- return False
- for u in updates:
- ui.run(u.cmd, f"updating {u.label}")
- importlib.invalidate_caches()
- ui.log("[producer] updates installed")
- return True
-
-
-def run_update_command(args: list[str]) -> int:
- """`producer update [--yes]`: check for updates and install them now."""
- assume_yes = any(a in ("-y", "--yes") for a in args)
- check_and_prompt(force=True, assume_yes=assume_yes)
- return 0