| import os |
| from dataclasses import dataclass |
| import numpy as np |
|
|
|
|
| @dataclass(frozen=True) |
| class SpeakerMatch: |
| """Result of assigning an embedding to a tracked speaker profile.""" |
|
|
| speaker: str |
| confidence: float |
| is_new: bool |
|
|
|
|
| def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float: |
| """Compute cosine similarity between two 1D vectors.""" |
| dot = np.dot(v1, v2) |
| norm1 = np.linalg.norm(v1) |
| norm2 = np.linalg.norm(v2) |
| return float(dot / (norm1 * norm2)) if norm1 > 0 and norm2 > 0 else 0.0 |
|
|
| class SpeakerTracker: |
| def __init__(self, threshold: float = None, max_speakers: int = None): |
| if threshold is None: |
| threshold = float(os.getenv("SPEAKER_TRACKING_THRESHOLD", "0.80")) |
| self.threshold = threshold |
| if max_speakers is None: |
| max_speakers = int(os.getenv("SPEAKER_MAX_PROFILES", "2")) |
| self.max_speakers = max(1, max_speakers) |
| self.speakers = {} |
| self.running_averages = {} |
| self.speaker_names = [] |
|
|
| def _next_speaker_label(self) -> str: |
| letter = chr(65 + len(self.speaker_names)) |
| label = f"Speaker_{letter}" |
| self.speaker_names.append(label) |
| return label |
|
|
| def track_speaker_with_confidence(self, embedding: np.ndarray) -> SpeakerMatch: |
| """ |
| Match a new embedding against known speaker profiles. |
| Returns the speaker label plus the matching confidence. |
| """ |
| embedding = np.asarray(embedding, dtype=np.float32).reshape(-1) |
| if embedding.size == 0: |
| embedding = np.zeros(192, dtype=np.float32) |
| if not np.any(embedding): |
| label = self.speaker_names[-1] if self.speaker_names else self._next_speaker_label() |
| self.speakers.setdefault(label, []) |
| self.running_averages.setdefault(label, embedding) |
| return SpeakerMatch(speaker=label, confidence=0.0, is_new=not self.speakers[label]) |
|
|
| if not self.running_averages: |
| label = self._next_speaker_label() |
| self.speakers[label] = [embedding] |
| self.running_averages[label] = embedding |
| return SpeakerMatch(speaker=label, confidence=1.0, is_new=True) |
|
|
| best_label = None |
| best_score = -1.0 |
| for label, embeddings in self.speakers.items(): |
| avg_emb = self.running_averages[label] |
| sim_avg = cosine_similarity(embedding, avg_emb) |
| |
| |
| recent_embs = embeddings[-15:] |
| sim_indivs = [cosine_similarity(embedding, past_emb) for past_emb in recent_embs] |
| sim_max_indiv = max(sim_indivs) if sim_indivs else sim_avg |
| |
| |
| sim = 0.4 * sim_avg + 0.6 * sim_max_indiv |
| if sim > best_score: |
| best_score = sim |
| best_label = label |
|
|
| if best_score >= self.threshold: |
| |
| self.speakers[best_label].append(embedding) |
| self.running_averages[best_label] = np.mean(self.speakers[best_label], axis=0) |
| return SpeakerMatch(speaker=best_label, confidence=round(max(0.0, min(1.0, best_score)), 4), is_new=False) |
|
|
| if len(self.speaker_names) >= self.max_speakers: |
| self.speakers[best_label].append(embedding) |
| self.running_averages[best_label] = np.mean(self.speakers[best_label], axis=0) |
| capped_confidence = max(0.0, min(1.0, best_score)) |
| return SpeakerMatch(speaker=best_label, confidence=round(capped_confidence, 4), is_new=False) |
|
|
| |
| |
| label = self._next_speaker_label() |
| self.speakers[label] = [embedding] |
| self.running_averages[label] = embedding |
| new_confidence = 1.0 - max(0.0, min(1.0, best_score)) |
| return SpeakerMatch(speaker=label, confidence=round(new_confidence, 4), is_new=True) |
|
|
| def track_speaker(self, embedding: np.ndarray) -> str: |
| """Backward-compatible speaker tracking API.""" |
| return self.track_speaker_with_confidence(embedding).speaker |
|
|