voiceprint.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """
  2. Voiceprint (声纹) Recognition — Zvec-backed vector store
  3. - Multi-user registration, identification, management
  4. - PyTorch-based speaker embedding (torchaudio ECAPA-TDNN or fallback)
  5. - Zvec embedded vector DB for storage & similarity search
  6. - Persistent storage via mounted /data/voiceprints volume
  7. """
  8. from __future__ import annotations
  9. import logging
  10. import os
  11. import time
  12. from typing import Optional
  13. import numpy as np
  14. logger = logging.getLogger("voiceprint")
  15. VP_DB_PATH = os.environ.get("VP_DB_PATH", "/data/voiceprints")
  16. VP_SIMILARITY_THRESHOLD = float(os.environ.get("VP_SIMILARITY_THRESHOLD", "0.65"))
  17. _EMBEDDING_DIM = 192 # ECAPA-TDNN output dim
  18. # ── Lazy-loaded encoder ──
  19. _encoder = None
  20. def _get_encoder():
  21. global _encoder, _EMBEDDING_DIM
  22. if _encoder is not None:
  23. return _encoder
  24. try:
  25. from scipy.fft import dct
  26. class _MFCCEncoder:
  27. def embed_utterance(self, audio: np.ndarray) -> np.ndarray | None:
  28. """audio: float32 16kHz mono → MFCC statistics (128-dim)."""
  29. if len(audio) < 8000:
  30. return None
  31. # Pre-emphasis
  32. audio = np.append(audio[0], audio[1:] - 0.97 * audio[:-1])
  33. # STFT
  34. frame_len = int(0.025 * 16000)
  35. frame_step = int(0.010 * 16000)
  36. frames = []
  37. for start in range(0, len(audio) - frame_len, frame_step):
  38. frame = audio[start:start + frame_len]
  39. frames.append(frame * np.hamming(len(frame)))
  40. if len(frames) < 3:
  41. return None
  42. spec = np.abs(np.fft.rfft(np.array(frames), n=512))
  43. # Mel filterbank
  44. mel = _mel_filterbank(40, 512, 16000)
  45. mel_energy = np.dot(spec[:, :257], mel.T) + 1e-10
  46. mel_db = 20 * np.log10(mel_energy)
  47. # DCT for MFCC
  48. mfcc = dct(mel_db, type=2, axis=1, norm="ortho")[:, :20]
  49. # Delta + delta-delta
  50. d1 = np.vstack([np.zeros((2, 20)), mfcc[2:] - mfcc[:-2], np.zeros((2, 20))])
  51. d2 = np.vstack([np.zeros((2, 20)), d1[2:] - d1[:-2], np.zeros((2, 20))])
  52. feats = np.concatenate([
  53. mfcc.mean(axis=0), mfcc.std(axis=0),
  54. d1.mean(axis=0), d1.std(axis=0),
  55. d2.mean(axis=0), d2.std(axis=0),
  56. ])
  57. feats = feats / (np.linalg.norm(feats) + 1e-8)
  58. return feats.astype(np.float32)
  59. _EMBEDDING_DIM = 120 # 20 mfcc * 2 stats * 3 (mfcc+delta+delta2)
  60. _encoder = _MFCCEncoder()
  61. logger.info("Voiceprint: using scipy MFCC (%d dim)", _EMBEDDING_DIM)
  62. return _encoder
  63. except Exception as e:
  64. logger.warning("Voiceprint encoder init failed: %s", e)
  65. return None
  66. def _mel_filterbank(n_filters: int, n_fft: int, sr: int) -> np.ndarray:
  67. """Create mel filterbank matrix."""
  68. low_mel = 0
  69. high_mel = 2595 * np.log10(1 + (sr / 2) / 700)
  70. mel_points = np.linspace(low_mel, high_mel, n_filters + 2)
  71. hz_points = 700 * (10 ** (mel_points / 2595) - 1)
  72. bins = np.floor((n_fft + 1) * hz_points / sr).astype(int)
  73. filters = np.zeros((n_filters, n_fft // 2 + 1))
  74. for i in range(1, n_filters + 1):
  75. for j in range(bins[i - 1], bins[i]):
  76. filters[i - 1, j] = (j - bins[i - 1]) / (bins[i] - bins[i - 1] + 1e-8)
  77. for j in range(bins[i], bins[i + 1]):
  78. filters[i - 1, j] = (bins[i + 1] - j) / (bins[i + 1] - bins[i] + 1e-8)
  79. return filters
  80. # ── Zvec store ──
  81. _collection = None
  82. def _get_collection():
  83. global _collection, _EMBEDDING_DIM
  84. if _collection is not None:
  85. return _collection
  86. try:
  87. import zvec
  88. schema = zvec.CollectionSchema(
  89. name="voiceprints",
  90. fields=[
  91. zvec.FieldSchema(name="display_name", data_type=zvec.DataType.STRING),
  92. zvec.FieldSchema(name="registered_at", data_type=zvec.DataType.FLOAT64),
  93. zvec.FieldSchema(name="last_matched_at", data_type=zvec.DataType.FLOAT64),
  94. ],
  95. vectors=[
  96. zvec.VectorSchema(
  97. name="embedding",
  98. data_type=zvec.DataType.VECTOR_FP32,
  99. dimension=_EMBEDDING_DIM,
  100. index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE),
  101. ),
  102. ],
  103. )
  104. os.makedirs(VP_DB_PATH, exist_ok=True)
  105. _collection = zvec.create_and_open(path=VP_DB_PATH, schema=schema)
  106. _collection.optimize()
  107. logger.info("Zvec voiceprint DB at %s (dim=%d, rows=%d)",
  108. VP_DB_PATH, _EMBEDDING_DIM, _collection.stats.row_count)
  109. except ImportError:
  110. logger.warning("zvec not installed — voiceprint disabled")
  111. return None
  112. except Exception:
  113. logger.exception("Failed to open Zvec voiceprint DB")
  114. return None
  115. return _collection
  116. class VoiceprintStore:
  117. """Zvec-backed voiceprint database with persistent storage."""
  118. def __init__(self):
  119. self._col = _get_collection()
  120. @property
  121. def enabled(self) -> bool:
  122. return self._col is not None and _get_encoder() is not None
  123. def register(self, user_id: str, display_name: str, audio: np.ndarray) -> bool:
  124. enc = _get_encoder()
  125. col = self._col
  126. if enc is None or col is None:
  127. return False
  128. if len(audio) < 8000:
  129. logger.warning("Audio too short for voiceprint (%d samples)", len(audio))
  130. return False
  131. emb = enc.embed_utterance(audio)
  132. if emb is None:
  133. return False
  134. import zvec
  135. try:
  136. col.delete(ids=user_id)
  137. except Exception:
  138. pass
  139. col.insert(zvec.Doc(
  140. id=user_id,
  141. vectors={"embedding": emb.tolist()},
  142. fields={
  143. "display_name": display_name,
  144. "registered_at": time.time(),
  145. "last_matched_at": 0.0,
  146. },
  147. ))
  148. col.optimize()
  149. logger.info("Voiceprint registered: %s (%s)", display_name, user_id)
  150. return True
  151. def identify(self, audio: np.ndarray) -> tuple[Optional[str], Optional[str], float]:
  152. enc = _get_encoder()
  153. col = self._col
  154. if enc is None or col is None:
  155. return None, None, 0.0
  156. if len(audio) < 8000:
  157. return None, None, 0.0
  158. emb = enc.embed_utterance(audio)
  159. if emb is None:
  160. return None, None, 0.0
  161. import zvec
  162. result = col.query(
  163. queries=zvec.Query(field_name="embedding", vector=emb.tolist()),
  164. topk=1,
  165. )
  166. if not result:
  167. return None, None, 0.0
  168. doc = result[0]
  169. sim = float(doc.score)
  170. if sim >= VP_SIMILARITY_THRESHOLD:
  171. uid = doc.id
  172. name = doc.fields.get("display_name", uid)
  173. return uid, name, sim
  174. return None, None, sim
  175. def get_speaker_context(self, speaker_name: str | None) -> str:
  176. if speaker_name:
  177. return f"当前说话人是 {speaker_name},请用对方习惯的方式回应。"
  178. return ""
  179. _store: VoiceprintStore | None = None
  180. def get_store() -> VoiceprintStore:
  181. global _store
  182. if _store is None:
  183. _store = VoiceprintStore()
  184. return _store
  185. # ═══════════════════════════════════════════════════════════════
  186. # Conversation Memory — speaker-aware chat history (JSON)
  187. # ═══════════════════════════════════════════════════════════════
  188. import json as _json
  189. import threading as _threading
  190. _MEM_LOCK = _threading.Lock()
  191. _MEMORY: dict[str, list[dict]] = {}
  192. _MEMORY_LOADED = False
  193. def _load_memories():
  194. global _MEMORY, _MEMORY_LOADED
  195. path = os.path.join(VP_DB_PATH, "conversations.json")
  196. try:
  197. if os.path.exists(path):
  198. with open(path) as f:
  199. _MEMORY = _json.load(f)
  200. logger.info("Loaded conversation memory: %d speakers", len(_MEMORY))
  201. except Exception:
  202. pass
  203. _MEMORY_LOADED = True
  204. def _save_memories():
  205. path = os.path.join(VP_DB_PATH, "conversations.json")
  206. try:
  207. os.makedirs(VP_DB_PATH, exist_ok=True)
  208. with open(path, "w") as f:
  209. _json.dump(_MEMORY, f, ensure_ascii=False, indent=2)
  210. except Exception:
  211. logger.exception("Failed to save conversation memory")
  212. def save_conversation(speaker_id: str, speaker_name: str, user_query: str, reply: str):
  213. """Save a conversation turn to persistent memory."""
  214. with _MEM_LOCK:
  215. if not _MEMORY_LOADED:
  216. _load_memories()
  217. if speaker_id not in _MEMORY:
  218. _MEMORY[speaker_id] = []
  219. entry = {
  220. "name": speaker_name,
  221. "query": user_query,
  222. "reply": reply,
  223. "time": time.time(),
  224. }
  225. _MEMORY[speaker_id].append(entry)
  226. _MEMORY[speaker_id] = _MEMORY[speaker_id][-20:] # keep last 20
  227. _save_memories()
  228. logger.info("Memory saved: %s (%d turns)", speaker_id, len(_MEMORY[speaker_id]))
  229. def load_conversation_context(speaker_id: str | None, speaker_name: str | None, limit: int = 5) -> str:
  230. """Retrieve recent conversation history as formatted text for system prompt."""
  231. if not speaker_id:
  232. return ""
  233. with _MEM_LOCK:
  234. if not _MEMORY_LOADED:
  235. _load_memories()
  236. entries = _MEMORY.get(speaker_id, [])
  237. if not entries:
  238. return ""
  239. history = []
  240. for e in entries[-limit:]:
  241. q, r = e.get("query", ""), e.get("reply", "")
  242. if q and r:
  243. history.append(f"用户: {q}\n助手: {r}")
  244. if history:
  245. return "以下是之前和该用户的对话记录,请参考上下文回应:\n" + "\n".join(history)
  246. return ""
  247. def load_conversation_hist(speaker_id: str, limit: int = 6) -> list[dict]:
  248. """Return conversation history as OpenAI message list for self.hist."""
  249. with _MEM_LOCK:
  250. if not _MEMORY_LOADED:
  251. _load_memories()
  252. entries = _MEMORY.get(speaker_id, [])
  253. hist = []
  254. for e in entries[-limit:]:
  255. q, r = e.get("query", ""), e.get("reply", "")
  256. if q and r:
  257. hist.append({"role": "user", "content": q})
  258. hist.append({"role": "assistant", "content": r})
  259. return hist