voicegrab/voicegrab.py

609 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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: 30120 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())