From 62c4659486d639fe602be7b01cb31f585c8833ad Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 25 Aug 2026 19:04:05 -0600 Subject: [PATCH] intial commit --- GPU-upgrade.md | 235 ++++++++++ README.md | 71 +++ __pycache__/voicegrab.cpython-314.pyc | Bin 0 -> 42121 bytes assets/icon.ico | Bin 0 -> 6729 bytes assets/icon.png | Bin 0 -> 2034 bytes installer/VoiceGrab.iss | 41 ++ installer/build_linux.sh | 40 ++ installer/build_win.ps1 | 41 ++ installer/voicegrab.desktop | 8 + requirements.txt | 5 + voicegrab.py | 608 ++++++++++++++++++++++++++ 11 files changed, 1049 insertions(+) create mode 100644 GPU-upgrade.md create mode 100644 README.md create mode 100644 __pycache__/voicegrab.cpython-314.pyc create mode 100644 assets/icon.ico create mode 100644 assets/icon.png create mode 100644 installer/VoiceGrab.iss create mode 100644 installer/build_linux.sh create mode 100644 installer/build_win.ps1 create mode 100644 installer/voicegrab.desktop create mode 100644 requirements.txt create mode 100644 voicegrab.py 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 0000000000000000000000000000000000000000..8e2cb3f89063d5e6d7b58a647b6c8b14b2fc784c GIT binary patch literal 42121 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 R73#mJ&{DYGc%;Md{{q&FhD`tf literal 0 HcmV?d00001 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d6d3c59637e047bbffb97e92db6ca9bfd6b7c2e5 GIT binary patch literal 6729 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 ZH~u+ot!L4a&@i_tB9o{q#=k$!{|nR7HuC@g literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2eae17678fdbca18751f8298cc8908f3c383ec5a GIT binary patch literal 2034 zcmd5-`#)Q09)5EqL{vqiC|cREX5DJE`q{cp(g=c9)n&1+ZC7<#EfpHtCOK~7vJ^AZ zac`8;HmG1eO-Z*hrKPCqbPcLmmsS~<2-%e&WRuML?CgKA=a+Nd?|I(m^FH6p^XB^b zdTL{gFaQ8;FACWo0EFTb0nqA-S9s$6a{y?xdy$U?rc^EoToX-#3{if^QuiVULo>WA zvg4ljpSI4_%MDd?OyeTbd}2b)zpCd=yp~XX;}#_z^mt}-tcQz>f=cxS$vD>$)=%W? zT}<8x(O14Lru~xt-FPL(KCI54(y5yk%N@unN}M8{3>@pj51&D?Y+Ch>N!>B7WA3&Y z94*`pc5VogAdKYuWU}%;iyXOQWI3&cv?rITlWa($UoOye;|LI%P_^`Qo2$1pbH^-l=PaEQOk zsMm)@UCH*;BTWkAi?UnyEEPz*f8>X2e*vblQ$1!cZqg7i{cC-gM;JJIIJOHzl%B3S zm1?XcS}5qPiUmWXGf;vgoBr_AYa}R$umUvZ=(6|jlLoNJ%_LSlhXw^z%3#jyaoZCL zG*6%vUBzO^#X`d7%-fz^>)+*db*7I>Sv|%4i5l#x!)n9x6984PYg^nIp!@g4^9uID ziym|Fr_`?qoKY$05dKZkvW&K`M;G6MMkF2ST@q^YrKUcaBEsHFt^z?tuqT{Z^Yy{` zj+82h^7*yin&!e~OPJi6Y2EMC-Uce=l74-5+(Ogb?f2ziPp?sZ_Mxqo$}@e#}B^2`Vl>o?2^6a=5c0HXjfR0^T%H%i0l%rOga z{x|~p(~NL5hP6+%Af8oWjwLx8(8l8fcQ(K;^uUpNP<=?jZmHb#ADn{g|Kt~Xy8m7w zuY?oG-SVjW%|R9F!r$+xLk4UKLw0m4B;c#rXA_6%G4&vwt^fUn%?Bia7gcg8_=`EM4|zCL5&4l_Ojvv{248G*r?1slkLdKKYcHLCV_6_XktOTR1m7)M zr)*<<^41NpqEE6FEkk`b!TfnqlYT{dZhL$STk%Xf-B7#S< zkgb=2ycDAsWw^AVks5$0zj6dCE7v2Qe(iSsE*AMz17-JewsRXTSqu2&$D(T$aZ}ML zZa-kD`K*cY-;@W=b$M*mwct<)J8d=814sNogI4x04K1rZ~wr6$ZO(7JNnOgYD^xDSa$})l3Sx&|AiDqB)U3d$N zdhP`t&rTVkEYS#xodt^TGYHk<>#Zu|vdttdaLEa0;Q(#?9vwbUDuvLJqA^@X|vT zFwsdbZL((?t(dqX<-~msS6iJ>OE)cn(88}$4&O=}nym(VUfS(NGi)*VJLdxwfjaQb z^#fl*)MOJVT#tz13rkYPGJF+^h%@aoJ7?%0`&){9w=ZAIrVRM%L z#86n&e@C%}hv8=sduNqKp&HqBI?!B{`40{=$Ak0EIww7nFfox0KXF&|e*ClKOD+|O zX8TJ`56my!!=QG&A{)aWPs2_WSC)hH2aJ-&r=4-T4PZ+!?Nojer!s?<0Mw)*SgCwj zr-?6}H!XdjfcUB)4i!BbVRtTgsMWHmDS z$-egTt4Ke{Zm`ki1YNxvIoVO>SiX(*)8wbNiQ7I#0CS+cFIu^XTi|_sP~`WF-9%0P zC2MM{EQ9C`BDqecVO?_^*bh*Xw znW&mwqX)z0Sl&F*hz0Uozln6VFYe<(@3c0nto^^ 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())