Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['src\\voice_ref_curator\\__main__.py'],
pathex=['src'],
binaries=[],
datas=[],
hiddenimports=['voice_ref_curator', 'voice_ref_curator.analyser', 'voice_ref_curator.gui'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='VoiceRefCurator',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='VoiceRefCurator',
)
+24
View File
@@ -0,0 +1,24 @@
@echo off
:: Build a standalone Windows executable with PyInstaller.
:: Output: dist\VoiceRefCurator\VoiceRefCurator.exe
cd /d "%~dp0"
call ..\.venv\Scripts\activate.bat
pip install pyinstaller --quiet
pyinstaller ^
--windowed ^
--onedir ^
--name VoiceRefCurator ^
--paths src ^
--hidden-import voice_ref_curator ^
--hidden-import voice_ref_curator.analyser ^
--hidden-import voice_ref_curator.gui ^
src\voice_ref_curator\__main__.py
echo.
echo ============================================================
echo Build complete.
echo Executable: dist\VoiceRefCurator\VoiceRefCurator.exe
echo ============================================================
pause
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "voice-ref-curator"
version = "1.0.0"
description = "GUI tool to rank, score and prune Javis voice reference clips."
requires-python = ">=3.10"
dependencies = [
"numpy>=1.24,<2.0",
"scipy>=1.11",
"soundfile>=0.12",
"pyyaml>=6.0",
"PySide6>=6.6",
]
[project.scripts]
voice-ref-curator = "voice_ref_curator.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
+6
View File
@@ -0,0 +1,6 @@
@echo off
:: Voice Reference Curator — run with the parent Javis venv
cd /d "%~dp0"
call ..\.venv\Scripts\activate.bat
set PYTHONPATH=%~dp0src
python -m voice_ref_curator %*
@@ -0,0 +1 @@
"""Voice Reference Curator package."""
@@ -0,0 +1,10 @@
"""Entry point — launches the GUI."""
from .gui import run_gui
def main() -> None:
run_gui()
if __name__ == "__main__":
main()
@@ -0,0 +1,349 @@
"""
Core audio analysis logic for Voice Reference Curator.
Metrics computed per clip
--------------------------
duration_s — clip length in seconds
rms_db — mean frame-level RMS in dBFS
snr_est_db — SNR estimate: 75th − 10th percentile frame level (dB)
voiced_ratio — fraction of frames above the silence threshold
pitch_mean_hz — median F0 in voiced frames (normalized autocorrelation)
pitch_stability — 1 − (std/mean) of voiced F0 values; bounded [0, 1]
mfcc_vec — L2-normalised mean MFCC vector (shape: N_MFCC,)
mfcc_sim — cosine similarity to the per-profile MFCC centroid
score — weighted composite score (higher = better reference clip)
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Union
import numpy as np
import soundfile as sf
import yaml
from scipy.fft import dct as scipy_dct
from scipy.signal import lfilter
if TYPE_CHECKING:
from .speaker import NullEmbedder, SpeakerEmbedder
_Embedder = Union[SpeakerEmbedder, NullEmbedder]
# ── tuneable constants ────────────────────────────────────────────────────────
FRAME_MS: int = 25 # analysis frame in ms
HOP_MS: int = 10 # hop in ms
PITCH_FMIN: float = 70.0 # Hz — minimum voiced pitch
PITCH_FMAX: float = 400.0 # Hz — maximum voiced pitch
SILENCE_DB: float = -40.0 # dBFS — frames below this are silent
PITCH_AC_THRESH: float = 0.30 # min autocorrelation peak to declare voiced
# XTTS ideal reference length is 5–12 s; outside gets a score penalty
REF_IDEAL_MIN_S: float = 5.0
REF_IDEAL_MAX_S: float = 12.0
REF_LEN_PENALTY: float = 0.85
N_MELS: int = 40
N_MFCC: int = 20
MEL_FMAX: float = 8000.0
# Composite score weights (must sum to 1.0)
W_SNR: float = 0.30
W_VOICED: float = 0.25
W_PITCH_STAB: float = 0.20
W_MFCC_SIM: float = 0.25
SNR_NORM_DB: float = 40.0 # 40 dB SNR maps to score 1.0
# ── data class ────────────────────────────────────────────────────────────────
@dataclass
class ClipMetrics:
path: Path
duration_s: float
rms_db: float
snr_est_db: float
voiced_ratio: float
pitch_mean_hz: float
pitch_stability: float
mfcc_vec: np.ndarray = field(repr=False)
mfcc_sim: float = 0.0
score: float = 0.0
# Speaker-separation fields (populated by compute_speaker_separation)
embed_vec: np.ndarray = field(
default_factory=lambda: np.zeros(192, dtype=np.float32), repr=False
)
speaker_label: str = "" # "primary" | "speaker 2" | … | "" = not computed
# ── audio helpers ─────────────────────────────────────────────────────────────
def _load_mono(path: Path) -> tuple[np.ndarray, int]:
audio, sr = sf.read(str(path), dtype="float32", always_2d=True)
mono = audio.mean(axis=1) if audio.shape[1] > 1 else audio[:, 0]
return mono, int(sr)
def _rms_db_frames(audio: np.ndarray, sr: int) -> np.ndarray:
fl = max(1, int(sr * FRAME_MS / 1000))
hl = max(1, int(sr * HOP_MS / 1000))
n = max(1, (len(audio) - fl) // hl + 1)
out = np.empty(n, dtype=np.float32)
for i in range(n):
s = i * hl
rms = float(np.sqrt(np.mean(audio[s:s + fl] ** 2) + 1e-12))
out[i] = 20.0 * math.log10(rms + 1e-12)
return out
def _snr_db(rms_db: np.ndarray) -> float:
if len(rms_db) < 4:
return 0.0
return float(max(0.0, np.percentile(rms_db, 75) - np.percentile(rms_db, 10)))
def _voiced_ratio(rms_db: np.ndarray) -> float:
return float(np.mean(rms_db > SILENCE_DB))
def _pitch_f0(audio: np.ndarray, sr: int, rms_db: np.ndarray) -> list[float]:
"""Normalized autocorrelation F0 extraction. Returns voiced Hz values only."""
fl = max(1, int(sr * FRAME_MS / 1000))
hl = max(1, int(sr * HOP_MS / 1000))
min_lag = max(1, int(sr / PITCH_FMAX))
max_lag = int(sr / PITCH_FMIN)
hann = np.hanning(fl)
n = min(len(rms_db), max(1, (len(audio) - fl) // hl + 1))
voiced: list[float] = []
for i in range(n):
if rms_db[i] < SILENCE_DB:
continue
s = i * hl
frame = audio[s:s + fl]
if len(frame) < fl:
break
fft = np.fft.rfft(frame * hann, n=2 * fl)
ac = np.fft.irfft(fft * np.conj(fft))[:fl].real
if ac[0] < 1e-10:
continue
ac /= ac[0]
region = ac[min_lag:max_lag + 1]
if region.size == 0:
continue
pk = int(np.argmax(region))
if region[pk] > PITCH_AC_THRESH:
voiced.append(float(sr / (pk + min_lag)))
return voiced
@lru_cache(maxsize=16)
def _mel_filterbank(sr: int, n_fft: int) -> np.ndarray:
"""Build (N_MELS, n_fft//2+1) triangular mel filterbank (cached by sr+n_fft)."""
mel_lo = 1127.0 * math.log(1.0 + PITCH_FMIN / 700.0)
mel_hi = 1127.0 * math.log(1.0 + min(sr / 2.0, MEL_FMAX) / 700.0)
mel_pts = np.linspace(mel_lo, mel_hi, N_MELS + 2)
hz_pts = 700.0 * (np.exp(mel_pts / 1127.0) - 1.0)
bins = np.floor((n_fft + 1) * hz_pts / sr).astype(int)
fb = np.zeros((N_MELS, n_fft // 2 + 1), dtype=np.float32)
for m in range(1, N_MELS + 1):
lo, ctr, hi = bins[m - 1], bins[m], bins[m + 1]
if ctr > lo:
for k in range(lo, ctr):
fb[m - 1, k] = (k - lo) / (ctr - lo)
if hi > ctr:
for k in range(ctr, hi):
fb[m - 1, k] = (hi - k) / (hi - ctr)
return fb
def _compute_mfcc(audio: np.ndarray, sr: int) -> np.ndarray:
"""Mean MFCC vector (N_MFCC,), L2-normalised. Uses scipy DCT; no librosa needed."""
fl = max(1, int(sr * FRAME_MS / 1000))
hl = max(1, int(sr * HOP_MS / 1000))
pre = lfilter([1.0, -0.97], [1.0], audio)
fb = _mel_filterbank(sr, fl)
hann = np.hanning(fl)
n = max(1, (len(pre) - fl) // hl + 1)
log_mel_list: list[np.ndarray] = []
for i in range(n):
s = i * hl
frame = pre[s:s + fl]
if len(frame) < fl:
break
spec = np.abs(np.fft.rfft(frame * hann)) ** 2
log_mel_list.append(np.log(fb @ spec + 1e-10))
if not log_mel_list:
return np.zeros(N_MFCC, dtype=np.float32)
log_mel = np.stack(log_mel_list, axis=0) # (T, N_MELS)
mfcc = scipy_dct(log_mel, type=2, n=N_MFCC, axis=1, norm="ortho") # (T, N_MFCC)
mean_vec = mfcc.mean(axis=0).astype(np.float32)
norm = float(np.linalg.norm(mean_vec))
return mean_vec / norm if norm > 1e-10 else mean_vec
# ── per-clip analysis ─────────────────────────────────────────────────────────
def analyse_clip(
path: Path,
embedder: Optional["_Embedder"] = None,
) -> Optional[ClipMetrics]:
"""Compute all metrics for one WAV.
When *embedder* is provided and available, an ECAPA-TDNN speaker
embedding is also computed and stored in ``ClipMetrics.embed_vec``
so that :func:`compute_speaker_separation` can cluster clips without
re-loading audio from disk.
Returns None on error or if the clip is too short (< 0.5 s).
"""
try:
audio, sr = _load_mono(path)
except Exception:
return None
if len(audio) < sr * 0.5:
return None
dur = len(audio) / sr
rdb = _rms_db_frames(audio, sr)
snr = _snr_db(rdb)
vr = _voiced_ratio(rdb)
pitches = _pitch_f0(audio, sr, rdb)
if pitches:
pa = np.array(pitches, dtype=np.float32)
pmean = float(np.median(pa))
pstab = float(max(0.0, 1.0 - float(np.std(pa)) / (pmean + 1e-6)))
else:
pmean, pstab = 0.0, 0.0
# Optionally compute speaker embedding while audio is already in memory.
if embedder is not None and getattr(embedder, "available", False):
embed_vec = embedder.embed(audio, sr)
else:
embed_vec = np.zeros(192, dtype=np.float32)
return ClipMetrics(
path=path,
duration_s=dur,
rms_db=float(np.mean(rdb)),
snr_est_db=snr,
voiced_ratio=vr,
pitch_mean_hz=pmean,
pitch_stability=pstab,
mfcc_vec=_compute_mfcc(audio, sr),
embed_vec=embed_vec,
)
# ── scoring ───────────────────────────────────────────────────────────────────
_SPEAKER_PENALTY: float = 0.45 # score multiplier for non-primary speaker clips
def _mfcc_centroid_sim(source_clips: list[ClipMetrics], all_clips: list[ClipMetrics]) -> None:
"""Compute cosine similarity of every clip in *all_clips* against the MFCC
centroid built from *source_clips*. Mutates ``mfcc_sim`` in place."""
non_zero = [c for c in source_clips if np.any(c.mfcc_vec != 0)]
if non_zero:
stacked = np.stack([c.mfcc_vec for c in non_zero], axis=0)
centroid = stacked.mean(axis=0)
cn = float(np.linalg.norm(centroid))
if cn > 1e-10:
centroid /= cn
for c in all_clips:
c.mfcc_sim = (
float(max(0.0, np.dot(c.mfcc_vec, centroid)))
if np.any(c.mfcc_vec != 0)
else 0.0
)
else:
for c in all_clips:
c.mfcc_sim = 0.0
def compute_speaker_separation(clips: list[ClipMetrics]) -> None:
"""Cluster clips by speaker embedding and set ``speaker_label`` on each.
Expects ``ClipMetrics.embed_vec`` to be populated (non-zero) by
:func:`analyse_clip` called with a live ``SpeakerEmbedder``. When all
embeddings are zero (embedder unavailable), the function returns without
modifying any clip so that behaviour is identical to the pre-separation
baseline.
Label convention:
* ``"primary"`` — largest speaker cluster (target voice)
* ``"speaker 2"`` — second-largest cluster
* ``"speaker 3"`` — etc.
* ``""`` — separation was not run
"""
if not clips:
return
# Skip gracefully when no embeddings were computed.
if not any(np.any(c.embed_vec != 0) for c in clips):
return
from .speaker import cluster_speakers
embeddings = [c.embed_vec for c in clips]
labels = cluster_speakers(embeddings)
for clip, label in zip(clips, labels):
clip.speaker_label = "primary" if label == 0 else f"speaker {label + 1}"
def compute_scores(clips: list[ClipMetrics]) -> None:
"""Compute per-profile centroid similarity and final weighted score (mutates in place).
When speaker separation has been run (``speaker_label`` is set), the MFCC
centroid is built exclusively from **primary-speaker** clips. This means
non-primary clips get a naturally lower ``mfcc_sim`` because their spectral
profile diverges from the target voice — on top of the explicit
``_SPEAKER_PENALTY`` applied to their raw score.
"""
speaker_labels = {c.speaker_label for c in clips}
separation_done = bool(speaker_labels - {""})
if separation_done:
primary_clips = [c for c in clips if c.speaker_label == "primary"]
source = primary_clips if primary_clips else clips
else:
source = clips
_mfcc_centroid_sim(source, clips)
for c in clips:
snr_s = min(1.0, c.snr_est_db / SNR_NORM_DB)
base = (
W_SNR * snr_s
+ W_VOICED * c.voiced_ratio
+ W_PITCH_STAB * c.pitch_stability
+ W_MFCC_SIM * c.mfcc_sim
)
if c.duration_s < REF_IDEAL_MIN_S or c.duration_s > REF_IDEAL_MAX_S:
base *= REF_LEN_PENALTY
if separation_done and c.speaker_label and c.speaker_label != "primary":
base *= _SPEAKER_PENALTY
c.score = float(base)
# ── voices dir auto-detection ─────────────────────────────────────────────────
def detect_voices_dir() -> Path:
"""Walk up from this file's location looking for config.yaml with paths.base_dir."""
here = Path(__file__).resolve().parent
for candidate in [here, here.parent, here.parent.parent, here.parent.parent.parent]:
cfg = candidate / "config.yaml"
if cfg.exists():
try:
data = yaml.safe_load(cfg.read_text(encoding="utf-8")) or {}
base = data.get("paths", {}).get("base_dir")
if base:
return Path(base).expanduser() / "voices"
except Exception:
pass
return Path.home() / ".javis" / "voices"
@@ -0,0 +1,708 @@
"""Voice Reference Curator — PySide6 GUI."""
from __future__ import annotations
import math
import os
import shutil
import sys
import traceback
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
QSpinBox,
QStatusBar,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from .analyser import (
ClipMetrics,
analyse_clip,
compute_scores,
compute_speaker_separation,
detect_voices_dir,
)
# ── table column layout ───────────────────────────────────────────────────────
COL_CHECK = 0
COL_PROFILE = 1
COL_SPEAKER = 2
COL_FILE = 3
COL_DUR = 4
COL_SNR = 5
COL_VOICED = 6
COL_PITCH = 7
COL_PSTAB = 8
COL_SIM = 9
COL_SCORE = 10
N_COLS = 11
HEADERS = ["", "Profile", "Speaker", "File", "Dur(s)", "SNR(dB)", "Voiced", "Pitch(Hz)", "PStab", "Sim", "Score"]
_SPEAKER_COLORS = {
"primary": None, # use score-based colour
"speaker 2": QColor(255, 213, 170), # light orange
"speaker 3": QColor(200, 210, 255), # light blue
"speaker 4": QColor(220, 255, 200), # light green (different from score-green)
}
def _row_color(score: float, speaker_label: str) -> QColor:
"""Background colour for a table row. Non-primary speakers get a fixed
accent colour so they stand out regardless of their quality score."""
if speaker_label and speaker_label != "primary":
return _SPEAKER_COLORS.get(speaker_label, QColor(230, 200, 255)) # light purple fallback
if score >= 0.72:
return QColor(198, 239, 206) # light green
if score >= 0.50:
return QColor(255, 235, 156) # light yellow
return QColor(255, 199, 206) # light red / pink
class _NumItem(QTableWidgetItem):
"""QTableWidgetItem that sorts by its Qt.UserRole float value."""
def __lt__(self, other: QTableWidgetItem) -> bool:
try:
return float(self.data(Qt.UserRole)) < float(other.data(Qt.UserRole))
except (TypeError, ValueError):
return super().__lt__(other)
def _num_item(value: float, fmt: str = ".1f") -> _NumItem:
item = _NumItem(format(value, fmt))
item.setData(Qt.UserRole, value)
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
return item
# ── background worker ─────────────────────────────────────────────────────────
class _AnalysisWorker(QThread):
progress_total = Signal(int) # total WAV count
progress_step = Signal(int, str) # (n_done, current_filename)
profile_done = Signal(str, list) # (profile_name, list[ClipMetrics]) — scored
status_msg = Signal(str) # free-form status text for the UI
finished = Signal()
error = Signal(str)
def __init__(
self,
voices_dir: Path,
profile_filter: Optional[str] = None,
use_speaker_sep: bool = False,
):
super().__init__()
self._voices_dir = voices_dir
self._profile_filter = profile_filter
self._use_speaker_sep = use_speaker_sep
self._cancelled = False
def cancel(self) -> None:
self._cancelled = True
def run(self) -> None:
try:
# ── Optional: load speaker embedder ──────────────────────────────
embedder = None
if self._use_speaker_sep:
self.status_msg.emit("Loading ECAPA-TDNN speaker model…")
from .speaker import get_embedder
embedder = get_embedder()
if embedder.available:
self.status_msg.emit("Speaker model ready — analysing clips…")
else:
self.status_msg.emit(
"speechbrain not installed — speaker separation disabled"
)
embedder = None
if self._profile_filter:
profile_dirs = [self._voices_dir / self._profile_filter]
else:
profile_dirs = [
p for p in sorted(self._voices_dir.iterdir()) if p.is_dir()
]
# Collect all (profile_dir, wav_path) pairs
all_items: list[tuple[Path, Path]] = []
for pd in profile_dirs:
refs = pd / "refs"
if refs.is_dir():
for wav in sorted(refs.glob("*.wav")):
all_items.append((pd, wav))
self.progress_total.emit(len(all_items))
current_pd: Optional[Path] = None
current_clips: list[ClipMetrics] = []
done = 0
for pd, wav in all_items:
if self._cancelled:
return
# New profile boundary — emit the previous batch
if pd is not current_pd:
if current_pd is not None and current_clips:
if embedder is not None:
compute_speaker_separation(current_clips)
compute_scores(current_clips)
self.profile_done.emit(current_pd.name, list(current_clips))
current_pd = pd
current_clips = []
self.progress_step.emit(done, wav.name)
m = analyse_clip(wav, embedder)
if m is not None:
current_clips.append(m)
done += 1
# Emit the final profile
if current_pd is not None and current_clips:
if embedder is not None:
compute_speaker_separation(current_clips)
compute_scores(current_clips)
self.profile_done.emit(current_pd.name, list(current_clips))
self.finished.emit()
except Exception:
self.error.emit(traceback.format_exc())
# ── main window ───────────────────────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Voice Reference Curator")
self.resize(1060, 680)
self._clips: list[tuple[str, ClipMetrics]] = [] # (profile_name, metrics)
self._worker: Optional[_AnalysisWorker] = None
self._adding_rows = False
self._build_ui()
self._auto_detect()
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self) -> None:
root = QWidget()
self.setCentralWidget(root)
vbox = QVBoxLayout(root)
vbox.setSpacing(6)
vbox.setContentsMargins(10, 10, 10, 6)
# ── Config group ──────────────────────────────────────────────────────
cfg = QGroupBox("Configuration")
cfg_v = QVBoxLayout(cfg)
cfg_v.setSpacing(4)
# Voices directory row
dir_row = QHBoxLayout()
dir_row.addWidget(QLabel("Voices dir:"))
self._dir_edit = QLineEdit()
self._dir_edit.setPlaceholderText("Path to Javis voices directory…")
dir_row.addWidget(self._dir_edit, 1)
btn_browse = QPushButton("Browse…")
btn_browse.clicked.connect(self._browse)
dir_row.addWidget(btn_browse)
btn_auto = QPushButton("Auto-detect")
btn_auto.clicked.connect(self._auto_detect)
dir_row.addWidget(btn_auto)
cfg_v.addLayout(dir_row)
# Profile + options row
opt_row = QHBoxLayout()
opt_row.addWidget(QLabel("Profile:"))
self._profile_combo = QComboBox()
self._profile_combo.setMinimumWidth(160)
opt_row.addWidget(self._profile_combo)
opt_row.addSpacing(16)
opt_row.addWidget(QLabel("Prune worst:"))
self._prune_spin = QDoubleSpinBox()
self._prune_spin.setRange(0.0, 50.0)
self._prune_spin.setSingleStep(0.5)
self._prune_spin.setValue(1.0)
self._prune_spin.setSuffix(" %")
self._prune_spin.setDecimals(1)
opt_row.addWidget(self._prune_spin)
opt_row.addSpacing(16)
self._dry_run_chk = QCheckBox("Dry run (no files moved)")
opt_row.addWidget(self._dry_run_chk)
opt_row.addSpacing(16)
self._speaker_sep_chk = QCheckBox("Separate voices (ECAPA-TDNN)")
self._speaker_sep_chk.setToolTip(
"Use speechbrain ECAPA-TDNN speaker embeddings to detect and\n"
"label multiple voices within each profile's refs/ folder.\n"
"Requires: pip install speechbrain torch"
)
opt_row.addWidget(self._speaker_sep_chk)
opt_row.addStretch()
self._analyse_btn = QPushButton("▶ Analyse")
font = self._analyse_btn.font()
font.setBold(True)
self._analyse_btn.setFont(font)
self._analyse_btn.setMinimumWidth(110)
self._analyse_btn.clicked.connect(self._start_analysis)
opt_row.addWidget(self._analyse_btn)
cfg_v.addLayout(opt_row)
vbox.addWidget(cfg)
# ── Progress row ──────────────────────────────────────────────────────
prog_row = QHBoxLayout()
self._progress = QProgressBar()
self._progress.setTextVisible(False)
self._progress.setFixedHeight(14)
prog_row.addWidget(self._progress, 1)
self._prog_label = QLabel("Ready")
self._prog_label.setMinimumWidth(220)
prog_row.addWidget(self._prog_label)
vbox.addLayout(prog_row)
# ── Results table ─────────────────────────────────────────────────────
self._table = QTableWidget(0, N_COLS)
self._table.setHorizontalHeaderLabels(HEADERS)
self._table.horizontalHeader().setSortIndicatorShown(True)
self._table.setSortingEnabled(False) # enabled after analysis completes
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
self._table.setAlternatingRowColors(False)
self._table.verticalHeader().setVisible(False)
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self._table.setShowGrid(True)
self._table.cellDoubleClicked.connect(self._open_in_explorer)
self._table.itemChanged.connect(self._on_item_changed)
hh = self._table.horizontalHeader()
hh.setSectionResizeMode(COL_CHECK, QHeaderView.Fixed)
hh.setSectionResizeMode(COL_PROFILE, QHeaderView.ResizeToContents)
hh.setSectionResizeMode(COL_SPEAKER, QHeaderView.ResizeToContents)
hh.setSectionResizeMode(COL_FILE, QHeaderView.Stretch)
for col in (COL_DUR, COL_SNR, COL_VOICED, COL_PITCH, COL_PSTAB, COL_SIM, COL_SCORE):
hh.setSectionResizeMode(col, QHeaderView.ResizeToContents)
self._table.setColumnWidth(COL_CHECK, 28)
vbox.addWidget(self._table, 1)
# ── Action bar ────────────────────────────────────────────────────────
act_row = QHBoxLayout()
btn_worst = QPushButton("Select Worst %")
btn_worst.setToolTip("Check the worst N% based on the Prune % setting above")
btn_worst.clicked.connect(self._select_worst)
act_row.addWidget(btn_worst)
btn_all = QPushButton("Select All")
btn_all.clicked.connect(self._select_all)
act_row.addWidget(btn_all)
btn_none = QPushButton("Deselect All")
btn_none.clicked.connect(self._deselect_all)
act_row.addWidget(btn_none)
self._sel_nonprimary_btn = QPushButton("Select Non-primary")
self._sel_nonprimary_btn.setToolTip(
"Select all clips that were identified as a secondary speaker "
"(requires speaker separation to have been run)"
)
self._sel_nonprimary_btn.clicked.connect(self._select_non_primary)
self._sel_nonprimary_btn.setEnabled(False)
act_row.addWidget(self._sel_nonprimary_btn)
act_row.addStretch()
act_row.addWidget(QLabel("Top:"))
self._top_spin = QSpinBox()
self._top_spin.setRange(1, 999)
self._top_spin.setValue(12)
act_row.addWidget(self._top_spin)
btn_copy = QPushButton("Copy Top Paths")
btn_copy.setToolTip("Copy the top-N clip paths to clipboard (paste into config.yaml or scripts)")
btn_copy.clicked.connect(self._copy_top_paths)
act_row.addWidget(btn_copy)
act_row.addSpacing(16)
self._prune_btn = QPushButton("🗑 Prune Selected")
self._prune_btn.setEnabled(False)
self._prune_btn.clicked.connect(self._prune_selected)
act_row.addWidget(self._prune_btn)
vbox.addLayout(act_row)
# ── Status bar ────────────────────────────────────────────────────────
self._status = QStatusBar()
self.setStatusBar(self._status)
self._status.showMessage("Load a voices directory and click Analyse.")
# ── helpers ───────────────────────────────────────────────────────────────
def _set_status(self, msg: str) -> None:
self._status.showMessage(msg)
def _refresh_profile_combo(self) -> None:
vd = Path(self._dir_edit.text().strip())
self._profile_combo.clear()
self._profile_combo.addItem("All profiles", None)
if vd.is_dir():
for p in sorted(vd.iterdir()):
if p.is_dir():
self._profile_combo.addItem(p.name, p.name)
def _update_prune_btn(self) -> None:
n = self._count_checked()
self._prune_btn.setEnabled(n > 0)
label = f"🗑 Prune Selected ({n})" if n > 0 else "🗑 Prune Selected"
self._prune_btn.setText(label)
def _count_checked(self) -> int:
count = 0
for row in range(self._table.rowCount()):
item = self._table.item(row, COL_CHECK)
if item and item.checkState() == Qt.Checked:
count += 1
return count
def _get_checked_rows(self) -> list[int]:
return [
row for row in range(self._table.rowCount())
if (item := self._table.item(row, COL_CHECK)) and item.checkState() == Qt.Checked
]
# ── actions ───────────────────────────────────────────────────────────────
def _browse(self) -> None:
d = QFileDialog.getExistingDirectory(
self, "Select Voices Directory", self._dir_edit.text() or str(Path.home())
)
if d:
self._dir_edit.setText(d)
self._refresh_profile_combo()
def _auto_detect(self) -> None:
path = detect_voices_dir()
self._dir_edit.setText(str(path))
self._refresh_profile_combo()
if not path.exists():
self._set_status(f"Auto-detected path does not exist yet: {path}")
def _start_analysis(self) -> None:
voices_dir = Path(self._dir_edit.text().strip())
if not voices_dir.is_dir():
QMessageBox.warning(self, "Directory Not Found",
f"Voices directory not found:\n{voices_dir}")
return
# Stop any running worker
if self._worker and self._worker.isRunning():
self._worker.cancel()
self._worker.wait(3000)
# Clear previous state
self._clips.clear()
self._table.blockSignals(True)
self._table.setSortingEnabled(False)
self._table.setRowCount(0)
self._table.blockSignals(False)
profile_filter: Optional[str] = self._profile_combo.currentData()
self._analyse_btn.setEnabled(False)
self._progress.setValue(0)
self._progress.setMaximum(1)
self._prog_label.setText("Counting clips…")
self._sel_nonprimary_btn.setEnabled(False)
self._worker = _AnalysisWorker(
voices_dir,
profile_filter,
use_speaker_sep=self._speaker_sep_chk.isChecked(),
)
self._worker.progress_total.connect(self._on_total)
self._worker.progress_step.connect(self._on_step)
self._worker.profile_done.connect(self._on_profile_done)
self._worker.status_msg.connect(self._set_status)
self._worker.finished.connect(self._on_finished)
self._worker.error.connect(self._on_error)
self._worker.start()
def _select_all(self) -> None:
self._table.blockSignals(True)
for row in range(self._table.rowCount()):
item = self._table.item(row, COL_CHECK)
if item:
item.setCheckState(Qt.Checked)
self._table.blockSignals(False)
self._update_prune_btn()
def _deselect_all(self) -> None:
self._table.blockSignals(True)
for row in range(self._table.rowCount()):
item = self._table.item(row, COL_CHECK)
if item:
item.setCheckState(Qt.Unchecked)
self._table.blockSignals(False)
self._update_prune_btn()
def _select_non_primary(self) -> None:
"""Check all clips whose speaker_label is not 'primary'."""
self._table.blockSignals(True)
for row in range(self._table.rowCount()):
spk_item = self._table.item(row, COL_SPEAKER)
chk_item = self._table.item(row, COL_CHECK)
if spk_item and chk_item:
label = spk_item.text()
is_non_primary = bool(label) and label != "primary" and label != "—"
chk_item.setCheckState(Qt.Checked if is_non_primary else Qt.Unchecked)
self._table.blockSignals(False)
self._update_prune_btn()
def _select_worst(self) -> None:
n_total = self._table.rowCount()
if n_total == 0:
return
n_prune = max(0, math.floor(n_total * self._prune_spin.value() / 100.0))
# Collect (score, row) — use UserRole float for correctness after sort
scored: list[tuple[float, int]] = []
for row in range(n_total):
item = self._table.item(row, COL_SCORE)
if item:
scored.append((float(item.data(Qt.UserRole) or 0.0), row))
scored.sort(key=lambda x: x[0]) # ascending — worst first
worst = {row for _, row in scored[:n_prune]}
self._table.blockSignals(True)
for row in range(n_total):
item = self._table.item(row, COL_CHECK)
if item:
item.setCheckState(Qt.Checked if row in worst else Qt.Unchecked)
self._table.blockSignals(False)
self._update_prune_btn()
def _prune_selected(self) -> None:
checked_rows = self._get_checked_rows()
if not checked_rows:
return
paths: list[Path] = []
for row in checked_rows:
item = self._table.item(row, COL_FILE)
if item:
paths.append(Path(str(item.data(Qt.UserRole))))
dry_run = self._dry_run_chk.isChecked()
if not dry_run:
reply = QMessageBox.question(
self, "Confirm Prune",
f"Move {len(paths)} clip(s) to refs/pruned/?\n\n"
"They will NOT be deleted — you can restore them manually.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
moved, errors = 0, []
for p in paths:
try:
pruned_dir = p.parent / "pruned"
if not dry_run:
pruned_dir.mkdir(exist_ok=True)
shutil.move(str(p), str(pruned_dir / p.name))
moved += 1
except Exception as exc:
errors.append(f"{p.name}: {exc}")
if errors:
QMessageBox.warning(self, "Prune Errors", "\n".join(errors))
if dry_run:
self._set_status(f"[DRY RUN] Would have moved {moved} clip(s) to refs/pruned/.")
else:
self._set_status(f"Pruned {moved} clip(s) → refs/pruned/.")
# Remove pruned rows from the table
for row in sorted(checked_rows, reverse=True):
self._table.removeRow(row)
self._update_prune_btn()
def _copy_top_paths(self) -> None:
n = self._top_spin.value()
rows_scores: list[tuple[float, int]] = []
for row in range(self._table.rowCount()):
item = self._table.item(row, COL_SCORE)
if item:
rows_scores.append((float(item.data(Qt.UserRole) or 0.0), row))
rows_scores.sort(key=lambda x: x[0], reverse=True)
lines: list[str] = []
for i, (_, row) in enumerate(rows_scores[:n], 1):
item = self._table.item(row, COL_FILE)
if item:
lines.append(f"{i:2}. {item.data(Qt.UserRole)}")
if lines:
QApplication.clipboard().setText("\n".join(lines))
self._set_status(f"Top {len(lines)} paths copied to clipboard.")
def _open_in_explorer(self, row: int, _col: int) -> None:
"""Double-click a row → open its parent folder in Explorer/Finder."""
item = self._table.item(row, COL_FILE)
if not item:
return
path = Path(str(item.data(Qt.UserRole)))
if sys.platform == "win32":
os.startfile(str(path.parent)) # noqa: S606
# ── worker slots ─────────────────────────────────────────────────────────
def _on_total(self, total: int) -> None:
self._progress.setMaximum(max(1, total))
self._prog_label.setText(f"0 / {total}")
def _on_step(self, done: int, name: str) -> None:
self._progress.setValue(done)
total = self._progress.maximum()
self._prog_label.setText(f"{done} / {total} — {name}")
def _on_profile_done(self, profile_name: str, clips: list) -> None:
self._adding_rows = True
self._table.blockSignals(True)
for m in clips:
self._clips.append((profile_name, m))
self._insert_row(profile_name, m)
self._table.blockSignals(False)
self._adding_rows = False
def _on_finished(self) -> None:
self._analyse_btn.setEnabled(True)
self._table.setSortingEnabled(True)
self._table.sortByColumn(COL_SCORE, Qt.DescendingOrder)
self._progress.setValue(self._progress.maximum())
n_clips = len(self._clips)
n_profiles = len({p for p, _ in self._clips})
self._prog_label.setText("Analysis complete")
# Enable "Select non-primary" button only when separation was run
has_labels = any(m.speaker_label for _, m in self._clips)
self._sel_nonprimary_btn.setEnabled(has_labels)
n_nonprimary = sum(
1 for _, m in self._clips
if m.speaker_label and m.speaker_label != "primary"
)
sep_note = f", {n_nonprimary} non-primary clip(s) detected" if has_labels else ""
self._set_status(
f"{n_clips} clips analysed across {n_profiles} profile(s){sep_note}."
)
# Auto-select worst N% as a starting suggestion
self._select_worst()
def _on_error(self, msg: str) -> None:
self._analyse_btn.setEnabled(True)
self._prog_label.setText("Error")
QMessageBox.critical(self, "Analysis Error", msg)
def _on_item_changed(self, item: QTableWidgetItem) -> None:
if not self._adding_rows and item.column() == COL_CHECK:
self._update_prune_btn()
# ── row insertion ─────────────────────────────────────────────────────────
def _insert_row(self, profile_name: str, m: ClipMetrics) -> None:
row = self._table.rowCount()
self._table.insertRow(row)
# Checkbox cell
chk = QTableWidgetItem()
chk.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
chk.setCheckState(Qt.Unchecked)
chk.setData(Qt.UserRole, 0.0)
self._table.setItem(row, COL_CHECK, chk)
# Profile
p_item = QTableWidgetItem(profile_name)
p_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self._table.setItem(row, COL_PROFILE, p_item)
# Speaker label ("—" when separation was not run)
spk_text = m.speaker_label if m.speaker_label else "—"
spk_item = QTableWidgetItem(spk_text)
spk_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
spk_item.setTextAlignment(Qt.AlignCenter | Qt.AlignVCenter)
if m.speaker_label and m.speaker_label != "primary":
font = spk_item.font()
font.setBold(True)
spk_item.setFont(font)
self._table.setItem(row, COL_SPEAKER, spk_item)
# File (full path stored in UserRole for later operations)
f_item = QTableWidgetItem(m.path.name)
f_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
f_item.setData(Qt.UserRole, str(m.path))
f_item.setToolTip(str(m.path))
self._table.setItem(row, COL_FILE, f_item)
# Numeric columns
self._table.setItem(row, COL_DUR, _num_item(m.duration_s))
self._table.setItem(row, COL_SNR, _num_item(m.snr_est_db))
self._table.setItem(row, COL_VOICED, _num_item(m.voiced_ratio, ".2f"))
pitch_item = _NumItem(f"{m.pitch_mean_hz:.0f}" if m.pitch_mean_hz > 0 else "N/A")
pitch_item.setData(Qt.UserRole, m.pitch_mean_hz)
pitch_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
pitch_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self._table.setItem(row, COL_PITCH, pitch_item)
self._table.setItem(row, COL_PSTAB, _num_item(m.pitch_stability, ".2f"))
self._table.setItem(row, COL_SIM, _num_item(m.mfcc_sim, ".2f"))
self._table.setItem(row, COL_SCORE, _num_item(m.score, ".3f"))
# Row background: non-primary speaker overrides score colour
color = _row_color(m.score, m.speaker_label)
for col in range(COL_PROFILE, N_COLS):
cell = self._table.item(row, col)
if cell:
cell.setBackground(color)
# ── cleanup ───────────────────────────────────────────────────────────────
def closeEvent(self, event) -> None:
if self._worker and self._worker.isRunning():
self._worker.cancel()
self._worker.wait(3000)
event.accept()
# ── application entry point ───────────────────────────────────────────────────
def run_gui() -> None:
app = QApplication.instance() or QApplication(sys.argv)
app.setApplicationName("Voice Reference Curator")
app.setApplicationVersion("1.0")
w = MainWindow()
w.show()
sys.exit(app.exec())
@@ -0,0 +1,228 @@
"""Standalone speaker embedder for Voice Reference Curator.
Uses speechbrain ECAPA-TDNN to produce speaker-level embeddings and provides
agglomerative clustering to separate multiple voices within a ``refs/`` folder.
Falls back to ``NullEmbedder`` silently when speechbrain / torch is not
installed, leaving analysis behaviour identical to the baseline version.
"""
from __future__ import annotations
import logging
from collections import Counter
from pathlib import Path
from typing import Optional, Union
import numpy as np
log = logging.getLogger("voice_ref_curator.speaker")
_MODEL_SOURCE = "speechbrain/spkrec-ecapa-voxceleb"
_TARGET_RMS: float = 0.05
_MIN_EMBED_S: float = 0.5
# Reuse the Javis model cache when available so the model need not be
# downloaded twice if Javis is already installed on the same machine.
_DEFAULT_CACHE_DIR = Path.home() / ".javis" / "models" / "ecapa-tdnn"
# ── helpers ──────────────────────────────────────────────────────────────────
def _l2(v: np.ndarray) -> np.ndarray:
n = np.linalg.norm(v)
return v / n if n > 0 else v
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
na, nb = np.linalg.norm(a), np.linalg.norm(b)
if na == 0 or nb == 0:
return 0.0
return float(np.dot(a, b) / (na * nb))
def _normalize_rms(audio: np.ndarray) -> np.ndarray:
rms = float(np.sqrt(np.mean(audio.astype(np.float64) ** 2)))
if rms < 1e-8:
return audio
return (audio * (_TARGET_RMS / rms)).astype(np.float32)
# ── null fallback ─────────────────────────────────────────────────────────────
class NullEmbedder:
"""Silent no-op used when speechbrain / torch is unavailable."""
available: bool = False
def embed(self, audio_np: np.ndarray, sr: int) -> np.ndarray: # noqa: ARG002
return np.zeros(192, dtype=np.float32)
# ── main embedder ─────────────────────────────────────────────────────────────
class SpeakerEmbedder:
"""ECAPA-TDNN speaker embedder using speechbrain (no Javis Config needed)."""
available: bool = False
def __init__(self, cache_dir: Optional[Path] = None) -> None:
self._cache_dir = cache_dir or _DEFAULT_CACHE_DIR
self._encoder = None
# ── lifecycle ─────────────────────────────────────────────────────────────
def load(self) -> bool:
"""Download / load the ECAPA-TDNN model. Returns True on success."""
try:
import os
import warnings
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
warnings.filterwarnings(
"ignore",
message=r".*speechbrain\.pretrained.*deprecated.*",
category=UserWarning,
)
warnings.filterwarnings(
"ignore",
message=r".*Requested Pretrainer collection using symlinks.*",
category=UserWarning,
)
from speechbrain.inference.classifiers import EncoderClassifier
device = "cuda:0" if self._try_cuda() else "cpu"
self._cache_dir.mkdir(parents=True, exist_ok=True)
log.info(f"[speaker] loading ECAPA-TDNN on {device} → {self._cache_dir}")
self._encoder = EncoderClassifier.from_hparams(
source=_MODEL_SOURCE,
savedir=str(self._cache_dir),
run_opts={"device": device},
)
self.available = True
log.info("[speaker] ECAPA-TDNN ready")
return True
except Exception as exc:
log.warning(
f"[speaker] speechbrain unavailable ({exc}) — "
"speaker separation disabled"
)
return False
@staticmethod
def _try_cuda() -> bool:
try:
import torch
return torch.cuda.is_available()
except Exception:
return False
# ── embedding ─────────────────────────────────────────────────────────────
def embed(self, audio_np: np.ndarray, sr: int) -> np.ndarray:
"""Return an L2-normalised ECAPA-TDNN embedding for *audio_np*."""
if self._encoder is None:
return np.zeros(192, dtype=np.float32)
import torch
dur = len(audio_np) / sr
if dur < _MIN_EMBED_S:
log.warning(
f"[speaker] clip too short ({dur:.2f}s) — embedding may be unreliable"
)
# ECAPA-TDNN was trained at 16 kHz; resample if needed.
if sr != 16000:
from fractions import Fraction
import scipy.signal
frac = Fraction(16000, sr).limit_denominator(1000)
audio_np = scipy.signal.resample_poly(
audio_np, frac.numerator, frac.denominator
).astype(np.float32)
audio_np = _normalize_rms(audio_np)
wav = torch.tensor(audio_np.astype(np.float32)).unsqueeze(0) # [1, T]
wav_lens = torch.tensor([1.0])
with torch.no_grad():
emb = self._encoder.encode_batch(wav, wav_lens) # [1, 1, D]
return _l2(emb.squeeze().cpu().numpy())
# ── clustering ────────────────────────────────────────────────────────────────
def cluster_speakers(
embeddings: list[np.ndarray],
threshold: float = 0.20,
) -> list[int]:
"""Group speaker embeddings with single-linkage agglomerative clustering.
Parameters
----------
embeddings:
L2-normalised ECAPA-TDNN embedding vectors (one per audio clip).
threshold:
Cosine *distance* threshold (= 1 − cosine similarity). Pairs whose
distance is below this value are merged into the same cluster.
``0.20`` works well for same-speaker across microphone / session
variation; increase to ``0.30`` if the target speaker has high
within-session variability.
Returns
-------
Integer cluster labels (one per input embedding). **Label 0 always
identifies the largest cluster** (the "primary speaker"); minority
clusters are numbered 1, 2, … in descending size order.
"""
n = len(embeddings)
if n == 0:
return []
if n == 1:
return [0]
# Pairwise cosine-distance matrix
dist = np.zeros((n, n), dtype=np.float32)
for i in range(n):
for j in range(i + 1, n):
d = 1.0 - max(0.0, _cosine(embeddings[i], embeddings[j]))
dist[i, j] = d
dist[j, i] = d
# Single-linkage agglomeration: merge any two clusters whose minimum
# pairwise distance falls below *threshold*.
labels: list[int] = list(range(n))
changed = True
while changed:
changed = False
unique = sorted(set(labels))
for ci in unique:
if changed:
break
for cj in unique:
if ci >= cj:
continue
idxs_i = [k for k, lb in enumerate(labels) if lb == ci]
idxs_j = [k for k, lb in enumerate(labels) if lb == cj]
min_d = float(np.min(dist[np.ix_(idxs_i, idxs_j)]))
if min_d < threshold:
labels = [ci if lb == cj else lb for lb in labels]
changed = True
break
# Renumber so the largest cluster becomes 0
counts = Counter(labels)
rank = {old: new for new, (old, _) in enumerate(counts.most_common())}
return [rank[lb] for lb in labels]
# ── factory ───────────────────────────────────────────────────────────────────
def get_embedder(
cache_dir: Optional[Path] = None,
) -> Union[SpeakerEmbedder, NullEmbedder]:
"""Load and return a ``SpeakerEmbedder``; fall back to ``NullEmbedder``."""
e = SpeakerEmbedder(cache_dir)
if e.load():
return e
return NullEmbedder()