intial commit
This commit is contained in:
commit
62c4659486
|
|
@ -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.*
|
||||||
|
|
@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# VoiceGrab — Linux build (produces dist/VoiceGrab-<arch> 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
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
PySide6>=6.6
|
||||||
|
numpy
|
||||||
|
scipy
|
||||||
|
soundfile
|
||||||
|
noisereduce
|
||||||
|
|
@ -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 <input>_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 <input> <start> <stop> [output.mp3]
|
||||||
|
sys.argv = [a for a in sys.argv if a != "--cli"]
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("usage: voicegrab --cli <input> <start> <stop> [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())
|
||||||
Loading…
Reference in New Issue