Просмотр исходного кода

feat(voiceprint): Zvec-backed scipy MFCC voiceprint with registration+identification

- Pure scipy MFCC encoder (no compilation needed)
- Zvec embedded vector DB for persistent storage
- Registration via voice commands ('注册声纹 张三')
- Parallel identification after each ASR utterance
- Speaker context injected into LLM prompt
- Mounted volume /data/voiceprints for persistence
wenhongquan 3 недель назад
Родитель
Сommit
4a7f85b94d
1 измененных файлов с 68 добавлено и 65 удалено
  1. 68 65
      asr_agent/voiceprint.py

+ 68 - 65
asr_agent/voiceprint.py

@@ -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