Add real-time speaker diarization support

- New whisper_live/diarization.py: SpeakerDiarizer with online clustering
- Uses pyannote.audio speaker embeddings (optional dependency)
- Cosine similarity threshold for speaker matching (default 0.55)
- Running average embedding update for speaker stability
- Configurable max_speakers limit (default 10)
- Client options: enable_diarization, max_speakers
- Segments include 'speaker' field when diarization is active
- Graceful fallback: logs warning if pyannote not installed
- Added 12 unit tests (mock-based, no GPU required)
This commit is contained in:
Aaron Boxer
2026-04-17 10:25:10 -04:00
committed by Aaron Boxer
parent 3d63e82571
commit 18b897277f
7 changed files with 364 additions and 6 deletions
+34 -4
View File
@@ -38,6 +38,7 @@ class ServeClientBase(object):
clip_audio=False,
same_output_threshold=10,
translation_queue=None,
diarization=None,
):
self.client_uid = client_uid
self.websocket = websocket
@@ -45,6 +46,7 @@ class ServeClientBase(object):
self.no_speech_thresh = no_speech_thresh
self.clip_audio = clip_audio
self.same_output_threshold = same_output_threshold
self.diarization = diarization
self.frames = b""
self.timestamp_offset = 0.0
@@ -116,7 +118,7 @@ class ServeClientBase(object):
def handle_transcription_output(self, result, duration):
raise NotImplementedError
def format_segment(self, start, end, text, completed=False):
def format_segment(self, start, end, text, completed=False, speaker=None):
"""
Formats a transcription segment with precise start and end times alongside the transcribed text.
@@ -124,18 +126,22 @@ class ServeClientBase(object):
start (float): The start time of the transcription segment in seconds.
end (float): The end time of the transcription segment in seconds.
text (str): The transcribed text corresponding to the segment.
speaker (str, optional): Speaker label from diarization.
Returns:
dict: A dictionary representing the formatted transcription segment, including
'start' and 'end' times as strings with three decimal places and the 'text'
of the transcription.
"""
return {
seg = {
'start': "{:.3f}".format(start),
'end': "{:.3f}".format(end),
'text': text,
'completed': completed
'completed': completed,
}
if speaker is not None:
seg['speaker'] = speaker
return seg
def add_frames(self, frame_np):
"""
@@ -292,6 +298,29 @@ class ServeClientBase(object):
def get_segment_end(self, segment):
return getattr(segment, "end", getattr(segment, "end_ts", 0))
def _identify_speaker(self, segment):
"""Run diarization on a segment's audio slice if diarization is enabled.
Returns:
str or None: Speaker label, or None if diarization is disabled or audio unavailable.
"""
if self.diarization is None or self.frames_np is None:
return None
try:
seg_start = self.get_segment_start(segment)
seg_end = self.get_segment_end(segment)
start_sample = int(seg_start * self.RATE)
end_sample = int(seg_end * self.RATE)
# Extract audio relative to the current buffer
samples_offset = max(0, int((self.timestamp_offset - self.frames_offset) * self.RATE))
audio_slice = self.frames_np[samples_offset + start_sample:samples_offset + end_sample]
if len(audio_slice) < self.RATE * 0.3:
return None
return self.diarization.identify_speaker(audio_slice, self.RATE)
except Exception as e:
logging.error(f"Diarization error: {e}")
return None
def update_segments(self, segments, duration):
"""
Processes the segments from Whisper and updates the transcript.
@@ -321,7 +350,8 @@ class ServeClientBase(object):
continue
if self.get_segment_no_speech_prob(s) > self.no_speech_thresh:
continue
completed_segment = self.format_segment(start, end, text_, completed=True)
speaker = self._identify_speaker(s)
completed_segment = self.format_segment(start, end, text_, completed=True, speaker=speaker)
self.transcript.append(completed_segment)
if self.translation_queue: