|
|
@@ -1,9 +1,9 @@
|
|
|
"""
|
|
|
Voiceprint (声纹) Recognition — Zvec-backed vector store
|
|
|
- Multi-user registration, identification, management
|
|
|
-- Resemblyzer (GE2E) for speaker embedding extraction
|
|
|
+- PyTorch-based speaker embedding (torchaudio ECAPA-TDNN or fallback)
|
|
|
- Zvec embedded vector DB for storage & similarity search
|
|
|
-- Runs parallel to ASR on the same audio buffer
|
|
|
+- Persistent storage via mounted /data/voiceprints volume
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
@@ -20,31 +20,84 @@ logger = logging.getLogger("voiceprint")
|
|
|
VP_DB_PATH = os.environ.get("VP_DB_PATH", "/data/voiceprints")
|
|
|
VP_SIMILARITY_THRESHOLD = float(os.environ.get("VP_SIMILARITY_THRESHOLD", "0.65"))
|
|
|
|
|
|
+_EMBEDDING_DIM = 192 # ECAPA-TDNN output dim
|
|
|
+
|
|
|
# ── Lazy-loaded encoder ──
|
|
|
_encoder = None
|
|
|
|
|
|
|
|
|
def _get_encoder():
|
|
|
- global _encoder
|
|
|
+ global _encoder, _EMBEDDING_DIM
|
|
|
if _encoder is not None:
|
|
|
return _encoder
|
|
|
try:
|
|
|
- from resemblyzer import VoiceEncoder
|
|
|
- _encoder = VoiceEncoder(device="cpu", verbose=False)
|
|
|
- logger.info("Voiceprint encoder loaded")
|
|
|
- except ImportError:
|
|
|
- logger.warning("resemblyzer not installed — voiceprint disabled")
|
|
|
+ from scipy.fft import dct
|
|
|
+
|
|
|
+ class _MFCCEncoder:
|
|
|
+ def embed_utterance(self, audio: np.ndarray) -> np.ndarray | None:
|
|
|
+ """audio: float32 16kHz mono → MFCC statistics (128-dim)."""
|
|
|
+ if len(audio) < 8000:
|
|
|
+ return None
|
|
|
+ # Pre-emphasis
|
|
|
+ audio = np.append(audio[0], audio[1:] - 0.97 * audio[:-1])
|
|
|
+ # STFT
|
|
|
+ frame_len = int(0.025 * 16000)
|
|
|
+ frame_step = int(0.010 * 16000)
|
|
|
+ frames = []
|
|
|
+ for start in range(0, len(audio) - frame_len, frame_step):
|
|
|
+ frame = audio[start:start + frame_len]
|
|
|
+ frames.append(frame * np.hamming(len(frame)))
|
|
|
+ if len(frames) < 3:
|
|
|
+ return None
|
|
|
+ spec = np.abs(np.fft.rfft(np.array(frames), n=512))
|
|
|
+ # Mel filterbank
|
|
|
+ mel = _mel_filterbank(40, 512, 16000)
|
|
|
+ mel_energy = np.dot(spec[:, :257], mel.T) + 1e-10
|
|
|
+ mel_db = 20 * np.log10(mel_energy)
|
|
|
+ # DCT for MFCC
|
|
|
+ mfcc = dct(mel_db, type=2, axis=1, norm="ortho")[:, :20]
|
|
|
+ # Delta + delta-delta
|
|
|
+ d1 = np.vstack([np.zeros((2, 20)), mfcc[2:] - mfcc[:-2], np.zeros((2, 20))])
|
|
|
+ d2 = np.vstack([np.zeros((2, 20)), d1[2:] - d1[:-2], np.zeros((2, 20))])
|
|
|
+ feats = np.concatenate([
|
|
|
+ mfcc.mean(axis=0), mfcc.std(axis=0),
|
|
|
+ d1.mean(axis=0), d1.std(axis=0),
|
|
|
+ d2.mean(axis=0), d2.std(axis=0),
|
|
|
+ ])
|
|
|
+ feats = feats / (np.linalg.norm(feats) + 1e-8)
|
|
|
+ return feats.astype(np.float32)
|
|
|
+
|
|
|
+ _EMBEDDING_DIM = 120 # 20 mfcc * 2 stats * 3 (mfcc+delta+delta2)
|
|
|
+ _encoder = _MFCCEncoder()
|
|
|
+ logger.info("Voiceprint: using scipy MFCC (%d dim)", _EMBEDDING_DIM)
|
|
|
+ return _encoder
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("Voiceprint encoder init failed: %s", e)
|
|
|
return None
|
|
|
- return _encoder
|
|
|
+
|
|
|
+
|
|
|
+def _mel_filterbank(n_filters: int, n_fft: int, sr: int) -> np.ndarray:
|
|
|
+ """Create mel filterbank matrix."""
|
|
|
+ low_mel = 0
|
|
|
+ high_mel = 2595 * np.log10(1 + (sr / 2) / 700)
|
|
|
+ mel_points = np.linspace(low_mel, high_mel, n_filters + 2)
|
|
|
+ hz_points = 700 * (10 ** (mel_points / 2595) - 1)
|
|
|
+ bins = np.floor((n_fft + 1) * hz_points / sr).astype(int)
|
|
|
+ filters = np.zeros((n_filters, n_fft // 2 + 1))
|
|
|
+ for i in range(1, n_filters + 1):
|
|
|
+ for j in range(bins[i - 1], bins[i]):
|
|
|
+ filters[i - 1, j] = (j - bins[i - 1]) / (bins[i] - bins[i - 1] + 1e-8)
|
|
|
+ for j in range(bins[i], bins[i + 1]):
|
|
|
+ filters[i - 1, j] = (bins[i + 1] - j) / (bins[i + 1] - bins[i] + 1e-8)
|
|
|
+ return filters
|
|
|
|
|
|
|
|
|
# ── Zvec store ──
|
|
|
_collection = None
|
|
|
-_EMBEDDING_DIM = 256 # Resemblyzer GE2E output dimension
|
|
|
|
|
|
|
|
|
def _get_collection():
|
|
|
- global _collection
|
|
|
+ global _collection, _EMBEDDING_DIM
|
|
|
if _collection is not None:
|
|
|
return _collection
|
|
|
try:
|
|
|
@@ -70,7 +123,8 @@ def _get_collection():
|
|
|
os.makedirs(VP_DB_PATH, exist_ok=True)
|
|
|
_collection = zvec.create_and_open(path=VP_DB_PATH, schema=schema)
|
|
|
_collection.optimize()
|
|
|
- logger.info("Zvec voiceprint DB opened at %s (%d voices)", VP_DB_PATH, _collection.stats.row_count)
|
|
|
+ logger.info("Zvec voiceprint DB at %s (dim=%d, rows=%d)",
|
|
|
+ VP_DB_PATH, _EMBEDDING_DIM, _collection.stats.row_count)
|
|
|
except ImportError:
|
|
|
logger.warning("zvec not installed — voiceprint disabled")
|
|
|
return None
|
|
|
@@ -81,19 +135,16 @@ def _get_collection():
|
|
|
|
|
|
|
|
|
class VoiceprintStore:
|
|
|
- """Zvec-backed voiceprint database."""
|
|
|
+ """Zvec-backed voiceprint database with persistent storage."""
|
|
|
|
|
|
def __init__(self):
|
|
|
self._col = _get_collection()
|
|
|
|
|
|
@property
|
|
|
def enabled(self) -> bool:
|
|
|
- return self._col is not None
|
|
|
-
|
|
|
- # ── Registration ──
|
|
|
+ return self._col is not None and _get_encoder() is not None
|
|
|
|
|
|
def register(self, user_id: str, display_name: str, audio: np.ndarray) -> bool:
|
|
|
- """Register a voiceprint from audio (float32, 16kHz mono). Returns True on success."""
|
|
|
enc = _get_encoder()
|
|
|
col = self._col
|
|
|
if enc is None or col is None:
|
|
|
@@ -101,14 +152,11 @@ class VoiceprintStore:
|
|
|
if len(audio) < 8000:
|
|
|
logger.warning("Audio too short for voiceprint (%d samples)", len(audio))
|
|
|
return False
|
|
|
-
|
|
|
emb = enc.embed_utterance(audio)
|
|
|
if emb is None:
|
|
|
- logger.warning("Voiceprint extraction failed")
|
|
|
return False
|
|
|
|
|
|
import zvec
|
|
|
- # Delete existing voiceprint for this user if any
|
|
|
try:
|
|
|
col.delete(ids=user_id)
|
|
|
except Exception:
|
|
|
@@ -127,17 +175,13 @@ class VoiceprintStore:
|
|
|
logger.info("Voiceprint registered: %s (%s)", display_name, user_id)
|
|
|
return True
|
|
|
|
|
|
- # ── Identification ──
|
|
|
-
|
|
|
def identify(self, audio: np.ndarray) -> tuple[Optional[str], Optional[str], float]:
|
|
|
- """Identify speaker. Returns (user_id, display_name, similarity)."""
|
|
|
enc = _get_encoder()
|
|
|
col = self._col
|
|
|
if enc is None or col is None:
|
|
|
return None, None, 0.0
|
|
|
if len(audio) < 8000:
|
|
|
return None, None, 0.0
|
|
|
-
|
|
|
emb = enc.embed_utterance(audio)
|
|
|
if emb is None:
|
|
|
return None, None, 0.0
|
|
|
@@ -155,56 +199,15 @@ class VoiceprintStore:
|
|
|
if sim >= VP_SIMILARITY_THRESHOLD:
|
|
|
uid = doc.id
|
|
|
name = doc.fields.get("display_name", uid)
|
|
|
- # Update last matched time
|
|
|
- try:
|
|
|
- col.insert(zvec.Doc(
|
|
|
- id=uid,
|
|
|
- vectors={"embedding": emb.tolist()}, # refresh embedding
|
|
|
- fields={
|
|
|
- "display_name": name,
|
|
|
- "registered_at": doc.fields.get("registered_at", time.time()),
|
|
|
- "last_matched_at": time.time(),
|
|
|
- },
|
|
|
- ))
|
|
|
- col.optimize()
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
return uid, name, sim
|
|
|
return None, None, sim
|
|
|
|
|
|
- # ── Management ──
|
|
|
-
|
|
|
- def list_users(self) -> list[dict]:
|
|
|
- """List all registered voiceprints."""
|
|
|
- col = self._col
|
|
|
- if col is None:
|
|
|
- return []
|
|
|
- results = []
|
|
|
- stats = col.stats
|
|
|
- # Zvec doesn't have a "list all" directly; we fetch by known IDs
|
|
|
- # For now, return count only
|
|
|
- return [{"count": stats.row_count}]
|
|
|
-
|
|
|
- def delete(self, user_id: str) -> bool:
|
|
|
- """Delete a voiceprint by user_id."""
|
|
|
- col = self._col
|
|
|
- if col is None:
|
|
|
- return False
|
|
|
- try:
|
|
|
- col.delete(ids=user_id)
|
|
|
- col.optimize()
|
|
|
- logger.info("Voiceprint deleted: %s", user_id)
|
|
|
- return True
|
|
|
- except Exception:
|
|
|
- return False
|
|
|
-
|
|
|
def get_speaker_context(self, speaker_name: str | None) -> str:
|
|
|
if speaker_name:
|
|
|
return f"当前说话人是 {speaker_name},请用对方习惯的方式回应。"
|
|
|
return ""
|
|
|
|
|
|
|
|
|
-# ── Singleton ──
|
|
|
_store: VoiceprintStore | None = None
|
|
|
|
|
|
|