Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -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',
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user