aboutsummaryrefslogtreecommitdiff
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
parent39b0f2bbed74f6487a41b82501ae3c6799e4b5c4 (diff)
downloadproducer-84dd2d068317998f6fb59400c534ef5be6b51b53.tar.gz
slop rewriteHEADmain
-rw-r--r--.gitignore15
-rw-r--r--LICENSE12
-rw-r--r--LICENSE.md13
-rw-r--r--README.md321
-rw-r--r--config.toml105
-rw-r--r--lib/archive/mastered/README.md36
-rw-r--r--lib/project/examples/voice.toml56
-rw-r--r--lib/project/pyproject.toml29
-rw-r--r--lib/project/src/voiceforge/__init__.py3
-rw-r--r--lib/project/src/voiceforge/__main__.py3
-rw-r--r--lib/project/src/voiceforge/ai.py157
-rw-r--r--lib/project/src/voiceforge/ai_worker.py161
-rw-r--r--lib/project/src/voiceforge/audio.py128
-rw-r--r--lib/project/src/voiceforge/cli.py193
-rw-r--r--lib/project/src/voiceforge/config.py118
-rw-r--r--lib/project/src/voiceforge/pipeline.py199
-rw-r--r--lib/project/src/voiceforge/progress.py40
-rw-r--r--lib/project/src/voiceforge/setup.py203
-rw-r--r--lib/project/tests/conftest.py43
-rw-r--r--lib/project/tests/test_ai.py366
-rw-r--r--lib/project/tests/test_cli.py287
-rw-r--r--lib/project/tests/test_config.py134
-rw-r--r--lib/project/tests/test_install.py225
-rw-r--r--lib/project/tests/test_pipeline.py273
-rw-r--r--lib/pytest.ini3
-rw-r--r--lib/requirements-core.txt4
-rw-r--r--lib/ruff.toml12
-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
-rw-r--r--lib/tests/conftest.py70
-rw-r--r--lib/tests/test_chunking.py250
-rw-r--r--lib/tests/test_cli.py357
-rw-r--r--lib/tests/test_dsp.py130
-rw-r--r--lib/tests/test_engines.py515
-rw-r--r--lib/tests/test_lazy.py286
-rw-r--r--lib/tests/test_loudness.py30
-rw-r--r--lib/tests/test_meters.py65
-rw-r--r--lib/tests/test_pipeline.py114
-rw-r--r--lib/tests/test_ui.py169
-rw-r--r--lib/tests/test_updates.py221
-rwxr-xr-xproducer68
-rwxr-xr-xproducer.sh186
63 files changed, 3154 insertions, 5675 deletions
diff --git a/.gitignore b/.gitignore
index ce844a2..c7c0792 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,11 @@
-lib/.venv/
-lib/bin/
-lib/models/
-lib/cache/
-lib/venvs/
+.venv/
__pycache__/
-*.pyc
.pytest_cache/
-.ruff_cache/
+*.egg-info/
+dist/
+build/
+lib/runtime/
*.wav
+*.mp3
+*.flac
+*.report.json
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9e1eda4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,12 @@
+Copyright (c) 2026 VoiceForge contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
deleted file mode 100644
index d3061a3..0000000
--- a/LICENSE.md
+++ /dev/null
@@ -1,13 +0,0 @@
-Zero-Clause BSD
-=============
-
-Permission to use, copy, modify, and/or distribute this software for
-any purpose with or without fee is hereby granted.
-
-THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
-WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
-OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
-FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
-DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
-AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
-OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/README.md b/README.md
index a5aebaa..fcf3b64 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,314 @@
-# producer
+# Producer
-One-click audio file mastering. Give it a raw recording; it denoises, enhances, applies a warm voice chain, and delivers a loudness-normalized master.
+Local, offline speech cleanup and mono mastering for WAV recordings, launched
+with a single script: `producer.sh`. The only AI backend is **DeepFilterNet3**;
+FFmpeg provides filtering, dynamics, loudness normalization and encoding. There
+is no MossFormer backend. Audio processing is local; initial dependency and
+model setup requires internet access.
-```bash ./producer <filename>.wav ```
+Defaults produce a **48 kHz, 24-bit mono WAV**, using two-pass loudness
+normalization targeting **-19 LUFS** and **-1.5 dBTP**. MP3 is optional, at
+192 kb/s by default. Originals are never overwritten, including with
+`--overwrite`.
-## Requirements
+## Quick Start
-- curl
-- ffmpeg
-- NVidia GPU (optional)
+```bash
+./producer.sh process recording.wav
+```
+
+The first run bootstraps everything the program needs into `lib/runtime/`:
+a managed Python for the application (3.13), the application dependencies, a
+bundled FFmpeg, and — on first AI use — the separate pinned Python 3.11
+DeepFilterNet3 environment and model weights. Every
+later run reuses that runtime, including fully offline once the needed assets
+are present. Nothing is installed outside this directory: no `PATH` changes,
+no binaries on your system, no sudo, and nothing written to your home
+directory.
-## The sound
+Requirements for the first bootstrap: a glibc Linux with `bash`, and either
+an existing `uv` or a Python 3 with venv support (`python3 -m venv`). Download
+trust is PyPI (uv, wheels), Astral's Python distribution, and — for AI —
+download.pytorch.org plus the pinned model host. There is no curl-to-shell.
+
+```bash
+./producer.sh doctor # check tools and driver, no AI downloads
+./producer.sh setup --device cuda # install and self-test the AI backend
+./producer.sh process recording.wav --device cuda --mp3
+```
-The default `audiobook` profile targets a close-narration master: high-pass at 80 Hz, a gentle mud cut, low-shelf warmth, serial compression (2:1 then 3:1), de-essing, restrained presence/air, breath ducking, then normalization to RMS -20 dB with a -3 dB true-peak ceiling.
+**Manage the NVIDIA driver yourself**, using the appropriate packages for your
+GPU and kernel. Check `nvidia-smi` before CUDA setup. The launcher never
+installs drivers or a system CUDA toolkit. The pinned CUDA backend uses
+PyTorch's CUDA 12.1 wheels and needs a compatible NVIDIA driver on Linux
+x86_64. For CPU use, replace `cuda` with `cpu`. The default `auto` selects
+based on availability; explicit `cuda` fails rather than silently using CPU.
-`--profile podcast` switches to broadcast loudness: -16 LUFS integrated, -1.5 dBTP ceiling, 48 kHz, slightly brighter EQ. `--profile radio` targets the deep, warm broadcast voice: +3 dB low shelf at 100 Hz, a tighter mud cut, heavy serial compression, dynamic resonance control (`soothe`) that ducks boxy 200-450 Hz and harsh 2.5-6 kHz peaks only while they stick out, and asymmetric tape saturation (`tape`) for the even-harmonic analog sheen.
+### Moving or renaming the directory
-## Engines
+Move or rename the whole directory freely; nothing breaks. The next launch
+detects that the runtime moved, drops only the path-bound environments, and
+rebuilds them from the intact download caches — usually within seconds and
+without re-downloading. Models, caches, and recordings are preserved.
+`./producer.sh --rebuild process ...` forces the same repair after you edit
+the source code under `lib/project/`.
-| Stage | Choices | Notes |
-| ----------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `--denoise` | `dfn3` (default), `zipenhancer`, `spectral`, `off` | [DeepFilterNet3](https://github.com/Rikorose/DeepFilterNet): 48 kHz full-band, faithful, fast on CPU, CUDA optional. [ZipEnhancer](https://github.com/gyj1201/zipEnhancer) (ICASSP 2025 SOTA, PESQ 3.69): 16 kHz native, bandwidth is restored after, slightly softer highs. `spectral`: pure-DSP decision-directed Wiener gate over a **file-global noise profile** (measured from the whole file's quiet frames) — deep ~20-35 dB suppression of steady noise and tape hiss in sentence gaps, deterministic, no model download, zero gain wobble. Strength defaults to 0.9: the small dry blend flattens residual denoiser gain wobble; use `--denoise-strength 1.0` for maximum suppression. |
-| `--enhance` | `off` (default), `mossformer2`, `resemble` | [MossFormer2_SE_48K](https://github.com/modelscope/ClearerVoice-Studio) (ClearVoice): full-band studio restoration, GPU-strong. [Resemble Enhance](https://github.com/resemble-ai/resemble-enhance): generative restoration for badly damaged audio, runs in an isolated venv; can alter voice timbre, so it is opt-in. |
+### Removing the program
-Engine dependencies (torch ~2.5 GB on CUDA, model weights ~8 MB) are installed lazily on first use into `lib/` (`lib/models`, `lib/venvs`); zipenhancer additionally pulls modelscope plus any helpers its import chain needs at first use. Prefer `--denoise spectral` or `--denoise off` for zero heavy downloads. The DeepFilterNet post filter (`--denoise-pf`, or `pf = true` under `[denoise]` in `config.toml`) adds extra noise reduction but can eat soft speech on clean recordings, so it is off by default.
+Delete the directory. Recordings and their outputs always live wherever the
+inputs are (or where you point `--output`/`--output-dir`), never inside the
+runtime, so there is nothing else to clean up.
-## Usage
+## Everyday Use
```bash
- ./producer in.wav [-o out.wav] [--profile audiobook|podcast|radio]
- [--denoise dfn3|zipenhancer|spectral|off] [--denoise-strength 0-1] [--denoise-pf] [--enhance off|mossformer2|resemble]
- [--enhance-strength 0-1] [--no-dsp] [--no-levelling] [--hpf-hz N] [--mud N] [--warmth N]
- [--soothe N] [--compress N] [--tape N] [--deess N] [--presence N] [--air N] [--breath N]
- [--target N] [--ceiling N] [--format wav|flac|mp3] [--sample-rateN] [--bit-depth 16|24|32]
- [--device auto|cuda|cpu] [--engine-chunk SEC] [--engine-overlap SEC]
- [--batch] [--report] [--dry-run] [-v]
+# Master with the natural profile; outputs appear next to the input:
+# recording.natural.wav, recording.natural.report.json
+./producer.sh process recording.wav
+
+# Other starting points; optional MP3 alongside WAV and report
+./producer.sh process recording.wav --profile narrator --mp3
+./producer.sh process recording.wav --profile radio
+./producer.sh process recording.wav --profile cleanup-only
+
+# Batch: every input keeps its outputs beside itself, so same-named files in
+# different directories never collide
+./producer.sh process take1.wav take2.wav --mp3
+
+# Explicit destinations instead of the default
+./producer.sh process recording.wav --output-dir delivery --mp3
+./producer.sh process recording.wav -o delivery/episode.wav
+
+# Stereo requires an explicit choice; final export is always mono
+./producer.sh process stereo.wav --channel left
+# Alternatives: --channel right, or --channel mix (equal-weight average)
+
+# Explicit non-AI fallback: never selected automatically after an AI error
+./producer.sh process recording.wav --denoiser fft --fft-reduction-db 6
+./producer.sh process recording.wav --no-denoise
+
+# Measurements as JSON on stdout, progress/logs on stderr
+./producer.sh analyze recording.wav > analysis.json
```
+Default outputs are written **beside each input**:
+`<input-dir>/<stem>.<profile>.wav`, plus `<stem>.<profile>.report.json`, and
+`<stem>.<profile>.mp3` when requested. Previews default to
+`<input-dir>/<stem>.<profile>.preview.wav`. `--output-dir` collects all
+results in one directory instead; `-o/--output` sets one explicit WAV
+destination (single input) and cannot be combined with `--output-dir`.
+Existing outputs or sidecars cause an error unless `--overwrite` is supplied.
+Duplicate batch output names require separate runs with distinct `--output`
+paths. Processing accepts mono or stereo WAV inputs, not surround audio; it
+does not preserve stereo. Avoid `mix` for opposite-polarity channels, which
+can cancel the voice. The whole batch plan (all inputs, all outputs, all
+sidecars) is validated before any input is processed.
+
+| Profile | Intended starting point |
+| --- | --- |
+| `natural` | Mild EQ, 2:1 compression, light de-essing and normalization |
+| `narrator` | Gentler 1.6:1 compression, slower attack, wider loudness range |
+| `radio` | Stronger 3.5:1 compression, more warmth/presence, narrower range |
+| `cleanup-only` | Denoising and highpass; no leveling, EQ, compression, de-essing, normalization or limiter by default |
+
+These presets are engineering starting points, **not tuned by listening** to
+your recording or validated as universally good sounding. Listen before
+publishing.
+
+## Configuration
+
+There is no config file at the root on purpose: configuration is only loaded
+from an explicit `--config` path. A complete annotated reference with
+natural-profile defaults lives at
+[`lib/project/examples/voice.toml`](lib/project/examples/voice.toml).
+
+```bash
+./producer.sh process recording.wav --config lib/project/examples/voice.toml
+./producer.sh process recording.wav --denoise-strength 0.65 --no-eq --hum-hz 50
+./producer.sh process recording.wav --set 'denoise_strength=0.65' --set 'device="cpu"'
+
+# Inspect or generate resolved settings without downloading AI
+./producer.sh config --profile narrator
+./producer.sh config --profile radio --json
+./producer.sh config --profile natural > voice.toml
+./producer.sh config --config voice.toml --no-compression
+./producer.sh process --help
+```
+
+Settings have named flags: replace underscores with hyphens, such as
+`true_peak_db` -> `--true-peak-db`. Booleans support both forms, such as
+`--eq` and `--no-eq`. Repeated `--set KEY=VALUE` accepts TOML values (strings
+may also be unquoted). Unknown keys, incorrect types and out-of-range values
+are rejected. Select presets with `--profile`; `--set profile=radio` also
+selects and validates the profile before applying its defaults — except for
+`preview`, where profiles come from `--profiles` and a profile override via
+`--set` is rejected so file names always match the rendered configuration.
+
+Precedence is defaults, selected profile, TOML values, named flags, then
+`--set` (last assignment wins). `--profile` selects ahead of a file's
+`profile`, but other explicit file values still override the selected preset.
+A full natural config therefore masks narrator/radio settings; remove keys you
+want the preset to supply.
+
+`--no-normalize` alone **leaves the default limiter on**. Use
+`--no-normalize --no-limiter` to disable both. Normalization requires
+limiting, so `--no-limiter` alone is invalid with normalizing profiles.
+Denoising can be bypassed with `--no-denoise`, `--denoiser none`, or
+`--denoise-strength 0`.
-## Troubleshooting
+## Previews
-If the gain wobbles, it's likely the denoiser. Try `--denoise-strength 0.5` or `--denoise zipenhancer`.
+```bash
+./producer.sh preview recording.wav --start 10 --duration 30 \
+ --profiles natural narrator radio cleanup-only
+```
+
+Previews go beside the input by default, with reports and optional MP3s.
+Start must be nonnegative; duration must be greater than zero and at most 300
+seconds. The default comparison includes natural, narrator and radio.
+Previews **force normalization and limiting**, even for `cleanup-only` and
+when `--no-normalize` is given, to compare at the configured loudness target.
+They are not a raw A/B comparison, and there is currently no raw-original
+preview export. Explicit config overrides can also make presets sound
+identical.
+
+## Limits and Reports
+
+- Leveling is one **static recording-level gain correction**, estimated from
+ the cleaned audio and capped by `max_gain_db`. The estimate comes from a
+ histogram of 100 ms RMS blocks; on pause-dominated recordings it falls back
+ to the loud tail so sparse speech is not mistaken for silence, and if the
+ estimate is still indistinguishable from the noise floor, leveling is
+ skipped with a warning instead of applying a maximum gain. It is not slow
+ automatic gain riding and does not independently repair quiet phrases.
+- Speech/noise estimates use that same histogram, not speech recognition.
+ Noise floor is a low-confidence quiet-block heuristic and may be absent
+ without sufficient room tone. It is not a certified noise measurement.
+- Denoising can affect timbre or introduce artifacts. Clipped peaks, severe
+ room reverb and missing detail cannot be perfectly restored. There is no
+ promise of perfect restoration or ACX compliance; check the actual delivery
+ specification.
+- Normalization uses a measured first pass and a render pass, then verifies
+ the exported WAV and decoded MP3. The final resample dithers explicitly at
+ 24-bit depth. FFmpeg may use dynamic rather than linear normalization; the
+ report records the mode. Loudness misses over 0.5 LU and limited WAV
+ true-peak overshoots over 0.1 dB are **warn-only**, not automatic rejection
+ or correction. MP3 overshoots also warn; use more headroom and rerun.
+- Reports include resolved settings, input/cleaned/master measurements, gain
+ correction, loudness and warnings. Inspect `warnings` before publication;
+ successful exit alone does not certify delivery targets.
+- AI working memory is bounded by 10-second chunks, with 2-second context and
+ 50 ms overlap, rather than loading the whole recording. Chunked inference is
+ not bit-identical to whole-recording inference. Measurement also uses
+ bounded memory.
+- Float intermediates trade RAM for disk and multiple full-file passes. A mono
+ 48 kHz float file costs about 0.69 GB per hour on the output filesystem;
+ intermediates are deleted as soon as each stage has consumed them. Allow
+ several GB of free space per hour on the output filesystem, separately from
+ runtime storage. Runtime includes repeated disk I/O.
+- Files are staged beside the output and published only after processing and
+ verification. Publication is **atomic per file**, and a directory lock
+ prevents concurrent jobs writing to the same output directory from
+ interleaving their WAV/report/MP3 sets. Publication across the three files
+ of one job is not transactional: a failure between publications can leave a
+ partial set, or mixed old/new outputs with `--overwrite`.
+
+## Progress and Troubleshooting
+
+The CLI sends progress, diagnostics and completion messages to **stderr**.
+`analyze` and `config --json` emit JSON on **stdout**; `config` emits TOML
+there. Interactive terminals show stage progress and elapsed/remaining time;
+redirected logs get stage changes and periodic updates. Unknown totals show
+activity, not a meaningful percentage. Internal worker stdout carries
+prefixed JSON progress records, consumed by the parent; this is not a public
+CLI JSON-progress mode. Ctrl-C exits with code 130 and terminates the whole
+active process group — including grandchildren that outlive their parent —
+escalating from SIGTERM to SIGKILL after five seconds if needed. Ordinary
+failures exit 1. Temporary-file cleanup runs on handled cancellation/errors;
+SIGKILL or power loss can leave temporary directories. Already published
+files remain.
+
+- **First bootstrap fails:** the launcher needs `uv`, or `python3` with venv
+ support, plus network access to PyPI. Install the missing tool with your
+ system package manager and rerun; the launcher never runs sudo itself.
+- **CUDA unavailable:** run `./producer.sh doctor`, inspect `nvidia-smi`, the
+ driver and `CUDA_VISIBLE_DEVICES`, then retry `./producer.sh setup
+ --device cuda`. Use `--device cpu` explicitly if needed. `doctor` probes
+ tools/driver without AI downloads; `setup` actually self-tests Torch and
+ DeepFilterNet.
+- **Download/checksum or incomplete model failure:** check network access and
+ free space; never bypass the checksum. For an incomplete-model error, remove
+ only the reported `lib/runtime/models/df3-0.5.6` directory when no jobs are
+ running, then retry setup.
+- **FFmpeg missing/filter or MP3 encoder missing:** the bundled FFmpeg is
+ preferred; a system `ffmpeg` is used where the bundle cannot be installed.
+ Run `./producer.sh doctor`.
+- **Changed source code:** the runtime runs an installed copy of
+ `lib/project`; run `./producer.sh --rebuild` after editing it.
+- **Musl/Alpine:** AI requires Linux glibc >= 2.28; musl is explicitly
+ unsupported. On musl, the launcher falls back to a system FFmpeg, and
+ soundfile needs a system libsndfile with an unversioned symlink (for
+ example `apk add libsndfile-dev`). A separate temporary glibc test
+ confirmed DeepFilterNet CPU execution; CUDA was not tested. These are test
+ boundaries, not a claim of end-to-end CUDA validation.
+- **Silent/too-short audio or bad stereo mix:** inspect the source with
+ `analyze`, select the correct channel and use enough audible speech for
+ loudness analysis. If cleanup sounds watery or thin, lower denoise strength
+ and compare previews.
+
+## Layout
+
+```text
+producer.sh the only entry point; bootstrap + run
+LICENSE 0BSD
+README.md
+lib/
+ project/ application source, tests, example configuration
+ runtime/ managed Python, dependencies, bundled FFmpeg, AI
+ environments, model weights, download caches (gitignored)
+ archive/ previously generated listening samples and reports
+```
+
+`lib/runtime/` is fully regenerable: deleting it only costs re-downloads on
+the next launch.
+
+## Development
+
+On a glibc development host with Python >= 3.11, pip and FFmpeg:
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install -e 'lib/project[test]'
+.venv/bin/python -m pytest -q lib/project/tests
+./producer.sh --rebuild config --config lib/project/examples/voice.toml --json
+```
+
+Alternatively run the same bootstrap the launcher uses. Tests cover
+configuration, synthetic audio, FFmpeg processing, output safety, the
+launcher contract, and AI subprocess handling with controlled model/download
+stubs; they do not download/exercise the real AI backend or substitute for
+listening. Real CPU inference was verified separately.
+
+For the musl test container only, where the declared bundled-FFmpeg wheel is
+unavailable, use system FFmpeg and explicitly install core/test dependencies:
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install 'hatchling==1.27.0' 'rich==14.1.0' \
+ 'numpy>=1.26.4,<3' 'soundfile==0.13.1' 'pytest>=8,<10'
+.venv/bin/python -m pip install --no-deps -e 'lib/project'
+.venv/bin/python -m pytest -q lib/project/tests
+```
## License
-0BSD
+This application is licensed under [0BSD](LICENSE). Review upstream terms
+before redistributing dependencies, binaries or weights:
+[DeepFilterNet code and models](https://github.com/Rikorose/DeepFilterNet/tree/v0.5.6),
+[PyTorch](https://github.com/pytorch/pytorch/blob/v2.5.1/LICENSE),
+[Torchaudio](https://github.com/pytorch/audio/blob/v2.5.1/LICENSE),
+[FFmpeg](https://ffmpeg.org/legal.html),
+[imageio-ffmpeg](https://github.com/imageio/imageio-ffmpeg), and
+[uv](https://github.com/astral-sh/uv). FFmpeg obligations depend on the build
+and enabled components. Upstream licenses do not assign a license to this
+application's code.
diff --git a/config.toml b/config.toml
deleted file mode 100644
index 1f05b75..0000000
--- a/config.toml
+++ /dev/null
@@ -1,105 +0,0 @@
-# producer settings — CLI flags always override anything set here.
-
-# "audiobook" = close-narration voice chain, RMS -20 dB, true peak <= -3 dB, 44.1 kHz
-# "podcast" = broadcast chain, -16 LUFS, true peak <= -1.5 dBTP, 48 kHz, brighter EQ
-# "radio" = deep, warm broadcast voice: +3 dB low shelf @ 100 Hz, tighter mud cut,
-# heavy compression, tape saturation; -16 LUFS, 48 kHz
-profile = "radio"
-
-# Output container/codec: wav | flac | mp3 (default: wav)
-# format = "wav"
-
-# Bit depth: 16 | 24 | 32 (default: 32)
-# bit_depth = 32
-
-# Output sample rate in Hz (default: 44100 audiobook / 48000 podcast)
-# sample_rate = 44100
-
-# Compute device: auto (CUDA if present) | cuda | cpu
-# device = "auto"
-
-# GPU engine processing: engines receive the whole file by default so models
-# run one warm pass with no seam artifacts and a single model cold-start.
-# chunk > 0 forces chunked streaming in seconds (very long files / low VRAM);
-# whole-file runs fall back to chunks automatically if they run out of memory.
-# engine_chunk = 0.0 # 0 = whole file (default)
-# engine_overlap = 0.5 # crossfade between chunks in seconds
-
-# Loudness target and true-peak ceiling in dB.
-# Without these, each profile uses RMS -20 / ceiling -3 (audiobook) or -16 LUFS / -1.5 (podcast).
-# target = -20.0
-# ceiling = -3.0
-
-# How much of the denoised/enhanced signal to blend in (0-1). 0 disables the stage.
-# denoise_strength = 1.0
-# enhance_strength = 1.0
-
-# Denoiser: "dfn3" (DeepFilterNet3, default, fast, 48 kHz full-band)
-# "zipenhancer" (ICASSP 2025 SOTA, 16 kHz native, bandwidth restored)
-# "spectral" (pure-DSP dd-wiener with a file-global noise profile,
-# no downloads, no wobble)
-# "off" (pure DSP, no heavy downloads)
-# Heavy deps + model weights are installed lazily on first use.
-[denoise]
-engine = "dfn3"
-# 0.9 flattens residual denoiser gain wobble (volume pumping); the small dry
-# blend trades a whisper of noise back for level stability. 1.0 = max attack.
-strength = 0.9
-# pf = false # DeepFilterNet post filter: extra noise reduction, opt-in —
-# # may eat soft speech on clean recordings (off by default)
-
-# Speech enhancement: "off" (default), "mossformer2" (studio restoration),
-# "resemble" (generative, opt-in isolated venv, may alter timbre)
-[enhance]
-engine = "off"
-
-# Per-profile voice DSP strengths, 0-1 each (CLI flags --hpf, --mud, ... always win).
-# 0 disables a stage entirely; every stage is optional.
-# hpf = strength of the high-pass (0 turns it off; corner set via hpf_hz)
-# mud = cut around 300 Hz (boxy mud)
-# warmth = low-shelf lift (position set per profile: 150 Hz audiobook/podcast, 100 Hz radio)
-# soothe = dynamic EQ that ducks boxy (200-450 Hz) and harsh (2.5-6 kHz) resonances
-# only while they stick out — unlike static EQ, "off" when the voice is clean
-# compress = serial 2:1 + 3:1 compression
-# tape = asymmetric soft-clip saturation — analog/even-harmonic radio warmth
-# deess = de-esser active around 5.5-8 kHz
-# presence = boost around 3 kHz
-# air = high shelf from 9-10 kHz
-# breath = downward expander depth used to duck breaths and residual hiss
-# in sentence gaps (0 disables; up to ~24 dB of ducking at 1.0)
-
-[audiobook]
-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
-# hpf_hz = 80.0 # high-pass corner frequency (audiobook default 80)
-
-[podcast]
-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
-# hpf_hz = 80.0 # high-pass corner frequency (podcast default 80)
-
-[radio]
-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
-# hpf_hz = 70.0 # high-pass corner frequency (radio default 70)
diff --git a/lib/archive/mastered/README.md b/lib/archive/mastered/README.md
new file mode 100644
index 0000000..99284b5
--- /dev/null
+++ b/lib/archive/mastered/README.md
@@ -0,0 +1,36 @@
+# Test Recording Outputs
+
+The original `/workspace/testwav.wav` was not changed. These files are listening
+samples, not a claim that the presets have been tuned by listening.
+
+- `testwav.deepfilter-cleaned.wav`: full recording after DeepFilterNet3 at 85%
+ wet strength, mono 48 kHz float WAV, with exactly 5,684,577 frames.
+- `testwav.natural.wav`: the cleaned file through the natural mastering profile,
+ 48 kHz / 24-bit mono, measured at -19.11 LUFS and -1.49 dBTP.
+- `testwav.natural.mp3`: 192 kb/s delivery copy, decoded measurement -19.38 LUFS
+ and -1.75 dBTP.
+- `testwav.fft.wav`: the original processed with the explicit non-AI FFT denoiser,
+ for comparison, measured at -19.10 LUFS and -1.50 dBTP.
+- JSON sidecars record settings and technical measurements for each mastering run.
+- `../previews/` contains 30-second natural, narrator and radio samples starting
+ at 10 seconds in the DeepFilterNet-cleaned recording, normalized to similar
+ loudness for comparison.
+
+The container uses musl rather than Arch's glibc. Real CPU DeepFilterNet inference
+was tested using a temporary glibc loader; the cleaned output was then mastered
+with the normal CLI and `--no-denoise`. Therefore the natural report correctly
+lists the cleaned file as its source and `denoiser = none`. This two-step test did
+not exercise the standard Arch installer or CUDA. It also ran denoising before
+highpass filtering, whereas the normal one-command pipeline prepares/filters the
+input before denoising, so a fresh Arch run is not expected to be bit-identical.
+
+On Arch, reproduce the intended one-command workflow with the launcher:
+
+```bash
+./producer.sh process testwav.wav --device cuda --mp3 -o arch-natural.wav
+./producer.sh preview testwav.wav --device cuda --start 10 --duration 30 \
+ --profiles natural narrator radio --output-dir arch-previews
+```
+
+These samples were moved to `lib/archive/` so the checkout root stays clean;
+the files themselves are unchanged.
diff --git a/lib/project/examples/voice.toml b/lib/project/examples/voice.toml
new file mode 100644
index 0000000..6a5251a
--- /dev/null
+++ b/lib/project/examples/voice.toml
@@ -0,0 +1,56 @@
+# Complete natural-profile defaults. Load with --config lib/project/examples/voice.toml.
+# Flat TOML: no section headers. Named CLI flags override these values;
+# --set KEY=VALUE overrides named flags. Use --profile, not --set profile=...
+# All explicit values below override preset defaults, even with --profile radio.
+# Remove settings you want a different profile to supply.
+
+profile = "natural" # natural, narrator, radio, cleanup-only
+denoiser = "deepfilter" # DeepFilterNet3 only; fft is explicit non-AI fallback; none
+device = "auto" # auto, cpu, cuda; explicit cuda fails if unavailable
+denoise_strength = 0.85 # 0..1; AI dry/wet blend; zero skips denoising and AI setup
+fft_reduction_db = 8.0 # 0.01..30; FFT reduction is scaled by denoise_strength
+channel = "auto" # auto accepts mono only; stereo requires left, right or mix
+
+highpass = true
+highpass_hz = 70.0 # 20..300
+lowpass = false
+lowpass_hz = 16000.0 # 4000..22000; must be below output Nyquist when enabled
+hum_hz = 0 # 0 (off), 50 or 60; notches fundamental and next two harmonics
+
+# One static gain adjustment for the whole recording, not automatic gain riding.
+leveling = true
+level_target_db = -23.0 # -40..-12; heuristic speech level in dBFS, not LUFS
+max_gain_db = 18.0 # 0..30; caps both gain and attenuation
+
+expansion = false
+expansion_threshold_db = -50.0 # -90..-20
+expansion_ratio = 1.5 # 1..4
+expansion_range_db = 12.0 # 0..40
+
+eq = true
+warmth_db = 0.5 # -12..12; 140 Hz
+mud_db = -1.5 # -12..12; 300 Hz
+presence_db = 1.0 # -12..12; 3500 Hz
+
+compression = true
+compressor_threshold_db = -21.0 # -50..-5
+compressor_ratio = 2.0 # 1..10
+compressor_attack_ms = 15.0 # 0.1..200
+compressor_release_ms = 150.0 # 10..2000
+deess = true
+deess_intensity = 0.15 # 0..1
+deess_amount = 0.4 # 0..1
+
+# Two-pass normalization plus export verification; target misses warn only.
+# Turning normalize off leaves limiter on unless you also disable limiter.
+# normalize=true requires limiter=true. Previews force both on, even cleanup-only.
+normalize = true
+target_lufs = -19.0 # -30..-12; mono delivery target, not ACX certification
+loudness_range = 9.0 # 1..20 LU
+limiter = true
+true_peak_db = -1.5 # -9..-0.5 dBTP; MP3 can overshoot, inspect report warnings
+
+# Final exports are always mono, 24-bit PCM WAV, with an optional MP3 sidecar.
+sample_rate = 48000 # 44100 or 48000; intermediate processing is 48 kHz float
+mp3 = false
+mp3_bitrate = 192 # 128, 160, 192, 224, 256 or 320 kb/s
diff --git a/lib/project/pyproject.toml b/lib/project/pyproject.toml
new file mode 100644
index 0000000..082b944
--- /dev/null
+++ b/lib/project/pyproject.toml
@@ -0,0 +1,29 @@
+[build-system]
+requires = ["hatchling==1.27.0"]
+build-backend = "hatchling.build"
+
+[project]
+name = "voiceforge"
+version = "0.2.0"
+description = "Local, configurable speech cleanup and podcast mastering"
+license = "0BSD"
+requires-python = ">=3.11"
+dependencies = ["rich==14.1.0", "numpy>=1.26.4,<3", "soundfile==0.13.1"]
+
+[project.optional-dependencies]
+test = ["pytest>=8,<10"]
+# Preferred by producer.sh; falls back to a system FFmpeg where the wheel
+# does not exist (for example musl systems).
+bundled-ffmpeg = ["imageio-ffmpeg==0.6.0"]
+
+[project.scripts]
+voiceforge = "voiceforge.cli:main"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/voiceforge"]
+
+[tool.hatch.build.targets.sdist]
+include = ["/src", "/tests", "/examples", "/pyproject.toml"]
diff --git a/lib/project/src/voiceforge/__init__.py b/lib/project/src/voiceforge/__init__.py
new file mode 100644
index 0000000..1d8663d
--- /dev/null
+++ b/lib/project/src/voiceforge/__init__.py
@@ -0,0 +1,3 @@
+"""VoiceForge: local speech cleanup and mastering."""
+
+__version__ = "0.2.0"
diff --git a/lib/project/src/voiceforge/__main__.py b/lib/project/src/voiceforge/__main__.py
new file mode 100644
index 0000000..4e28416
--- /dev/null
+++ b/lib/project/src/voiceforge/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+main()
diff --git a/lib/project/src/voiceforge/ai.py b/lib/project/src/voiceforge/ai.py
new file mode 100644
index 0000000..493be65
--- /dev/null
+++ b/lib/project/src/voiceforge/ai.py
@@ -0,0 +1,157 @@
+"""Automatic, isolated DeepFilterNet3 inference (Linux, Python 3.11 worker).
+
+Public progress callback: (stage: str, completed: float | None, total: float | None).
+Known totals are audio frames or download bytes (identified by stage);
+unknown totals use elapsed seconds as completed.
+First use downloads Python, wheels, and upstream model weights into the
+VoiceForge data directory (VOICEFORGE_HOME prefix or the XDG data location).
+"""
+from __future__ import annotations
+
+import ctypes
+import fcntl
+import math
+import os
+from pathlib import Path
+import platform
+import shutil
+import time
+
+from .setup import Progress, data_dir, ensure_uv, run_process, tool_env
+
+
+def _cuda_available() -> bool:
+ # Probe the driver without importing Torch or installing CUDA in the host env.
+ if os.environ.get("CUDA_VISIBLE_DEVICES") in {"", "-1"}:
+ return False
+ try:
+ driver = ctypes.CDLL("libcuda.so.1")
+ count = ctypes.c_int()
+ return driver.cuInit(0) == 0 and driver.cuDeviceGetCount(ctypes.byref(count)) == 0 and count.value > 0
+ except (OSError, AttributeError):
+ return False
+
+
+def _flavor(device: str) -> str:
+ if device not in {"auto", "cpu", "cuda"}:
+ raise ValueError("device must be 'auto', 'cpu', or 'cuda'")
+ if device == "cuda" or (device == "auto" and _cuda_available()):
+ if platform.machine() not in {"x86_64", "AMD64"}:
+ raise RuntimeError("The pinned CUDA 12.1 backend requires Linux x86_64.")
+ return "cu121"
+ return "cpu"
+
+
+def _worker_env() -> dict[str, str]:
+ # Same isolation as tool_env(); kept as a named seam for worker launches.
+ return tool_env()
+
+
+def _interpreter_is_local(python: Path, root: Path) -> bool:
+ """Whether the environment's interpreter belongs to this prefix.
+
+ An installation duplicated by copying (while the original remained) keeps
+ interpreter links that resolve into the original directory. Such an
+ environment must be rebuilt, or the copy would silently depend on files
+ outside this prefix and break when the original is removed.
+ """
+ try:
+ return python.resolve().is_relative_to(root)
+ except OSError:
+ return False
+
+
+def ensure_ai(device: str = "auto", progress: Progress | None = None,
+ verify: bool = False) -> Path:
+ """Install/validate the backend and model; return its Python executable.
+
+ CPU and CUDA environments are separate. Explicit CUDA never falls back;
+ auto uses Torch's availability check in the selected worker. Downloads trust
+ PyPI, download.pytorch.org, Astral's Python distribution, and upstream DF.
+ verify forces a real self-test even when the environment is marked ready
+ ('setup' uses this); ordinary processing trusts the marker so a ready
+ environment does not pay for a second model load per run.
+ """
+ flavor = _flavor(device)
+ try:
+ libc = os.confstr("CS_GNU_LIBC_VERSION") or ""
+ except (ValueError, OSError):
+ libc = ""
+ if platform.system() != "Linux" or not libc.startswith("glibc ") or tuple(
+ int(part) for part in libc.split()[1].split(".")[:2]
+ ) < (2, 28):
+ raise RuntimeError("The pinned DeepFilterNet3 backend requires Linux with glibc >= 2.28 "
+ "(for example Ubuntu 22.04+ or Debian 12+). "
+ "Alpine/musl is not supported by its prebuilt wheels.")
+ root = data_dir()
+ root.mkdir(parents=True, exist_ok=True)
+ environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1"
+ python = environment / "bin/python"
+ marker = environment / ".voiceforge-ready"
+ started = time.monotonic()
+ # Serialize bootstrap/model downloads, including calls from separate CLIs.
+ with (root / "ai.lock").open("a") as lock:
+ while True:
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ break
+ except BlockingIOError:
+ if progress:
+ progress("Waiting for AI setup", time.monotonic() - started, None)
+ time.sleep(0.25)
+ ready = (marker.is_file() and python.is_file()
+ and _interpreter_is_local(python, root))
+ if not ready:
+ # A moved, copied, or half-installed environment cannot be
+ # repaired in place: interpreter links reference absolute paths.
+ # Rebuild it; the shared wheel caches make this fast, usually
+ # offline.
+ if environment.exists():
+ shutil.rmtree(environment)
+ uv = ensure_uv(progress)
+ env = _worker_env()
+ run_process([uv, "venv", "--python", "3.11", "--managed-python",
+ "--relocatable", "--allow-existing", str(environment)],
+ "Preparing AI Python 3.11", progress, env=env)
+ run_process([uv, "pip", "install", "--python", str(python),
+ "--index-url", f"https://download.pytorch.org/whl/{flavor}",
+ "torch==2.5.1", "torchaudio==2.5.1"],
+ f"Installing PyTorch ({flavor})", progress, env=env)
+ run_process([uv, "pip", "install", "--python", str(python),
+ "--only-binary", ":all:",
+ "--index-url", "https://pypi.org/simple", "deepfilternet==0.5.6",
+ "numpy==1.26.4", "soundfile==0.12.1"],
+ "Installing DeepFilterNet3", progress, env=env)
+ # Check a freshly built environment (and any explicit setup) with a
+ # real model load and inference; this also downloads the weights.
+ if verify or not ready:
+ run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")),
+ "--device", device, "--model-dir", str(root / "models/df3-0.5.6"),
+ "--check"], "Checking DeepFilterNet3", progress,
+ env=_worker_env())
+ marker.touch()
+ return python
+
+
+def denoise(source: Path, target: Path, device: str = "auto", strength: float = 1.0,
+ progress: Progress | None = None) -> None:
+ """Denoise mono 48 kHz WAV to float WAV, preserving the exact sample count.
+
+ strength is a dry/wet mix in [0, 1]. The worker publishes output atomically;
+ errors/cancellation leave an existing target untouched. Chunked inference is
+ bounded-memory, not bit-identical to inference over an entire recording.
+ """
+ if not math.isfinite(strength) or not 0 <= strength <= 1:
+ raise ValueError("strength must be finite and between 0 and 1")
+ source, target = Path(source).resolve(), Path(target).resolve()
+ if source == target:
+ raise ValueError("source and target must be different files")
+ if not source.is_file():
+ raise FileNotFoundError(source)
+ if not target.parent.is_dir():
+ raise FileNotFoundError(target.parent)
+ python = ensure_ai(device, progress)
+ run_process([str(python), "-I", str(Path(__file__).with_name("ai_worker.py")),
+ "--device", device, "--source", str(source), "--target", str(target),
+ "--model-dir", str(data_dir() / "models/df3-0.5.6"),
+ "--strength", str(strength)], "Denoising", progress, env=_worker_env())
diff --git a/lib/project/src/voiceforge/ai_worker.py b/lib/project/src/voiceforge/ai_worker.py
new file mode 100644
index 0000000..99ec9f5
--- /dev/null
+++ b/lib/project/src/voiceforge/ai_worker.py
@@ -0,0 +1,161 @@
+"""Private script executed only by the isolated backend Python."""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import signal
+import tempfile
+import urllib.request
+import zipfile
+
+
+def report(stage: str, completed: float | None = None, total: float | None = None) -> None:
+ print("VOICEFORGE_PROGRESS " + json.dumps([stage, completed, total]), flush=True)
+
+
+def ensure_model(directory: Path) -> Path:
+ """Install a release-pinned, hash-checked archive atomically under setup lock."""
+ model = directory / "DeepFilterNet3"
+ if (model / "config.ini").is_file() and (model / "checkpoints/model_120.ckpt.best").is_file():
+ return model
+ if directory.exists():
+ raise RuntimeError(f"Incomplete model directory: {directory}. Remove it and retry AI setup.")
+ directory.parent.mkdir(parents=True, exist_ok=True)
+ url = "https://raw.githubusercontent.com/Rikorose/DeepFilterNet/v0.5.6/models/DeepFilterNet3.zip"
+ expected = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2"
+ # Release archive size is fixed alongside its hash, even without Content-Length.
+ expected_bytes = 7986207
+ downloaded = 0
+ report("Downloading DeepFilterNet3 model (bytes)", 0, expected_bytes)
+ with tempfile.TemporaryDirectory(prefix=".df3-", dir=directory.parent) as staging:
+ archive = Path(staging) / "model.zip"
+ digest = hashlib.sha256()
+ with urllib.request.urlopen(url, timeout=60) as response, archive.open("wb") as output:
+ while chunk := response.read(256 * 1024):
+ downloaded += len(chunk)
+ if downloaded > expected_bytes:
+ raise RuntimeError("DeepFilterNet3 model download exceeds the expected size")
+ digest.update(chunk)
+ output.write(chunk)
+ report("Downloading DeepFilterNet3 model (bytes)", downloaded, expected_bytes)
+ if downloaded != expected_bytes or digest.hexdigest() != expected:
+ raise RuntimeError("DeepFilterNet3 model checksum mismatch; refusing to load weights")
+ report("Verifying and extracting DeepFilterNet3 model")
+ unpacked = Path(staging) / "unpacked"
+ with zipfile.ZipFile(archive) as bundle:
+ for member in bundle.infolist():
+ destination = (unpacked / member.filename).resolve()
+ if not destination.is_relative_to(unpacked.resolve()):
+ raise RuntimeError("Unsafe path in model archive")
+ bundle.extractall(unpacked)
+ os.replace(unpacked, directory)
+ return model
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--device", choices=("auto", "cpu", "cuda"), required=True)
+ parser.add_argument("--check", action="store_true")
+ parser.add_argument("--model-dir", type=Path, required=True)
+ parser.add_argument("--source", type=Path)
+ parser.add_argument("--target", type=Path)
+ parser.add_argument("--strength", type=float, default=1.0)
+ args = parser.parse_args()
+
+ # SIGTERM from the parent takes the same cleanup path as Ctrl-C.
+ def terminate(signum, frame):
+ raise KeyboardInterrupt
+
+ signal.signal(signal.SIGTERM, terminate)
+
+ import numpy as np
+ import soundfile as sf
+ import torch
+
+ torch.set_num_threads(min(4, os.cpu_count() or 1))
+ available = torch.cuda.is_available()
+ if args.device == "cuda" and not available:
+ raise RuntimeError("CUDA was explicitly requested, but PyTorch cannot use it. "
+ "Check the NVIDIA driver and CUDA_VISIBLE_DEVICES, or select cpu.")
+ device = "cuda" if args.device != "cpu" and available else "cpu"
+ # df.utils.get_device() consults DEVICE on every call, including model creation.
+ os.environ["DEVICE"] = device
+ from df.enhance import enhance, init_df
+ from df.model import ModelParams
+ from libdf import DF
+
+ model_path = ensure_model(args.model_dir)
+ report(f"Loading DeepFilterNet3 ({device})")
+ model, state, _ = init_df(str(model_path), log_level="ERROR", log_file=None)
+ if state.sr() != 48000:
+ raise RuntimeError("DeepFilterNet3 model must use 48 kHz")
+ if args.check:
+ # Exercise both model execution and the compiled libdf/NumPy ABI.
+ sample = enhance(model, state, torch.zeros(1, 4800), pad=True)
+ if sample.shape != (1, 4800) or not torch.isfinite(sample).all():
+ raise RuntimeError("DeepFilterNet3 self-test failed")
+ report(f"DeepFilterNet3 ready ({device})", 1, 1)
+ return
+ if args.source is None or args.target is None:
+ parser.error("--source and --target are required unless --check is used")
+ if not np.isfinite(args.strength) or not 0 <= args.strength <= 1:
+ parser.error("--strength must be in [0, 1]")
+
+ temporary = None
+ try:
+ with sf.SoundFile(args.source) as source:
+ if source.samplerate != 48000 or source.channels != 1 or source.format not in {"WAV", "WAVEX", "RF64"}:
+ raise ValueError("AI input must be a mono 48 kHz WAV; convert it before denoising")
+ frames = len(source)
+ fd, name = tempfile.mkstemp(prefix=".voiceforge-ai-", suffix=".wav", dir=args.target.parent)
+ os.close(fd)
+ temporary = Path(name)
+ # RF64 avoids RIFF's 4 GiB limit on very long recordings.
+ output_format = "RF64" if frames * 4 > 0xFFFFFFFF - 4096 else "WAV"
+ with sf.SoundFile(temporary, "w", samplerate=48000, channels=1,
+ format=output_format, subtype="FLOAT") as target:
+ block, context, overlap = 480000, 96000, 2400
+ previous = None
+ params = ModelParams()
+ report(f"Denoising ({device})", 0, frames)
+ for start in range(0, frames, block):
+ end = min(start + block, frames)
+ left, right = max(0, start - context), min(frames, end + overlap + context)
+ source.seek(left)
+ audio = source.read(right - left, dtype="float32")
+ if not np.isfinite(audio).all():
+ raise ValueError("Input contains non-finite audio samples")
+ if args.strength:
+ # Fresh STFT state per window; context warms normalization and
+ # recurrent layers. Pad to a whole hop before delay compensation.
+ state = DF(sr=params.sr, fft_size=params.fft_size,
+ hop_size=params.hop_size, nb_bands=params.nb_erb,
+ min_nb_erb_freqs=params.min_nb_freqs)
+ padded = np.pad(audio, (0, (-len(audio)) % params.hop_size))
+ wet = enhance(model, state, torch.from_numpy(padded).unsqueeze(0),
+ pad=True).squeeze(0).numpy()[:len(audio)]
+ if len(wet) != len(audio) or not np.isfinite(wet).all():
+ raise RuntimeError("DeepFilterNet3 returned invalid audio")
+ audio = audio * (1 - args.strength) + wet * args.strength
+ kept = audio[start - left:min(frames, end + overlap) - left].copy()
+ if previous is not None and args.strength:
+ n = len(previous)
+ fade = np.linspace(0, 1, n, dtype=np.float32)
+ kept[:n] = previous * (1 - fade) + kept[:n] * fade
+ target.write(kept[:end - start])
+ previous = kept[end - start:].copy()
+ report(f"Denoising ({device})", end, frames)
+ if target.tell() != frames:
+ raise RuntimeError("Output frame count does not match input")
+ os.replace(temporary, args.target)
+ temporary = None
+ finally:
+ if temporary is not None:
+ temporary.unlink(missing_ok=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lib/project/src/voiceforge/audio.py b/lib/project/src/voiceforge/audio.py
new file mode 100644
index 0000000..7fee0c2
--- /dev/null
+++ b/lib/project/src/voiceforge/audio.py
@@ -0,0 +1,128 @@
+"""Streaming measurement and FFmpeg execution. No shell interpolation."""
+from collections import deque
+import json
+import math
+import os
+from pathlib import Path
+import selectors
+import signal
+import subprocess
+import time
+
+import numpy as np
+import soundfile as sf
+
+from .setup import terminate_group
+
+
+def ffmpeg_run(ffmpeg: str, args: list[str], stage: str, duration: float,
+ progress) -> str:
+ command = [ffmpeg, "-hide_banner", "-nostdin", "-y", "-nostats",
+ "-progress", "pipe:1"] + args
+ progress(stage, 0, duration or None)
+ diagnostics = deque(maxlen=128)
+ pending = b""
+ completed = 0.0
+ last_advance = time.monotonic()
+ with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ start_new_session=True) as process:
+ try:
+ with selectors.DefaultSelector() as selector:
+ for stream in (process.stdout, process.stderr):
+ os.set_blocking(stream.fileno(), False)
+ selector.register(stream, selectors.EVENT_READ)
+ while selector.get_map() or process.poll() is None:
+ for key, _ in selector.select(timeout=0.2):
+ data = os.read(key.fd, 8192)
+ if not data:
+ selector.unregister(key.fileobj)
+ continue
+ if key.fileobj is process.stderr:
+ diagnostics.append(data)
+ else:
+ pending += data
+ while b"\n" in pending:
+ line, pending = pending.split(b"\n", 1)
+ if line.startswith(b"out_time_us="):
+ try:
+ value = max(0.0, int(line.split(b"=", 1)[1]) / 1e6)
+ except ValueError:
+ continue
+ if value > completed:
+ last_advance = time.monotonic()
+ completed = value
+ label = stage
+ if time.monotonic() - last_advance > 60:
+ label += " (no new audio progress for 60s; still waiting)"
+ progress(label, min(completed, duration), duration or None)
+ code = process.wait()
+ output = b"".join(diagnostics).decode(errors="replace")
+ if code:
+ raise RuntimeError(f"{stage} failed (exit {code}):\n{output[-8000:]}")
+ progress(stage, duration, duration or None)
+ return output
+ except BaseException:
+ terminate_group(process)
+ raise
+
+
+def measure(path: Path, progress, stage="Analyzing audio") -> dict:
+ """100ms RMS histogram is bounded in memory, including multi-hour input."""
+ with sf.SoundFile(path) as source:
+ if source.channels not in (1, 2):
+ raise ValueError("Only mono or stereo voice recordings are supported")
+ if not len(source):
+ raise ValueError("The recording is empty")
+ peaks = np.zeros(source.channels)
+ sums = np.zeros(source.channels)
+ squares = np.zeros(source.channels)
+ clipped = 0
+ histogram = np.zeros(121, dtype=np.int64)
+ frames = 0
+ progress(stage, 0, len(source))
+ for audio in source.blocks(blocksize=max(1, source.samplerate // 10),
+ dtype="float64", always_2d=True):
+ if not np.isfinite(audio).all():
+ raise ValueError("Recording contains non-finite samples")
+ peaks = np.maximum(peaks, np.max(np.abs(audio), axis=0))
+ sums += audio.sum(axis=0)
+ squares += (audio * audio).sum(axis=0)
+ clipped += int(np.count_nonzero(np.abs(audio) >= 0.9999))
+ rms = float(np.sqrt(np.mean(audio * audio)))
+ db = 20 * math.log10(max(rms, 1e-6))
+ histogram[int(np.clip(round(db) + 120, 0, 120))] += 1
+ frames += len(audio)
+ progress(stage, frames, len(source))
+ cumulative = histogram.cumsum()
+ def percentile(p):
+ return int(np.searchsorted(cumulative, max(1, math.ceil(cumulative[-1] * p)))) - 120
+ quiet, speech = percentile(0.1), percentile(0.8)
+ if speech - quiet < 15:
+ # Pause-dominated recordings: the 80th percentile of all blocks
+ # sits in room tone, so estimate the level from the loud tail
+ # instead of mistaking sparse active speech for silence.
+ speech = max(speech, percentile(0.98))
+ noise_available = speech - quiet >= 15 and quiet > -110
+ return {"frames": frames, "duration_seconds": frames / source.samplerate,
+ "sample_rate": source.samplerate, "channels": source.channels,
+ "subtype": source.subtype,
+ "sample_peak_dbfs": float(20 * np.log10(max(float(peaks.max()), 1e-12))),
+ "channel_rms_dbfs": (20 * np.log10(np.maximum(np.sqrt(squares / frames), 1e-12))).tolist(),
+ "dc_offset": (sums / frames).tolist(), "near_full_scale_samples": clipped,
+ "speech_level_estimate_dbfs": speech,
+ "quiet_blocks_dbfs": quiet,
+ "noise_floor_estimate_dbfs": quiet if noise_available else None,
+ "noise_floor_confidence": "low (quiet-block heuristic, not speech recognition)" if noise_available else "insufficient room tone"}
+
+
+def loudness(ffmpeg, source, settings, duration, progress, stage="Measuring loudness"):
+ output = ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af",
+ f"loudnorm=I={settings.target_lufs}:TP={settings.true_peak_db}:LRA={settings.loudness_range}:print_format=json",
+ "-f", "null", "-"], stage, duration, progress)
+ start, end = output.rfind("{"), output.rfind("}")
+ if start < 0 or end < start:
+ raise RuntimeError("FFmpeg did not return loudness measurements")
+ raw = json.loads(output[start:end + 1])
+ return {key: (float(value) if math.isfinite(float(value)) else None)
+ for key, value in raw.items() if key != "normalization_type"} | {
+ "normalization_type": raw.get("normalization_type")}
diff --git a/lib/project/src/voiceforge/cli.py b/lib/project/src/voiceforge/cli.py
new file mode 100644
index 0000000..52cd1bd
--- /dev/null
+++ b/lib/project/src/voiceforge/cli.py
@@ -0,0 +1,193 @@
+"""Command-line entry point; configuration errors fail before model downloads."""
+import argparse
+from dataclasses import asdict, fields
+import json
+import math
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import tomllib
+
+from rich.console import Console
+
+from . import __version__
+from .audio import ffmpeg_run, loudness, measure
+from .config import PROFILES, Settings, resolve, toml
+from .pipeline import check_target, process, targets_for
+from .progress import Display
+from .setup import data_dir, ensure_ffmpeg
+
+
+def parser():
+ app = argparse.ArgumentParser(prog="producer", description="Local speech cleanup and podcast mastering. Originals are never overwritten.")
+ app.add_argument("--version", action="version", version=__version__)
+ sub = app.add_subparsers(dest="command", required=True)
+ for name in ("process", "preview", "config", "analyze"):
+ cmd = sub.add_parser(name)
+ cmd.add_argument("--config", type=Path, help="TOML configuration file")
+ cmd.add_argument("--profile", choices=PROFILES, default=None)
+ cmd.add_argument("--set", action="append", default=[], metavar="KEY=VALUE", help="Override any setting using TOML value syntax")
+ for field in fields(Settings):
+ if field.name == "profile":
+ continue
+ default = getattr(Settings(), field.name)
+ option = "--" + field.name.replace("_", "-")
+ if isinstance(default, bool):
+ cmd.add_argument(option, action=argparse.BooleanOptionalAction, default=None)
+ else:
+ cmd.add_argument(option, type=type(default), default=None)
+ cmd.add_argument("--no-denoise", dest="denoiser", action="store_const", const="none")
+ if name == "config":
+ cmd.add_argument("--json", action="store_true")
+ else:
+ cmd.add_argument("inputs", type=Path, nargs="+" if name == "process" else 1)
+ if name in ("process", "preview"):
+ cmd.add_argument("--output-dir", type=Path, default=None,
+ help="Directory for generated outputs (default: beside each input)")
+ cmd.add_argument("--overwrite", action="store_true", help="Replace generated outputs, never input files")
+ if name == "process":
+ cmd.add_argument("-o", "--output", type=Path, help="Explicit WAV destination (one input only)")
+ if name == "preview":
+ cmd.add_argument("--start", type=float, default=0)
+ cmd.add_argument("--duration", type=float, default=30)
+ cmd.add_argument("--profiles", nargs="+", choices=PROFILES, default=["natural", "narrator", "radio"])
+ sub.add_parser("doctor", help="Check tools and driver without downloading AI")
+ setup = sub.add_parser("setup", help="Download and self-test the isolated AI backend")
+ setup.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
+ return app
+
+
+def settings_for(args, profile=None):
+ overrides = {field.name: getattr(args, field.name) for field in fields(Settings)
+ if field.name != "profile" and getattr(args, field.name, None) is not None}
+ for item in args.set:
+ key, sep, value = item.partition("=")
+ if not sep:
+ raise ValueError("--set expects KEY=VALUE")
+ # Strings can be supplied without TOML quotes for CLI convenience.
+ try:
+ parsed = tomllib.loads("value = " + value)["value"]
+ except tomllib.TOMLDecodeError:
+ parsed = value
+ overrides[key.strip()] = parsed
+ return resolve(args.config, profile or args.profile, overrides)
+
+
+def require_input(source: Path) -> None:
+ if not source.is_file() or source.suffix.lower() != ".wav":
+ raise ValueError(f"Input must be an existing WAV file: {source}")
+
+
+def main():
+ app = parser()
+ args = app.parse_args()
+ console = Console(stderr=True)
+ try:
+ if args.command == "doctor":
+ from .ai import _cuda_available
+ ffmpeg = ensure_ffmpeg()
+ version = subprocess.run([ffmpeg, "-version"], capture_output=True, text=True, check=True).stdout.splitlines()[0]
+ console.print(version, markup=False)
+ console.print(f"FFmpeg: {ffmpeg}\nData: {data_dir()}\nCUDA driver available: {_cuda_available()}\nAI is installed and self-tested by './producer.sh setup'.", markup=False)
+ return
+ if args.command == "setup":
+ from .ai import ensure_ai
+ with Display() as display:
+ ensure_ffmpeg()
+ python = ensure_ai(args.device, display.update, verify=True)
+ console.print(f"AI ready: {python}", markup=False)
+ return
+ settings = settings_for(args)
+ if args.command == "config":
+ print(json.dumps(asdict(settings), indent=2) if args.json else toml(settings), end="\n" if args.json else "")
+ return
+ with Display() as display:
+ if args.command == "analyze":
+ stats = measure(args.inputs[0], display.update)
+ stats["loudness"] = loudness(ensure_ffmpeg(), args.inputs[0], settings, stats["duration_seconds"], display.update)
+ print(json.dumps(stats, indent=2, allow_nan=False))
+ return
+ if args.command == "process":
+ if args.output and len(args.inputs) != 1:
+ raise ValueError("--output requires exactly one input")
+ if args.output and args.output_dir:
+ raise ValueError("--output and --output-dir cannot be combined")
+ # Without an explicit destination, outputs are written beside
+ # each input; --output-dir keeps every result in one directory.
+ outputs = [args.output or (args.output_dir or p.parent) / f"{p.stem}.{settings.profile}.wav"
+ for p in args.inputs]
+ # Preflight the whole plan so a predictable conflict cannot
+ # fail a batch after earlier inputs have already been published.
+ sources = {p.resolve() for p in args.inputs}
+ claimed: set[Path] = set()
+ for source, output in zip(args.inputs, outputs):
+ require_input(source)
+ targets = targets_for(output.absolute(), settings)
+ resolved = {target.resolve() for target in targets}
+ if len(resolved) < len(targets) or resolved & claimed:
+ raise ValueError("Inputs have duplicate output names; process them separately with --output")
+ claimed |= resolved
+ if resolved & sources or any(
+ target.exists() and os.path.samefile(source, target) for target in targets):
+ raise ValueError("An output would overwrite an input recording")
+ for target in targets:
+ check_target(source, target, args.overwrite)
+ for source, output in zip(args.inputs, outputs):
+ report = process(source, output, settings, display.update, args.overwrite)
+ console.print(f"Written: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False)
+ for warning in report["warnings"]:
+ console.print(f"Warning: {warning}", markup=False)
+ return
+ if not math.isfinite(args.start) or args.start < 0 or not 0 < args.duration <= 300:
+ raise ValueError("Preview start must be >= 0 and duration must be in (0, 300] seconds")
+ if len(set(args.profiles)) != len(args.profiles):
+ raise ValueError("Preview profiles must be unique")
+ if any(item.partition("=")[0].strip() == "profile" for item in args.set):
+ raise ValueError("Preview profiles come from --profiles; remove the profile override from --set")
+ source = args.inputs[0]
+ require_input(source)
+ original = source.resolve()
+ output_dir = args.output_dir or source.parent
+ # Preflight every preview destination before extracting the excerpt.
+ claimed: set[Path] = set()
+ for profile in args.profiles:
+ output = output_dir / f"{source.stem}.{profile}.preview.wav"
+ if output.resolve() == original:
+ raise ValueError("Preview output would overwrite the original")
+ targets = [output, output.with_suffix(".report.json")]
+ resolved = {target.resolve() for target in targets}
+ if len(resolved) < len(targets) or resolved & claimed:
+ raise ValueError("Preview outputs have duplicate names")
+ claimed |= resolved
+ for target in targets:
+ check_target(original, target, args.overwrite)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix=".preview-", dir=output_dir) as tmp:
+ excerpt = Path(tmp) / "excerpt.wav"
+ ffmpeg_run(ensure_ffmpeg(), ["-ss", str(args.start), "-i", str(original), "-t", str(args.duration),
+ "-map", "0:a:0", "-c:a", "pcm_f32le", str(excerpt)], "Extracting preview", args.duration, display.update)
+ for profile in args.profiles:
+ configured = settings_for(args, profile)
+ # Equal loudness prevents a louder preset winning an unfair A/B.
+ configured.normalize = True
+ configured.limiter = True
+ output = output_dir / f"{source.stem}.{profile}.preview.wav"
+ report = process(excerpt, output, configured, display.update, args.overwrite,
+ provenance={"original": str(original), "preview_start_seconds": args.start,
+ "requested_duration_seconds": args.duration,
+ "normalization_forced": True})
+ console.print(f"Preview: {output} | {report['master_loudness']['input_i']} LUFS | {report['master_loudness']['input_tp']} dBTP", markup=False)
+ for warning in report["warnings"]:
+ console.print(f"Warning: {warning}", markup=False)
+ except KeyboardInterrupt:
+ console.print("Cancelled; temporary files removed. Completed outputs may remain.")
+ raise SystemExit(130)
+ except (ValueError, OSError, RuntimeError, subprocess.SubprocessError) as error:
+ console.print(f"Error: {error}", markup=False)
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lib/project/src/voiceforge/config.py b/lib/project/src/voiceforge/config.py
new file mode 100644
index 0000000..9cbd59d
--- /dev/null
+++ b/lib/project/src/voiceforge/config.py
@@ -0,0 +1,118 @@
+"""Flat, strict TOML configuration; CLI overrides are applied last."""
+from dataclasses import asdict, dataclass, fields
+import math
+from pathlib import Path
+import tomllib
+
+
+@dataclass
+class Settings:
+ profile: str = "natural"
+ denoiser: str = "deepfilter"
+ device: str = "auto"
+ denoise_strength: float = 0.85
+ fft_reduction_db: float = 8.0
+ channel: str = "auto"
+ highpass: bool = True
+ highpass_hz: float = 70.0
+ lowpass: bool = False
+ lowpass_hz: float = 16000.0
+ hum_hz: int = 0
+ leveling: bool = True
+ level_target_db: float = -23.0
+ max_gain_db: float = 18.0
+ expansion: bool = False
+ expansion_threshold_db: float = -50.0
+ expansion_ratio: float = 1.5
+ expansion_range_db: float = 12.0
+ eq: bool = True
+ warmth_db: float = 0.5
+ mud_db: float = -1.5
+ presence_db: float = 1.0
+ compression: bool = True
+ compressor_threshold_db: float = -21.0
+ compressor_ratio: float = 2.0
+ compressor_attack_ms: float = 15.0
+ compressor_release_ms: float = 150.0
+ deess: bool = True
+ deess_intensity: float = 0.15
+ deess_amount: float = 0.4
+ normalize: bool = True
+ target_lufs: float = -19.0
+ loudness_range: float = 9.0
+ limiter: bool = True
+ true_peak_db: float = -1.5
+ sample_rate: int = 48000
+ mp3: bool = False
+ mp3_bitrate: int = 192
+
+
+PROFILES = {
+ "natural": {},
+ "narrator": {"compressor_ratio": 1.6, "compressor_attack_ms": 25.0,
+ "presence_db": 0.5, "warmth_db": 1.0, "loudness_range": 11.0},
+ "radio": {"compressor_ratio": 3.5, "compressor_threshold_db": -24.0,
+ "compressor_attack_ms": 8.0, "compressor_release_ms": 100.0,
+ "warmth_db": 2.0, "mud_db": -2.0, "presence_db": 2.0,
+ "deess_intensity": 0.25, "loudness_range": 6.0},
+ "cleanup-only": {"leveling": False, "eq": False, "compression": False,
+ "deess": False, "normalize": False, "limiter": False},
+}
+
+RANGES = {
+ "denoise_strength": (0, 1), "fft_reduction_db": (0.01, 30),
+ "highpass_hz": (20, 300), "lowpass_hz": (4000, 22000),
+ "level_target_db": (-40, -12), "max_gain_db": (0, 30),
+ "expansion_threshold_db": (-90, -20), "expansion_ratio": (1, 4),
+ "expansion_range_db": (0, 40), "warmth_db": (-12, 12),
+ "mud_db": (-12, 12), "presence_db": (-12, 12),
+ "compressor_threshold_db": (-50, -5), "compressor_ratio": (1, 10),
+ "compressor_attack_ms": (0.1, 200), "compressor_release_ms": (10, 2000),
+ "deess_intensity": (0, 1), "deess_amount": (0, 1),
+ "target_lufs": (-30, -12), "loudness_range": (1, 20),
+ "true_peak_db": (-9, -0.5),
+}
+
+
+def resolve(path: Path | None = None, profile: str | None = None,
+ overrides: dict | None = None) -> Settings:
+ values = tomllib.loads(path.read_text()) if path else {}
+ chosen = (overrides or {}).get("profile", profile or values.get("profile", "natural"))
+ if not isinstance(chosen, str) or chosen not in PROFILES:
+ raise ValueError(f"Unknown profile {chosen!r}; choose {', '.join(PROFILES)}")
+ merged = asdict(Settings()) | PROFILES[chosen] | values | {"profile": chosen}
+ merged.update(overrides or {})
+ defaults = asdict(Settings())
+ unknown = merged.keys() - defaults.keys()
+ if unknown:
+ raise ValueError(f"Unknown setting(s): {', '.join(sorted(unknown))}")
+ for key, value in merged.items():
+ expected = type(defaults[key])
+ if expected is float:
+ if type(value) not in (int, float) or not math.isfinite(value):
+ raise ValueError(f"{key} must be a finite number")
+ elif type(value) is not expected:
+ raise ValueError(f"{key} must be {expected.__name__}")
+ if key in RANGES and not RANGES[key][0] <= value <= RANGES[key][1]:
+ raise ValueError(f"{key} must be between {RANGES[key][0]} and {RANGES[key][1]}")
+ for key, options in {
+ "denoiser": ("deepfilter", "fft", "none"), "device": ("auto", "cpu", "cuda"),
+ "channel": ("auto", "left", "right", "mix"), "hum_hz": (0, 50, 60),
+ "sample_rate": (44100, 48000), "mp3_bitrate": (128, 160, 192, 224, 256, 320),
+ }.items():
+ if merged[key] not in options:
+ raise ValueError(f"{key} must be one of {options}")
+ if merged["lowpass"] and merged["lowpass_hz"] >= merged["sample_rate"] / 2:
+ raise ValueError("lowpass_hz must be below the output Nyquist frequency")
+ if merged["normalize"] and not merged["limiter"]:
+ raise ValueError("Normalization includes true-peak limiting; use --no-normalize to bypass it")
+ return Settings(**merged)
+
+
+def toml(settings: Settings) -> str:
+ lines = ["# VoiceForge configuration. CLI flags override these values."]
+ for field in fields(settings):
+ value = getattr(settings, field.name)
+ text = str(value).lower() if isinstance(value, bool) else repr(value)
+ lines.append(f"{field.name} = {text}")
+ return "\n".join(lines) + "\n"
diff --git a/lib/project/src/voiceforge/pipeline.py b/lib/project/src/voiceforge/pipeline.py
new file mode 100644
index 0000000..a143dcf
--- /dev/null
+++ b/lib/project/src/voiceforge/pipeline.py
@@ -0,0 +1,199 @@
+"""Offline mono voice mastering with float intermediates and atomic outputs."""
+from dataclasses import asdict
+import fcntl
+import json
+import math
+import os
+from pathlib import Path
+import tempfile
+
+from . import __version__
+from .audio import ffmpeg_run, loudness, measure
+from .config import Settings
+from .setup import ensure_ffmpeg
+
+
+def filters(settings: Settings) -> list[str]:
+ s = settings
+ result = []
+ if s.expansion:
+ result.append(f"agate=threshold={10 ** (s.expansion_threshold_db / 20)}:ratio={s.expansion_ratio}:range={10 ** (-s.expansion_range_db / 20)}:attack=10:release=250:detection=rms")
+ if s.eq:
+ result += [f"equalizer=f=140:t=q:w=0.7:g={s.warmth_db}",
+ f"equalizer=f=300:t=q:w=0.8:g={s.mud_db}",
+ f"equalizer=f=3500:t=q:w=0.7:g={s.presence_db}"]
+ if s.compression:
+ result.append(f"acompressor=threshold={10 ** (s.compressor_threshold_db / 20)}:ratio={s.compressor_ratio}:attack={s.compressor_attack_ms}:release={s.compressor_release_ms}:knee=4:makeup=1:detection=rms")
+ if s.deess:
+ result.append(f"deesser=i={s.deess_intensity}:m={s.deess_amount}:f=0.5")
+ if s.lowpass:
+ result.append(f"lowpass=f={s.lowpass_hz}:p=2")
+ return result or ["anull"]
+
+
+def targets_for(destination: Path, settings: Settings) -> list[Path]:
+ """Every path a successful run publishes: master WAV, report, optional MP3."""
+ targets = [destination, destination.with_suffix(".report.json")]
+ if settings.mp3:
+ targets.append(destination.with_suffix(".mp3"))
+ return targets
+
+
+def check_target(source: Path, target: Path, overwrite: bool) -> None:
+ """Preflight one published path; the original recording is never touched."""
+ if target.resolve() == source or (target.exists() and os.path.samefile(source, target)):
+ raise ValueError("Refusing to overwrite the original recording")
+ # lexists also rejects dangling symlinks, which publication would only hit late.
+ if os.path.lexists(target) and not overwrite:
+ raise FileExistsError(f"Output exists: {target}; use --overwrite to replace outputs")
+ if target.exists() and not target.is_file():
+ raise ValueError(f"Output is not a regular file: {target}")
+
+
+def publish(source: Path, target: Path, overwrite: bool):
+ if overwrite:
+ os.replace(source, target)
+ else:
+ # Link is atomic and fails if another process created the destination.
+ os.link(source, target)
+ source.unlink()
+
+
+def process(source: Path, destination: Path, settings: Settings, progress,
+ overwrite=False, provenance: dict | None = None) -> dict:
+ source, destination = source.resolve(), destination.absolute()
+ if not source.is_file() or source.suffix.lower() != ".wav":
+ raise ValueError(f"Input must be an existing WAV file: {source}")
+ if destination.suffix.lower() != ".wav":
+ raise ValueError("Master output must have a .wav extension")
+ report_path = destination.with_suffix(".report.json")
+ for target in targets_for(destination, settings):
+ check_target(source, target, overwrite)
+ ffmpeg = ensure_ffmpeg()
+ original = measure(source, progress, "Analyzing original")
+ if original["sample_peak_dbfs"] < -100:
+ raise ValueError("Recording is silent or too quiet to master safely")
+ channel = settings.channel
+ if original["channels"] == 2 and channel == "auto":
+ raise ValueError("Stereo input: select --channel left, right, or mix explicitly. Auto downmix could cancel your voice.")
+ if original["channels"] == 1 and channel == "right":
+ raise ValueError("Cannot select the right channel of a mono recording")
+ duration = original["duration_seconds"]
+ warnings = []
+ if original["near_full_scale_samples"]:
+ warnings.append("Input has near-full-scale samples: inspect for clipping; lost peaks cannot be reliably restored.")
+ if max(abs(x) for x in original["dc_offset"]) > 0.01:
+ warnings.append("Significant input DC offset detected; enable highpass filtering to remove it.")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix=".voiceforge-", dir=destination.parent) as temporary:
+ work = Path(temporary)
+ prepared, cleaned, shaped = [work / name for name in ("prepared.wav", "cleaned.wav", "shaped.wav")]
+ input_filters = []
+ if original["channels"] == 2:
+ input_filters.append({"left": "pan=mono|c0=c0", "right": "pan=mono|c0=c1",
+ "mix": "pan=mono|c0=0.5*c0+0.5*c1"}[channel])
+ if settings.highpass:
+ input_filters.append(f"highpass=f={settings.highpass_hz}:p=2")
+ if settings.hum_hz:
+ input_filters += [f"equalizer=f={settings.hum_hz * n}:t=q:w=25:g=-18" for n in (1, 2, 3)]
+ ffmpeg_run(ffmpeg, ["-i", str(source), "-map", "0:a:0", "-af",
+ ",".join(input_filters or ["anull"]), "-ar", "48000", "-ac", "1",
+ "-c:a", "pcm_f32le", "-rf64", "auto", str(prepared)], "Preparing mono 48 kHz audio", duration, progress)
+ if settings.denoiser == "deepfilter" and settings.denoise_strength > 0:
+ from .ai import denoise
+ denoise(prepared, cleaned, settings.device, settings.denoise_strength, progress)
+ elif settings.denoiser == "fft" and settings.denoise_strength > 0:
+ ffmpeg_run(ffmpeg, ["-i", str(prepared), "-af",
+ f"afftdn=nr={max(0.01, settings.fft_reduction_db * settings.denoise_strength)}:tn=1",
+ "-c:a", "pcm_f32le", "-rf64", "auto", str(cleaned)], "FFT noise reduction", duration, progress)
+ else:
+ cleaned = prepared
+ clean_stats = measure(cleaned, progress, "Measuring cleaned voice")
+ if clean_stats["sample_peak_dbfs"] < -100:
+ raise ValueError("The selected audio is silent or too quiet to master safely; "
+ "check --channel, the mix, and cleanup settings")
+ gain = 0.0
+ if settings.leveling:
+ speech_db = clean_stats["speech_level_estimate_dbfs"]
+ if speech_db <= -110:
+ warnings.append("Speech level estimate is unreliable; leveling skipped. "
+ "Inspect levels or rely on normalization.")
+ else:
+ desired = settings.level_target_db - speech_db
+ gain = max(-settings.max_gain_db, min(settings.max_gain_db, desired))
+ if abs(desired) > settings.max_gain_db:
+ warnings.append("Input level correction reached its configured gain limit.")
+ chain = ([f"volume={gain}dB"] if gain else []) + filters(settings)
+ ffmpeg_run(ffmpeg, ["-i", str(cleaned), "-af", ",".join(chain),
+ "-c:a", "pcm_f32le", "-rf64", "auto", str(shaped)], "EQ, dynamics and de-essing", duration, progress)
+ before = loudness(ffmpeg, shaped, settings, duration, progress, "Loudness analysis (pass 1)")
+ if settings.normalize and any(before.get(k) is None for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")):
+ raise ValueError("Cannot normalize: insufficient measurable audio after processing")
+ final_filters = []
+ if settings.normalize:
+ final_filters.append(f"loudnorm=I={settings.target_lufs}:TP={settings.true_peak_db}:LRA={settings.loudness_range}:"
+ f"measured_I={before['input_i']}:measured_TP={before['input_tp']}:measured_LRA={before['input_lra']}:"
+ f"measured_thresh={before['input_thresh']}:offset={before['target_offset']}:linear=true:print_format=json")
+ elif settings.limiter:
+ final_filters += ["aresample=192000", f"alimiter=limit={10 ** (settings.true_peak_db / 20)}:level=false:latency=true"]
+ final_filters.append(f"aresample={settings.sample_rate}:output_sample_bits=24:dither_method=triangular")
+ master = work / "master.wav"
+ render_log = ffmpeg_run(ffmpeg, ["-i", str(shaped), "-af", ",".join(final_filters),
+ "-ar", str(settings.sample_rate), "-c:a", "pcm_s24le", "-rf64", "auto", str(master)],
+ "Mastering (pass 2)", duration, progress)
+ # Bounded disk use: drop consumed intermediates instead of waiting for
+ # the temporary directory teardown at the end of the run.
+ shaped.unlink(missing_ok=True)
+ for intermediate in {prepared, cleaned}:
+ intermediate.unlink(missing_ok=True)
+ normalization_mode = "disabled"
+ if settings.normalize:
+ start, end = render_log.rfind("{"), render_log.rfind("}")
+ if start < 0 or end < start:
+ raise RuntimeError("FFmpeg did not report the applied normalization mode")
+ normalization_mode = json.loads(render_log[start:end + 1])["normalization_type"]
+ final = loudness(ffmpeg, master, settings, duration, progress, "Verifying exported master")
+ final_stats = measure(master, progress, "Checking exported samples")
+ if abs(final_stats["duration_seconds"] - duration) > 0.02:
+ raise RuntimeError("Output duration differs from input by more than 20 ms")
+ if (final_stats["channels"] != 1 or final_stats["sample_rate"] != settings.sample_rate
+ or final_stats["subtype"] != "PCM_24"):
+ raise RuntimeError("Exported master is not the expected mono PCM_24 WAV")
+ if settings.normalize and (final["input_i"] is None or abs(final["input_i"] - settings.target_lufs) > 0.5):
+ warnings.append("Master misses the loudness target by more than 0.5 LU; inspect the report before publishing.")
+ if settings.limiter and final["input_tp"] is not None and final["input_tp"] > settings.true_peak_db + 0.1:
+ warnings.append("Master exceeds the true-peak target by more than 0.1 dB; inspect before publishing.")
+ if final_stats["near_full_scale_samples"]:
+ warnings.append("Export has near-full-scale samples. Enable limiting or lower gains.")
+ mp3_stats = None
+ if settings.mp3:
+ mp3 = work / "delivery.mp3"
+ ffmpeg_run(ffmpeg, ["-i", str(master), "-c:a", "libmp3lame", "-b:a",
+ f"{settings.mp3_bitrate}k", str(mp3)], "Encoding MP3", duration, progress)
+ mp3_stats = loudness(ffmpeg, mp3, settings, duration, progress, "Verifying decoded MP3")
+ if mp3_stats["input_tp"] is not None and mp3_stats["input_tp"] > settings.true_peak_db + 0.1:
+ warnings.append("MP3 encoding increased true peak beyond the configured ceiling; use more headroom.")
+ report = {"voiceforge_version": __version__, "source": str(source),
+ "output": str(destination), "settings": asdict(settings), "input": original,
+ "cleaned": clean_stats, "level_correction_db": gain,
+ "pre_master_loudness": before, "master_loudness": final,
+ "master": final_stats, "mp3_loudness": mp3_stats,
+ "normalization_mode": normalization_mode,
+ "warnings": warnings}
+ if provenance:
+ report["provenance"] = provenance
+ staged_report = work / "report.json"
+ staged_report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
+ # Publish only after all processing and verification succeeded. The
+ # directory lock serializes publication so concurrent jobs targeting
+ # the same output directory cannot interleave WAV/report/MP3 sets.
+ publication_fd = os.open(destination.parent, os.O_RDONLY)
+ try:
+ fcntl.flock(publication_fd, fcntl.LOCK_EX)
+ if settings.mp3:
+ publish(mp3, destination.with_suffix(".mp3"), overwrite)
+ publish(staged_report, report_path, overwrite)
+ publish(master, destination, overwrite)
+ finally:
+ os.close(publication_fd)
+ return report
diff --git a/lib/project/src/voiceforge/progress.py b/lib/project/src/voiceforge/progress.py
new file mode 100644
index 0000000..a8c5476
--- /dev/null
+++ b/lib/project/src/voiceforge/progress.py
@@ -0,0 +1,40 @@
+"""A refresh thread keeps elapsed time moving even during native processing."""
+import time
+
+from rich.console import Console
+from rich.progress import (BarColumn, Progress, SpinnerColumn, TaskProgressColumn,
+ TextColumn, TimeElapsedColumn, TimeRemainingColumn)
+
+
+class Display:
+ def __init__(self):
+ self.console = Console(stderr=True)
+ self.progress = Progress(SpinnerColumn(), TextColumn("{task.description}"),
+ BarColumn(), TaskProgressColumn(), TimeElapsedColumn(),
+ TimeRemainingColumn(), console=self.console,
+ refresh_per_second=5, disable=not self.console.is_terminal)
+ self.task = None
+ self.stage = ""
+ self.started = time.monotonic()
+ self.last_print = 0.0
+
+ def __enter__(self):
+ self.progress.start()
+ return self
+
+ def __exit__(self, *args):
+ self.progress.stop()
+
+ def update(self, stage, completed=None, total=None):
+ changed = stage != self.stage
+ if changed:
+ if self.task is not None:
+ self.progress.remove_task(self.task)
+ self.task = self.progress.add_task(stage, total=total)
+ self.stage = stage
+ self.progress.update(self.task, completed=completed if total else 0, total=total)
+ now = time.monotonic()
+ if not self.console.is_terminal and (changed or now - self.last_print >= 5):
+ fraction = f" {min(100, 100 * (completed or 0) / total):.0f}%" if total else ""
+ self.console.print(f"[{now - self.started:7.1f}s] {stage}{fraction}", markup=False)
+ self.last_print = now
diff --git a/lib/project/src/voiceforge/setup.py b/lib/project/src/voiceforge/setup.py
new file mode 100644
index 0000000..9afeb44
--- /dev/null
+++ b/lib/project/src/voiceforge/setup.py
@@ -0,0 +1,203 @@
+"""User-local tools. No sudo and no changes to the application's interpreter."""
+from __future__ import annotations
+
+from collections import deque
+from collections.abc import Callable
+import json
+import os
+from pathlib import Path
+import selectors
+import shutil
+import signal
+import subprocess
+import sys
+import time
+
+Progress = Callable[[str, float | None, float | None], None]
+
+
+def data_dir() -> Path:
+ """The VoiceForge data/prefix directory.
+
+ VOICEFORGE_HOME wins (the single-directory launcher exports it), then
+ XDG_DATA_HOME/voiceforge, then ~/.local/share/voiceforge. A relative
+ VOICEFORGE_HOME resolves against the current directory.
+ """
+ home = os.environ.get("VOICEFORGE_HOME")
+ if home:
+ return Path(home).absolute()
+ root = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
+ if not root.is_absolute():
+ root = Path.home() / ".local/share"
+ return root / "voiceforge"
+
+
+def tool_env() -> dict[str, str]:
+ """Sanitized environment for bootstrap, setup, and worker subprocesses.
+
+ Host Python settings are removed so a virtualenv or sitecustomize on the
+ machine cannot leak into managed environments. Installation-target,
+ index, and constraint overrides are dropped and package-manager
+ configuration files disabled, so packages can only come from the
+ explicitly passed trusted indexes and land inside the data directory;
+ every cache (pip, uv, managed Python, XDG, temporary files) stays there
+ too.
+ """
+ env = os.environ.copy()
+ for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV",
+ "PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS",
+ "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT",
+ "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL",
+ "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"):
+ env.pop(name, None)
+ env["PYTHONNOUSERSITE"] = "1"
+ env["PIP_CONFIG_FILE"] = os.devnull
+ env["UV_NO_CONFIG"] = "1"
+ cache = data_dir() / "cache"
+ env["XDG_CACHE_HOME"] = str(cache)
+ env["PIP_CACHE_DIR"] = str(cache / "pip")
+ env["UV_CACHE_DIR"] = str(cache / "uv")
+ env["UV_PYTHON_INSTALL_DIR"] = str(data_dir() / "uv/python")
+ tmp = data_dir() / "tmp"
+ try:
+ tmp.mkdir(parents=True, exist_ok=True)
+ env["TMPDIR"] = str(tmp)
+ except OSError:
+ pass # Best effort: never break subprocesses over temporary-file containment.
+ return env
+
+
+def terminate_group(process: subprocess.Popen) -> None:
+ """SIGTERM the child's whole process group, escalate to SIGKILL, and reap.
+
+ The group is signalled even when the leader has already exited, because
+ descendants can outlive it while still holding the output pipe open.
+ A race between exit and signalling is tolerated.
+ """
+ for sig in (signal.SIGTERM, signal.SIGKILL):
+ try:
+ os.killpg(process.pid, sig)
+ except ProcessLookupError:
+ pass
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ continue
+ try:
+ os.killpg(process.pid, 0)
+ except ProcessLookupError:
+ return
+ process.wait()
+
+
+def run_process(args: list[str], stage: str, progress: Progress | None = None,
+ *, env: dict[str, str] | None = None) -> None:
+ """Drain output without blocking; send heartbeat callbacks every 0.25s.
+
+ Worker JSON records carry frame/byte counts. Other processes report elapsed seconds
+ as completed with total=None. Only the last 64 KiB of diagnostics are retained.
+ Callback exceptions (including cancellation) terminate the process group.
+ """
+ started = time.monotonic()
+ current_stage, completed, total = stage, None, None
+ diagnostics: deque[bytes] = deque(maxlen=16)
+ pending = b""
+ if progress:
+ progress(stage, None, None)
+ with subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ env=env, start_new_session=True) as process:
+ assert process.stdout is not None
+ os.set_blocking(process.stdout.fileno(), False)
+ try:
+ with selectors.DefaultSelector() as selector:
+ selector.register(process.stdout, selectors.EVENT_READ)
+ while selector.get_map() or process.poll() is None:
+ for key, _ in selector.select(timeout=0.25):
+ chunk = os.read(key.fd, 4096)
+ if not chunk:
+ selector.unregister(key.fileobj)
+ continue
+ diagnostics.append(chunk)
+ pending += chunk
+ while b"\n" in pending:
+ line, pending = pending.split(b"\n", 1)
+ if line.startswith(b"VOICEFORGE_PROGRESS "):
+ event = json.loads(line[len(b"VOICEFORGE_PROGRESS "):])
+ current_stage, completed, total = event
+ if len(pending) > 65536:
+ pending = pending[-4096:]
+ if progress:
+ progress(current_stage, completed if total is not None else
+ time.monotonic() - started, total)
+ code = process.wait()
+ if code:
+ detail = b"".join(diagnostics).decode("utf-8", errors="replace")
+ raise RuntimeError(f"{stage} failed (exit {code}):\n{detail}")
+ except BaseException:
+ terminate_group(process)
+ raise
+
+
+def _uv_supports_relocatable(uv: str) -> bool:
+ """Whether this uv understands the venv flags AI setup relies on.
+
+ A host uv can be older than the pinned one; capability is checked
+ directly instead of trusting the version string.
+ """
+ try:
+ result = subprocess.run([uv, "venv", "--help"], capture_output=True,
+ text=True, check=True)
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return "--relocatable" in result.stdout + result.stderr
+
+
+def ensure_uv(progress: Progress | None = None) -> str:
+ """Return a uv that supports the flags AI setup relies on.
+
+ The launcher's contained bootstrap uv is preferred; a host uv is trusted
+ only if it actually supports --relocatable. Otherwise uv==0.8.17 is
+ installed from PyPI (trust PyPI's distribution, not a downloaded shell
+ script).
+ """
+ contained = data_dir() / "bootstrap/bin/uv"
+ if contained.is_file() and os.access(contained, os.X_OK):
+ return str(contained)
+ found = shutil.which("uv")
+ if found and _uv_supports_relocatable(found):
+ return found
+ root = contained.parent.parent
+ root.parent.mkdir(parents=True, exist_ok=True)
+ python = sys.executable or shutil.which("python3")
+ env = tool_env()
+ run_process([python, "-m", "venv", str(root)], "Creating uv bootstrap",
+ progress, env=env)
+ run_process([str(root / "bin/python"), "-m", "pip", "install",
+ "--index-url", "https://pypi.org/simple", "uv==0.8.17"],
+ "Installing uv from PyPI", progress, env=env)
+ if not (contained.is_file() and os.access(contained, os.X_OK)):
+ raise RuntimeError("Installing uv from PyPI did not produce a usable "
+ "uv; check for host pip settings (such as "
+ "PIP_TARGET) that redirect installations outside "
+ "the data directory.")
+ return str(contained)
+
+
+def ensure_ffmpeg() -> str:
+ """Return the bundled FFmpeg, or a system one where the bundle is
+ absent, not ffprobe.
+
+ The launcher installs imageio-ffmpeg via the bundled-ffmpeg extra; direct
+ package installs should declare it as a dependency. This helper never
+ runs pip in the current interpreter.
+ """
+ try:
+ import imageio_ffmpeg
+ return imageio_ffmpeg.get_ffmpeg_exe()
+ except (ImportError, RuntimeError) as error:
+ found = shutil.which("ffmpeg")
+ if found:
+ return found
+ raise RuntimeError("FFmpeg is unavailable. Rebuild the contained "
+ "runtime with './producer.sh --rebuild', or "
+ "install a system FFmpeg.") from error
diff --git a/lib/project/tests/conftest.py b/lib/project/tests/conftest.py
new file mode 100644
index 0000000..7c75358
--- /dev/null
+++ b/lib/project/tests/conftest.py
@@ -0,0 +1,43 @@
+from pathlib import Path
+import sys
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+# Support running the suite before the project is installed.
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+
+from voiceforge.config import resolve
+from voiceforge.setup import ensure_ffmpeg
+
+
+@pytest.fixture
+def progress():
+ return lambda *_: None
+
+
+@pytest.fixture
+def wav(tmp_path):
+ def write(samples=None, *, rate=48000, name="input.wav"):
+ if samples is None:
+ time = np.arange(rate * 3) / rate
+ samples = 0.15 * np.sin(2 * np.pi * 440 * time)
+ path = tmp_path / name
+ sf.write(path, np.asarray(samples), rate, format="WAV", subtype="FLOAT")
+ return path
+
+ return write
+
+
+@pytest.fixture
+def bypass():
+ return resolve(profile="cleanup-only", overrides={"denoiser": "none", "highpass": False})
+
+
+@pytest.fixture
+def ffmpeg():
+ try:
+ return ensure_ffmpeg()
+ except RuntimeError as error:
+ pytest.skip(str(error))
diff --git a/lib/project/tests/test_ai.py b/lib/project/tests/test_ai.py
new file mode 100644
index 0000000..e8c5872
--- /dev/null
+++ b/lib/project/tests/test_ai.py
@@ -0,0 +1,366 @@
+"""Offline AI contract tests; no Torch installation, model download, or GPU needed."""
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import sys
+import time
+from types import SimpleNamespace
+from unittest.mock import Mock
+import zipfile
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+from voiceforge import ai, ai_worker, setup
+
+
+MODEL_BYTES = 7986207
+MODEL_HASH = "49c52edc8947ae1f9bf50d81530beaf3a2c3245aeaf34b6f31ff535cd22284d2"
+
+
+@pytest.mark.parametrize("size,message", [
+ (10, "checksum mismatch"),
+ (MODEL_BYTES, "checksum mismatch"),
+ (MODEL_BYTES + 1, "exceeds the expected size"),
+])
+def test_model_rejects_truncated_corrupt_and_oversized_downloads(tmp_path, monkeypatch, size, message):
+ monkeypatch.setattr(ai_worker.urllib.request, "urlopen", Mock(return_value=io.BytesIO(b"x" * size)))
+ progress = Mock()
+ monkeypatch.setattr(ai_worker, "report", progress)
+ with pytest.raises(RuntimeError, match=message):
+ ai_worker.ensure_model(tmp_path / "model")
+ assert list(tmp_path.iterdir()) == []
+ events = [call.args for call in progress.call_args_list]
+ assert events[0] == ("Downloading DeepFilterNet3 model (bytes)", 0, MODEL_BYTES)
+ counts = [completed for _, completed, total in events if total == MODEL_BYTES]
+ assert counts == sorted(counts)
+ assert all(0 <= count <= MODEL_BYTES for count in counts)
+
+
+@pytest.mark.parametrize("unsafe", [False, True])
+def test_mocked_model_archive_progress_extraction_and_reuse(tmp_path, monkeypatch, unsafe):
+ # Mock only the release digest for a synthetic archive; corruption tests above
+ # exercise the real SHA-256 gate. Keep its real byte count and ZIP extraction.
+ def archive(padding):
+ stream = io.BytesIO()
+ with zipfile.ZipFile(stream, "w") as bundle:
+ bundle.writestr("DeepFilterNet3/config.ini", "[df]\nsr=48000\n")
+ bundle.writestr("DeepFilterNet3/checkpoints/model_120.ckpt.best", "checkpoint")
+ bundle.writestr("../escape" if unsafe else "padding", b"x" * padding)
+ return stream.getvalue()
+
+ payload = archive(MODEL_BYTES - len(archive(0)))
+ assert len(payload) == MODEL_BYTES
+ digest = Mock(wraps=hashlib.sha256())
+ digest.hexdigest.return_value = MODEL_HASH
+ monkeypatch.setattr(ai_worker.hashlib, "sha256", lambda: digest)
+ download = Mock(return_value=io.BytesIO(payload))
+ monkeypatch.setattr(ai_worker.urllib.request, "urlopen", download)
+ progress = Mock()
+ monkeypatch.setattr(ai_worker, "report", progress)
+ directory = tmp_path / "model"
+ if unsafe:
+ with pytest.raises(RuntimeError, match="Unsafe path"):
+ ai_worker.ensure_model(directory)
+ assert not (tmp_path / "escape").exists()
+ assert list(tmp_path.iterdir()) == []
+ else:
+ model = ai_worker.ensure_model(directory)
+ assert (model / "config.ini").is_file()
+ assert (model / "checkpoints/model_120.ckpt.best").is_file()
+ assert ai_worker.ensure_model(directory) == model
+ download.assert_called_once()
+ assert b"".join(call.args[0] for call in digest.update.call_args_list) == payload
+ events = [call.args for call in progress.call_args_list]
+ byte_events = [event for event in events if "(bytes)" in event[0]]
+ assert byte_events[0][1:] == (0, MODEL_BYTES)
+ assert byte_events[-1][1:] == (MODEL_BYTES, MODEL_BYTES)
+ assert len(byte_events) > 2
+ assert events[-1] == ("Verifying and extracting DeepFilterNet3 model",)
+
+
+def test_incomplete_model_fails_without_network(tmp_path, monkeypatch):
+ directory = tmp_path / "model"
+ directory.mkdir()
+ download = Mock(side_effect=AssertionError("Unexpected network call"))
+ monkeypatch.setattr(ai_worker.urllib.request, "urlopen", download)
+ with pytest.raises(RuntimeError, match="Incomplete model directory"):
+ ai_worker.ensure_model(directory)
+ download.assert_not_called()
+
+
+@pytest.mark.parametrize("close_output", [False, True])
+def test_subprocess_heartbeat_does_not_block_on_silence_or_closed_output(close_output):
+ events = []
+ code = "import os,time; "
+ if close_output:
+ code += "os.close(1); os.close(2); "
+ code += "time.sleep(0.8)"
+ setup.run_process([sys.executable, "-c", code], "Waiting", lambda *event: events.append(event))
+ assert events[0] == ("Waiting", None, None)
+ elapsed = [completed for _, completed, total in events[1:] if total is None]
+ assert len(elapsed) >= 2
+ assert elapsed == sorted(elapsed)
+
+
+def test_subprocess_parses_split_byte_progress_records():
+ events = []
+ record = "VOICEFORGE_PROGRESS " + json.dumps(["Download (bytes)", 123, 456]) + "\n"
+ code = f"import os,time; os.write(1,{record[:12].encode()!r}); time.sleep(.3); os.write(1,{record[12:].encode()!r})"
+ setup.run_process([sys.executable, "-c", code], "Download", lambda *event: events.append(event))
+ assert events[-1] == ("Download (bytes)", 123, 456)
+
+
+@pytest.mark.parametrize("exception", [KeyboardInterrupt, RuntimeError])
+def test_callback_cancellation_terminates_and_reaps_child(monkeypatch, exception):
+ popen = setup.subprocess.Popen
+ children = []
+
+ def launch(*args, **kwargs):
+ child = popen(*args, **kwargs)
+ children.append(child)
+ return child
+
+ def cancel(stage, completed, total):
+ if completed is not None:
+ raise exception("cancelled")
+
+ monkeypatch.setattr(setup.subprocess, "Popen", launch)
+ started = time.monotonic()
+ with pytest.raises(exception, match="cancelled"):
+ setup.run_process([sys.executable, "-c", "import time; time.sleep(30)"], "Waiting", cancel)
+ assert len(children) == 1 and children[0].poll() is not None
+ assert time.monotonic() - started < 10
+
+
+def test_cancellation_kills_descendants_that_outlive_the_leader(tmp_path, monkeypatch):
+ sentinel = tmp_path / "grandchild.pid"
+ monkeypatch.setenv("GRANDCHILD_PID_FILE", str(sentinel))
+ child_code = (
+ "import os, subprocess, sys, time\n"
+ "subprocess.Popen([sys.executable, '-c', "
+ "\"import os, time; open(os.environ['GRANDCHILD_PID_FILE'], 'w')"
+ ".write(str(os.getpid())); time.sleep(60)\"])\n"
+ "while not os.path.exists(os.environ['GRANDCHILD_PID_FILE']):\n"
+ " time.sleep(0.02)\n"
+ )
+
+ def cancel(stage, completed, total):
+ if completed is not None and children and children[0].poll() is not None:
+ raise KeyboardInterrupt("cancelled")
+
+ popen = setup.subprocess.Popen
+ children = []
+
+ def launch(*args, **kwargs):
+ child = popen(*args, **kwargs)
+ children.append(child)
+ return child
+
+ monkeypatch.setattr(setup.subprocess, "Popen", launch)
+ with pytest.raises(KeyboardInterrupt, match="cancelled"):
+ setup.run_process([sys.executable, "-c", child_code], "Waiting", cancel)
+
+ def alive(pid):
+ try:
+ for line in Path(f"/proc/{pid}/status").read_text().splitlines():
+ if line.startswith("State:"):
+ return "Z" not in line
+ return True
+ except FileNotFoundError:
+ return False
+
+ grandchild = int(sentinel.read_text())
+ for _ in range(50):
+ if not alive(grandchild):
+ break
+ time.sleep(0.1)
+ else:
+ pytest.fail("A descendant survived the cancellation of its process group")
+
+
+def test_subprocess_failure_keeps_bounded_diagnostics():
+ code = "import sys; print('x'*100000); print('specific failure'); sys.exit(7)"
+ with pytest.raises(RuntimeError, match="exit 7") as error:
+ setup.run_process([sys.executable, "-c", code], "Worker")
+ assert "specific failure" in str(error.value)
+ assert len(str(error.value)) < 66000
+
+
+@pytest.mark.parametrize("strength", [-1, 2, float("nan"), float("inf")])
+def test_invalid_strength_fails_before_setup(tmp_path, monkeypatch, strength):
+ install = Mock(side_effect=AssertionError("Unexpected setup"))
+ monkeypatch.setattr(ai, "ensure_ai", install)
+ with pytest.raises(ValueError, match="strength"):
+ ai.denoise(tmp_path / "input.wav", tmp_path / "output.wav", strength=strength)
+ install.assert_not_called()
+
+
+def _ready_environment(root, flavor="cpu"):
+ environment = root / f"ai-df-0.5.6-torch-2.5.1-{flavor}-py311-v1"
+ (environment / "bin").mkdir(parents=True)
+ (environment / "bin/python").write_text("#!/bin/sh\n")
+ (environment / ".voiceforge-ready").touch()
+ return environment
+
+
+def test_ready_environment_skips_the_check_worker(tmp_path, monkeypatch):
+ root = tmp_path / "prefix"
+ environment = _ready_environment(root)
+ monkeypatch.setenv("VOICEFORGE_HOME", str(root))
+ monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39")
+ executed = []
+ monkeypatch.setattr(ai, "run_process", lambda args, *rest, **kwargs: executed.append(args))
+ python = ai.ensure_ai("cpu")
+ assert executed == []
+ assert python == environment / "bin/python"
+
+
+def test_setup_verifies_even_a_ready_environment(tmp_path, monkeypatch):
+ root = tmp_path / "prefix"
+ _ready_environment(root)
+ monkeypatch.setenv("VOICEFORGE_HOME", str(root))
+ monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39")
+ executed = []
+ monkeypatch.setattr(ai, "run_process", lambda args, *rest, **kwargs: executed.append(args))
+ ai.ensure_ai("cpu", verify=True)
+ assert len(executed) == 1
+ assert executed[0][-1] == "--check"
+
+
+def test_broken_environment_is_rebuilt_and_self_tested(tmp_path, monkeypatch):
+ root = tmp_path / "prefix"
+ environment = _ready_environment(root)
+ (environment / "bin/python").unlink()
+ (environment / "bin/python").symlink_to("/nonexistent/python3.11")
+ leftover = environment / "leftover.txt"
+ leftover.write_text("from the previous location")
+ monkeypatch.setenv("VOICEFORGE_HOME", str(root))
+ monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39")
+ stages, commands = [], []
+
+ def fake_run(args, stage, progress=None, **kwargs):
+ commands.append(args)
+ stages.append(stage)
+ if stage == "Checking DeepFilterNet3":
+ environment.mkdir(parents=True, exist_ok=True)
+ (environment / ".voiceforge-ready").touch()
+
+ monkeypatch.setattr(ai, "run_process", fake_run)
+ monkeypatch.setattr(ai, "ensure_uv", lambda progress=None: "uv")
+ ai.ensure_ai("cpu")
+ assert not leftover.exists()
+ assert stages == ["Preparing AI Python 3.11", "Installing PyTorch (cpu)",
+ "Installing DeepFilterNet3", "Checking DeepFilterNet3"]
+ assert commands[0][-1] == str(environment)
+ assert (environment / ".voiceforge-ready").is_file()
+
+
+def test_copied_environment_is_rebuilt_not_trusted(tmp_path, monkeypatch):
+ # Copying an installation while the original remains leaves the copy's
+ # interpreter links resolving into the original prefix; that must not
+ # count as ready, or the copy breaks when the original is removed.
+ root = tmp_path / "prefix"
+ environment = _ready_environment(root)
+ original = _ready_environment(tmp_path / "other")
+ (environment / "bin/python").unlink()
+ (environment / "bin/python").symlink_to(original / "bin/python")
+ monkeypatch.setenv("VOICEFORGE_HOME", str(root))
+ monkeypatch.setattr(ai.os, "confstr", lambda *_: "glibc 2.39")
+ stages = []
+
+ def fake_run(args, stage, progress=None, **kwargs):
+ stages.append(stage)
+ if stage == "Checking DeepFilterNet3":
+ environment.mkdir(parents=True, exist_ok=True)
+ (environment / ".voiceforge-ready").touch()
+
+ monkeypatch.setattr(ai, "run_process", fake_run)
+ monkeypatch.setattr(ai, "ensure_uv", lambda progress=None: "uv")
+ ai.ensure_ai("cpu")
+ assert stages[0] == "Preparing AI Python 3.11"
+
+
+@pytest.fixture
+def mocked_worker(tmp_path, monkeypatch):
+ class Tensor:
+ def __init__(self, array):
+ self.array = array
+
+ def unsqueeze(self, axis):
+ return Tensor(np.expand_dims(self.array, axis))
+
+ def squeeze(self, axis):
+ return Tensor(np.squeeze(self.array, axis))
+
+ def numpy(self):
+ return self.array
+
+ calls = []
+
+ def enhance(model, state, tensor, pad):
+ assert pad is True
+ assert tensor.array.shape[1] % 480 == 0
+ calls.append(tensor.array.shape[1])
+ return Tensor(tensor.array * 0.5)
+
+ state = SimpleNamespace(sr=lambda: 48000)
+ backend = SimpleNamespace(enhance=enhance, init_df=lambda *a, **kw: (None, state, None))
+ for name, module in {
+ "torch": SimpleNamespace(set_num_threads=lambda n: None, from_numpy=Tensor,
+ cuda=SimpleNamespace(is_available=lambda: False)),
+ "df": SimpleNamespace(),
+ "df.enhance": backend,
+ "df.model": SimpleNamespace(ModelParams=lambda: SimpleNamespace(
+ sr=48000, fft_size=960, hop_size=480, nb_erb=32, min_nb_freqs=2)),
+ "libdf": SimpleNamespace(DF=lambda **kw: state),
+ }.items():
+ monkeypatch.setitem(sys.modules, name, module)
+ monkeypatch.setattr(ai_worker, "ensure_model", lambda path: path)
+ monkeypatch.setattr(ai_worker.signal, "signal", lambda *args: None)
+ monkeypatch.setenv("DEVICE", "cpu")
+ progress = Mock()
+ monkeypatch.setattr(ai_worker, "report", progress)
+
+ def run(source, target, strength):
+ monkeypatch.setattr(sys, "argv", ["worker", "--device", "cpu", "--model-dir", str(tmp_path),
+ "--source", str(source), "--target", str(target),
+ "--strength", str(strength)])
+ ai_worker.main()
+
+ return run, calls, backend, progress
+
+
+@pytest.mark.parametrize("frames", [0, 1, 479, 481, 480001, 960017])
+@pytest.mark.parametrize("strength", [0, 0.85, 1])
+def test_mocked_worker_exact_length_mix_and_bounded_windows(tmp_path, wav, mocked_worker, frames, strength):
+ run, calls, _, progress = mocked_worker
+ audio = np.random.default_rng(42).normal(0, 0.1, frames).astype("float32")
+ source = wav(audio)
+ target = tmp_path / "clean.wav"
+ run(source, target, strength)
+ result, rate = sf.read(target, dtype="float32")
+ assert len(result) == frames and rate == 48000
+ assert sf.info(target).subtype == "FLOAT"
+ np.testing.assert_allclose(result, audio * (1 - strength * 0.5), rtol=2e-6, atol=1e-8)
+ if strength == 0:
+ np.testing.assert_array_equal(result, audio)
+ assert calls == []
+ assert max(calls, default=0) <= 674400
+ assert progress.call_args.args == ("Denoising (cpu)", frames, frames)
+ assert not list(tmp_path.glob(".voiceforge-ai-*"))
+
+
+def test_worker_failure_preserves_existing_target(tmp_path, wav, mocked_worker):
+ run, _, backend, _ = mocked_worker
+ backend.enhance = Mock(side_effect=RuntimeError("inference failed"))
+ source = wav()
+ target = tmp_path / "existing.wav"
+ target.write_bytes(b"existing output")
+ with pytest.raises(RuntimeError, match="inference failed"):
+ run(source, target, 0.85)
+ assert target.read_bytes() == b"existing output"
+ assert not list(tmp_path.glob(".voiceforge-ai-*"))
diff --git a/lib/project/tests/test_cli.py b/lib/project/tests/test_cli.py
new file mode 100644
index 0000000..a5437b7
--- /dev/null
+++ b/lib/project/tests/test_cli.py
@@ -0,0 +1,287 @@
+import json
+from pathlib import Path
+import sys
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+from voiceforge import cli, pipeline
+
+
+@pytest.fixture
+def invoke(monkeypatch):
+ def run(*args):
+ monkeypatch.setattr(sys, "argv", ["voiceforge", *map(str, args)])
+ return cli.main()
+
+ return run
+
+
+def test_preview_real_audio_preserves_original_and_reports_provenance(
+ wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch
+):
+ time = np.arange(48000 * 7) / 48000
+ source = wav(0.15 * np.sin(2 * np.pi * 440 * time))
+ original = source.read_bytes()
+ output_dir = tmp_path / "previews"
+ modes = []
+ real_run = pipeline.ffmpeg_run
+
+ def record_render(*args, **kwargs):
+ log = real_run(*args, **kwargs)
+ if args[2] == "Mastering (pass 2)":
+ modes.append(json.loads(log[log.rfind("{"):log.rfind("}") + 1])["normalization_type"])
+ return log
+
+ monkeypatch.setattr(pipeline, "ffmpeg_run", record_render)
+ invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "5",
+ "--profiles", "natural", "radio", "--no-normalize", "--no-limiter",
+ "--output-dir", output_dir)
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "Preview:" in captured.err
+ assert source.read_bytes() == original
+ assert len(modes) == 2
+ assert {path.name for path in output_dir.iterdir()} == {
+ f"input.{profile}.preview{suffix}"
+ for profile in ("natural", "radio") for suffix in (".wav", ".report.json")
+ }
+ for profile, mode in zip(("natural", "radio"), modes):
+ target = output_dir / f"input.{profile}.preview.wav"
+ report = json.loads(target.with_suffix(".report.json").read_text())
+ info = sf.info(target)
+ assert info.duration == pytest.approx(5, abs=0.02)
+ assert info.channels == 1
+ assert info.subtype == "PCM_24"
+ assert report["output"] == str(target)
+ assert report["provenance"] == {
+ "original": str(source.resolve()), "preview_start_seconds": 1,
+ "requested_duration_seconds": 5, "normalization_forced": True,
+ }
+ assert not Path(report["source"]).exists()
+ assert report["settings"]["profile"] == profile
+ assert report["settings"]["denoiser"] == "none"
+ assert report["settings"]["normalize"] is True
+ assert report["settings"]["limiter"] is True
+ assert report["settings"]["compressor_ratio"] == {"natural": 2.0, "radio": 3.5}[profile]
+ assert report["normalization_mode"] == mode
+ assert report["master_loudness"]["input_i"] == pytest.approx(-19, abs=0.5)
+ assert report["master_loudness"]["input_tp"] <= -1.4
+
+
+def test_config_stdout_json_and_set_precedence(tmp_path, invoke, capsys):
+ config = tmp_path / "settings.toml"
+ config.write_text('profile = "cleanup-only"\ntarget_lufs = -24.0\n')
+ invoke("config", "--json", "--config", config, "--profile", "narrator",
+ "--target-lufs", "-21", "--set", "target_lufs=-18",
+ "--set", "profile=radio", "--no-denoise")
+ captured = capsys.readouterr()
+ settings = json.loads(captured.out)
+ assert captured.err == ""
+ assert settings["profile"] == "radio"
+ assert settings["compressor_ratio"] == 3.5
+ assert settings["compressor_attack_ms"] == 8
+ assert settings["normalize"] is True
+ assert settings["target_lufs"] == -18
+ assert settings["denoiser"] == "none"
+
+
+def test_analyze_stdout_is_json(wav, ffmpeg, invoke, capsys):
+ source = wav()
+ original = source.read_bytes()
+ invoke("analyze", source, "--no-denoise")
+ stats = json.loads(capsys.readouterr().out)
+ assert stats["duration_seconds"] == pytest.approx(3)
+ assert stats["channels"] == 1
+ assert stats["frames"] == 144000
+ assert np.isfinite(stats["loudness"]["input_i"])
+ assert source.read_bytes() == original
+ assert list(source.parent.iterdir()) == [source]
+
+
+@pytest.mark.parametrize("start", ["nan", "inf", "-inf"])
+def test_preview_rejects_nonfinite_start_before_tool_setup(
+ tmp_path, invoke, capsys, monkeypatch, start
+):
+ monkeypatch.setattr(cli, "ensure_ffmpeg", lambda: pytest.fail("Invalid start reached FFmpeg"))
+ output_dir = tmp_path / "previews"
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", tmp_path / "missing.wav", f"--start={start}",
+ "--output-dir", output_dir)
+ assert error.value.code == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "Preview start" in captured.err
+ assert not output_dir.exists()
+
+
+def test_unknown_setting_is_a_cli_error(invoke, capsys):
+ with pytest.raises(SystemExit) as error:
+ invoke("config", "--json", "--set", "unknown_setting=1")
+ assert error.value.code == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "Unknown setting(s): unknown_setting" in captured.err
+
+
+def test_missing_input_fails_before_tool_setup(tmp_path, invoke, capsys, monkeypatch):
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Missing input reached FFmpeg"))
+ target = tmp_path / "master.wav"
+ with pytest.raises(SystemExit) as error:
+ invoke("process", tmp_path / "missing.wav", "--no-denoise", "-o", target)
+ assert error.value.code == 1
+ assert "existing WAV file" in capsys.readouterr().err
+ assert not target.exists()
+
+
+def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ target = tmp_path / "master.wav"
+ target.write_bytes(b"previous master")
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Existing output reached FFmpeg"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", source, "--no-denoise", "-o", target)
+ assert error.value.code == 1
+ assert "Output exists" in capsys.readouterr().err
+ assert target.read_bytes() == b"previous master"
+ assert source.read_bytes() == original
+ assert not target.with_suffix(".report.json").exists()
+
+
+def test_overwrite_cannot_replace_input(wav, invoke, capsys, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Input collision reached processing"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", source, "-o", source, "--overwrite")
+ assert error.value.code == 1
+ assert "overwrite an input recording" in capsys.readouterr().err
+ assert source.read_bytes() == original
+
+
+def test_duplicate_batch_names_fail_before_processing(wav, tmp_path, invoke, capsys, monkeypatch):
+ (tmp_path / "other").mkdir()
+ sources = [wav(), wav(name="other/input.wav")]
+ originals = [source.read_bytes() for source in sources]
+ output_dir = tmp_path / "masters"
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Duplicate names reached processing"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", *sources, "--output-dir", output_dir, "--overwrite")
+ assert error.value.code == 1
+ assert "duplicate output names" in capsys.readouterr().err
+ assert not output_dir.exists()
+ assert [source.read_bytes() for source in sources] == originals
+
+
+def test_default_outputs_are_written_beside_each_input(wav, tmp_path, ffmpeg, invoke, capsys):
+ (tmp_path / "a").mkdir()
+ (tmp_path / "b").mkdir()
+ sources = [wav(name="a/take.wav"), wav(name="b/take.wav")]
+ invoke("process", *sources, "--no-denoise")
+ for source in sources:
+ assert {path.name for path in source.parent.iterdir()} == {
+ "take.wav", "take.natural.wav", "take.natural.report.json"}
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert captured.err.count("Written:") == 2
+
+
+def test_output_and_output_dir_cannot_be_combined(wav, tmp_path, invoke, capsys):
+ with pytest.raises(SystemExit) as error:
+ invoke("process", wav(), "-o", tmp_path / "x.wav", "--output-dir", tmp_path / "outs")
+ assert error.value.code == 1
+ assert "--output-dir cannot be combined" in capsys.readouterr().err
+
+
+def test_missing_later_input_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch):
+ source = wav()
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", source, tmp_path / "missing.wav", "--no-denoise")
+ assert error.value.code == 1
+ assert "existing WAV file" in capsys.readouterr().err
+ assert not (tmp_path / "input.natural.wav").exists()
+
+
+def test_existing_output_in_batch_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch):
+ (tmp_path / "a").mkdir()
+ (tmp_path / "b").mkdir()
+ sources = [wav(name="a/take.wav"), wav(name="b/take.wav")]
+ (tmp_path / "b" / "take.natural.wav").write_bytes(b"previous result")
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", *sources, "--no-denoise")
+ assert error.value.code == 1
+ assert "Output exists" in capsys.readouterr().err
+ assert not (tmp_path / "a" / "take.natural.wav").exists()
+
+
+def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, capsys, monkeypatch):
+ source = wav()
+ monkeypatch.setattr(cli, "ensure_ffmpeg", lambda: pytest.fail("Invalid preview plan reached FFmpeg"))
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", source, "--no-denoise", "--profiles", "natural", "natural")
+ assert error.value.code == 1
+ assert "unique" in capsys.readouterr().err
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", source, "--no-denoise", "--profiles", "natural",
+ "--set", "profile=radio")
+ assert error.value.code == 1
+ assert "remove the profile override" in capsys.readouterr().err
+ assert list(source.parent.iterdir()) == [source]
+
+
+def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys):
+ source = wav()
+ original = source.read_bytes()
+ invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "2",
+ "--profiles", "natural")
+ assert {path.name for path in tmp_path.iterdir()} == {
+ "input.wav", "input.natural.preview.wav", "input.natural.preview.report.json"}
+ report = json.loads((tmp_path / "input.natural.preview.report.json").read_text())
+ assert report["settings"]["profile"] == "natural"
+ assert source.read_bytes() == original
+
+
+def test_preview_cancellation_cleans_excerpt(wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ output_dir = tmp_path / "previews"
+ excerpts = []
+
+ def cancel(excerpt, *args, **kwargs):
+ assert excerpt.is_file()
+ excerpts.append(excerpt)
+ raise KeyboardInterrupt
+
+ monkeypatch.setattr(cli, "process", cancel)
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", source, "--no-denoise", "--output-dir", output_dir)
+ assert error.value.code == 130
+ assert "Cancelled" in capsys.readouterr().err
+ assert len(excerpts) == 1
+ assert not excerpts[0].exists()
+ assert list(output_dir.iterdir()) == []
+ assert source.read_bytes() == original
+
+
+@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"])
+def test_nonfile_output_with_overwrite_fails_before_tool_setup(
+ wav, tmp_path, bypass, progress, monkeypatch, suffix
+):
+ source = wav()
+ original = source.read_bytes()
+ target = tmp_path / "master.wav"
+ nonfile = target.with_suffix(suffix)
+ nonfile.mkdir()
+ bypass.mp3 = True
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Non-file output reached FFmpeg"))
+ with pytest.raises(ValueError, match="Output is not a regular file"):
+ pipeline.process(source, target, bypass, progress, overwrite=True)
+ assert nonfile.is_dir()
+ assert list(nonfile.iterdir()) == []
+ assert set(tmp_path.iterdir()) == {source, nonfile}
+ assert source.read_bytes() == original
diff --git a/lib/project/tests/test_config.py b/lib/project/tests/test_config.py
new file mode 100644
index 0000000..8144fc8
--- /dev/null
+++ b/lib/project/tests/test_config.py
@@ -0,0 +1,134 @@
+from dataclasses import asdict, fields
+
+import pytest
+
+from voiceforge.cli import main, parser, settings_for
+from voiceforge.config import PROFILES, RANGES, Settings, resolve, toml
+from voiceforge.pipeline import filters
+
+
+@pytest.mark.parametrize("profile", PROFILES)
+def test_profiles_and_toml_round_trip(profile, tmp_path):
+ settings = resolve(profile=profile)
+ assert asdict(settings) == asdict(Settings()) | PROFILES[profile] | {"profile": profile}
+ path = tmp_path / "settings.toml"
+ path.write_text(toml(settings))
+ assert resolve(path) == settings
+
+
+def test_precedence_defaults_profile_file_flags_then_set(tmp_path):
+ path = tmp_path / "settings.toml"
+ path.write_text('profile = "narrator"\ncompressor_ratio = 2.2\nwarmth_db = 3\n')
+ args = parser().parse_args([
+ "config", "--config", str(path), "--profile", "radio",
+ "--warmth-db", "4", "--set", "warmth_db=5", "--set", "warmth_db=6",
+ ])
+ settings = settings_for(args)
+ assert settings.profile == "radio"
+ assert settings.compressor_attack_ms == 8 # Selected profile beats file profile.
+ assert settings.compressor_ratio == 2.2 # File values beat profile defaults.
+ assert settings.warmth_db == 6 # Last --set beats named flags.
+ assert settings.sample_rate == 48000
+
+
+@pytest.mark.parametrize("field", [f.name for f in fields(Settings) if type(getattr(Settings(), f.name)) is bool])
+def test_boolean_flags_preserve_unspecified_and_accept_both_forms(field):
+ assert getattr(parser().parse_args(["config"]), field) is None
+ for enabled in (True, False):
+ flag = "--" + ("" if enabled else "no-") + field.replace("_", "-")
+ extra = ["--limiter"] if field == "normalize" and enabled else []
+ args = parser().parse_args(["config", "--profile", "cleanup-only", flag, *extra])
+ assert getattr(settings_for(args), field) is enabled
+
+
+def test_false_cli_toggle_overrides_true_file_value(tmp_path):
+ path = tmp_path / "settings.toml"
+ path.write_text("eq = true\n")
+ args = parser().parse_args(["config", "--config", str(path), "--no-eq"])
+ assert settings_for(args).eq is False
+
+
+def test_no_denoise_and_unquoted_set_strings():
+ assert settings_for(parser().parse_args(["config", "--no-denoise"])).denoiser == "none"
+ args = parser().parse_args(["config", "--no-denoise", "--set", "denoiser=fft"])
+ assert settings_for(args).denoiser == "fft"
+
+
+@pytest.mark.parametrize("key", RANGES)
+def test_numeric_ranges_are_inclusive_and_reject_outside(key):
+ low, high = RANGES[key]
+ for value in (low, high):
+ assert getattr(resolve(overrides={key: value}), key) == value
+ for value in (low - 0.01, high + 0.01):
+ with pytest.raises(ValueError, match=key):
+ resolve(overrides={key: value})
+
+
+@pytest.mark.parametrize("overrides,match", [
+ ({"typo": True}, "Unknown setting"),
+ ({"highpass": 1}, "highpass must be bool"),
+ ({"sample_rate": 48000.0}, "sample_rate must be int"),
+ ({"target_lufs": True}, "finite number"),
+ ({"target_lufs": float("nan")}, "finite number"),
+ ({"target_lufs": float("inf")}, "finite number"),
+ ({"denoiser": "unknown"}, "denoiser must be one of"),
+ ({"device": "gpu"}, "device must be one of"),
+ ({"channel": "stereo"}, "channel must be one of"),
+ ({"hum_hz": 55}, "hum_hz must be one of"),
+ ({"sample_rate": 96000}, "sample_rate must be one of"),
+ ({"mp3_bitrate": 64}, "mp3_bitrate must be one of"),
+ ({"limiter": False}, "Normalization includes true-peak limiting"),
+])
+def test_invalid_configuration(overrides, match):
+ with pytest.raises(ValueError, match=match):
+ resolve(overrides=overrides)
+
+
+def test_unknown_profile_and_malformed_toml(tmp_path):
+ with pytest.raises(ValueError, match="Unknown profile"):
+ resolve(profile="missing")
+ path = tmp_path / "bad.toml"
+ path.write_text("normalize = [")
+ with pytest.raises(ValueError):
+ resolve(path)
+
+
+def test_invalid_cli_configuration_fails_before_processing(monkeypatch, capsys):
+ def unexpected(*args, **kwargs):
+ pytest.fail("Invalid configuration must not reach processing or tool setup")
+
+ monkeypatch.setattr("voiceforge.cli.process", unexpected)
+ monkeypatch.setattr("voiceforge.cli.ensure_ffmpeg", unexpected)
+ monkeypatch.setattr("sys.argv", ["voiceforge", "process", "missing.wav", "--target-lufs", "0"])
+ with pytest.raises(SystemExit) as error:
+ main()
+ assert error.value.code == 1
+ assert "target_lufs must be between" in capsys.readouterr().err
+
+
+def test_set_requires_assignment():
+ with pytest.raises(ValueError, match="KEY=VALUE"):
+ settings_for(parser().parse_args(["config", "--set", "normalize"]))
+
+
+@pytest.mark.parametrize("profile", ["radio", "not-a-profile"])
+def test_set_profile_uses_normal_profile_resolution(profile):
+ args = parser().parse_args(["config", "--set", f"profile={profile}"])
+ if profile not in PROFILES:
+ with pytest.raises(ValueError, match="Unknown profile"):
+ settings_for(args)
+ else:
+ assert settings_for(args) == resolve(profile=profile)
+
+
+@pytest.mark.parametrize("toggle,prefix,count", [
+ ("expansion", "agate=", 1), ("eq", "equalizer=", 3),
+ ("compression", "acompressor=", 1), ("deess", "deesser=", 1),
+ ("lowpass", "lowpass=", 1),
+])
+def test_shaping_filter_toggles(bypass, toggle, prefix, count):
+ assert filters(bypass) == ["anull"]
+ setattr(bypass, toggle, True)
+ chain = filters(bypass)
+ assert len(chain) == count
+ assert all(item.startswith(prefix) for item in chain)
diff --git a/lib/project/tests/test_install.py b/lib/project/tests/test_install.py
new file mode 100644
index 0000000..f1dfcf2
--- /dev/null
+++ b/lib/project/tests/test_install.py
@@ -0,0 +1,225 @@
+"""Data-dir resolution, contained tool environment, and launcher script checks."""
+import os
+import subprocess
+from pathlib import Path
+import sys
+import types
+
+import pytest
+
+from voiceforge import setup
+
+ROOT = Path(__file__).resolve().parents[3]
+LAUNCHER = ROOT / "producer.sh"
+
+
+@pytest.fixture
+def shell_env(tmp_path):
+ env = os.environ.copy()
+ env.pop("VOICEFORGE_HOME", None)
+ env.pop("XDG_DATA_HOME", None)
+ env["HOME"] = str(tmp_path / "home")
+ return env
+
+
+# --- data_dir resolution ---------------------------------------------------
+
+def test_data_dir_defaults_and_precedence(tmp_path, monkeypatch):
+ monkeypatch.delenv("VOICEFORGE_HOME", raising=False)
+ monkeypatch.delenv("XDG_DATA_HOME", raising=False)
+ monkeypatch.setenv("HOME", str(tmp_path))
+ assert setup.data_dir() == tmp_path / ".local/share/voiceforge"
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg"))
+ assert setup.data_dir() == tmp_path / "xdg/voiceforge"
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ assert setup.data_dir() == tmp_path / "prefix"
+
+
+def test_relative_voiceforge_home_resolves_against_cwd(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", "voiceforge-home")
+ monkeypatch.chdir(tmp_path)
+ assert setup.data_dir() == tmp_path / "voiceforge-home"
+
+
+def test_relative_xdg_data_home_falls_back_to_home(tmp_path, monkeypatch):
+ monkeypatch.delenv("VOICEFORGE_HOME", raising=False)
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("XDG_DATA_HOME", "relative/path")
+ assert setup.data_dir() == tmp_path / ".local/share/voiceforge"
+
+
+# --- contained tool environment --------------------------------------------
+
+def test_tool_env_is_sanitized_and_contained(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ monkeypatch.setenv("PYTHONPATH", "/host/site-packages")
+ monkeypatch.setenv("PYTHONHOME", "/host/python")
+ monkeypatch.setenv("VIRTUAL_ENV", "/host/venv")
+ monkeypatch.setenv("PIP_TARGET", "/host/target")
+ monkeypatch.setenv("PIP_PREFIX", "/host/prefix")
+ monkeypatch.setenv("PIP_USER", "1")
+ monkeypatch.setenv("PIP_FIND_LINKS", "/host/wheels")
+ monkeypatch.setenv("PIP_INDEX_URL", "https://host.example/simple")
+ monkeypatch.setenv("PIP_EXTRA_INDEX_URL", "https://host.example/extra")
+ monkeypatch.setenv("PIP_CONSTRAINT", "/host/constraints.txt")
+ monkeypatch.setenv("UV_INDEX_URL", "https://host.example/simple")
+ monkeypatch.setenv("UV_DEFAULT_INDEX", "https://host.example/simple")
+ monkeypatch.setenv("UV_EXTRA_INDEX_URL", "https://host.example/extra")
+ monkeypatch.setenv("UV_INDEX", "https://host.example/extra")
+ monkeypatch.setenv("UV_FIND_LINKS", "/host/wheels")
+ monkeypatch.setenv("UV_CONSTRAINT", "/host/constraints.txt")
+ monkeypatch.delenv("PIP_CACHE_DIR", raising=False)
+ env = setup.tool_env()
+ prefix = str(tmp_path / "prefix")
+ assert "PYTHONPATH" not in env and "PYTHONHOME" not in env and "VIRTUAL_ENV" not in env
+ for name in ("PIP_TARGET", "PIP_PREFIX", "PIP_USER", "PIP_FIND_LINKS",
+ "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONSTRAINT",
+ "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL",
+ "UV_INDEX", "UV_FIND_LINKS", "UV_CONSTRAINT"):
+ assert name not in env, name
+ assert env["PYTHONNOUSERSITE"] == "1"
+ assert env["PIP_CONFIG_FILE"] == os.devnull
+ assert env["UV_NO_CONFIG"] == "1"
+ assert env["XDG_CACHE_HOME"] == f"{prefix}/cache"
+ assert env["PIP_CACHE_DIR"] == f"{prefix}/cache/pip"
+ assert env["UV_CACHE_DIR"] == f"{prefix}/cache/uv"
+ assert env["UV_PYTHON_INSTALL_DIR"] == f"{prefix}/uv/python"
+ assert env["TMPDIR"] == f"{prefix}/tmp"
+ assert (tmp_path / "prefix/tmp").is_dir()
+
+
+def test_tool_env_tolerates_an_uncreatable_data_dir(monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", "/vf-prefix-unwritable")
+ env = setup.tool_env()
+ assert env["PIP_CACHE_DIR"] == "/vf-prefix-unwritable/cache/pip"
+ assert "TMPDIR" not in env
+
+
+def test_tool_env_leaves_process_environment_untouched(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ monkeypatch.setenv("PYTHONPATH", "/host/site-packages")
+ setup.tool_env()
+ assert os.environ["PYTHONPATH"] == "/host/site-packages"
+
+
+def test_ensure_uv_bootstraps_into_data_dir_with_contained_pip_cache(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ monkeypatch.setattr(setup.shutil, "which", lambda name: None)
+ calls = []
+ uv_binary = tmp_path / "prefix/bootstrap/bin/uv"
+
+ def fake_run(args, stage, progress=None, *, env=None):
+ calls.append((args, env))
+ # Simulate pip actually producing the pinned uv.
+ if stage == "Installing uv from PyPI":
+ uv_binary.parent.mkdir(parents=True, exist_ok=True)
+ uv_binary.write_text("#!/bin/sh\n")
+ uv_binary.chmod(0o755)
+
+ monkeypatch.setattr(setup, "run_process", fake_run)
+ uv = setup.ensure_uv()
+ assert uv == str(uv_binary)
+ venv_args, venv_env = calls[0]
+ pip_args, pip_env = calls[1]
+ assert venv_args[-1] == str(tmp_path / "prefix/bootstrap")
+ assert pip_env["PIP_CACHE_DIR"] == str(tmp_path / "prefix/cache/pip")
+ assert pip_env["UV_CACHE_DIR"] == str(tmp_path / "prefix/cache/uv")
+ assert pip_env["PIP_CONFIG_FILE"] == os.devnull
+ assert "PYTHONPATH" not in pip_env
+ assert "uv==0.8.17" in pip_args
+
+
+def test_ensure_uv_reuses_contained_bootstrap(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ uv_binary = tmp_path / "prefix/bootstrap/bin/uv"
+ uv_binary.parent.mkdir(parents=True)
+ uv_binary.write_text("#!/bin/sh\n")
+ uv_binary.chmod(0o755)
+ monkeypatch.setattr(setup.shutil, "which", lambda name: None)
+ assert setup.ensure_uv() == str(uv_binary)
+
+
+def test_ensure_uv_prefers_the_contained_bootstrap_over_a_host_uv(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ uv_binary = tmp_path / "prefix/bootstrap/bin/uv"
+ uv_binary.parent.mkdir(parents=True)
+ uv_binary.write_text("#!/bin/sh\n")
+ uv_binary.chmod(0o755)
+ monkeypatch.setattr(setup.shutil, "which", lambda name: "/host/bin/uv")
+ assert setup.ensure_uv() == str(uv_binary)
+
+
+def test_ensure_uv_ignores_a_host_uv_without_required_capability(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ monkeypatch.setattr(setup.shutil, "which", lambda name: "/host/bin/uv")
+ monkeypatch.setattr(setup, "_uv_supports_relocatable", lambda uv: False)
+ stages = []
+ uv_binary = tmp_path / "prefix/bootstrap/bin/uv"
+
+ def fake_run(args, stage, progress=None, *, env=None):
+ stages.append(stage)
+ if stage == "Installing uv from PyPI":
+ uv_binary.parent.mkdir(parents=True, exist_ok=True)
+ uv_binary.write_text("#!/bin/sh\n")
+ uv_binary.chmod(0o755)
+
+ monkeypatch.setattr(setup, "run_process", fake_run)
+ assert setup.ensure_uv() == str(uv_binary)
+ assert stages == ["Creating uv bootstrap", "Installing uv from PyPI"]
+
+
+def test_ensure_uv_fails_when_bootstrap_does_not_produce_uv(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOICEFORGE_HOME", str(tmp_path / "prefix"))
+ monkeypatch.setattr(setup.shutil, "which", lambda name: None)
+ monkeypatch.setattr(setup, "run_process", lambda *args, **kwargs: None)
+ with pytest.raises(RuntimeError, match="did not produce a usable uv"):
+ setup.ensure_uv()
+
+
+# --- FFmpeg selection ---------------------------------------------------------
+
+def test_ensure_ffmpeg_prefers_the_bundled_binary_over_host(tmp_path, monkeypatch):
+ bundled = tmp_path / "bundled/ffmpeg"
+ bundled.parent.mkdir(parents=True)
+ bundled.write_text("#!/bin/sh\n")
+ monkeypatch.setitem(sys.modules, "imageio_ffmpeg",
+ types.SimpleNamespace(get_ffmpeg_exe=lambda: str(bundled)))
+ monkeypatch.setattr(setup.shutil, "which", lambda name: "/usr/bin/ffmpeg")
+ assert setup.ensure_ffmpeg() == str(bundled)
+
+
+def test_ensure_ffmpeg_falls_back_to_system_without_the_bundle(monkeypatch):
+ monkeypatch.setitem(sys.modules, "imageio_ffmpeg", None)
+ monkeypatch.setattr(setup.shutil, "which", lambda name: "/usr/bin/ffmpeg")
+ assert setup.ensure_ffmpeg() == "/usr/bin/ffmpeg"
+
+
+def test_ensure_ffmpeg_error_points_at_the_launcher(monkeypatch):
+ monkeypatch.setitem(sys.modules, "imageio_ffmpeg", None)
+ monkeypatch.setattr(setup.shutil, "which", lambda name: None)
+ with pytest.raises(RuntimeError, match=r"\./producer\.sh --rebuild") as error:
+ setup.ensure_ffmpeg()
+ assert "install.sh" not in str(error.value)
+
+
+# --- producer.sh launcher ----------------------------------------------------
+
+@pytest.mark.parametrize("argv", [["--help"], ["--rebuild", "--help"]])
+def test_launcher_forwards_help_without_bootstrapping(argv, shell_env, tmp_path, monkeypatch):
+ # A missing lib/ must not be created by a pure help request.
+ staging = tmp_path / "checkout"
+ staging.mkdir()
+ (staging / "producer.sh").write_text(LAUNCHER.read_text())
+ (staging / "producer.sh").chmod(0o755)
+ monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ result = subprocess.run(["./producer.sh", *argv], cwd=staging, env=shell_env,
+ capture_output=True, text=True, timeout=30)
+ assert result.returncode == 0
+ assert "process" in result.stdout
+ assert not (staging / "lib").exists()
+
+
+def test_launcher_script_parses():
+ result = subprocess.run(["bash", "-n", str(LAUNCHER)],
+ capture_output=True, text=True, timeout=30)
+ assert result.returncode == 0, result.stderr
diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py
new file mode 100644
index 0000000..0fa1c52
--- /dev/null
+++ b/lib/project/tests/test_pipeline.py
@@ -0,0 +1,273 @@
+import json
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+from voiceforge import pipeline
+from voiceforge.audio import loudness, measure
+from voiceforge.config import resolve
+
+
+@pytest.mark.parametrize("kind", ["missing", "input-extension", "output-extension"])
+def test_invalid_paths_fail_before_tool_setup(wav, tmp_path, bypass, progress, monkeypatch, kind):
+ source = wav(name="input.txt" if kind == "input-extension" else "input.wav")
+ if kind == "missing":
+ source = tmp_path / "missing.wav"
+ target = tmp_path / ("master.mp3" if kind == "output-extension" else "master.wav")
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Invalid paths must fail preflight"))
+ with pytest.raises(ValueError, match="WAV file|.wav extension"):
+ pipeline.process(source, target, bypass, progress)
+ assert not target.exists()
+
+
+@pytest.mark.parametrize("alias", ["same", "symlink", "hardlink"])
+@pytest.mark.parametrize("overwrite", [False, True])
+def test_original_is_never_overwritten(wav, tmp_path, bypass, progress, monkeypatch, alias, overwrite):
+ source = wav()
+ original = source.read_bytes()
+ target = source if alias == "same" else tmp_path / "alias.wav"
+ if alias == "symlink":
+ target.symlink_to(source)
+ elif alias == "hardlink":
+ target.hardlink_to(source)
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Preflight must reject the input"))
+ with pytest.raises(ValueError, match="original recording"):
+ pipeline.process(source, target, bypass, progress, overwrite=overwrite)
+ assert source.read_bytes() == original
+
+
+@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"])
+def test_existing_output_or_sidecar_rejected_before_processing(wav, tmp_path, bypass, progress, monkeypatch, suffix):
+ source = wav()
+ target = tmp_path / "master.wav"
+ existing = target.with_suffix(suffix)
+ existing.write_bytes(b"existing output")
+ bypass.mp3 = True
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Preflight must reject existing outputs"))
+ with pytest.raises(FileExistsError, match="Output exists"):
+ pipeline.process(source, target, bypass, progress)
+ assert existing.read_bytes() == b"existing output"
+
+
+@pytest.mark.parametrize("kind,message", [
+ ("empty", "empty"), ("silence", "silent or too quiet"),
+ ("quiet", "silent or too quiet"), ("surround", "Only mono or stereo"),
+ ("nonfinite", "non-finite"), ("stereo", "select --channel"),
+ ("mono-right", "right channel of a mono"),
+])
+def test_unusable_inputs_leave_no_outputs(wav, tmp_path, bypass, progress, ffmpeg, kind, message):
+ samples = {
+ "empty": np.empty(0), "silence": np.zeros(48000),
+ "quiet": np.full(48000, 1e-6), "surround": np.ones((4800, 3)) * 0.1,
+ "nonfinite": np.array([0.1, np.nan]), "stereo": np.ones((4800, 2)) * 0.1,
+ "mono-right": np.ones(4800) * 0.1,
+ }[kind]
+ source = wav(samples)
+ target = tmp_path / "outputs" / "master.wav"
+ if kind == "mono-right":
+ bypass.channel = "right"
+ with pytest.raises(ValueError, match=message):
+ pipeline.process(source, target, bypass, progress)
+ assert not target.parent.exists()
+
+
+def test_measure_reports_channels_peaks_clipping_and_progress(wav):
+ samples = np.tile([1.0, -0.25], (4800, 1))
+ events = []
+ result = measure(wav(samples), lambda *event: events.append(event))
+ assert result["duration_seconds"] == 0.1
+ assert result["channels"] == 2
+ assert result["sample_peak_dbfs"] == 0
+ assert result["channel_rms_dbfs"] == pytest.approx([0, 20 * np.log10(0.25)])
+ assert result["dc_offset"] == pytest.approx([1, -0.25])
+ assert result["near_full_scale_samples"] == 4800
+ assert events[0][1:] == (0, 4800)
+ assert events[-1][1:] == (4800, 4800)
+
+
+@pytest.mark.parametrize("channel", ["left", "right", "mix"])
+def test_explicit_stereo_selection_preserves_signal_and_duration(wav, tmp_path, bypass, progress, ffmpeg, channel):
+ time = np.arange(48000 * 3) / 48000
+ left = 0.2 * np.sin(2 * np.pi * 440 * time)
+ right = 0.1 * np.sin(2 * np.pi * 880 * time)
+ source = wav(np.column_stack([left, right]))
+ bypass.channel = channel
+ target = tmp_path / "master.wav"
+ report = pipeline.process(source, target, bypass, progress)
+ actual, rate = sf.read(target)
+ expected = {"left": left, "right": right, "mix": (left + right) / 2}[channel]
+ assert actual.ndim == 1
+ assert rate == 48000
+ assert len(actual) == len(expected)
+ np.testing.assert_allclose(actual, expected, atol=5e-7, rtol=0)
+ assert report["normalization_mode"] == "disabled"
+ assert report["level_correction_db"] == 0
+ assert sf.info(target).subtype == "PCM_24"
+
+
+@pytest.mark.parametrize("rate", [44100, 48000])
+def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, rate):
+ source = wav()
+ original = source.read_bytes()
+ target = tmp_path / "nested" / "master.wav"
+ settings = resolve(overrides={"denoiser": "none", "sample_rate": rate})
+ report = pipeline.process(source, target, settings, progress)
+ stats = sf.info(target)
+ assert stats.channels == 1
+ assert stats.samplerate == rate
+ assert stats.subtype == "PCM_24"
+ assert abs(stats.duration - 3) <= 0.02
+ verified = loudness(ffmpeg, target, settings, stats.duration, progress)
+ assert verified["input_i"] == pytest.approx(settings.target_lufs, abs=0.5)
+ assert verified["input_tp"] <= settings.true_peak_db + 0.1
+ assert report["master"]["near_full_scale_samples"] == 0
+ assert report["master"]["sample_peak_dbfs"] <= settings.true_peak_db + 0.1
+ assert json.loads(target.with_suffix(".report.json").read_text()) == report
+ assert source.read_bytes() == original
+ assert not list(target.parent.glob(".voiceforge-*"))
+
+
+def test_limiter_without_normalization(wav, tmp_path, bypass, progress, ffmpeg):
+ time = np.arange(48000 * 3) / 48000
+ source = wav(0.99 * np.sin(2 * np.pi * 440 * time))
+ bypass.limiter = True
+ bypass.true_peak_db = -6
+ report = pipeline.process(source, tmp_path / "master.wav", bypass, progress)
+ assert report["normalization_mode"] == "disabled"
+ assert report["master_loudness"]["input_tp"] <= -5.9
+ assert report["master"]["sample_peak_dbfs"] > -7
+
+
+def test_speech_estimate_survives_pause_dominated_recordings(wav):
+ time = np.arange(48000 * 3) / 48000
+ audio = np.concatenate([np.zeros(48000 * 27), 0.02 * np.sin(2 * np.pi * 440 * time)])
+ stats = measure(wav(audio), lambda *_: None)
+ assert stats["quiet_blocks_dbfs"] <= -110
+ assert stats["speech_level_estimate_dbfs"] == pytest.approx(-37, abs=2)
+
+
+def test_leveling_uses_active_speech_not_pauses(wav, tmp_path, progress, ffmpeg):
+ time = np.arange(48000 * 3) / 48000
+ audio = np.concatenate([np.zeros(48000 * 27), 0.02 * np.sin(2 * np.pi * 440 * time)])
+ source = wav(audio)
+ settings = resolve(profile="cleanup-only", overrides={"denoiser": "none", "leveling": True})
+ report = pipeline.process(source, tmp_path / "master.wav", settings, progress)
+ assert 10 < report["level_correction_db"] < settings.max_gain_db
+ assert not any("gain limit" in warning for warning in report["warnings"])
+
+
+def test_leveling_skipped_when_speech_estimate_is_unreliable(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+ real_measure = pipeline.measure
+
+ def measured(path, callback, stage="Analyzing audio"):
+ stats = real_measure(path, callback, stage)
+ if stage == "Measuring cleaned voice":
+ stats["speech_level_estimate_dbfs"] = -120
+ return stats
+
+ monkeypatch.setattr(pipeline, "measure", measured)
+ bypass.leveling = True
+ report = pipeline.process(wav(), tmp_path / "master.wav", bypass, progress)
+ assert report["level_correction_db"] == 0
+ assert any("leveling skipped" in warning for warning in report["warnings"])
+
+
+def test_antiphase_mix_is_rejected_after_selection(wav, tmp_path, bypass, progress, ffmpeg):
+ time = np.arange(48000 * 3) / 48000
+ tone = 0.2 * np.sin(2 * np.pi * 440 * time)
+ source = wav(np.column_stack([tone, -tone]))
+ bypass.channel = "mix"
+ target = tmp_path / "master.wav"
+ with pytest.raises(ValueError, match="selected audio is silent or too quiet"):
+ pipeline.process(source, target, bypass, progress)
+ assert not target.exists()
+ assert not target.with_suffix(".report.json").exists()
+ assert not list(tmp_path.glob(".voiceforge-*"))
+
+
+def test_silent_selected_channel_is_rejected_after_selection(wav, tmp_path, bypass, progress, ffmpeg):
+ time = np.arange(48000 * 3) / 48000
+ tone = 0.2 * np.sin(2 * np.pi * 440 * time)
+ source = wav(np.column_stack([tone, np.zeros_like(tone)]))
+ bypass.channel = "right"
+ target = tmp_path / "master.wav"
+ with pytest.raises(ValueError, match="selected audio is silent or too quiet"):
+ pipeline.process(source, target, bypass, progress)
+ assert not target.exists()
+
+
+def test_zero_denoise_strength_skips_ai(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+ def unexpected(*args, **kwargs):
+ pytest.fail("Zero denoise strength must not invoke AI")
+
+ monkeypatch.setattr("voiceforge.ai.denoise", unexpected)
+ bypass.denoiser = "deepfilter"
+ bypass.denoise_strength = 0
+ report = pipeline.process(wav(), tmp_path / "master.wav", bypass, progress)
+ assert report["cleaned"]["frames"] == report["input"]["frames"]
+
+
+def test_fft_and_mp3_export_can_replace_generated_outputs(wav, tmp_path, bypass, progress, ffmpeg):
+ source = wav()
+ original = source.read_bytes()
+ bypass.denoiser = "fft"
+ bypass.mp3 = True
+ target = tmp_path / "master.wav"
+ for suffix in (".wav", ".report.json", ".mp3"):
+ target.with_suffix(suffix).write_bytes(b"old output")
+ events = []
+ report = pipeline.process(source, target, bypass, lambda *event: events.append(event), overwrite=True)
+ assert any(event[0] == "FFT noise reduction" for event in events)
+ assert target.with_suffix(".mp3").stat().st_size > 1000
+ assert report["mp3_loudness"]["input_i"] is not None
+ assert json.loads(target.with_suffix(".report.json").read_text()) == report
+ assert sf.info(target).channels == 1
+ assert source.read_bytes() == original
+
+
+def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+ source = wav()
+ target = tmp_path / "master.wav"
+ bypass.mp3 = True
+ outputs = [target.with_suffix(suffix) for suffix in (".wav", ".report.json", ".mp3")]
+ for path in outputs:
+ path.write_bytes(b"previous publication")
+ real_run = pipeline.ffmpeg_run
+
+ def fail_encoding(executable, args, stage, duration, callback):
+ if stage == "Encoding MP3":
+ raise RuntimeError("injected encoder failure")
+ return real_run(executable, args, stage, duration, callback)
+
+ monkeypatch.setattr(pipeline, "ffmpeg_run", fail_encoding)
+ with pytest.raises(RuntimeError, match="injected encoder failure"):
+ pipeline.process(source, target, bypass, progress, overwrite=True)
+ assert all(path.read_bytes() == b"previous publication" for path in outputs)
+ assert not list(tmp_path.glob(".voiceforge-*"))
+
+
+@pytest.mark.parametrize("overwrite", [False, True])
+def test_publish_collision_respects_overwrite(tmp_path, overwrite):
+ staged = tmp_path / "staged"
+ target = tmp_path / "target"
+ staged.write_bytes(b"new")
+ target.write_bytes(b"concurrent output")
+ if overwrite:
+ pipeline.publish(staged, target, overwrite=True)
+ assert target.read_bytes() == b"new"
+ assert not staged.exists()
+ else:
+ with pytest.raises(FileExistsError):
+ pipeline.publish(staged, target, overwrite=False)
+ assert target.read_bytes() == b"concurrent output"
+ assert staged.read_bytes() == b"new"
+
+
+def test_publish_new_output_moves_staged_file(tmp_path):
+ staged = tmp_path / "staged"
+ target = tmp_path / "target"
+ staged.write_bytes(b"finished")
+ pipeline.publish(staged, target, overwrite=False)
+ assert target.read_bytes() == b"finished"
+ assert not staged.exists()
diff --git a/lib/pytest.ini b/lib/pytest.ini
deleted file mode 100644
index 6b66778..0000000
--- a/lib/pytest.ini
+++ /dev/null
@@ -1,3 +0,0 @@
-[pytest]
-markers =
- slow: engine integration tests (need torch/model deps)
diff --git a/lib/requirements-core.txt b/lib/requirements-core.txt
deleted file mode 100644
index bb55492..0000000
--- a/lib/requirements-core.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-numpy==1.26.4
-scipy>=1.11,<1.15
-soundfile>=0.12,<0.13
-pyloudnorm==0.2.0
diff --git a/lib/ruff.toml b/lib/ruff.toml
deleted file mode 100644
index c1a0a5e..0000000
--- a/lib/ruff.toml
+++ /dev/null
@@ -1,12 +0,0 @@
-line-length = 100
-target-version = "py310"
-
-[lint]
-select = ["E", "F", "W", "I", "UP", "B", "SIM", "NPY", "RUF"]
-ignore = ["E501"]
-
-[lint.isort]
-known-first-party = ["producer"]
-
-[format]
-quote-style = "double"
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
diff --git a/lib/tests/conftest.py b/lib/tests/conftest.py
deleted file mode 100644
index 885d22b..0000000
--- a/lib/tests/conftest.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from __future__ import annotations
-
-import sys
-from pathlib import Path
-
-import numpy as np
-import pytest
-
-SRC = Path(__file__).resolve().parents[1] / "src"
-sys.path.insert(0, str(SRC))
-sys.path.insert(0, str(Path(__file__).resolve().parent))
-
-SR = 44100
-
-
-def speechish(
- dur: float,
- sr: int = SR,
- level_dbfs: float = -20.0,
- seed: int = 0,
-) -> np.ndarray:
- rng = np.random.default_rng(seed)
- n = int(sr * dur)
- t = np.arange(n) / sr
- f0 = 110.0 * (1.0 + 0.02 * np.sin(2 * np.pi * 0.9 * t))
- phase = 2 * np.pi * np.cumsum(f0) / sr
- x = np.zeros(n)
- for k in range(1, 9):
- x += (1.0 / k**1.3) * np.sin(k * phase + 0.3 * k)
- syll = 0.5 + 0.5 * np.sin(2 * np.pi * 3.0 * t + float(rng.uniform(0, 6)))
- pauses = (np.sin(2 * np.pi * 0.5 * t) > -0.6).astype(float)
- env = np.clip(syll, 0.02, 1.0) ** 0.6 * np.maximum(pauses, 0.05)
- x = x * env
- x /= np.max(np.abs(x)) + 1e-12
- return (x * (10 ** (level_dbfs / 20.0))).astype(np.float32)
-
-
-def sine(freq: float, dur: float, sr: int = SR, peak_dbfs: float = -20.0) -> np.ndarray:
- t = np.arange(int(sr * dur)) / sr
- return (10 ** (peak_dbfs / 20.0) * np.sin(2 * np.pi * freq * t)).astype(np.float32)
-
-
-def band_db(x: np.ndarray, sr: int, lo: float, hi: float) -> float:
- from scipy import signal
-
- sos = signal.butter(4, [lo, hi], btype="bandpass", fs=sr, output="sos")
- y = signal.sosfilt(sos, x.astype(np.float64))
- r = np.sqrt(np.mean(np.square(y)))
- if r <= 0:
- return -120.0
- return float(20 * np.log10(r))
-
-
-@pytest.fixture
-def sr() -> int:
- return SR
-
-
-@pytest.fixture
-def speech() -> np.ndarray:
- return speechish(6.0, level_dbfs=-20.0)
-
-
-@pytest.fixture
-def noisy_speech(sr, speech) -> np.ndarray:
- rng = np.random.default_rng(7)
- noise = rng.standard_normal(speech.size)
- noise *= (10 ** (-48.0 / 20.0)) / np.sqrt(np.mean(np.square(noise)))
- hum = 0.003 * np.sin(2 * np.pi * 50.0 * np.arange(speech.size) / sr)
- return (speech + noise + hum).astype(np.float32)
diff --git a/lib/tests/test_chunking.py b/lib/tests/test_chunking.py
deleted file mode 100644
index f00c04e..0000000
--- a/lib/tests/test_chunking.py
+++ /dev/null
@@ -1,250 +0,0 @@
-import numpy as np
-import pytest
-
-from producer.engines.base import blend
-from producer.engines.chunking import apply_chunked, plan_chunks, stitch
-
-
-def test_plan_chunks_basic():
- assert plan_chunks(0, 5, 2) == []
- assert plan_chunks(4, 10, 2) == [(0, 4)]
- assert plan_chunks(10, 5, 0) == [(0, 5), (5, 10)]
- assert plan_chunks(10, 5, 2) == [(0, 5), (3, 8), (6, 10)]
- assert plan_chunks(7, 5, 2) == [(0, 5), (3, 7)]
-
-
-def test_plan_chunks_last_span_covers_tail():
- spans = plan_chunks(11, 5, 4)
- assert spans[-1][1] == 11
- assert spans[-1][1] - spans[-1][0] >= 5
-
-
-def test_plan_chunks_rejects_overlap_ge_chunk():
- with pytest.raises(ValueError):
- plan_chunks(100, 5, 5)
- with pytest.raises(ValueError):
- plan_chunks(100, 5, 6)
-
-
-def test_stitch_identity_pieces_reconstruct_signal():
- rng = np.random.default_rng(0)
- x = rng.standard_normal(10_000).astype(np.float32)
- spans = plan_chunks(x.size, 2500, 300)
- y = stitch(spans, x.size, (x[a:b] for a, b in spans))
- assert y.dtype == np.float32
- assert y.shape == x.shape
- np.testing.assert_allclose(y, x, atol=1e-5)
-
-
-def test_stitch_single_span_passthrough():
- x = np.arange(100, dtype=np.float32)
- y = stitch([(0, 100)], 100, [x])
- np.testing.assert_array_equal(y, x)
-
-
-def test_stitch_crossfade_of_complementary_pieces():
- # piece A is silence, piece B is a full-scale ramp; the crossfade zone
- # must be a smooth blend, not a jump.
- spans = [(0, 10), (5, 15)]
- pieces = [np.zeros(10, dtype=np.float32), np.ones(10, dtype=np.float32)]
- y = stitch(spans, 15, pieces)
- mid = y[7] # 50% through the overlap
- assert 0.4 < mid < 0.6
- assert y[0] == 0.0
- assert y[14] == 1.0
-
-
-def test_stitch_pads_short_pieces():
- spans = [(0, 10), (5, 15)]
- pieces = [np.arange(10, dtype=np.float32), np.arange(8, dtype=np.float32)]
- y = stitch(spans, 15, pieces)
- assert y.shape == (15,)
- assert np.all(np.isfinite(y))
-
-
-def test_apply_chunked_identity_matches_whole_file():
- rng = np.random.default_rng(1)
- x = rng.standard_normal(50_000).astype(np.float32)
- sr = 8000
- calls = []
-
- def fn(chunk):
- calls.append(chunk.size)
- return chunk
-
- y = apply_chunked(x, sr, 7.0, 0.05, fn)
- assert calls == [50_000]
- np.testing.assert_array_equal(y, x)
-
- calls.clear()
- y = apply_chunked(x, sr, 0.5, 0.05, fn)
- assert len(calls) >= 10
- assert all(c <= 4000 for c in calls)
- np.testing.assert_allclose(y, x, atol=1e-5)
-
-
-def test_apply_chunked_zero_disables_chunking():
- x = np.zeros(10, dtype=np.float32)
- calls = []
-
- def fn(chunk):
- calls.append(chunk.size)
- return chunk
-
- apply_chunked(x, 8000, 0.0, 0.5, fn)
- assert calls == [10]
-
-
-def test_apply_chunked_context_reconstructs_identity():
- rng = np.random.default_rng(3)
- x = rng.standard_normal(30_000).astype(np.float32)
- y = apply_chunked(x, 8000, 1.5, 0.25, lambda c: c, context_s=0.5)
- np.testing.assert_allclose(y, x, atol=1e-5)
-
-
-def test_apply_chunked_context_feeds_padded_chunks():
- x = np.ones(5_000, dtype=np.float32)
- sizes = []
-
- def fn(chunk):
- sizes.append(chunk.size)
- return chunk
-
- apply_chunked(x, 1000, 2.0, 0.0, fn, context_s=0.5)
- # plan_chunks(5000, 2000, 0) -> (0,2000), (2000,4000), (4000,5000)
- assert sizes == [2500, 3000, 1500]
-
-
-def test_apply_chunked_context_trims_cold_start_artifacts():
- x = np.ones(6_000, dtype=np.float32)
-
- def fn(chunk):
- out = chunk.copy()
- out[0] = 0.0 # cold-start artifact at the start of every model call
- return out
-
- broken = apply_chunked(x, 1000, 2.0, 0.0, fn)
- assert int(np.sum(broken == 0.0)) > 1
-
- y = apply_chunked(x, 1000, 2.0, 0.0, fn, context_s=0.5)
- assert y[0] == 0.0 # only the true file start stays degraded
- assert np.all(y[1:] == 1.0)
-
-
-def test_apply_chunked_context_survives_oom_retry():
- x = np.ones(8_000, dtype=np.float32)
- attempts = []
-
- def fn(chunk):
- attempts.append(chunk.size)
- if chunk.size > 3000:
- raise MemoryError("simulated oom")
- out = chunk.copy()
- out[0] = 0.0
- return out
-
- y = apply_chunked(x, 1000, 6.0, 0.0, fn, min_chunk_s=1.0, context_s=0.5)
- assert max(attempts) > 3000
- assert y[0] == 0.0
- assert np.all(y[1:] == 1.0)
-
-
-def test_apply_chunked_retries_smaller_on_oom():
- x = np.random.default_rng(2).standard_normal(40_000).astype(np.float32)
- sr = 8000
- attempts = []
-
- def fn(chunk):
- attempts.append(chunk.size)
- if chunk.size > 3000:
- raise MemoryError("simulated oom")
- return chunk
-
- y = apply_chunked(x, sr, 5.0, 0.0, fn, min_chunk_s=0.25)
- assert attempts[0] == 40_000
- assert attempts[-1] == 2500
- assert max(a for a in attempts if a <= 3000) == 2500
- np.testing.assert_allclose(y, x, atol=1e-5)
-
-
-def test_apply_chunked_oom_gives_up_at_min_chunk():
- x = np.zeros(40_000, dtype=np.float32)
-
- def fn(chunk):
- raise MemoryError("always")
-
- with pytest.raises(MemoryError):
- apply_chunked(x, 8000, 1.0, 0.0, fn, min_chunk_s=0.5)
-
-
-def test_apply_chunked_progress_callback():
- x = np.random.default_rng(3).standard_normal(20_000).astype(np.float32)
- sr = 8000
- seen: list[tuple[int, int]] = []
- y = apply_chunked(x, sr, 0.5, 0.0, lambda c: c, on_progress=lambda d, t: seen.append((d, t)))
- total = len(plan_chunks(x.size, round(0.5 * sr), 0))
- assert seen[0] == (0, total)
- assert seen[-1] == (total, total)
- assert [d for d, _t in seen[1:]] == list(range(1, total + 1))
- np.testing.assert_allclose(y, x, atol=1e-6)
-
-
-def test_apply_chunked_progress_resets_after_oom_retry():
- x = np.random.default_rng(4).standard_normal(40_000).astype(np.float32)
- sr = 8000
- seen: list[tuple[int, int]] = []
-
- def fn(chunk):
- if chunk.size > 3000:
- raise MemoryError("simulated oom")
- return chunk
-
- y = apply_chunked(
- x, sr, 5.0, 0.0, fn, min_chunk_s=0.25, on_progress=lambda d, t: seen.append((d, t))
- )
- assert seen[0] == (0, 1) # first attempt is one whole-file chunk
- done, total = seen[-1]
- assert done == total and total > 2 # retried into smaller chunks
- np.testing.assert_allclose(y, x, atol=1e-5)
-
-
-def test_apply_chunked_progress_absent_when_disabled():
- x = np.zeros(10_000, dtype=np.float32)
- y = apply_chunked(x, 8000, 0.5, 0.0, lambda c: c, on_progress=None)
- assert y.shape == x.shape
-
-
-def test_apply_chunked_whole_file_mode_falls_back_on_oom():
- x = np.random.default_rng(5).standard_normal(10_000).astype(np.float32)
- sr = 1000
-
- def fn(chunk):
- if chunk.size > 1500:
- raise MemoryError("simulated oom")
- return chunk * 2
-
- y = apply_chunked(x, sr, 0.0, 0.0, fn, min_chunk_s=0.5)
- np.testing.assert_allclose(y, x * 2, atol=1e-6)
-
-
-def test_blend_float32_no_float64_temporaries():
- x = np.full(1000, 0.25, dtype=np.float32)
- y = np.full(1000, 0.75, dtype=np.float32)
- out = blend(x, y, 0.5)
- assert out.dtype == np.float32
- np.testing.assert_allclose(out, 0.5, atol=1e-7)
- assert blend(x, y, 1.0) is y
- np.testing.assert_allclose(blend(x, y, 0.0), x, atol=1e-7)
-
-
-def test_blend_realigns_resample_drift():
- # a 48k -> 16k -> 48k round trip can come back a sample or two long
- # (resample_poly emits ceil(n * up/down) per hop); blend must cope
- x = np.ones(5, dtype=np.float32)
- long_y = np.full(7, 0.5, dtype=np.float32)
- np.testing.assert_allclose(blend(x, long_y, 0.5), np.full(5, 0.75), atol=1e-7)
-
- short_y = np.full(4, 0.5, dtype=np.float32)
- out = blend(x, short_y, 0.5)
- assert out.shape == x.shape
- np.testing.assert_allclose(out, [0.75, 0.75, 0.75, 0.75, 0.5], atol=1e-7)
diff --git a/lib/tests/test_cli.py b/lib/tests/test_cli.py
deleted file mode 100644
index 76952ba..0000000
--- a/lib/tests/test_cli.py
+++ /dev/null
@@ -1,357 +0,0 @@
-import sys
-from types import SimpleNamespace
-
-import numpy as np
-import pytest
-import soundfile as sf
-from conftest import speechish
-
-from producer import io as pio
-from producer.cli import _apply_args, build_parser, process_one
-from producer.config import Options
-
-
-def _mk_wav(tmp_path, name="in.wav", stereo=False):
- x = speechish(3.0, level_dbfs=-30.0)
- if stereo:
- data = np.stack([x, x * 0.5], axis=1)
- sf.write(str(tmp_path / name), data, 44100, subtype="PCM_16")
- else:
- sf.write(str(tmp_path / name), x, 44100, subtype="PCM_16")
- return tmp_path / name
-
-
-def _opts(**kw):
- opts = Options()
- opts.denoise = "off"
- opts.enhance = "off"
- for k, v in kw.items():
- setattr(opts, k, v)
- return opts
-
-
-def test_decode_stereo_mixdown(tmp_path):
- p = _mk_wav(tmp_path, "st.wav", stereo=True)
- x, sr = pio.decode(p)
- assert sr == 44100
- assert x.dtype.name == "float32"
- assert x.ndim == 1
-
-
-def test_encode_wav_bitdepths(tmp_path):
- x = speechish(2.0, level_dbfs=-20.0)
- for depth in (16, 24, 32):
- out = tmp_path / f"o{depth}.wav"
- pio.encode(x, 44100, out, "wav", depth)
- y, sr = pio.decode(out)
- assert sr == 44100
- assert (
- abs(
- float(np.sqrt(np.mean(y.astype(np.float64) ** 2)))
- - float(np.sqrt(np.mean(x.astype(np.float64) ** 2)))
- )
- < 1e-3
- )
-
-
-def test_encode_flac(tmp_path):
- x = speechish(2.0, level_dbfs=-20.0)
- out = tmp_path / "o.flac"
- pio.encode(x, 44100, out, "flac", 24)
- y, sr = pio.decode(out)
- assert sr == 44100
- assert np.corrcoef(x, y)[0, 1] > 0.999
-
-
-def test_mp3_roundtrip(tmp_path):
- import pytest as _pt
-
- if not pio.ffmpeg_available():
- _pt.skip("ffmpeg missing")
- x = speechish(4.0, level_dbfs=-20.0)
- out = tmp_path / "o.mp3"
- pio.encode(x, 44100, out, "mp3", 16)
- y, sr = pio.decode(out)
- assert sr == 44100
- from producer import meters
-
- assert abs(meters.rms_db(x) - meters.rms_db(y)) < 0.7
-
-
-def test_decode_via_ffmpeg_fallback(tmp_path):
- import pytest as _pt
-
- if not pio.ffmpeg_available():
- _pt.skip("ffmpeg missing")
- x = speechish(4.0, level_dbfs=-20.0)
- out = tmp_path / "o.mp3"
- pio.encode(x, 44100, out, "mp3", 16)
- y, sr = pio.decode(out)
- assert sr == 44100
- assert y.size > 0
-
-
-def test_process_one_end_to_end(tmp_path, capsys):
- import json
-
- from producer import meters
-
- inp = _mk_wav(tmp_path, "e2e.wav")
- out = tmp_path / "e2e_processed.wav"
- rc = process_one(inp, _opts(report=True), single=True)
- assert rc == out
- assert out.exists()
- rep_path = out.with_name("e2e_processed.report.json")
- assert rep_path.exists()
- rep = json.loads(rep_path.read_text())
- y, sr = pio.decode(out)
- assert abs(meters.rms_db(y) + 20.0) < 0.6
- assert meters.true_peak_db(y, sr) <= -2.9
- assert rep["after"]["rms_db"] != 0
-
-
-def test_dry_run_listing(tmp_path, capsys):
- from producer.cli import main
-
- inp = _mk_wav(tmp_path, "dry.wav")
- rc = main([str(inp), "--dry-run", "--denoise", "off"])
- assert rc == 0
- out = capsys.readouterr().out
- assert "denoise" in out and "dsp" in out and "levelling" in out
-
-
-def test_arg_parsing_precedence():
- parser = build_parser()
- args = parser.parse_args(
- ["in.wav", "--profile", "podcast", "--warmth", "0.1", "--ceiling", "-2.0"]
- )
- opts = Options()
- _apply_args(opts, args)
- assert opts.profile == "podcast"
- assert opts.loudness_mode() == "lufs"
- assert opts.strengths["warmth"] == 0.1
- assert opts.ceiling() == -2.0
- assert opts.strengths["air"] is None
-
-
-def test_radio_profile_has_tuned_defaults():
- from producer import pipeline
-
- opts = Options(profile="radio")
- assert opts.eff("tape") > 0 and opts.eff("soothe") > 0
- assert opts.loudness_mode() == "lufs"
- stages = pipeline.build_stages(opts)
- assert [st.name for st in stages] == ["denoise", "enhance", "dsp", "levelling"]
- assert opts.denoise_strength is not None
-
-
-def test_tape_and_soothe_flags_override():
- args = build_parser().parse_args(
- ["in.wav", "--profile", "radio", "--tape", "0.4", "--soothe", "0.7"]
- )
- opts = Options()
- _apply_args(opts, args)
- assert opts.profile == "radio"
- assert opts.strengths["tape"] == 0.4
- assert opts.strengths["soothe"] == 0.7
-
-
-def test_default_output_suffix_processed(tmp_path):
- from producer.cli import _resolve_output
-
- inp = tmp_path / "song.wav"
- assert _resolve_output(inp, _opts(), single=True) == tmp_path / "song_processed.wav"
- odir = tmp_path / "out"
- opts = _opts(output=str(odir))
- assert _resolve_output(inp, opts, single=False) == odir / "song_processed.wav"
-
-
-def test_engine_chunk_flags():
- args = build_parser().parse_args(["in.wav", "--engine-chunk", "15", "--engine-overlap", "1.0"])
- opts = Options()
- _apply_args(opts, args)
- assert opts.engine_chunk_s == 15.0
- assert opts.engine_overlap_s == 1.0
-
-
-def test_default_options_whole_file_and_soft_denoise():
- opts = Options()
- assert opts.engine_chunk_s == 0.0
- assert opts.denoise_strength == 0.9
-
-
-def test_engine_chunk_validation():
- with pytest.raises(SystemExit):
- args = build_parser().parse_args(["in.wav", "--engine-chunk", "5", "--engine-overlap", "5"])
- _apply_args(Options(), args)
- with pytest.raises(SystemExit):
- args = build_parser().parse_args(["in.wav", "--engine-chunk", "-1"])
- _apply_args(Options(), args)
-
-
-def test_engine_chunk_config_override(tmp_path):
- from producer import config as cfgmod
-
- cfg = tmp_path / "config.toml"
- cfg.write_text("engine_chunk = 10.0\nengine_overlap = 1.0\n")
- opts = Options()
- cfgmod.apply_config(opts, cfgmod.load_config(cfg))
- assert opts.engine_chunk_s == 10.0
- assert opts.engine_overlap_s == 1.0
-
-
-def test_denoise_pf_flag_and_config_plumbing(tmp_path):
- from producer import config as cfgmod
-
- opts = Options()
- _apply_args(opts, build_parser().parse_args(["in.wav"]))
- assert opts.denoise_pf is False
-
- opts = Options()
- _apply_args(opts, build_parser().parse_args(["in.wav", "--denoise-pf"]))
- assert opts.denoise_pf is True
-
- cfg = tmp_path / "config.toml"
- cfg.write_text('[denoise]\nengine = "dfn3"\nstrength = 0.8\npf = true\n')
- opts = Options()
- cfgmod.apply_config(opts, cfgmod.load_config(cfg))
- assert opts.denoise_strength == 0.8
- assert opts.denoise_pf is True
-
-
-def test_spectral_denoise_choice(tmp_path):
- from producer import config as cfgmod
-
- opts = Options()
- _apply_args(opts, build_parser().parse_args(["in.wav", "--denoise", "spectral"]))
- assert opts.denoise == "spectral"
-
- cfg = tmp_path / "config.toml"
- cfg.write_text('[denoise]\nengine = "spectral"\nstrength = 0.8\n')
- opts = Options()
- cfgmod.apply_config(opts, cfgmod.load_config(cfg))
- assert opts.denoise == "spectral"
- assert opts.denoise_strength == 0.8
-
-
-def test_conflict_auto_renames_when_not_a_tty(tmp_path):
- inp = _mk_wav(tmp_path, "in.wav")
- first = process_one(inp, _opts(), single=True)
- assert first == tmp_path / "in_processed.wav"
- assert process_one(inp, _opts(), single=True) == tmp_path / "in_processed_1.wav"
- assert process_one(inp, _opts(), single=True) == tmp_path / "in_processed_2.wav"
- assert (tmp_path / "in_processed.wav").exists()
-
-
-def test_conflict_prompt_overwrite(tmp_path, monkeypatch):
- inp = _mk_wav(tmp_path, "in.wav")
- out = tmp_path / "in_processed.wav"
- process_one(inp, _opts(), single=True)
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
- monkeypatch.setattr("builtins.input", lambda _prompt: "o")
- assert process_one(inp, _opts(), single=True) == out
- assert out.exists()
-
-
-def test_conflict_prompt_rename(tmp_path, monkeypatch):
- inp = _mk_wav(tmp_path, "in.wav")
- out = tmp_path / "in_processed.wav"
- process_one(inp, _opts(), single=True)
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
- monkeypatch.setattr("builtins.input", lambda _prompt: "r")
- second = process_one(inp, _opts(), single=True)
- assert second == tmp_path / "in_processed_1.wav"
- assert second.exists()
- assert out.exists()
-
-
-def test_conflict_prompt_invalid_then_overwrite(tmp_path, monkeypatch):
- inp = _mk_wav(tmp_path, "in.wav")
- out = tmp_path / "in_processed.wav"
- process_one(inp, _opts(), single=True)
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
- answers = iter(["maybe", "O"])
- monkeypatch.setattr("builtins.input", lambda _prompt: next(answers))
- assert process_one(inp, _opts(), single=True) == out
-
-
-def test_conflict_prompt_cancel(tmp_path, monkeypatch, capsys):
- inp = _mk_wav(tmp_path, "in.wav")
- out = tmp_path / "in_processed.wav"
- process_one(inp, _opts(), single=True)
- before = out.read_bytes()
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
- monkeypatch.setattr("builtins.input", lambda _prompt: "c")
- assert process_one(inp, _opts(), single=True) is None
- assert out.read_bytes() == before
- assert "skipped" in capsys.readouterr().out
-
-
-def test_conflict_prompt_eof_cancels(tmp_path, monkeypatch):
- inp = _mk_wav(tmp_path, "in.wav")
- process_one(inp, _opts(), single=True)
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
-
- def _eof(_prompt):
- raise EOFError
-
- monkeypatch.setattr("builtins.input", _eof)
- assert process_one(inp, _opts(), single=True) is None
-
-
-def test_process_one_reports_stage_status(tmp_path, capsys):
- inp = _mk_wav(tmp_path, "status.wav")
- out_path = process_one(inp, _opts(), single=True)
- assert out_path is not None
- out = capsys.readouterr().out
- assert f"[producer] {inp} (" in out
- assert "dsp done in" in out
- assert "levelling done in" in out
- assert f"[producer] wrote {out_path}" in out
-
-
-def test_multi_file_run_gets_position_prefixes(tmp_path, capsys):
- from producer.cli import main
-
- a = _mk_wav(tmp_path, "a.wav")
- b = _mk_wav(tmp_path, "b.wav")
- rc = main([str(a), str(b), "--denoise", "off"])
- assert rc == 0
- out = capsys.readouterr().out
- assert "[1/2]" in out and "[2/2]" in out
- assert (tmp_path / "a_processed.wav").exists()
- assert (tmp_path / "b_processed.wav").exists()
-
-
-def test_output_file_rejected_for_multiple_inputs(tmp_path):
- from producer.cli import main
-
- a = _mk_wav(tmp_path, "a.wav")
- b = _mk_wav(tmp_path, "b.wav")
- with pytest.raises(SystemExit):
- main([str(a), str(b), "-o", str(tmp_path / "out.wav"), "--denoise", "off"])
-
-
-def test_output_dir_accepted_for_multiple_inputs(tmp_path):
- from producer.cli import main
-
- a = _mk_wav(tmp_path, "a.wav")
- b = _mk_wav(tmp_path, "b.wav")
- outdir = tmp_path / "masters"
- rc = main([str(a), str(b), "-o", str(outdir), "--denoise", "off"])
- assert rc == 0
- assert (outdir / "a_processed.wav").exists()
- assert (outdir / "b_processed.wav").exists()
-
-
-def test_batch_failure_continues_to_next_file(tmp_path, capsys):
- from producer.cli import main
-
- good = _mk_wav(tmp_path, "good.wav")
- missing = tmp_path / "missing.wav"
- rc = main([str(missing), str(good), "--denoise", "off"])
- assert rc == 1
- captured = capsys.readouterr()
- assert "[2/2]" in captured.out
- assert "[producer] ERROR" in captured.err
- assert (tmp_path / "good_processed.wav").exists()
diff --git a/lib/tests/test_dsp.py b/lib/tests/test_dsp.py
deleted file mode 100644
index b9508ab..0000000
--- a/lib/tests/test_dsp.py
+++ /dev/null
@@ -1,130 +0,0 @@
-import numpy as np
-from conftest import band_db, sine, speechish
-
-from producer import dsp, meters
-
-
-def test_hpf_removes_rumble(sr):
- x = sine(40, 3.0, sr, -20.0) + sine(200, 3.0, sr, -20.0)
- y = dsp.hpf(x, sr, 80.0)
- assert band_db(x, sr, 35, 45) - band_db(y, sr, 35, 45) > 10.0
- assert abs(band_db(x, sr, 190, 210) - band_db(y, sr, 190, 210)) < 0.5
-
-
-def test_peak_eq_mud_cut(sr):
- x = sine(300, 3.0, sr, -20.0) + sine(1000, 3.0, sr, -20.0)
- y = dsp.peak_eq(x, sr, 300.0, -3.0, 1.0)
- d300 = band_db(x, sr, 280, 320) - band_db(y, sr, 280, 320)
- d1k = band_db(x, sr, 950, 1050) - band_db(y, sr, 950, 1050)
- assert 2.4 < d300 < 3.6
- assert abs(d1k) < 0.3
-
-
-def test_shelf(sr):
- x = sine(60, 3.0, sr, -20.0) + sine(3000, 3.0, sr, -20.0)
- y = dsp.shelf(x, sr, 150.0, 1.5, low=True)
- d60 = band_db(y, sr, 50, 70) - band_db(x, sr, 50, 70)
- assert 1.1 < d60 < 1.9
-
-
-def test_compressor_steady_sine(sr):
- x = sine(440, 4.0, sr, peak_dbfs=-10.0)
- y = dsp.compressor(x, sr, -20.0, 3.0, 15.0, 150.0, 6.0)
- in_rms = meters.rms_db(x)
- out_rms = meters.rms_db(y)
- expected_gr = (1.0 - 1.0 / 3.0) * (in_rms - (-20.0))
- assert abs((in_rms - out_rms) - expected_gr) < 0.4
-
-
-def test_compressor_quiet_signal_unaffected(sr):
- x = sine(440, 4.0, sr, peak_dbfs=-45.0)
- y = dsp.compressor(x, sr, -20.0, 3.0, 15.0, 150.0, 6.0)
- assert abs(meters.rms_db(x) - meters.rms_db(y)) < 0.1
-
-
-def test_deesser(sr):
- x = sine(1000, 4.0, sr, -20.0) + sine(6500, 4.0, sr, -30.0)
- y = dsp.deesser(x, sr, 5500.0, 8000.0, 8.0)
- d_sib = band_db(x, sr, 5800, 7200) - band_db(y, sr, 5800, 7200)
- d_mid = band_db(x, sr, 900, 1100) - band_db(y, sr, 900, 1100)
- assert 1.0 < d_sib < 3.0
- assert abs(d_mid) < 0.4
-
-
-def test_deesser_bypass(sr):
- x = sine(1000, 2.0, sr, -20.0)
- y = dsp.deesser(x, sr, 5500.0, 8000.0, 0.0)
- assert np.allclose(x, y)
-
-
-def test_expander_attenuates_pauses(sr):
- speech = speechish(10.0, sr, level_dbfs=-20.0)
- rng = np.random.default_rng(3)
- noise = rng.standard_normal(speech.size).astype(np.float64)
- noise *= (10 ** (-52.0 / 20.0)) / np.sqrt(np.mean(np.square(noise)))
- x = (speech + noise).astype(np.float32)
- y = dsp.expander(x, sr, max_drop_db=2.1)
- frame = int(0.02 * sr)
- nf = x.size // frame
- frms_x = np.sqrt(np.mean(np.square(x[: nf * frame].reshape(nf, frame)), axis=1))
- frms_y = np.sqrt(np.mean(np.square(y[: nf * frame].reshape(nf, frame)), axis=1))
- fdb_x = 20 * np.log10(frms_x + 1e-12)
- loud = fdb_x > np.percentile(fdb_x, 75)
- quiet = fdb_x < np.percentile(fdb_x, 15)
- stable_loud = np.convolve(loud.astype(int), np.ones(5, dtype=int), mode="same") == 5
- d_y = 20 * np.log10(frms_y + 1e-12)
- assert np.mean(fdb_x[stable_loud] - d_y[stable_loud]) < 0.6
- assert np.mean(fdb_x[quiet] - d_y[quiet]) > 1.5
-
-
-def test_limit_hits_ceiling(sr):
- x = speechish(6.0, sr, level_dbfs=-3.0)
- y = dsp.limit(x, sr, -3.0)
- assert meters.true_peak_db(y, sr) <= -3.0 + 0.1
- assert meters.rms_db(x) - meters.rms_db(y) < 1.0
-
-
-def test_limit_below_ceiling_transparent(sr):
- x = speechish(6.0, sr, level_dbfs=-20.0)
- y = dsp.limit(x, sr, -3.0)
- assert np.max(np.abs(x - y)) < 1e-6
-
-
-def test_resample_roundtrip(sr):
- x = speechish(4.0, sr)
- y = dsp.resample(dsp.resample(x, sr, 48000), 48000, sr)
- assert abs(y.size - x.size) <= 2
- assert abs(meters.rms_db(x) - meters.rms_db(y)) < 0.2
-
-
-def test_tape_bypass(sr):
- x = speechish(3.0, sr)
- assert np.array_equal(x, dsp.tape(x, sr, 0.0))
-
-
-def test_tape_adds_even_harmonics(sr):
- x = sine(220, 3.0, sr, -6.0)
- y = dsp.tape(x, sr, 1.0)
- assert np.all(np.isfinite(y))
- d_h2 = band_db(y, sr, 430, 460) - band_db(x, sr, 430, 460)
- d_h3 = band_db(y, sr, 655, 690) - band_db(x, sr, 655, 690)
- assert d_h2 > 1.0
- assert d_h3 > 1.0
- assert abs(meters.rms_db(x) - meters.rms_db(y)) < 2.0
-
-
-def test_soothe_bypass(sr):
- x = speechish(3.0, sr)
- assert np.array_equal(x, dsp.soothe(x, sr, 0.0))
-
-
-def test_soothe_reduces_resonant_bands(sr):
- x = sine(300, 4.0, sr, -6.0) + sine(3500, 4.0, sr, -6.0) + sine(1000, 4.0, sr, -30.0)
- y = dsp.soothe(x, sr, 1.0)
- d_low = band_db(x, sr, 260, 350) - band_db(y, sr, 260, 350)
- d_harsh = band_db(x, sr, 3200, 3800) - band_db(y, sr, 3200, 3800)
- assert 2.0 < d_low <= 3.6
- assert 2.0 < d_harsh <= 5.2
- # frequencies outside the bands stay untouched
- d_mid = band_db(x, sr, 900, 1200) - band_db(y, sr, 900, 1200)
- assert abs(d_mid) < 0.4
diff --git a/lib/tests/test_engines.py b/lib/tests/test_engines.py
deleted file mode 100644
index 9881859..0000000
--- a/lib/tests/test_engines.py
+++ /dev/null
@@ -1,515 +0,0 @@
-import os
-import sys
-import types
-import warnings
-
-import numpy as np
-import pytest
-
-AUDIO_META_MSG = (
- "`torchaudio.backend.common.AudioMetaData` has been moved to "
- "`torchaudio.AudioMetaData`. Please update the import path."
-)
-
-
-class _FakeTensor:
- def __init__(self, arr):
- self.arr = np.asarray(arr)
-
- def unsqueeze(self, axis):
- return _FakeTensor(self.arr[None, ...])
-
- def detach(self):
- return self
-
- def cpu(self):
- return self
-
- def numpy(self):
- return self.arr
-
-
-def _install_torch_stub(monkeypatch):
- fake = types.ModuleType("torch")
- fake.Tensor = _FakeTensor
- fake.from_numpy = lambda a: _FakeTensor(a)
- fake.cuda = types.SimpleNamespace(is_available=lambda: False)
- monkeypatch.setitem(sys.modules, "torch", fake)
-
-
-def _install_df_enhance_stub(monkeypatch, transform, calls, records=None):
- pkg = types.ModuleType("df")
-
- class _State:
- def sr(self):
- return 44100
-
- class _Model:
- def to(self, dev):
- return self
-
- def init_df(*_args, **kwargs):
- if records is not None:
- records["init_df"] = kwargs
- return _Model(), _State(), "fake"
-
- def enhance(_model, _state, audio, **kwargs):
- calls.append(audio.arr.shape[-1])
- if records is not None:
- records["enhance"] = kwargs
- return _FakeTensor(transform(audio.arr.copy()))
-
- pkg.enhance = types.ModuleType("df.enhance")
- pkg.enhance.init_df = init_df
- pkg.enhance.enhance = enhance
- for sub in ("df.io", "df.logger", "df.utils"):
- mod = types.ModuleType(sub)
- setattr(pkg, sub.split(".")[1], mod)
- monkeypatch.setitem(sys.modules, sub, mod)
- monkeypatch.setitem(sys.modules, "df", pkg)
- monkeypatch.setitem(sys.modules, "df.enhance", pkg.enhance)
-
-
-def test_dfn3_chunked_engine_stitches_full_length(monkeypatch, sr, noisy_speech):
- from producer.engines import denoise_dfn
-
- monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None)
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None)
- _install_torch_stub(monkeypatch)
- calls: list[int] = []
- _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5 + 0.001, calls)
-
- x = noisy_speech[: sr * 4]
- y, eng, dev = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=1.0, overlap_s=0.1)
- assert "dfn" in eng
- assert "cpu" in dev
- assert len(calls) > 2
- assert y.shape == x.shape
- np.testing.assert_allclose(y, x * 0.5 + 0.001, atol=1e-6)
-
-
-def test_dfn3_whole_file_mode_single_call(monkeypatch, sr, noisy_speech):
- from producer.engines import denoise_dfn
-
- monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None)
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None)
- _install_torch_stub(monkeypatch)
- calls: list[int] = []
- _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5, calls)
-
- x = noisy_speech[: sr * 2]
- y, _eng, _dev = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0)
- assert calls == [x.size]
- np.testing.assert_allclose(y, x * 0.5, atol=1e-7)
-
-
-def test_dfn3_post_filter_opt_in_no_atten_lim(monkeypatch, sr, noisy_speech):
- from producer.engines import denoise_dfn
-
- monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None)
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None)
- _install_torch_stub(monkeypatch)
- calls: list[int] = []
- records: dict = {}
- _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5, calls, records)
-
- x = noisy_speech[: sr * 2]
- denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0)
- assert records["init_df"]["post_filter"] is False
- assert records["enhance"] == {} # stock df_enhance call, no atten-lim override
-
- calls.clear()
- records.clear()
- denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0, post_filter=True)
- assert records["init_df"]["post_filter"] is True
-
-
-def test_dfn3_chunked_feeds_context_padding(monkeypatch, sr, noisy_speech):
- from producer.engines import denoise_dfn
-
- monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None)
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None)
- _install_torch_stub(monkeypatch)
- calls: list[int] = []
- _install_df_enhance_stub(monkeypatch, lambda a: a * 0.5, calls)
-
- x = noisy_speech[: sr * 10]
- y, _eng, _dev = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=3.0, overlap_s=0.5)
- # chunks are widened with context, then trimmed back to the spans
- assert max(calls) > 3.0 * sr
- assert min(calls) >= 3.0 * sr
- np.testing.assert_allclose(y, x * 0.5, atol=1e-7)
-
-
-_Z_MODEL_REPO = "iic/speech_zipenhancer_ans_multiloss_16k_base"
-
-
-def _install_zipenhancer_stub(monkeypatch, calls):
- fake = types.ModuleType("zipenhancer")
- fake.MODEL_ZIPENHANCER = _Z_MODEL_REPO
-
- def denoise(chunk, sample_rate, model=_Z_MODEL_REPO, normalize=True, strength=1.0, **_kw):
- calls.append(
- {"n": chunk.size, "model": model, "normalize": normalize, "strength": strength}
- )
- scale = 0.1 if len(calls) % 2 == 1 else 1.0
- return (chunk * scale, 0.0, chunk.size / sample_rate)
-
- fake.denoise = denoise
- monkeypatch.setitem(sys.modules, "zipenhancer", fake)
-
-
-def test_zipenhancer_chunked_normalizes_once(monkeypatch, sr):
- from producer.engines import denoise_zip
-
- seq: list[str] = []
- monkeypatch.setattr(
- "producer.lazy.ensure",
- lambda pkgs, **_k: seq.append("ensure:" + ",".join(str(p) for p in pkgs)),
- )
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: seq.append("torch"))
- monkeypatch.setattr(
- "producer.lazy.ensure_import", lambda mod, **_k: seq.append("import:" + mod)
- )
- monkeypatch.setattr("producer.lazy.ensure_call", lambda fn, **_k: (seq.append("call"), fn())[1])
- calls: list[dict] = []
- _install_zipenhancer_stub(monkeypatch, calls)
-
- n = 4 * 44100
- t = np.arange(n) / sr
- x = (0.5 * np.sin(2 * np.pi * 160.0 * t)).astype(np.float32)
- y, eng, _dev = denoise_zip.denoise(x, sr, 1.0, "cpu", chunk_s=1.0, overlap_s=0.0)
- assert "zipenhancer" in eng
- # torch is pinned before package installs; the undeclared modelscope
- # import ships with the engine; import and call probes run after both
- assert seq == [
- "torch",
- "ensure:zipenhancer==0.3.2,modelscope",
- "import:zipenhancer",
- "call",
- "call",
- "call",
- "call",
- ]
- assert len(calls) == 4
- # the library API takes the full modelscope repo id; the short name
- # would be treated as a repo id and fail with modelscope E3021
- assert all(c["model"] == _Z_MODEL_REPO for c in calls)
- assert all(c["normalize"] is False for c in calls)
- assert y.shape == x.shape
- peak = float(np.max(np.abs(y)))
- assert abs(peak - 10 ** (-3.0 / 20.0)) < 0.01
- even_rms = float(np.sqrt(np.mean(y[: n // 4].astype(np.float64) ** 2)))
- odd_rms = float(np.sqrt(np.mean(y[n // 4 : n // 2].astype(np.float64) ** 2)))
- assert 8.0 < odd_rms / even_rms < 12.0
-
-
-def test_zipenhancer_resample_roundtrip_length_realigned(monkeypatch):
- # 120s @ 48k round-tripped through the 16k engine can come back a sample
- # or two long (resample_poly emits ceil(n * up/down) per hop); the blend
- # used to crash on the mismatch instead of realigning
- from producer.engines import denoise_zip
-
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: None)
- monkeypatch.setattr("producer.lazy.ensure", lambda *_a, **_k: None)
- calls: list[dict] = []
- _install_zipenhancer_stub(monkeypatch, calls)
-
- sr = 48000
- n = 100001 # 48k -> 16k -> 48k drifts +1 for this length
- x = (0.3 * np.sin(2 * np.pi * 220.0 * np.arange(n) / sr)).astype(np.float32)
- y, _eng, _dev = denoise_zip.denoise(x, sr, 0.5, "cpu")
- assert y.shape == x.shape
-
-
-def test_mossformer_clearvoice_probe(monkeypatch, sr, noisy_speech):
- from producer.engines import enhance_mossformer
-
- seq: list[str] = []
- monkeypatch.setattr("producer.lazy.ensure_torch", lambda: seq.append("torch"))
- monkeypatch.setattr(
- "producer.lazy.ensure",
- lambda pkgs, **_k: seq.append("ensure:" + ",".join(str(p) for p in pkgs)),
- )
- monkeypatch.setattr(
- "producer.lazy.ensure_import", lambda mod, **_k: seq.append("import:" + mod)
- )
-
- fake = types.ModuleType("clearvoice")
-
- class _FakeCV:
- def __init__(self, task=None, model_name=None):
- pass
-
- def __call__(self, chunk):
- return (chunk * 0.5, 48000)
-
- fake.ClearVoice = _FakeCV
- monkeypatch.setitem(sys.modules, "clearvoice", fake)
-
- x = noisy_speech[: sr * 4]
- y, eng, _dev = enhance_mossformer.enhance(x, 48000, 1.0, "cpu", chunk_s=2.0)
- assert "mossformer2" in eng
- assert seq == ["torch", "ensure:clearvoice==0.1.2", "import:clearvoice"]
- assert y.shape == x.shape
- np.testing.assert_allclose(y, x * 0.5, atol=1e-6)
-
-
-def _hissy_speech(sr, dur=12.0, noise_db=-42.0, seed=3):
- from conftest import speechish
-
- x = speechish(dur, sr, level_dbfs=-20.0, seed=seed)
- rng = np.random.default_rng(seed)
- noise = rng.standard_normal(x.size)
- noise *= (10 ** (noise_db / 20.0)) / np.sqrt(np.mean(np.square(noise)))
- return (x + noise).astype(np.float32)
-
-
-def test_spectral_reduces_steady_noise(sr):
- from producer import meters
- from producer.engines import denoise_spectral
-
- x = _hissy_speech(sr)
- y, eng, dev = denoise_spectral.denoise(x, sr, 1.0, "cpu")
- assert "spectral" in eng and dev == "cpu"
- assert y.shape == x.shape
- assert np.all(np.isfinite(y))
- assert meters.noise_floor_db(y, sr) < meters.noise_floor_db(x, sr) - 8.0
- assert np.corrcoef(x.astype(np.float64), y.astype(np.float64))[0, 1] > 0.8
-
-
-def test_spectral_speech_level_flat(sr):
- from producer.engines import denoise_spectral
-
- x = _hissy_speech(sr)
- y, _eng, _dev = denoise_spectral.denoise(x, sr, 1.0, "cpu")
- frame = int(0.03 * sr)
- nf = x.size // frame
-
- def frms_db(z):
- rms = np.sqrt(np.mean(z[: nf * frame].reshape(nf, frame).astype(np.float64) ** 2, axis=1))
- return 20.0 * np.log10(rms + 1e-12)
-
- xdb, ydb = frms_db(x), frms_db(y)
- speech = xdb > np.percentile(xdb, 10) + 12.0
- assert speech.sum() > 20
- # the deterministic engine must not pump the speech level (dfn3's failure mode)
- swing = np.abs(ydb[speech] - xdb[speech])
- assert np.percentile(swing, 95) < 2.0
-
-
-def test_spectral_strength_zero_is_identity(sr):
- from producer.engines import denoise_spectral
-
- x = _hissy_speech(sr)
- y, _eng, _dev = denoise_spectral.denoise(x, sr, 0.0, "cpu")
- np.testing.assert_array_equal(y, x)
-
-
-def test_spectral_sample_aligned(sr):
- from producer.engines import denoise_spectral
-
- x = _hissy_speech(sr)
- y, _eng, _dev = denoise_spectral.denoise(x, sr, 1.0, "cpu")
- lo, hi = int(sr * 2.0), int(sr * 9.0)
- corrs = {k: float(np.corrcoef(x[lo:hi], y[lo + k : hi + k])[0, 1]) for k in range(-3, 4)}
- assert max(corrs, key=corrs.get) == 0
- assert corrs[0] > 0.8
-
-
-def test_spectral_suppression_capped(sr):
- from producer import meters
- from producer.engines import denoise_spectral
-
- noise = (0.008 * np.random.default_rng(5).standard_normal(sr * 6)).astype(np.float32)
- y, _eng, _dev = denoise_spectral.denoise(noise, sr, 1.0, "cpu")
- drop = meters.noise_floor_db(noise, sr) - meters.noise_floor_db(y, sr)
- # bounded: deep enough to matter, never gated to digital silence
- assert 20.0 < drop < 40.0
-
-
-def test_spectral_chunked_matches_whole_file(sr):
- from producer.engines import denoise_spectral
-
- x = _hissy_speech(sr, dur=20.0)
- y1, _e, _d = denoise_spectral.denoise(x, sr, 1.0, "cpu", chunk_s=60.0, overlap_s=0.5)
- y2, _e, _d = denoise_spectral.denoise(x, sr, 1.0, "cpu", chunk_s=8.0, overlap_s=0.5)
- assert y1.shape == y2.shape == x.shape
- corr = np.corrcoef(y1.astype(np.float64), y2.astype(np.float64))[0, 1]
- assert corr > 0.99
-
-
-def test_spectral_profile_global_not_per_chunk(sr):
- """Tail hiss must get the same suppression with or without speech up front.
-
- The old per-chunk percentile leaked speech into the noise estimate and
- under-suppressed exactly where it matters (between sentences).
- """
- from conftest import speechish
-
- from producer import meters
- from producer.engines import denoise_spectral
-
- speech = speechish(8.0, sr, level_dbfs=-20.0, seed=11)
- rng = np.random.default_rng(9)
- noise = rng.standard_normal(sr * 16).astype(np.float32)
- noise *= (10 ** (-40.0 / 20.0)) / np.sqrt(np.mean(np.square(noise)))
- tail_lo, tail_hi = sr * 10, sr * 16
- with_speech = np.concatenate([speech + noise[: speech.size], noise[speech.size :]]).astype(
- np.float32
- )
- hiss_only = noise.copy()
- y1, _e, _d = denoise_spectral.denoise(with_speech, sr, 1.0, "cpu")
- y2, _e, _d = denoise_spectral.denoise(hiss_only, sr, 1.0, "cpu")
- drop1 = meters.noise_floor_db(with_speech[tail_lo:tail_hi], sr) - meters.noise_floor_db(
- y1[tail_lo:tail_hi], sr
- )
- drop2 = meters.noise_floor_db(hiss_only[tail_lo:tail_hi], sr) - meters.noise_floor_db(
- y2[tail_lo:tail_hi], sr
- )
- assert drop1 > 14.0
- assert drop1 > drop2 - 4.0
-
-
-def _stub_df_modules(calls: list[str]) -> tuple[types.ModuleType, list[types.ModuleType]]:
- pkg = types.ModuleType("df")
- mods = []
- for full in ("df.utils", "df.logger", "df.io"):
- mod = types.ModuleType(full)
-
- def probe(name: str):
- def fn(*_args):
- calls.append(name)
- return "deadbeef"
-
- return fn
-
- for fn_name in ("get_git_root", "get_commit_hash", "get_branch_name"):
- setattr(mod, fn_name, probe(f"{full}.{fn_name}"))
- setattr(pkg, full.split(".")[1], mod)
- mods.append(mod)
- return pkg, mods
-
-
-def test_dfn_shim_neutralizes_git_probes(monkeypatch):
- from producer.engines import denoise_dfn
-
- calls: list[str] = []
- pkg, mods = _stub_df_modules(calls)
- for name, mod in zip(("df", "df.utils", "df.logger", "df.io"), (pkg, *mods), strict=True):
- monkeypatch.setitem(sys.modules, name, mod)
- assert pkg.logger.get_commit_hash() == "deadbeef"
- calls.clear()
- denoise_dfn._shim_df_git()
- for mod in mods:
- for fn_name in ("get_git_root", "get_commit_hash", "get_branch_name"):
- assert mod.__dict__[fn_name]() is None
- assert calls == []
-
-
-def test_dfn_shim_silences_torchaudio_warning():
- from producer.engines import denoise_dfn
-
- code = "import warnings\nwarnings.warn(MESSAGE, UserWarning)"
- denoise_dfn._shim_torchaudio_backend()
- with warnings.catch_warnings(record=True) as caught:
- exec(compile(code, "df/io.py", "exec"), {"__name__": "df.io", "MESSAGE": AUDIO_META_MSG})
- assert caught == []
- with warnings.catch_warnings(record=True) as caught:
- exec(
- compile(code, "other/mod.py", "exec"),
- {"__name__": "other.mod", "MESSAGE": AUDIO_META_MSG},
- )
- assert len(caught) == 1
-
-
-def _metrics_floor(x, sr):
- from producer import meters
-
- return meters.noise_floor_db(x, sr)
-
-
-@pytest.mark.slow
-def test_dfn3_reduces_noise(sr, noisy_speech):
- pytest.importorskip("torch")
- pytest.importorskip("df")
- from producer.engines import denoise_dfn
-
- x = noisy_speech[: sr * 4]
- y, eng, _dev = denoise_dfn.denoise(x, sr, 1.0, "cpu")
- assert "dfn" in eng
- assert _metrics_floor(y, sr) < _metrics_floor(x, sr) - 5.0
- assert np.corrcoef(x, y.astype(np.float64))[0, 1] > 0.9
-
-
-@pytest.mark.slow
-def test_dfn3_speech_level_flat(sr, noisy_speech):
- """Guard against dfn3's reported failure mode: volume wobble in sentences."""
- pytest.importorskip("torch")
- pytest.importorskip("df")
- from producer.engines import denoise_dfn
-
- x = noisy_speech[: sr * 8]
- y, _eng, _dev = denoise_dfn.denoise(x, sr, 0.9, "cpu")
- frame = int(0.03 * sr)
- nf = x.size // frame
-
- def frms_db(z):
- rms = np.sqrt(np.mean(z[: nf * frame].reshape(nf, frame).astype(np.float64) ** 2, axis=1))
- return 20.0 * np.log10(rms + 1e-12)
-
- xdb, ydb = frms_db(x), frms_db(y)
- speech = xdb > np.percentile(xdb, 10) + 12.0
- swing = np.abs(ydb[speech] - xdb[speech])
- assert np.percentile(swing, 95) < 2.5
-
-
-@pytest.mark.slow
-def test_dfn3_chunked_matches_whole_file(sr, noisy_speech):
- pytest.importorskip("torch")
- pytest.importorskip("df")
- from producer.engines import denoise_dfn
-
- x = noisy_speech[: sr * 60]
- y_full, _, _ = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=0.0)
- y_chunk, _, _ = denoise_dfn.denoise(x, sr, 1.0, "cpu", chunk_s=15.0, overlap_s=0.5)
- assert y_full.shape == y_chunk.shape == x.shape
- corr = np.corrcoef(y_full.astype(np.float64), y_chunk.astype(np.float64))[0, 1]
- assert corr > 0.99
- assert float(np.max(np.abs(y_full - y_chunk))) < 0.1
-
-
-@pytest.mark.slow
-def test_zipenhancer_reduces_noise(sr, noisy_speech):
- pytest.importorskip("torch")
- pytest.importorskip("zipenhancer")
- from producer.engines import denoise_zip
-
- x = noisy_speech[: sr * 4]
- y, eng, _ = denoise_zip.denoise(x, sr, 1.0, "cpu")
- assert "zipenhancer" in eng
- assert _metrics_floor(y, sr) < _metrics_floor(x, sr) - 5.0
-
-
-@pytest.mark.slow
-def test_mossformer2_enhances(sr, noisy_speech):
- pytest.importorskip("torch")
- pytest.importorskip("clearvoice")
- from producer.engines import enhance_mossformer
-
- x = noisy_speech[: sr * 4]
- y, eng, _ = enhance_mossformer.enhance(x, sr, 1.0, "cpu")
- assert "mossformer2" in eng
- assert np.all(np.isfinite(y))
-
-
-@pytest.mark.slow
-def test_resemble_enhance(sr, noisy_speech):
- if not os.environ.get("PRODUCER_TEST_RESEMBLE"):
- pytest.skip("set PRODUCER_TEST_RESEMBLE=1 to run the isolated-venv generative engine")
- from producer.engines import enhance_resemble
-
- x = noisy_speech[: sr * 4]
- y, eng, _ = enhance_resemble.enhance(x, sr, 1.0, "cpu")
- assert "resemble" in eng
- assert np.all(np.isfinite(y))
diff --git a/lib/tests/test_lazy.py b/lib/tests/test_lazy.py
deleted file mode 100644
index 5ab5b95..0000000
--- a/lib/tests/test_lazy.py
+++ /dev/null
@@ -1,286 +0,0 @@
-import types
-
-import pytest
-
-from producer import lazy
-
-PYPI_JSON = {
- "urls": [
- {
- "filename": "torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl",
- "url": "https://files.pythonhosted.org/packages/xx/torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl",
- "digests": {"sha256": "abc123"},
- "size": 766_668_798,
- },
- {
- "filename": "torch-2.7.1-cp311-cp311-win_amd64.whl",
- "url": "https://example.com/win.whl",
- "digests": {},
- "size": 1,
- },
- {
- "filename": "torch-2.7.1.tar.gz",
- "url": "https://example.com/src.tar.gz",
- "digests": {},
- "size": 1,
- },
- ]
-}
-
-_SHA = "e1a846516570851234567890abcdef1234567890abcdef1234567890abcdef12"
-GPU_HTML = (
- "<html><body>"
- '<a href="https://download-r2.pytorch.org/whl/cu126/torch-2.6.0%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl#sha256=00'
- f'00">old</a><a href="https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl#sha256={_SHA}">new</a>'
- '<a href="https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp311-cp311-win_amd64.whl#sha256=1111">win</a>'
- '<a href="torch-2.7.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl">relative-dup</a>'
- "</body></html>"
-)
-
-
-def test_choose_pypi_picks_linux_wheel():
- got = lazy._choose_pypi(PYPI_JSON)
- assert got is not None
- name, url, sha, size = got
- assert name.endswith("manylinux_2_28_x86_64.whl")
- assert "pythonhosted" in url
- assert sha == "abc123"
- assert size == 766_668_798
-
-
-def test_choose_gpu_matches_version_and_arch():
- got = lazy._choose_gpu(GPU_HTML, "torch", "2.7.1")
- assert got is not None
- name, url, sha, size = got
- assert name == "torch-2.7.1+cu126-cp311-cp311-manylinux_2_28_x86_64.whl"
- assert url == (
- "https://download-r2.pytorch.org/whl/cu126/"
- "torch-2.7.1%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl"
- )
- assert sha == _SHA
- assert size is None
-
-
-def test_choose_gpu_no_match_returns_none():
- assert lazy._choose_gpu("<html></html>", "torch", "2.7.1") is None
-
-
-def test_resolve_torch_wheels_needs_both_packages(monkeypatch):
- def fake(pkg, ver):
- return None if pkg == "torchaudio" else ("t.whl", "u", None, 1)
-
- monkeypatch.setattr(lazy, "_pypi_wheel", fake)
- assert lazy._resolve_torch_wheels(gpu=False) == []
-
-
-def test_resolve_torch_wheels_returns_both(monkeypatch):
- wheels = [("torch.whl", "u1", "s1", 1), ("torchaudio.whl", "u2", None, None)]
- monkeypatch.setattr(lazy, "_pypi_wheel", lambda pkg, ver: wheels.pop(0))
- got = lazy._resolve_torch_wheels(gpu=False)
- assert [w[0] for w in got] == ["torch.whl", "torchaudio.whl"]
-
-
-def test_ensure_torch_downloads_wheels_then_installs(monkeypatch, tmp_path):
- wheels = [
- ("torch-2.7.1-cp311.whl", "https://x/torch.whl", "sha", 10),
- ("torchaudio-2.7.1-cp311.whl", "https://x/ta.whl", None, 5),
- ]
- monkeypatch.setattr(lazy, "has_module", lambda name: False)
- monkeypatch.setattr(lazy, "_resolve_torch_wheels", lambda gpu: wheels)
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(lazy, "WHEEL_CACHE", tmp_path / "wheels")
- downloads: list[str] = []
- runs: list[tuple[list[str], str]] = []
-
- def fake_download(url, dest, label=None, expected_size=None, sha256=None, timeout=60.0):
- downloads.append(dest.name)
- return dest
-
- monkeypatch.setattr(lazy.ui, "download", fake_download)
- monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append((cmd, label)))
- lazy.ensure_torch()
- assert downloads == ["torch-2.7.1-cp311.whl", "torchaudio-2.7.1-cp311.whl"]
- assert len(runs) == 1
- cmd = runs[0][0]
- assert cmd[0] == "uv" and cmd[1] == "pip" and cmd[2] == "install"
- assert str(tmp_path / "wheels" / "torch-2.7.1-cp311.whl") in cmd
- assert str(tmp_path / "wheels" / "torchaudio-2.7.1-cp311.whl") in cmd
-
-
-def test_ensure_torch_falls_back_to_uv_index(monkeypatch):
- monkeypatch.setattr(lazy, "has_module", lambda name: False)
- monkeypatch.setattr(lazy, "_resolve_torch_wheels", lambda gpu: [])
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(lazy, "gpu_present", lambda: True)
- runs: list[list[str]] = []
- monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append(cmd))
- lazy.ensure_torch()
- cmd = runs[0]
- assert "torch==2.7.1+cu126" in cmd
- assert "--index-url" in cmd
- assert lazy.TORCH_GPU_INDEX in cmd
-
-
-def test_ensure_torch_falls_back_to_pypi_cpu(monkeypatch):
- monkeypatch.setattr(lazy, "has_module", lambda name: False)
- monkeypatch.setattr(lazy, "_resolve_torch_wheels", lambda gpu: [])
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(lazy, "gpu_present", lambda: False)
- runs: list[list[str]] = []
- monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append(cmd))
- lazy.ensure_torch()
- cmd = runs[0]
- assert "torch==2.7.1" in cmd
- assert "--index-url" not in cmd
-
-
-def test_ensure_torch_noop_when_installed(monkeypatch):
- monkeypatch.setattr(lazy, "has_module", lambda name: True)
- runs: list = []
- monkeypatch.setattr(lazy.ui, "run", lambda cmd, label, check=True: runs.append(cmd))
- lazy.ensure_torch()
- assert runs == []
-
-
-def test_ensure_import_noop_when_importable(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
- lazy.ensure_import("numpy", purpose="test")
- assert installs == []
-
-
-def test_ensure_import_installs_missing_module(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
- state = {"round": 0}
-
- def fake_import(name):
- if name == "fakeengine":
- if state["round"] == 0:
- state["round"] = 1
- raise ModuleNotFoundError("No module named 'addict'", name="addict")
- return types.ModuleType("fakeengine")
- raise ModuleNotFoundError(f"No module named {name!r}", name=name)
-
- monkeypatch.setattr(lazy.importlib, "import_module", fake_import)
- lazy.ensure_import("fakeengine", purpose="test")
- assert installs == [["addict"]]
-
-
-def test_ensure_import_dotted_missing_reraises_without_install(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
-
- def fake_import(name):
- raise ModuleNotFoundError("No module named 'pkg.sub'", name="pkg.sub")
-
- monkeypatch.setattr(lazy.importlib, "import_module", fake_import)
- with pytest.raises(ModuleNotFoundError):
- lazy.ensure_import("whatever", purpose="test")
- assert installs == []
-
-
-def test_ensure_import_gives_up_after_rounds(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
-
- def fake_import(name):
- raise ModuleNotFoundError("No module named 'ghost'", name="ghost")
-
- monkeypatch.setattr(lazy.importlib, "import_module", fake_import)
- with pytest.raises(lazy.EngineUnavailable, match="ghost"):
- lazy.ensure_import("whatever", purpose="test")
- assert len(installs) == 4
- assert all(pkgs == ["ghost"] for pkgs in installs)
-
-
-def test_ensure_import_maps_pil_to_pillow(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
- state = {"round": 0}
-
- def fake_import(name):
- if name == "fakeengine":
- if state["round"] == 0:
- state["round"] = 1
- raise ModuleNotFoundError("No module named 'PIL'", name="PIL")
- return types.ModuleType("fakeengine")
- raise ModuleNotFoundError(f"No module named {name!r}", name=name)
-
- monkeypatch.setattr(lazy.importlib, "import_module", fake_import)
- lazy.ensure_import("fakeengine", purpose="test")
- assert installs == [["pillow"]]
-
-
-def test_ensure_call_passthrough_without_install(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
- assert lazy.ensure_call(lambda: 42, purpose="test") == 42
- assert installs == []
-
-
-def test_ensure_call_installs_missing_module(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
- state = {"round": 0}
-
- def fake_fn():
- if state["round"] == 0:
- state["round"] = 1
- raise ModuleNotFoundError("No module named 'addict'", name="addict")
- return "ok"
-
- assert lazy.ensure_call(fake_fn, purpose="test") == "ok"
- assert installs == [["addict"]]
-
-
-def test_ensure_call_dotted_missing_reraises_without_install(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
-
- def fake_fn():
- raise ModuleNotFoundError("No module named 'pkg.sub'", name="pkg.sub")
-
- with pytest.raises(ModuleNotFoundError):
- lazy.ensure_call(fake_fn, purpose="test")
- assert installs == []
-
-
-def test_ensure_call_unnamed_missing_reraises_without_install(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
-
- def fake_fn():
- raise ModuleNotFoundError("boom")
-
- with pytest.raises(ModuleNotFoundError):
- lazy.ensure_call(fake_fn, purpose="test")
- assert installs == []
-
-
-def test_ensure_call_maps_pil_to_pillow(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
- state = {"round": 0}
-
- def fake_fn():
- if state["round"] == 0:
- state["round"] = 1
- raise ModuleNotFoundError("No module named 'PIL'", name="PIL")
- return "ok"
-
- assert lazy.ensure_call(fake_fn, purpose="test") == "ok"
- assert installs == [["pillow"]]
-
-
-def test_ensure_call_gives_up_after_rounds(monkeypatch):
- installs: list[list[str]] = []
- monkeypatch.setattr(lazy, "ensure", lambda pkgs, **_k: installs.append(list(pkgs)))
-
- def fake_fn():
- raise ModuleNotFoundError("No module named 'ghost'", name="ghost")
-
- with pytest.raises(lazy.EngineUnavailable, match="ghost"):
- lazy.ensure_call(fake_fn, purpose="test")
- assert len(installs) == 8
- assert all(pkgs == ["ghost"] for pkgs in installs)
diff --git a/lib/tests/test_loudness.py b/lib/tests/test_loudness.py
deleted file mode 100644
index 5cd84d6..0000000
--- a/lib/tests/test_loudness.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import numpy as np
-from conftest import speechish
-
-from producer import loudness, meters
-
-
-def test_rms_normalize_hits_target(sr):
- x = speechish(8.0, sr, level_dbfs=-40.0)
- y = loudness.normalize(x, sr, "rms", -20.0, -3.0)
- assert abs(meters.rms_db(y) + 20.0) < 0.5
- assert meters.true_peak_db(y, sr) <= -2.9
-
-
-def test_lufs_normalize_hits_target(sr):
- x = speechish(8.0, sr, level_dbfs=-35.0)
- y = loudness.normalize(x, sr, "lufs", -16.0, -1.5)
- assert abs(meters.lufs(y, sr) + 16.0) < 0.6
- assert meters.true_peak_db(y, sr) <= -1.4
-
-
-def test_silence_passthrough():
- y = loudness.normalize(np.zeros(44100 * 2, dtype=np.float32), 44100, "rms", -20.0, -3.0)
- assert np.allclose(y, 0.0)
-
-
-def test_idempotent(sr):
- x = speechish(8.0, sr, level_dbfs=-35.0)
- y1 = loudness.normalize(x, sr, "rms", -20.0, -3.0)
- y2 = loudness.normalize(y1, sr, "rms", -20.0, -3.0)
- assert abs(meters.rms_db(y1) - meters.rms_db(y2)) < 0.6
diff --git a/lib/tests/test_meters.py b/lib/tests/test_meters.py
deleted file mode 100644
index b013ec9..0000000
--- a/lib/tests/test_meters.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import numpy as np
-from conftest import sine, speechish
-
-from producer import meters
-
-
-def test_rms_and_peak_of_sine(sr):
- x = sine(1000, 3.0, sr, peak_dbfs=-17.0)
- assert abs(meters.rms_db(x) + 20.0) < 0.1
- assert abs(meters.sample_peak_db(x) + 17.0) < 0.05
-
-
-def test_true_peak_bounds(sr):
- x = sine(1000, 3.0, sr, peak_dbfs=-17.0)
- tp = meters.true_peak_db(x, sr)
- sp = meters.sample_peak_db(x)
- assert sp - 0.01 <= tp <= sp + 0.6
-
-
-def test_true_peak_blockwise_matches_whole_file(sr):
- from scipy import signal
-
- rng = np.random.default_rng(3)
- n = sr * 75
- x = (
- 0.5 * np.sin(2 * np.pi * 997.0 * np.arange(n) / sr) + 0.02 * rng.standard_normal(n)
- ).astype(np.float32)
- half = 10 * 4
- h = signal.firwin(2 * half + 1, 0.25, window=("kaiser", 8.0)).astype(np.float32)
- ref = meters.sample_peak_db(signal.resample_poly(x, 4, 1, window=h))
- assert abs(meters.true_peak_db(x, sr) - ref) < 0.01
-
-
-def test_lufs_of_sine(sr):
- x = sine(1000, 3.0, sr, peak_dbfs=-17.0)
- assert abs(meters.lufs(x, sr) + 20.05) < 0.2
-
-
-def test_lufs_silence():
- assert meters.lufs(np.zeros(48000, dtype=np.float32), 48000) == meters.SILENCE_DB
-
-
-def test_noise_floor_below_speech(sr):
- x = speechish(8.0, sr, level_dbfs=-20.0)
- floor = meters.noise_floor_db(x, sr)
- assert floor < meters.rms_db(x) - 4.0
-
-
-def test_speech_level_db_ignores_leading_silence(sr):
- x = speechish(6.0, sr, level_dbfs=-20.0)
- y = np.concatenate([np.zeros(sr * 4, dtype=np.float32), x])
- assert abs(meters.speech_level_db(x, sr) - meters.speech_level_db(y, sr)) < 1.0
- assert meters.speech_level_db(x, sr) > meters.rms_db(x)
-
-
-def test_all_meters_keys(sr):
- d = meters.all_meters(speechish(3.0, sr), sr)
- assert set(d) == {
- "rms_db",
- "sample_peak_db",
- "true_peak_db",
- "lufs",
- "noise_floor_db",
- "duration_s",
- }
diff --git a/lib/tests/test_pipeline.py b/lib/tests/test_pipeline.py
deleted file mode 100644
index b3f91f1..0000000
--- a/lib/tests/test_pipeline.py
+++ /dev/null
@@ -1,114 +0,0 @@
-import numpy as np
-from conftest import band_db, sine, speechish
-
-from producer import meters, pipeline
-from producer.config import Options
-
-
-def test_stage_order_and_names():
- opts = Options()
- opts.denoise = "off"
- opts.enhance = "off"
- res = pipeline.run_pipeline(np.zeros(44100, dtype=np.float32), 44100, opts)
- names = [s.name for s in res.stages]
- assert names == ["denoise", "enhance", "dsp", "levelling"]
- by_name = {s.name: s for s in res.stages}
- assert by_name["denoise"].enabled is False
- assert by_name["enhance"].enabled is False
- assert by_name["dsp"].enabled is True
- assert by_name["levelling"].enabled is True
-
-
-def test_denoise_stage_detail_shows_pf():
- opts = Options()
- stages = pipeline.build_stages(opts)
- assert "pf=off" in stages[0].detail
- assert "leveling" not in stages[0].detail
- opts.denoise_pf = True
- stages = pipeline.build_stages(opts)
- assert "pf=on" in stages[0].detail
-
-
-def test_full_chain_profile_bounds(sr):
- opts = Options()
- opts.denoise = "off"
- opts.enhance = "off"
- x = speechish(8.0, sr, level_dbfs=-35.0)
- res = pipeline.run_pipeline(x, sr, opts)
- assert abs(meters.rms_db(res.audio) + 20.0) < 0.6
- assert meters.true_peak_db(res.audio, sr) <= -2.9
- assert res.timings.get("levelling", 0) >= 0
-
-
-def test_passthrough_when_disabled(sr):
- opts = Options()
- opts.denoise = "off"
- opts.enhance = "off"
- opts.dsp = False
- opts.levelling = False
- x = speechish(4.0, sr, level_dbfs=-20.0)
- res = pipeline.run_pipeline(x, sr, opts)
- assert np.allclose(res.audio, x)
-
-
-def test_knob_zero_disables_eq(sr):
- base = Options()
- base.denoise = "off"
- base.enhance = "off"
- base.levelling = False
- base.strengths["warmth"] = 0.0
- warm = Options()
- warm.denoise = "off"
- warm.enhance = "off"
- warm.levelling = False
- x = sine(60, 4.0, sr, -20.0) + sine(3000, 4.0, sr, -20.0)
- y_flat = pipeline.run_pipeline(x, sr, base).audio
- y_warm = pipeline.run_pipeline(x, sr, warm).audio
- d_flat = band_db(y_flat, sr, 50, 70) - band_db(x, sr, 50, 70)
- d_warm = band_db(y_warm, sr, 50, 70) - band_db(x, sr, 50, 70)
- assert d_warm - d_flat > 0.8
-
-
-def test_podcast_profile_bounds(sr):
- opts = Options()
- opts.profile = "podcast"
- opts.denoise = "off"
- opts.enhance = "off"
- x = speechish(8.0, sr, level_dbfs=-35.0)
- res = pipeline.run_pipeline(x, sr, opts)
- assert abs(meters.lufs(res.audio, sr) + 16.0) < 0.8
- assert meters.true_peak_db(res.audio, sr) <= -1.4
-
-
-def test_dsp_pregain_makes_chain_level_invariant(sr):
- """The voice chain must treat a quiet and a hot take identically.
-
- Denoised files often arrive far below the chain's design level; without
- the pre-gain the absolute comp thresholds would idle (or slam) depending
- on input level alone.
- """
- hot = Options()
- hot.denoise = "off"
- hot.enhance = "off"
- hot.levelling = False
- quiet = Options()
- quiet.denoise = "off"
- quiet.enhance = "off"
- quiet.levelling = False
- x_hot = speechish(6.0, sr, level_dbfs=-14.0)
- x_quiet = speechish(6.0, sr, level_dbfs=-38.0)
- y_hot = pipeline.run_pipeline(x_hot, sr, hot).audio
- y_quiet = pipeline.run_pipeline(x_quiet, sr, quiet).audio
- diff = abs(meters.rms_db(y_hot) - meters.rms_db(y_quiet))
- assert diff < 1.0
-
-
-def test_dsp_pregain_note_recorded(sr):
- opts = Options()
- opts.denoise = "off"
- opts.enhance = "off"
- notes: list[str] = []
- stages = pipeline.build_stages(opts, notes)
- dsp_stage = next(s for s in stages if s.name == "dsp")
- dsp_stage.fn(speechish(4.0, sr, level_dbfs=-30.0), sr)
- assert any("pregain" in n for n in notes)
diff --git a/lib/tests/test_ui.py b/lib/tests/test_ui.py
deleted file mode 100644
index 156b3e8..0000000
--- a/lib/tests/test_ui.py
+++ /dev/null
@@ -1,169 +0,0 @@
-import hashlib
-import subprocess
-import sys
-
-import pytest
-
-from producer import ui
-
-
-class _FakeResp:
- def __init__(self, payload: bytes, length: str | None = None):
- self._payload = payload
- self.headers = {"Content-Length": length} if length else {}
-
- def read(self, n=-1):
- if not self._payload:
- return b""
- out, self._payload = self._payload[:n], self._payload[n:]
- return out
-
- def __enter__(self):
- return self
-
- def __exit__(self, *_a):
- return False
-
-
-def test_fmt_bytes():
- assert ui.fmt_bytes(512) == "512 B"
- assert ui.fmt_bytes(2048) == "2.0 KB"
- assert ui.fmt_bytes(8 << 20) == "8.0 MB"
- assert ui.fmt_bytes(None) == "?"
-
-
-def test_fmt_secs():
- assert ui.fmt_secs(0) == "0:00"
- assert ui.fmt_secs(59) == "0:59"
- assert ui.fmt_secs(61) == "1:01"
- assert ui.fmt_secs(3700) == "1:01:40"
- assert ui.fmt_secs(None) == "?"
-
-
-def test_bar_fills():
- assert ui._bar(0.0) == "[" + "-" * 22 + "]"
- assert ui._bar(1.0) == "[" + "#" * 22 + "]"
- half = ui._bar(0.5)
- assert half.count("#") == 11 and half.count("-") == 11
-
-
-def test_download_writes_file(tmp_path, monkeypatch, capsys):
- payload = b"x" * (2 << 20)
- sha = hashlib.sha256(payload).hexdigest()
- monkeypatch.setattr(
- "urllib.request.urlopen", lambda req, timeout=None: _FakeResp(payload, str(len(payload)))
- )
- dest = tmp_path / "big.bin"
- out = ui.download("https://example.com/big.bin", dest, sha256=sha)
- assert out == dest
- assert dest.read_bytes() == payload
- assert "done" in capsys.readouterr().out
-
-
-def test_download_checksum_mismatch(tmp_path, monkeypatch):
- monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=None: _FakeResp(b"abc"))
- dest = tmp_path / "f.bin"
- with pytest.raises(RuntimeError):
- ui.download("https://example.com/f.bin", dest, sha256="0" * 64)
- assert not dest.exists()
- assert not dest.with_name(dest.name + ".part").exists()
-
-
-def test_download_cached_skips(tmp_path, monkeypatch, capsys):
- dest = tmp_path / "cached.bin"
- dest.write_bytes(b"hello")
-
- def boom(*_a, **_k):
- raise AssertionError("should not download")
-
- monkeypatch.setattr("urllib.request.urlopen", boom)
- ui.download("https://example.com/cached.bin", dest)
- assert dest.read_bytes() == b"hello"
- assert "cached" in capsys.readouterr().out
-
-
-def test_download_size_mismatch_redownloads(tmp_path, monkeypatch):
- dest = tmp_path / "short.bin"
- dest.write_bytes(b"too short")
- monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout=None: _FakeResp(b"abcd"))
- ui.download("https://example.com/short.bin", dest, expected_size=4)
- assert dest.read_bytes() == b"abcd"
-
-
-def test_download_milestones_non_tty(tmp_path, monkeypatch, capsys):
- payload = b"y" * (8 << 20)
- monkeypatch.setattr(
- "urllib.request.urlopen", lambda req, timeout=None: _FakeResp(payload, str(len(payload)))
- )
- ui.download("https://example.com/m.bin", tmp_path / "m.bin")
- out = capsys.readouterr().out
- assert "8.0 MB/8.0 MB" in out
- assert "done" in out
-
-
-def test_run_piped_forwards_output(monkeypatch, capsys):
- monkeypatch.setattr(ui, "is_tty", lambda: False)
- rc = ui.run([sys.executable, "-c", "print('hello-uv-output')"], "test run")
- assert rc == 0
- assert "hello-uv-output" in capsys.readouterr().out
-
-
-def test_run_piped_raises_on_failure(monkeypatch):
- monkeypatch.setattr(ui, "is_tty", lambda: False)
- with pytest.raises(subprocess.CalledProcessError):
- ui.run([sys.executable, "-c", "raise SystemExit(3)"], "test run")
-
-
-def test_status_non_tty_milestones_and_done(capsys):
- s = ui.Status(prefix="[producer] in.wav — ")
- s.stage("denoise")
- for i in range(1, 11):
- s.tick(i, 10)
- s.stage_done("denoise", 2.5)
- s.stage("dsp")
- s.stage_done("dsp", 0.3)
- s.finish()
- out = capsys.readouterr().out
- assert "denoise 10/10 chunks (100%)" in out
- assert "denoise done in 2.5s (10/10 chunks)" in out
- assert "dsp done in 0.3s" in out
-
-
-def test_status_tty_live_line(monkeypatch, capsys):
- monkeypatch.setattr(ui, "is_tty", lambda: True)
- s = ui.Status(prefix="p — ")
- s.stage("denoise")
- s.tick(10, 10) # completion redraws immediately (bypasses the 0.1 s throttle)
- out = capsys.readouterr().out
- assert "denoise" in out and "10/10" in out
- s.finish()
-
-
-def test_progress_milestones_non_tty(capsys):
- p = ui.Progress("downloading x", total=1000)
- for i in (100, 300, 1000):
- p.update(i)
- p.close()
- out = capsys.readouterr().out
- assert "1000 B/1000 B" in out
- assert "done (1000 B" in out
-
-
-def test_progress_tty_bar(monkeypatch, capsys):
- monkeypatch.setattr(ui, "is_tty", lambda: True)
- p = ui.Progress("downloading torch", total=100)
- p.update(50)
- p.close()
- out = capsys.readouterr().out
- assert "50 B/100 B (50%)" in out
-
-
-def test_log_clears_live_line(monkeypatch, capsys):
- monkeypatch.setattr(ui, "is_tty", lambda: True)
- p = ui.Progress("downloading", total=100)
- p.update(50)
- ui.log("[producer] some message")
- out = capsys.readouterr().out
- assert "some message" in out
- assert out.index("some message") > out.index("downloading")
- p.close()
diff --git a/lib/tests/test_updates.py b/lib/tests/test_updates.py
deleted file mode 100644
index 7ad1727..0000000
--- a/lib/tests/test_updates.py
+++ /dev/null
@@ -1,221 +0,0 @@
-import subprocess
-import sys
-from types import SimpleNamespace
-
-from producer import lazy, updates
-
-
-def test_parse_would_install():
- out = "\n".join(
- [
- "Using Python 3.11.16 environment at: /x",
- "Resolved 2 packages in 1ms",
- "Would install 2 packages",
- " + scipy==1.14.1",
- " - numpy==1.26.4",
- " + numpy==2.2.6",
- " ~ cffi==2.1.1",
- "",
- ]
- )
- assert updates._parse_would_install(out) == {
- "scipy": "1.14.1",
- "numpy": "2.2.6",
- "cffi": "2.1.1",
- }
-
-
-def test_vkey_ordering():
- assert updates._vkey("1.26.4") < updates._vkey("2.2.6")
- assert updates._vkey("0.5.6") < updates._vkey("0.5.7")
- assert updates._vkey("2.7.1") < updates._vkey("2.7.1+cu126")
- assert updates._vkey("1.2.10") > updates._vkey("1.2.9")
- assert not updates._vkey("0.5.6") > updates._vkey("0.5.6")
-
-
-def test_core_names(tmp_path, monkeypatch):
- reqs = tmp_path / "requirements-core.txt"
- reqs.write_text("numpy==1.26.4\nscipy>=1.11,<1.15\n\n# comment\nsoundfile>=0.12,<0.13\n")
- monkeypatch.setattr(updates, "CORE_FILE", reqs)
- assert updates._core_names() == ["numpy", "scipy", "soundfile"]
-
-
-def test_probe_filters_to_keep(monkeypatch):
- calls = []
-
- def fake_run(cmd, capture_output, text, timeout):
- calls.append(cmd)
- return SimpleNamespace(
- returncode=0,
- stdout="Resolved 2 packages\n + scipy==1.14.1\n + numpy==2.2.6\n",
- )
-
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(updates.subprocess, "run", fake_run)
- got = updates._probe(["-U", "-r", "reqs.txt"], None, {"scipy"})
- assert got == {"scipy": "1.14.1"}
- assert calls[0][1:4] == ["pip", "install", "--dry-run"]
-
-
-def test_probe_failure_returns_empty(monkeypatch):
- def boom(cmd, capture_output, text, timeout):
- raise subprocess.TimeoutExpired(cmd, timeout)
-
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(updates.subprocess, "run", boom)
- assert updates._probe(["torch"], None, {"torch"}) == {}
-
-
-def test_collect_full_matrix(monkeypatch, tmp_path):
- installed = {
- "numpy": "1.26.4",
- "scipy": "1.13.1",
- "soundfile": "0.12.1",
- "pyloudnorm": "0.2.0",
- "torch": "2.7.1+cu126",
- "torchaudio": "2.7.1+cu126",
- "deepfilternet": "0.5.6",
- "zipenhancer": None,
- "clearvoice": None,
- }
-
- def fake_probe(args, python, keep):
- if "torch" in args and "torchaudio" in args and "--index-url" in args:
- return {"torch": "2.9.0+cu126", "torchaudio": "2.9.0+cu126"}
- if "deepfilternet" in args:
- return {"deepfilternet": "0.5.7", "torch": "2.14.0"}
- if "zipenhancer" in args:
- return {}
- return {"scipy": "1.14.1"}
-
- monkeypatch.setattr(updates, "_installed", lambda d: installed.get(d))
- monkeypatch.setattr(updates, "_probe", fake_probe)
- monkeypatch.setattr(lazy, "gpu_present", lambda: True)
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(updates, "RESEMBLE_PY", tmp_path / "missing" / "python")
-
- got = updates.collect()
- labels = [u.label for u in got]
- assert labels == ["scipy", "torch + torchaudio", "deepfilternet"]
-
- scipy = got[0]
- assert (scipy.old, scipy.new) == ("1.13.1", "1.14.1")
- assert "-U" in scipy.cmd and str(updates.CORE_FILE) in scipy.cmd
-
- torch_u = got[1]
- assert (torch_u.old, torch_u.new) == ("2.7.1+cu126", "2.9.0+cu126")
- assert "CUDA" in torch_u.note
- assert "torch==2.9.0+cu126" in torch_u.cmd
- assert "torchaudio==2.9.0+cu126" in torch_u.cmd
- assert lazy.TORCH_GPU_INDEX in torch_u.cmd
-
- dfn = got[2]
- assert (dfn.old, dfn.new) == ("0.5.6", "0.5.7")
- assert "torch==2.7.1" in dfn.cmd
- assert "deepfilternet==0.5.7" in dfn.cmd
- assert "torch==2.14.0" not in dfn.cmd
-
-
-def test_collect_skips_uninstalled_engines_and_missing_torch(monkeypatch, tmp_path):
- monkeypatch.setattr(updates, "_installed", lambda d: None)
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(updates, "RESEMBLE_PY", tmp_path / "missing" / "python")
-
- probed = []
- monkeypatch.setattr(updates, "_probe", lambda args, python, keep: probed.append(args) or {})
- assert updates.collect() == []
- assert len(probed) == 1
- assert "deepfilternet" not in probed[0] and "torch" not in probed[0]
-
-
-def test_collect_resemble_isolated_venv(monkeypatch, tmp_path):
- py = tmp_path / "resemble" / "bin" / "python"
- py.parent.mkdir(parents=True)
- py.write_text("")
- monkeypatch.setattr(updates, "_installed", lambda d: "2.7.1+cu126" if d == "torch" else None)
- monkeypatch.setattr(updates, "_installed_in", lambda p, d: "0.0.1")
- monkeypatch.setattr(lazy, "find_uv", lambda: "uv")
- monkeypatch.setattr(updates, "RESEMBLE_PY", py)
-
- def fake_probe(args, python, keep):
- if "resemble-enhance" in args:
- assert python == str(py)
- return {"resemble-enhance": "0.0.2"}
- return {}
-
- monkeypatch.setattr(updates, "_probe", fake_probe)
- got = updates.collect()
- assert len(got) == 1
- u = got[0]
- assert u.label == "resemble-enhance"
- assert "resemble-enhance==0.0.2" in u.cmd
- assert str(py) in u.cmd
-
-
-def test_confirm(monkeypatch):
- answers = iter(["", "no", "y", "yes"])
- monkeypatch.setattr("builtins.input", lambda prompt: next(answers))
- assert not updates._confirm("install updates?")
- assert not updates._confirm("install updates?")
- assert updates._confirm("install updates?")
- assert updates._confirm("install updates?")
-
-
-def test_check_and_prompt_non_tty_notices_only(monkeypatch, capsys):
- monkeypatch.setattr(
- updates,
- "collect",
- lambda: [updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv"])],
- )
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: False))
- assert updates.check_and_prompt() is False
- out = capsys.readouterr().out
- assert "updates available" in out
- assert "./producer update" in out
-
-
-def test_check_and_prompt_assume_yes_applies(monkeypatch, capsys):
- u = updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv", "pip", "install", "scipy"])
- monkeypatch.setattr(updates, "collect", lambda: [u])
- runs = []
- monkeypatch.setattr(updates.ui, "run", lambda cmd, label, check=True: runs.append(cmd))
- assert updates.check_and_prompt(assume_yes=True) is True
- assert runs == [["uv", "pip", "install", "scipy"]]
- assert "updates installed" in capsys.readouterr().out
-
-
-def test_check_and_prompt_declined(monkeypatch, capsys):
- u = updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv"])
- monkeypatch.setattr(updates, "collect", lambda: [u])
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
- monkeypatch.setattr(updates, "_confirm", lambda prompt: False)
- runs = []
- monkeypatch.setattr(updates.ui, "run", lambda cmd, label, check=True: runs.append(cmd))
- assert updates.check_and_prompt() is False
- assert runs == []
- assert "skipped updates" in capsys.readouterr().out
-
-
-def test_check_and_prompt_up_to_date(monkeypatch, capsys):
- monkeypatch.setattr(updates, "collect", lambda: [])
- assert updates.check_and_prompt(force=True) is False
- assert "up to date" in capsys.readouterr().out
-
-
-def test_update_command_requires_yes_when_not_interactive(monkeypatch):
- monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: False))
- monkeypatch.setattr(
- updates,
- "collect",
- lambda: [updates.Update("scipy", "1.13.1", "1.14.1", "", ["uv"])],
- )
- try:
- updates.run_update_command([])
- except SystemExit as e:
- assert "--yes" in str(e)
- else:
- raise AssertionError("expected SystemExit")
- runs = []
- monkeypatch.setattr(updates.ui, "run", lambda cmd, label, check=True: runs.append(cmd))
- assert updates.run_update_command(["--yes"]) == 0
- assert len(runs) == 1
diff --git a/producer b/producer
deleted file mode 100755
index 764ad01..0000000
--- a/producer
+++ /dev/null
@@ -1,68 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT="$0"
-while [ -L "$SCRIPT" ]; do
- DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
- SCRIPT="$(readlink "$SCRIPT")"
- [ "${SCRIPT#/}" != "$SCRIPT" ] || SCRIPT="$DIR/$SCRIPT"
-done
-ROOT="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
-LIB="$ROOT/lib"
-VENV="$LIB/.venv"
-UVBIN="$LIB/bin/uv"
-PYVER="3.11"
-
-# Fast path: healthy venv with core deps already matching -> run with zero setup.
-REQ_HASH="$(sha256sum "$LIB/requirements-core.txt" | cut -d' ' -f1)"
-if [ "${1:-}" != "--install-dev" ] &&
- [ -x "$VENV/bin/python" ] &&
- "$VENV/bin/python" -c pass >/dev/null 2>&1 &&
- [ "$(cat "$VENV/.core-hash" 2>/dev/null || echo none)" = "$REQ_HASH" ]; then
- exec "$VENV/bin/python" "$LIB/src" "$@"
-fi
-
-command -v python3 >/dev/null 2>&1 || { echo "producer: python3 is required" >&2; exit 1; }
-command -v curl >/dev/null 2>&1 || { echo "producer: curl is required to bootstrap uv" >&2; exit 1; }
-
-if [ ! -x "$UVBIN" ] && ! command -v uv >/dev/null 2>&1; then
- echo "[producer] bootstrapping uv..."
- mkdir -p "$LIB/bin"
- curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$LIB/bin" sh
-fi
-if [ -x "$UVBIN" ]; then
- UV="$UVBIN"
-else
- UV="$(command -v uv)"
-fi
-
-if ! "$UV" python list --only-installed 2>/dev/null | grep -q "$PYVER"; then
- echo "[producer] installing CPython $PYVER..."
- "$UV" python install "$PYVER"
-fi
-
-if [ ! -x "$VENV/bin/python" ]; then
- if [ -d "$VENV" ]; then
- echo "[producer] venv unusable (moved or broken) - rebuilding from local cache..."
- rm -rf "$VENV"
- else
- echo "[producer] creating venv (CPython $PYVER)..."
- fi
- "$UV" venv "$VENV" --python "$PYVER"
-fi
-
-if [ "$(cat "$VENV/.core-hash" 2>/dev/null || echo none)" != "$REQ_HASH" ]; then
- echo "[producer] installing core dependencies..."
- if ! "$UV" pip install --python "$VENV/bin/python" --offline -r "$LIB/requirements-core.txt" 2>/dev/null; then
- "$UV" pip install --python "$VENV/bin/python" -r "$LIB/requirements-core.txt"
- fi
- echo "$REQ_HASH" > "$VENV/.core-hash"
-fi
-
-if [ "${1:-}" = "--install-dev" ]; then
- "$UV" pip install --python "$VENV/bin/python" pytest ruff
- echo "[producer] dev tools installed"
- exit 0
-fi
-
-exec "$VENV/bin/python" "$LIB/src" "$@"
diff --git a/producer.sh b/producer.sh
new file mode 100755
index 0000000..3c792f3
--- /dev/null
+++ b/producer.sh
@@ -0,0 +1,186 @@
+#!/usr/bin/env bash
+# Producer: the single entry point for VoiceForge. The first run bootstraps a
+# fully contained runtime under lib/ (managed Python 3.13, dependencies,
+# bundled FFmpeg, and later the AI environments and model weights); every run
+# then executes the program from that runtime. Nothing is installed outside
+# this directory: no PATH changes, no sudo, no system Python changes.
+#
+# Trust boundary for downloads: official PyPI (uv, application wheels) and
+# Astral's managed CPython distribution. AI setup additionally trusts
+# download.pytorch.org and the pinned DeepFilterNet3 model host. Host
+# package-manager settings (extra indexes, targets, constraints, config
+# files) cannot add to or redirect these sources.
+#
+# First bootstrap needs an existing `uv`, or a Python 3 with venv support.
+# Later runs, including moving or renaming this whole directory, are handled
+# by the launcher itself; caches make the repair fast.
+set -euo pipefail
+
+SOURCE="${BASH_SOURCE[0]}"
+while [[ -L "$SOURCE" ]]; do
+ DIR="$(CDPATH='' cd -- "$(dirname -- "$SOURCE")" && pwd)"
+ SOURCE="$(readlink -- "$SOURCE")"
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
+done
+ROOT="$(CDPATH='' cd -- "$(dirname -- "$SOURCE")" && pwd)"
+LIB="$ROOT/lib"
+RUNTIME="$LIB/runtime"
+APP="$RUNTIME/app"
+PROJECT="$LIB/project"
+READY="$RUNTIME/.producer-ready"
+# Bump when the bootstrap recipe changes (python version, install steps...).
+RUNTIME_VERSION="2"
+
+usage() {
+ cat <<'USAGE'
+Producer - local speech cleanup and podcast mastering (VoiceForge).
+
+Usage: ./producer.sh COMMAND [options]
+
+ process RECORDING.wav... Master one or more WAV files
+ preview RECORDING.wav Render loudness-matched profile previews
+ analyze RECORDING.wav Print measurements as JSON on stdout
+ config Print resolved settings (TOML, or JSON with --json)
+ doctor Check FFmpeg, data directory and CUDA driver
+ setup --device cpu|cuda Install and self-test the AI backend
+
+Outputs are written beside each input by default. Run
+'./producer.sh COMMAND --help' for the options of a command.
+'./producer.sh --rebuild COMMAND...' re-runs the bootstrap first, for example
+after changing the source code under lib/project. A rebuild waits for
+running producer jobs to finish.
+USAGE
+}
+
+log() { printf '[producer] %s\n' "$*" >&2; }
+fail() { printf '[producer] Error: %s\n' "$*" >&2; exit 1; }
+
+case "${1:-}" in
+ -h|--help) usage; exit 0 ;;
+esac
+
+REBUILD=0
+ARGS=()
+for arg in "$@"; do
+ case "$arg" in
+ --rebuild) REBUILD=1 ;;
+ *) ARGS+=("$arg") ;;
+ esac
+done
+# 'producer.sh --rebuild --help' asks for help, not a rebuild.
+if [[ ${#ARGS[@]} -eq 1 && ( "${ARGS[0]}" == "-h" || "${ARGS[0]}" == "--help" ) ]]; then
+ usage
+ exit 0
+fi
+
+mkdir -p -- "$RUNTIME/cache/pip" "$RUNTIME/cache/uv" "$RUNTIME/uv" "$RUNTIME/tmp" \
+ || fail "Cannot create the runtime directory under $LIB"
+
+# Contained environment: host Python settings and package-manager caches
+# cannot leak in, and nothing (temporary files included) is written outside
+# lib/runtime. Installation-target, index, and constraint overrides from the
+# host are dropped and package-manager configuration files are disabled, so
+# packages can only come from the explicitly passed trusted indexes and land
+# inside lib/runtime. The application reads VOICEFORGE_HOME for AI envs and
+# models.
+unset PYTHONPATH PYTHONHOME VIRTUAL_ENV \
+ PIP_TARGET PIP_PREFIX PIP_USER PIP_FIND_LINKS PIP_INDEX_URL PIP_EXTRA_INDEX_URL \
+ PIP_CONSTRAINT \
+ UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL UV_INDEX UV_FIND_LINKS \
+ UV_CONSTRAINT
+export PYTHONNOUSERSITE=1
+export PIP_CACHE_DIR="$RUNTIME/cache/pip" UV_CACHE_DIR="$RUNTIME/cache/uv"
+export PIP_CONFIG_FILE=/dev/null UV_NO_CONFIG=1
+export UV_PYTHON_INSTALL_DIR="$RUNTIME/uv/python" XDG_CACHE_HOME="$RUNTIME/cache"
+export TMPDIR="$RUNTIME/tmp" VOICEFORGE_HOME="$RUNTIME"
+
+marker_current() {
+ # The marker must name this exact path, and the interpreter must exist:
+ # a failed or interrupted rebuild leaves neither, forcing a repair.
+ [[ -f "$READY" ]] \
+ && [[ "$(cat -- "$READY" 2>/dev/null)" == "$RUNTIME v$RUNTIME_VERSION" ]] \
+ && [[ -x "$APP/bin/python" ]]
+}
+
+bootstrap() {
+ command -v flock >/dev/null 2>&1 \
+ || fail "The 'flock' utility (util-linux) is required but was not found."
+ # Bootstrap lock (fd 9): serializes bootstrap across launches and covers
+ # the whole destructive window below. It is released before the
+ # application starts.
+ exec 9>"$RUNTIME/bootstrap.lock"
+ flock 9
+ if [[ "$REBUILD" -eq 0 ]] && marker_current; then
+ exec 9>&-
+ # Runtime lock (fd 8): held shared for the application's lifetime so
+ # a later --rebuild cannot delete files this process runs from.
+ exec 8>"$RUNTIME/app.lock"
+ flock -s 8
+ return 0
+ fi
+ # Exclusive runtime lock: waits out running jobs before anything is
+ # deleted, so a rebuild can never clobber a live environment.
+ exec 8>"$RUNTIME/app.lock"
+ if ! flock -n 8; then
+ log "Waiting for running producer jobs to finish before building the runtime"
+ flock 8
+ fi
+ # Invalidate the marker before any destructive work: if this bootstrap
+ # fails or is interrupted, the next launch repairs instead of trusting
+ # a half-built environment.
+ rm -f -- "$READY" "$READY.tmp"
+ log "Preparing the contained runtime in lib/runtime (first run, or the directory moved)"
+
+ local UV=""
+ local err=""
+ if [[ -x "$RUNTIME/bootstrap/bin/uv" ]] \
+ && "$RUNTIME/bootstrap/bin/uv" --version >/dev/null 2>&1; then
+ UV="$RUNTIME/bootstrap/bin/uv"
+ elif command -v uv >/dev/null 2>&1; then
+ UV="$(command -v uv)"
+ else
+ command -v python3 >/dev/null 2>&1 \
+ || fail "First-run bootstrap needs 'uv' or Python 3 with venv support; neither was found."
+ log "Bootstrapping uv from PyPI"
+ rm -rf -- "$RUNTIME/bootstrap"
+ python3 -m venv "$RUNTIME/bootstrap" \
+ || fail "python3 -m venv failed; install venv support with your system package manager and retry. Nothing was run with sudo."
+ "$RUNTIME/bootstrap/bin/python" -m pip install --index-url https://pypi.org/simple 'uv==0.8.17' >/dev/null \
+ || fail "Downloading uv from PyPI failed; check network access."
+ UV="$RUNTIME/bootstrap/bin/uv"
+ fi
+
+ # Managed-Python alias links are absolute; after a move they dangle while
+ # the downloaded payload is still intact. Drop stale links so uv
+ # re-resolves what is already on disk instead of re-downloading.
+ find "$RUNTIME/uv" -maxdepth 1 -type l ! -exec test -e {} \; -delete 2>/dev/null || true
+
+ rm -rf -- "$APP"
+ # Python 3.13 for the application runtime: earlier musl builds from uv
+ # misreport their extension suffix and cannot load musl-tagged wheels.
+ # The AI worker keeps its own pinned Python 3.11 (glibc only).
+ "$UV" venv --python 3.13 --managed-python --relocatable --allow-existing "$APP" \
+ || fail "Creating the managed Python 3.13 environment failed; check network access and free space."
+ # The bundled FFmpeg wheel is preferred; where it does not exist (for
+ # example musl systems), fall back to a system FFmpeg at runtime.
+ if ! err="$("$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple \
+ "$PROJECT[bundled-ffmpeg]" 2>&1 >/dev/null)"; then
+ log "Bundled FFmpeg wheel unavailable; installing core dependencies (a system FFmpeg is then required)."
+ if [[ -n "$err" ]]; then
+ sed 's/^/[producer] /' <<<"$err" >&2
+ fi
+ "$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple "$PROJECT" \
+ || fail "Installing the application dependencies failed; check network access and free space."
+ fi
+ "$APP/bin/python" -I -c "import voiceforge" >/dev/null 2>&1 \
+ || fail "The application environment failed verification."
+
+ printf '%s v%s\n' "$RUNTIME" "$RUNTIME_VERSION" > "$READY.tmp"
+ mv -f -- "$READY.tmp" "$READY"
+ # Downgrade to the shared runtime lock for the application's lifetime.
+ flock -s 8
+ exec 9>&-
+}
+
+bootstrap
+exec "$APP/bin/python" -I -m voiceforge ${ARGS[@]+"${ARGS[@]}"}