commit 62c4659486d639fe602be7b01cb31f585c8833ad Author: Brian Fertig Date: Tue Aug 25 19:04:05 2026 -0600 intial commit diff --git a/GPU-upgrade.md b/GPU-upgrade.md new file mode 100644 index 0000000..8ecdf34 --- /dev/null +++ b/GPU-upgrade.md @@ -0,0 +1,235 @@ +# GPU / AI-Separator Upgrade — Planning Notes (for me, the assistant) + +Status: **planned, not built.** Last updated: 2026-08-23. +Context: VoiceGrab core (`voicegrab.py`) currently isolates voice with the +`noisereduce` STFT pipeline (DSP). This note covers adding a deep-learning +separator (Demucs / UVR / RoFormer family) as an optional "heavy isolation" +mode for clips with music or competing speech. + +--- + +## 1. Problem the DSP path doesn't solve + +`noisereduce` (spectral gating) is great for **stationary / broadband noise**: +fan, hum, room tone, light traffic. It **cannot** separate: + +- background music (guitars, drums, other instruments overlap speech band) +- a second speaker talking +- intermittent noise bursts that contain voice-like formant structure + +Spectral gate will either leave the music or carve holes in the target voice +(pumping artifacts). For AI-voice-training samples, a *source separator* +(speech vs. non-speech) is the right tool. + +## 2. Candidate models (ranked for our use case) + +| Model | Size (ckpt) | What it does | Notes | +|---|---|---|---| +| **Demucs `htdemucs_ft`** | ~92 MB | 4-stem (vocals/drums/bass/other) | MIT code + weights, maintained, CLI-ready, best "default choice". `htdemucs` (80 MB) is the original. Fine-tuned `_ft` preserves voice quality better — prefer it. | +| **BS-RoFormer / Mel-Band RoFormer** (vocal separation & speech enhancement) | ~50–100 MB | SOTA speech/music separation quality | Checkpoint licensing is **research-leaning** — must verify before bundling/redistributing. | +| **UVR5 MDX-Net / VR Arch** checkpoints | ~70–100 MB each | Community vocal-sep models, many flavors | The *app* is a GUI wrapper; the checkpoints are community-made with mixed licenses — risky to ship. Good for *reference* only. | +| **Demucs `htdemucs_6s`** | ~100 MB | 6-stem incl. a dedicated "speech" stem | Heavier; only worth it if 4-stem vocals bleed. | + +Decision (revisit at build time): **`htdemucs_ft` as the default AI mode** — +permissive license, single dependency (`demucs` pulls `torch`), and its +"vocals" stem is exactly what we want. Keep an eye on RoFormer-family +license clarity; if permissive weights appear, they become the recommended +quality option. + +**License gate: never bundle a checkpoint we can't point to a permissive +license for.** Demucs/`htdemucs(_ft)` = MIT. Everything else: verify first. + +## 3. Hardware & performance reality check + +Local machine (`voicegrab2` host): **no NVIDIA GPU** (no `nvidia-smi`), +8 cores, 14 GB RAM. So: + +- Development/testing here will be **CPU-only**. That's fine for *correctness* + and pipeline integration; all real speed numbers below for "what the user + experiences" assume an RTX 30/40/50-class GPU. +- CPU fallback must still work (Windows users without GPU, Macs). + +Estimates (5 s clip → 60 s clip): + +| Backend | 5 s | 60 s | VRAM | Notes | +|---|---|---|---|---| +| DSP (current) | <1 s | ~10–20 s | ~200 MB RAM | Always works, CPU | +| Demucs CPU | ~10–25 s | ~2–6 min | ~1–2 GB RAM | usable for short samples | +| Demucs CUDA (4–6 GB VRAM) | ~1–3 s | ~15–40 s | ~2 GB peak | comfortable at 6 GB+ | +| 6-stem / RoFormer | similar +30–50% | similar +30–50% | ~2–4 GB | longer context = more VRAM | + +Practical guidance to surface in UI copy: +- Target clip length for AI voice samples is 30–120 s → **fine on 4 GB VRAM**. +- First run downloads model (~92 MB) to user data dir. Show this explicitly. +- CPU mode: set expectations ("a few minutes for a 60 s clip"). + +## 4. Architecture plan + +### 4.1 Backend abstraction (replace direct `VoiceReducer` calls) + +```python +# voicegrab.py (sketch) +class Separator: + name: str # "None" | "Noise reduction (fast)" | "AI separation (Demucs)" + def available(self) -> tuple[bool, str]: ... # (ok, reason) + def separate(self, y: np.ndarray, sr: int, progress) -> np.ndarray: ... + +class DSSeparator(Separator): # current noisereduce path, unchanged behavior + ... + +class DemucsSeparator(Separator): # lazy import; GPU-aware + def __init__(self): + import torch, demucs.apply, demucs.pretrained + ... + def separate(self, y, sr, progress): + wav = y[None, :, :] # demucs expects (1, C, T) float32 + model = self._model() # cached singleton + sources = demucs.apply.apply_model(model, wav, device=self.device, shifts=1) + vocals = sources[model.sources.index("vocals")] + return vocals[0, 0].cpu().numpy() # mono speech +``` + +Pipeline order becomes: **extract range → optional separation (DSP or AI) → +loudnorm → MP3 320k**. The AI path *replaces* DSP, not stacks on it — running +`noisereduce` after Demucs is usually counterproductive (double-processing +artifacts). Keep DSP as an independent mode, not a post-filter. + +### 4.2 Device selection + +```python +def pick_device(): + if torch.cuda.is_available(): + return "cuda" + if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): + return "mps" # Mac bonus, free + return "cpu" +``` + +Surface the active device in status text ("Using GPU (CUDA)" / "CPU mode — +will be slower"). + +### 4.3 GUI changes + +- Replace the single "Isolate voice" checkbox with a **mode selector**: + 1. `No processing` (just loudness-normalize — current non-isolate path) + 2. `Noise reduction (fast)` — current DSP, default, no downloads + 3. `AI voice separation (Demucs)` — shows a note: "First use downloads + ~92 MB of models. GPU strongly recommended." +- Progress: `demucs.apply.apply_model` supports a callback; route it into the + existing `Worker.log` signal. On first use, `pretrained.get_model("htdemucs_ft")` + download should report progress too (it goes through `torch.hub`/`urllib` — + wrap or at least show "Downloading model… (x MB)" in the log line). +- Model cache: `appdata_dir()/models/` (already the pattern for the old + profile idea; reuse `appdata_dir()`). + +### 4.4 Threading / UX + +- Keep the existing `Worker(QThread)`; add a **busy spinner + cancel button**. + Demucs on CPU can run minutes — `subprocess`-style cancellation isn't + available for in-process torch, so implement a soft cancel: worker checks a + flag between `apply_model` shifts (pass `shifts=2`, check between) — or accept + no-cancel for v1 and say so. +- Disable the Export button while running (already the pattern). + +## 5. Packaging — the hard part (be honest about it) + +`torch` + `demucs` is the weight problem. Options, ranked: + +**A. "AI Pack" as a separate installer component (recommended).** +Core app stays small & instant-start (PyInstaller onefile, ~40–80 MB with +PySide6+ffmpeg). Ship a second Inno Setup *component* ("AI voice separation +(Demucs), ~1.5–2.5 GB after install") that drops pre-wheeled `torch`+`demucs` +wheels into `%APPDATA%\VoiceGrab\ai\wheels`. On first AI-mode use, the app +builds a venv from those wheels (`python -m venv ~/.local/share/VoiceGrab/ai_venv`) +and imports from it (or runs separation as a **sidecar process** — see B). +Pros: core users pay nothing; opt-in size; works offline after download. +Cons: most moving parts; needs careful Windows testing. + +**B. Separation as a sidecar executable (cleanest runtime boundary).** +Build `separate.py` → `demucs-cli.exe` with PyInstaller (`--collect-all torch` +→ big, but it's an *isolated* binary, e.g. `VoiceGrab-AI.exe` next to the +core app). Core app shells out: `VoiceGrab-AI.exe --in clip.wav --out vocals.wav +--device cuda`. Pros: crash/firewall/GPU driver issues can't take down the GUI; +no venv bootstrapping; trivial to update independently; the `demucs` CLI +already does exactly this job. Cons: one more build artifact. +→ **I'd pick B for Windows, A-or-B for Linux.** In practice B generalizes: +it *is* the demucs CLI wrapped in a branded exe. + +**C. Single onefile with torch bundled.** ❌ Don't. 8 GB+ onefile, 60–120 s +extraction on every start, memory-mapped junk in `%TEMP%`. Only acceptable as +a "portable full build" niche option. + +### Linux specifics +- AppImage with torch = same bloat problem → ship `VoiceGrab` (core, as today) + + `VoiceGrab-AI` (sidecar binary, ~2 GB) or a `pip install -e .[ai]` path. +- A second `.deb` component with the wheels in `/usr/share/voicegrab-ai` is + also reasonable. + +## 6. Correctness / QA plan + +1. **Unit:** `separate()` returns float32 mono, shape `(T,)`, dtype invariance, + no NaN, SR unchanged (Demucs resamples to 44.1 k internally — **re-resample + back to 48 k before loudnorm**, or just feed 44.1 through to MP3; decide + and document. Voice training tools accept 44.1k fine → **simplest: feed + Demucs output at 44.1k straight to MP3**, skip the 48k round-trip). +2. **Regression:** existing `kage.mp4` 1–6 s test must produce a valid 320 kbps + MP3 in both DSP and AI modes; assert `ffprobe` duration ≈ 5.0 s ± 0.15 s + (MP3 encoder padding). +3. **A/B listening test:** pick 3 clips (clean speech; speech+fan; + speech+music) — currently only have `kage.mp4`; need 2 more fixtures. + Record: artifacts? "chipmunk" consonants (classic Demucs failure)? + residual music? +4. **GPU smoke:** on a CUDA machine: `torch.cuda.is_available()`, model load, + 60 s clip timing, VRAM peak via `torch.cuda.max_memory_allocated()`. + Acceptance: < 2× realtime on ≥ 6 GB VRAM. +5. **CPU timeout sanity:** 60 s clip on 8-core CPU must finish < 8 min; + surface estimated time in UI ("≈ 2–4 min on CPU"). +6. **No-GPU Windows box** (test VM): CPU path + model download + offline + second run (no re-download). + +## 7. Risks / open questions + +- **Model redistribution license** — only ship MIT (Demucs). Re-check + checkpoint license at build time; put a `THIRD_PARTY.md` in the installer. +- **Torch × Python 3.14** — current host is 3.14; verify wheel availability + at target versions (torch tracks newest Pythons well, but pin in CI). +- **CUDA wheel size on Windows** — `torch` with cu126 wheels ≈ 2.5 GB + (nvidia-cublas etc. are separate wheels). Sidecar build must + `--collect-all torch nvidia.*`. +- **AMD/Intel users** — ROCm wheels exist (Windows + Linux) but are finicky; + IPEX for Intel Arc. v1: NVIDIA + CPU only; document the rest as "may work". +- **Demucs drift** — `demucs` is PyTorch-Lightning-free now but its API + (`apply_model`, `pretrained.get_model`) has shifted across versions. + **Pin `demucs==4.1.x` in the AI-pack wheels.** +- **Voice fidelity tradeoff:** aggressive separation can thin out sibilance / + breath — exactly what TTS trainers want to *keep*. `htdemucs_ft` is the + gentler choice; also consider post-gain before loudnorm (separated vocals + sit ~6–10 dB lower than the mix). +- **Cancel semantics** for CPU-mode runs (see 4.4) — decide before build. + +## 8. Suggested build order (if I do this) + +1. `SeparationBackend` interface + move existing DSP behind it (small, no deps) — ✅ safe, half a day. +2. Add `demucs` to a **dev-only** venv extra; wire `DemucsSeparator` with + lazy import + `pick_device()`; test end-to-end on this CPU box with + `kage.mp4` (5 s, expect ~10–30 s). +3. UI: mode selector + progress + device line. (Half a day.) +4. Packaging: prototype **B** (sidecar) with PyInstaller on this box (CPU + build) to shake out `--collect-all` issues; finalize Windows build script. +5. QA pass from §6; write `THIRD_PARTY.md`; bump version to 1.1.0. + +Rough total: **2–4 focused days**, packaging being the long pole. + +## 9. Files this would touch + +- `voicegrab.py` — backend interface, `DemucsSeparator`, UI mode selector +- `installer/build_win.ps1` — AI sidecar build step +- `installer/VoiceGrab.iss` — new `[Components]`/`[Files]` entries +- `installer/build_linux.sh` — sidecar variant +- `requirements-ai.txt` (new) — `demucs==4.1.*`, `torch==2.*` +- `README.md` — AI mode docs + GPU guidance +- `tests/test_separators.py` (new) — §6 automation + +--- +*Reminder: the "big model" option is a **product feature behind a checkbox**, +never a hard dependency. Core app must stay <100 MB and start in <2 s.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..865e5a2 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# VoiceGrab + +Cut a time range out of any video file and export a clean, loudness-normalized +**MP3 voice sample** — ready to use for AI voice training / cloning. + +``` +Voice / audio file: [ kage.mp4 ] [Open…] +Start: 0:10.00 Stop: 0:35.00 [Load preview] [▶ Play range] +[x] Isolate voice (reduce background noise) strength: 80% +Output MP3: [ .../kage_voicegrab.mp3 ] [Browse…] +────────────────────────────────────────────────── +[ Export MP3 ] +``` + +## Features +- Works with any container ffmpeg understands: mp4, mkv, mov, webm, avi, flv, m4a… +- **Isolate voice** checkbox: STFT-based noise reduction (runs on CPU, no + downloads, no GPU). Strength slider + a "stationary noise" mode for + constant fans/hum. +- Export is **320 kbps MP3**, loudness-normalized to −16 LUFS (broadcast + reference — good levels for voice-training datasets). +- "▶ Play range" lets you audition the exact segment before saving. +- Also a headless CLI (see below) for scripting/batch work. + +## Requirements +- Python 3.10+ +- ffmpeg on your PATH (bundled automatically in installer builds) + +## Run from source +```bash +cd voicegrab2 +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python voicegrab.py +``` + +## CLI mode +```bash +voicegrab.py --cli input.mp4 10 35 output.mp3 +``` + +## Windows installer (single .exe + installer) +1. `pip install pyinstaller` +2. `python voicegrab.py.spec` is not needed — run: + ```bat + pyinstaller --onefile --windowed --name VoiceGrab ^ + --add-binary "C:\ffmpeg\bin\ffmpeg.exe;." ^ + --add-binary "C:\ffmpeg\bin\ffprobe.exe;." ^ + --icon assets/icon.ico voicegrab.py + ``` +3. Build the installer with Inno Setup: + ```bat + "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\VoiceGrab.iss + ``` + (Copy `dist\VoiceGrab.exe` to `installer\` first — the script assumes + `dist\VoiceGrab.exe` relative to the project root.) + +## Linux +- **AppImage**: `installer/build_linux.sh` (needs PyInstaller + + `linuxdeploy` or `appimagetool` + your ffmpeg in PATH). +- **Debian/Ubuntu**: just run the PyInstaller binary, or install ffmpeg via + apt and run from source. A `.desktop` entry template is in `installer/`. +- **Flatpak** is also a fine route if you want it in your store. + +## Notes on voice-isolation quality +The built-in reducer is classic DSP (spectral gating / STFT noise +estimation) — great for fan, hum, light room tone. For music or heavy +background speech, a deep-learning model (e.g. Demucs / UVR) gives +better separation but costs ~1–2 GB of downloads and runs far slower +without a GPU. For voice *training* samples, the DSP route is usually +more than enough, and it keeps the installer small and startup instant. diff --git a/__pycache__/voicegrab.cpython-314.pyc b/__pycache__/voicegrab.cpython-314.pyc new file mode 100644 index 0000000..8e2cb3f Binary files /dev/null and b/__pycache__/voicegrab.cpython-314.pyc differ diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..d6d3c59 Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..2eae176 Binary files /dev/null and b/assets/icon.png differ diff --git a/installer/VoiceGrab.iss b/installer/VoiceGrab.iss new file mode 100644 index 0000000..e793e8e --- /dev/null +++ b/installer/VoiceGrab.iss @@ -0,0 +1,41 @@ +; VoiceGrab — Inno Setup script +; Install Inno Setup 6: https://jrsoftware.org/isdl.php +; Compile: ISCC.exe installer\VoiceGrab.iss (from project root, after build_win.ps1) + +#define MyAppName "VoiceGrab" +#define MyAppVersion "1.0.0" +#define MyAppExeName "VoiceGrab.exe" + +[Setup] +AppId={{B7E5A9C2-3D41-4F6B-9E0A-1C8F5D2B7A11} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher=VoiceGrab +DefaultDirName={autopf}\VoiceGrab +DefaultGroupName=VoiceGrab +DisableProgramGroupPage=yes +OutputDir=dist +OutputBaseFilename=VoiceGrab-Setup-{#MyAppVersion} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ArchitecturesInstallIn64BitMode=x64 +ArchitecturesAllowed=x64 +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=dialog + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +Source: "dist\VoiceGrab.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\VoiceGrab"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\VoiceGrab"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/installer/build_linux.sh b/installer/build_linux.sh new file mode 100644 index 0000000..f463d60 --- /dev/null +++ b/installer/build_linux.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# VoiceGrab — Linux build (produces dist/VoiceGrab- binary; AppImage if linuxdeploy available) +set -euo pipefail +cd "$(dirname "$0")/.." + +PY="${PYTHON:-.venv/bin/python}" +[ -x "$PY" ] || { echo "Create the venv first: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"; exit 1; } +command -v ffmpeg >/dev/null || { echo "ffmpeg not found in PATH (needed to resolve the binary)"; exit 1; } + +"$PY" -m pip install pyinstaller + +FFMPEG_BIN="$(command -v ffmpeg)" +FFPROBE_BIN="$(command -v ffprobe || true)" + +EXTRA=() +if [ -n "${FFPROBE_BIN:-}" ]; then + EXTRA+=(--add-binary "$FFPROBE_BIN:.") +fi + +"$PY" -m PyInstaller --noconfirm --onefile --windowed --name VoiceGrab \ + --add-binary "$FFMPEG_BIN:." \ + "${EXTRA[@]}" \ + --add-data "assets/icon.png:assets" \ + --icon assets/icon.ico \ + voicegrab.py + +echo +echo "Binary: dist/VoiceGrab" +echo "Test: ./dist/VoiceGrab" + +# Optional: wrap in an AppImage +if command -v linuxdeploy >/dev/null 2>&1; then + echo "Building AppImage with linuxdeploy..." + linuxdeploy --appimage --input dist/ --output appimage +elif command -v appimagetool >/dev/null 2>&1; then + echo "linuxdeploy not found; appimagetool present — build a .dir first, then run:" + echo " appimagetool dist/VoiceGrab-Dir VoiceGrab-x86_64.AppImage" +else + echo "Tip: install linuxdeploy (or appimagetool) to produce an AppImage." +fi diff --git a/installer/build_win.ps1 b/installer/build_win.ps1 new file mode 100644 index 0000000..5e3777f --- /dev/null +++ b/installer/build_win.ps1 @@ -0,0 +1,41 @@ +# VoiceGrab — Windows build script +# Usage (from project root): powershell -ExecutionPolicy Bypass -File installer\build_win.ps1 +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +# 1. Ensure a venv with deps +if (-not (Test-Path ".venv\Scripts\python.exe")) { + python -m venv .venv +} +& ".venv\Scripts\python.exe" -m pip install --upgrade pip +& ".venv\Scripts\python.exe" -m pip install -r requirements.txt pyinstaller + +# 2. Grab a static ffmpeg build (BtbN build — includes libmp3lame) +$ffdir = "third_party\ffmpeg\bin" +if (-not (Test-Path "$ffdir\ffmpeg.exe")) { + New-Item -ItemType Directory -Force -Path $ffdir | Out-Null + $zip = "third_party\ffmpeg.zip" + $url = "https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip" + Write-Host "Downloading ffmpeg (one-time)..." + Invoke-WebRequest -Uri $url -OutFile $zip + Expand-Archive $zip -DestinationPath "third_party\ffmpeg_tmp" + $found = Get-ChildItem -Recurse "third_party\ffmpeg_tmp" -Filter "ffmpeg.exe" | Select-Object -First 1 + Copy-Item $found.FullName "$ffdir\" + $probe = Join-Path $found.DirectoryName "ffprobe.exe" + if (Test-Path $probe) { Copy-Item $probe "$ffdir\" } + Remove-Item $zip -Force; Remove-Item "third_party\ffmpeg_tmp" -Recurse -Force +} + +# 3. PyInstaller +& ".venv\Scripts\pyinstaller.exe" --noconfirm --onefile --windowed --name VoiceGrab ` + --add-binary "$ffdir\ffmpeg.exe;." ` + --add-binary "$ffdir\ffprobe.exe;." ` + --add-data "assets\icon.png;assets" ` + --icon "assets\icon.ico" ` + voicegrab.py + +Write-Host "Built dist\VoiceGrab.exe" +Write-Host "" +Write-Host "Next: build the installer with Inno Setup:" +Write-Host ' "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\VoiceGrab.iss' diff --git a/installer/voicegrab.desktop b/installer/voicegrab.desktop new file mode 100644 index 0000000..4a05df7 --- /dev/null +++ b/installer/voicegrab.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=VoiceGrab +Comment=Cut a time range from a video and export a clean MP3 voice sample +Exec=voicegrab +Icon=voicegrab +Categories=AudioVideo;Audio; +Keywords=audio;mp3;voice;clip; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a3fa95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PySide6>=6.6 +numpy +scipy +soundfile +noisereduce diff --git a/voicegrab.py b/voicegrab.py new file mode 100644 index 0000000..003ec5d --- /dev/null +++ b/voicegrab.py @@ -0,0 +1,608 @@ +#!/usr/bin/env python3 +"""VoiceGrab — cut a time range from a video file and export a clean MP3 voice sample. + +- Pick an input video (mp4, mkv, webm, avi, mov, ...) +- Set start / stop timestamps +- Optional: isolate voice with noise reduction (no GPU or big models needed) +- Export MP3 (320 kbps, loudness-normalized to -16 LUFS — good for AI voice training) +- Waveform preview with the selected range highlighted +""" +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import wave + +APP_NAME = "VoiceGrab" +APP_VERSION = "1.0.0" + +# --------------------------------------------------------------------------- +# Resource / executable helpers +# --------------------------------------------------------------------------- + + +def app_dir() -> str: + """Directory that contains our files (bundled data dir in frozen builds).""" + if getattr(sys, "frozen", False): + return sys._MEIPASS # type: ignore[attr-defined] + return os.path.dirname(os.path.abspath(__file__)) + + +def appdata_dir() -> str: + """Per-user directory for saved model noise profiles.""" + if platform.system() == "Windows": + base = os.environ.get("APPDATA", os.path.expanduser("~")) + elif platform.system() == "Darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) + d = os.path.join(base, APP_NAME) + os.makedirs(d, exist_ok=True) + return d + + +# --------------------------------------------------------------------------- +# ffmpeg helpers (bundled binary in frozen builds, system ffmpeg otherwise) +# --------------------------------------------------------------------------- + + +def ffmpeg_exe() -> str: + if getattr(sys, "frozen", False): + exe = os.path.join(app_dir(), "ffmpeg.exe" if platform.system() == "Windows" else "ffmpeg") + if os.path.exists(exe): + # ensure executable bit + try: + os.chmod(exe, 0o755) + except OSError: + pass + return exe + return "ffmpeg" + + +def ffprobe_exe() -> str: + if getattr(sys, "frozen", False): + exe = os.path.join(app_dir(), "ffprobe.exe" if platform.system() == "Windows" else "ffprobe") + if os.path.exists(exe): + try: + os.chmod(exe, 0o755) + except OSError: + pass + return exe + return "ffprobe" + + +class FfmpegError(RuntimeError): + pass + + +def probe_duration(path: str) -> float: + try: + out = subprocess.run( + [ffprobe_exe(), "-hide_banner", "-v", "error", + "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", path], + capture_output=True, text=True, timeout=60, + ) + return float(out.stdout.strip().splitlines()[0]) + except (ValueError, IndexError, subprocess.SubprocessError) as exc: + raise FfmpegError(f"Could not read media file: {path}") from exc + + +def extract_wav(path: str, start: float, stop: float, workdir: str) -> str: + """Extract the selected range as mono 48 kHz WAV (PCM 16-bit).""" + out = os.path.join(workdir, "clip.wav") + cmd = [ + ffmpeg_exe(), "-hide_banner", "-v", "error", "-y", + "-ss", f"{start:.3f}", "-to", f"{stop:.3f}", + "-i", path, + "-vn", "-ac", "1", "-ar", "48000", + "-c:a", "pcm_s16le", out, + ] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if r.returncode != 0 or not os.path.exists(out): + raise FfmpegError(f"Audio extraction failed:\n{r.stderr.strip()[:1500]}") + return out + + +def wav_stats(path: str): + """Return (nframes, samplerate, rms) of a 16-bit WAV.""" + with wave.open(path, "rb") as w: + n = w.getnframes() + sr = w.getframerate() + data = w.readframes(n) + import array + a = array.array("h") + a.frombytes(data[: len(data) // 2 * 2]) + if len(a) == 0: + return n, sr, 0.0 + rms = (sum((x / 32768.0) ** 2 for x in a) / len(a)) ** 0.5 + return n, sr, float(rms) + + +def peaks(path: str, buckets: int = 2000): + """Downsample a 16-bit WAV to (max, min) per bucket for plotting.""" + with wave.open(path, "rb") as w: + n = w.getnframes() + data = w.readframes(n) + import array + a = array.array("h") + a.frombytes(data[: len(data) // 2 * 2]) + m = len(a) + if m == 0: + return [], [] + step = max(1, m // buckets) + maxs, mins = [], [] + for i in range(0, m, step): + chunk = a[i:i + step] + maxs.append(max(chunk) / 32768.0) + mins.append(min(chunk) / 32768.0) + return maxs, mins + + +# --------------------------------------------------------------------------- +# Voice isolation (lightweight, CPU-only) +# --------------------------------------------------------------------------- + +try: + import numpy as np + import noisereduce as nr + HAVE_NR = True + NR_IMPORT_ERROR = "" +except Exception as _exc: # pragma: no cover + HAVE_NR = False + NR_IMPORT_ERROR = str(_exc) + + +class VoiceReducer: + """STFT noise reduction with an optional noise reference clip.""" + + def __init__(self, intensity: float, stationary: bool): + self.intensity = float(intensity) # 0.0 .. 1.0 + self.stationary = stationary + self.noise_ref = None # optional numpy array of noise + + def set_noise_ref(self, noise): + self.noise_ref = noise + + def reduce(self, y: "np.ndarray", sr: int) -> "np.ndarray": + kwargs = dict( + y=y, sr=sr, + prop_decrease=min(0.95, self.intensity), + stationary=self.stationary, + use_tqdm=False, + ) + if self.noise_ref is not None: + kwargs["y_noise"] = self.noise_ref + return nr.reduce_noise(**kwargs) + + +# --------------------------------------------------------------------------- +# Audio IO helpers (numpy/soundfile) +# --------------------------------------------------------------------------- + + +def read_wav_f32(path: str): + import soundfile as sf + data, sr = sf.read(path, dtype="float32") + return data, sr + + +def write_wav_f32(path: str, y, sr: int): + import soundfile as sf + sf.write(path, y, sr, subtype="PCM_16") + + +# --------------------------------------------------------------------------- +# GUI +# --------------------------------------------------------------------------- + +def _import_pyside(): + from PySide6 import QtCore, QtGui, QtWidgets # noqa: F401 + return QtCore, QtGui, QtWidgets + + +def run_gui() -> int: + QtCore, QtGui, QtWidgets = _import_pyside() + + def _normalize(y): + import numpy as _np + p = _np.percentile(_np.abs(y), 99.5) + if p < 1e-6: + return y + return (y / p * 0.891) # ~ -1 dBFS reference peak + + class Worker(QtCore.QThread): + log = QtCore.Signal(str) + done = QtCore.Signal(object, str) # (success, message) + + def __init__(self, job): + super().__init__() + self.job = job + + def run(self): + import numpy as _np + try: + self.log.emit("Extracting audio range…") + with tempfile.TemporaryDirectory(prefix="voicegrab-") as wd: + wav = extract_wav(self.job["input"], self.job["start"], self.job["stop"], wd) + y, sr = read_wav_f32(wav) + + if self.job["isolate"]: + if not HAVE_NR: + raise FfmpegError(f"Voice isolation unavailable: {NR_IMPORT_ERROR}") + self.log.emit("Isolating voice (noise reduction)\u2026") + red = VoiceReducer(self.job["intensity"], self.job["stationary"]) + y = red.reduce(y, sr) + else: + self.log.emit("Normalizing loudness…") + y = _normalize(y) + + work_wav = os.path.join(wd, "work.wav") + write_wav_f32(work_wav, y, sr) + + self.log.emit("Encoding MP3 (320 kbps, loudness-normalized)…") + enc = [ + ffmpeg_exe(), "-hide_banner", "-v", "error", "-y", + "-i", work_wav, + "-af", f"loudnorm=I={self.job['lufts']}:TP=-1.5:LRA=11", + "-c:a", "libmp3lame", "-b:a", "320k", self.job["output"], + ] + r = subprocess.run(enc, capture_output=True, text=True, timeout=300) + if r.returncode != 0 or not os.path.exists(self.job["output"]): + raise FfmpegError(f"MP3 encoding failed:\n{r.stderr.strip()[:1500]}") + self.log.emit("Done.") + self.done.emit(True, self.job["output"]) + except Exception as exc: + self.done.emit(False, str(exc)) + return _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) + + +def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setApplicationVersion(APP_VERSION) + + class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(f"{APP_NAME} — MP3 voice sample cutter") + self.resize(920, 640) + self.input_path = None + self.duration = 0.0 + self.worker = None + self._build() + + # ---------- UI ---------- + def _build(self): + central = QtWidgets.QWidget() + self.setCentralWidget(central) + lay = QtWidgets.QVBoxLayout(central) + lay.setContentsMargins(14, 14, 14, 14) + lay.setSpacing(12) + + # Row 1: input file + row1 = QtWidgets.QHBoxLayout() + row1.addWidget(QtWidgets.QLabel("Video / audio file:")) + self.in_edit = QtWidgets.QLineEdit() + self.in_edit.setPlaceholderText("Choose an .mp4 / .mkv / .mov / .webm … file") + row1.addWidget(self.in_edit, 1) + btn_open = QtWidgets.QPushButton("Open…") + btn_open.clicked.connect(self.choose_input) + row1.addWidget(btn_open) + self.lbl_info = QtWidgets.QLabel("") + self.lbl_info.setStyleSheet("color:#64748b;") + row1.addWidget(self.lbl_info) + lay.addLayout(row1) + + # Row 2: times + row2 = QtWidgets.QHBoxLayout() + row2.addWidget(QtWidgets.QLabel("Start:")) + self.t_start = QtWidgets.QLineEdit("0:00.00") + self.t_start.setFixedWidth(110) + row2.addWidget(self.t_start) + row2.addSpacing(12) + row2.addWidget(QtWidgets.QLabel("Stop:")) + self.t_stop = QtWidgets.QLineEdit("0:00.00") + self.t_stop.setFixedWidth(110) + row2.addWidget(self.t_stop) + self.lbl_range = QtWidgets.QLabel("") + self.lbl_range.setStyleSheet("color:#64748b;") + row2.addWidget(self.lbl_range) + row2.addStretch(1) + btn_preview = QtWidgets.QPushButton("Load preview") + btn_preview.setToolTip("Extract the selected range so you can listen before saving") + btn_preview.clicked.connect(self.load_preview) + row2.addWidget(btn_preview) + btn_listen = QtWidgets.QPushButton("▶ Play range") + btn_listen.clicked.connect(self.play_range) + row2.addWidget(btn_listen) + lay.addLayout(row2) + + # Row 3: isolation + output + self.chk_isolate = QtWidgets.QCheckBox("Isolate voice (reduce background noise)") + self.chk_isolate.setChecked(True) + self.chk_isolate.toggled.connect(self._isolate_toggled) + lay.addWidget(self.chk_isolate) + + iso_row = QtWidgets.QHBoxLayout() + iso_row.addWidget(QtWidgets.QLabel("Noise reduction strength:")) + self.spn_intensity = QtWidgets.QSpinBox() + self.spn_intensity.setRange(0, 100) + self.spn_intensity.setValue(80) + self.spn_intensity.setSuffix("%") + iso_row.addWidget(self.spn_intensity) + iso_row.addSpacing(16) + self.chk_stationary = QtWidgets.QCheckBox( + "Stationary noise (fan / hum — better if constant)") + iso_row.addWidget(self.chk_stationary) + iso_row.addStretch(1) + self.lbl_iso_status = QtWidgets.QLabel("") + self.lbl_iso_status.setStyleSheet("color:#64748b;") + iso_row.addWidget(self.lbl_iso_status) + lay.addLayout(iso_row) + self.iso_row_widget = iso_row + + out_row = QtWidgets.QHBoxLayout() + out_row.addWidget(QtWidgets.QLabel("Output MP3:")) + self.out_edit = QtWidgets.QLineEdit() + self.out_edit.setPlaceholderText("Defaults to _voicegrab.mp3 next to the source") + out_row.addWidget(self.out_edit, 1) + btn_out = QtWidgets.QPushButton("Browse…") + btn_out.clicked.connect(self.choose_output) + out_row.addWidget(btn_out) + lay.addLayout(out_row) + + # Waveform + lay.addWidget(QtWidgets.QLabel("Waveform of the selected range:")) + self.wave = WaveView() + self.wave.setMinimumHeight(160) + lay.addWidget(self.wave, 1) + + # Bottom bar + bar = QtWidgets.QHBoxLayout() + self.btn_export = QtWidgets.QPushButton("Export MP3") + self.btn_export.setMinimumHeight(40) + self.btn_export.setStyleSheet( + "font-size:14px; font-weight:600; background:#0ea5e9; color:white;" + "border-radius:6px; padding:4px 18px;") + self.btn_export.clicked.connect(self.export) + bar.addWidget(self.btn_export) + self.lbl_status = QtWidgets.QLabel("Ready.") + self.lbl_status.setStyleSheet("color:#64748b;") + bar.addWidget(self.lbl_status, 1) + lay.addLayout(bar) + + if not HAVE_NR: + self.lbl_iso_status.setText(f"⚠ voice isolation unavailable ({NR_IMPORT_ERROR})") + self.chk_isolate.setEnabled(False) + + # ---------- helpers ---------- + def _isolate_toggled(self, on): + for w in self.iso_row_widget.items(): + if isinstance(w, QtWidgets.QWidget) and w not in (self.lbl_iso_status,): + w.setEnabled(on) + + @staticmethod + def _parse_time(s: str) -> float: + """Accept 125, 1:05, 1:05.5, 1:05:00, 01:00:00.500.""" + s = s.strip() + parts = s.split(":") + if len(parts) > 3: + raise ValueError("Invalid time format") + t = 0.0 + for i, p in enumerate(parts): + v = float(p) + t = t * 60 + v + return max(0.0, t) + + def _fmt_time(self, t: float) -> str: + m, s = divmod(t, 60) + h, m = divmod(int(m), 60) + if h: + return f"{h}:{int(m):02d}:{s:05.2f}" + return f"{int(m)}:{s:05.2f}" + + def _times(self): + start = self._parse_time(self.t_start.text() or "0") + stop = self._parse_time(self.t_stop.text() or "0") + if start >= stop: + raise ValueError("Start must be before stop.") + return start, stop + + def refresh_info(self): + if self.input_path and self.duration: + self.lbl_info.setText( + f"Duration {self._fmt_time(self.duration)} Range {self._range_text()}") + else: + self.lbl_info.setText("") + + def _range_text(self): + try: + s, e = self._times() + return f"{s:.2f}s – {e:.2f}s (clip {e - s:.2f}s)" + except ValueError: + return "— invalid range —" + + # ---------- slots ---------- + def choose_input(self): + path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Choose video or audio file", "", + "Media files (*.mp4 *.mkv *.mov *.webm *.avi *.m4a *.mp3 *.wav *.ogg *.flv *.ts);;All files (*)") + if not path: + return + self.input_path = path + self.in_edit.setText(path) + self.t_stop.setText(self._fmt_time(self.duration)) # after probe below + try: + self.duration = probe_duration(path) + self.t_stop.setText(self._fmt_time(self.duration)) + except FfmpegError as exc: + self.status(str(exc)); return + self.refresh_info() + self.load_preview() + + def choose_output(self): + if not self.input_path: + return + base = os.path.splitext(os.path.basename(self.input_path))[0] + start = os.path.join(os.path.dirname(self.input_path), base + "_voicegrab.mp3") + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Save MP3 as", start, "MP3 files (*.mp3)") + if path: + self.out_edit.setText(path) + + def load_preview(self): + if not self.input_path: + return + try: + s, e = self._times() + except ValueError as exc: + self.status(str(exc)); return + try: + with tempfile.TemporaryDirectory(prefix="voicegrab-") as wd: + wav = extract_wav(self.input_path, s, e, wd) + maxs, mins = peaks(wav) + n, sr, rms = wav_stats(wav) + except FfmpegError as exc: + self.status(str(exc)); return + self.wave.set_peaks(maxs, mins) + self.lbl_range.setText(f"clip {n / sr:.2f}s @ {sr} Hz RMS {rms:.3f}") + + def play_range(self): + if not self.input_path: + return + try: + s, e = self._times() + except ValueError as exc: + self.status(str(exc)); return + player = os.path.join(tempfile.gettempdir(), "voicegrab_preview.mp3") + try: + cmd = [ + ffmpeg_exe(), "-hide_banner", "-v", "error", "-y", + "-ss", f"{s:.3f}", "-to", f"{e:.3f}", "-i", self.input_path, + "-vn", "-c:a", "libmp3lame", "-b:a", "128k", player, + ] + subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=300) + if platform.system() == "Windows": + os.startfile(player) # type: ignore[attr-defined] + elif platform.system() == "Darwin": + subprocess.Popen(["open", player]) + else: + subprocess.Popen(["xdg-open", player]) + self.status("Playing range in system player…") + except (subprocess.SubprocessError, OSError) as exc: + self.status(f"Could not play range: {exc}") + + def status(self, msg: str): + self.lbl_status.setText(msg) + + def export(self): + if self.worker is not None: + self.status("Already working…"); return + if not self.input_path: + QtWidgets.QMessageBox.warning(self, APP_NAME, "Choose an input file first.") + return + try: + s, e = self._times() + except ValueError as exc: + QtWidgets.QMessageBox.warning(self, APP_NAME, str(exc)); return + out = self.out_edit.text().strip() + if not out: + base = os.path.splitext(os.path.basename(self.input_path))[0] + out = os.path.join(os.path.dirname(os.path.abspath(self.input_path)), + base + "_voicegrab.mp3") + self.btn_export.setEnabled(False) + self.status("Working… (see log in status area)") + self.worker = Worker({ + "input": self.input_path, "start": s, "stop": e, + "output": out, + "isolate": self.chk_isolate.isChecked(), + "intensity": self.spn_intensity.value() / 100.0, + "stationary": self.chk_stationary.isChecked(), + "lufts": -16, + }) + self.worker.log.connect(self.status) + self.worker.done.connect(self._export_done) + self.worker.start() + + def _export_done(self, ok, msg): + self.btn_export.setEnabled(True) + self.worker = None + if ok: + self.status(f"✔ Saved: {msg}") + QtWidgets.QMessageBox.information( + self, APP_NAME, + f"MP3 saved to:\n{msg}\n\nTip: 30–120 s of clean speech is usually " + f"enough for high-quality AI voice cloning.") + else: + self.status(f"✖ {msg.splitlines()[0]}") + QtWidgets.QMessageBox.critical(self, APP_NAME, msg) + + # ---------------- wave view ---------------- + class WaveView(QtWidgets.QWidget): + def __init__(self): + super().__init__() + self.maxs: list = [] + self.mins: list = [] + self.setMinimumSize(120, 80) + + def set_peaks(self, maxs, mins): + self.maxs = list(maxs) + self.mins = list(mins) + self.update() + + def paintEvent(self, ev): + with QtGui.QPainter(self) as p: + w, h = self.width(), self.height() + mid = h / 2 + p.fillRect(0, 0, w, h, QtGui.QColor("#0f172a")) + p.setPen(QtGui.QColor("#334155")) + p.drawLine(0, int(mid), w, int(mid)) + if not self.maxs: + p.setPen(QtGui.QColor("#94a3b8")) + p.drawText(0, 0, w, h, int(QtCore.Qt.AlignCenter), "Open a file to see the waveform") + return + n = len(self.maxs) + color = QtGui.QColor("#38bdf8") + for i, (mx, mn) in enumerate(zip(self.maxs, self.mins)): + x = i * w / n + y1 = mid - mx * (mid - 4) + y2 = mid - mn * (mid - 4) + p.setPen(color) + p.drawLine(int(x), int(y1), int(x), max(int(y1), int(y2))) + + win = MainWindow() + win.show() + return app.exec() + +if __name__ == "__main__": + if "--cli" in sys.argv: + # Simple headless mode: voicegrab --cli [output.mp3] + sys.argv = [a for a in sys.argv if a != "--cli"] + if len(sys.argv) < 4: + print("usage: voicegrab --cli [output.mp3]") + raise SystemExit(2) + inp, s, e = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]) + out = sys.argv[4] if len(sys.argv) > 4 else os.path.join( + os.path.dirname(os.path.abspath(inp)), + os.path.splitext(os.path.basename(inp))[0] + "_voicegrab.mp3") + import numpy as _np + with tempfile.TemporaryDirectory(prefix="voicegrab-") as wd: + wav = extract_wav(inp, s, e, wd) + y, sr = read_wav_f32(wav) + if HAVE_NR: + red = VoiceReducer(0.8, stationary=True) + y = red.reduce(y, sr) + red_wav = os.path.join(wd, "reduced.wav") + write_wav_f32(red_wav, y, sr) + subprocess.run([ + ffmpeg_exe(), "-hide_banner", "-v", "error", "-y", + "-i", red_wav, "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", + "-c:a", "libmp3lame", "-b:a", "320k", out, + ], check=True) + print(f"Saved {out}") + else: + raise SystemExit(run_gui())