From 4a6868e3e1c35aea3c8a8d0899b0dd5c10673a67 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 21:13:21 +0100 Subject: [PATCH 01/10] correct processor attributes mixtral --- whisperlivekit/voxtral_hf_streaming.py | 45 +++++++++++++++----------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/whisperlivekit/voxtral_hf_streaming.py b/whisperlivekit/voxtral_hf_streaming.py index 2fee95f..89ffbd7 100644 --- a/whisperlivekit/voxtral_hf_streaming.py +++ b/whisperlivekit/voxtral_hf_streaming.py @@ -85,10 +85,11 @@ class VoxtralHFStreamingOnlineProcessor: processor = asr.processor self._first_chunk_samples = processor.num_samples_first_audio_chunk self._chunk_samples = processor.num_samples_per_audio_chunk - self._chunk_step = processor.num_samples_per_audio_chunk_step - self._right_pad_samples = int( - processor.num_right_pad_tokens * processor.raw_audio_length_per_tok - ) + self._chunk_step = processor.raw_audio_length_per_tok + n_right_pad = processor.num_right_pad_tokens + if callable(n_right_pad): + n_right_pad = n_right_pad() + self._right_pad_samples = int(n_right_pad * processor.raw_audio_length_per_tok) self._seconds_per_token = processor.raw_audio_length_per_tok / self.SAMPLING_RATE self._reset_state() @@ -238,10 +239,16 @@ class VoxtralHFStreamingOnlineProcessor: def run_generate(): try: with torch.no_grad(): + # Pass generator as input_features — the model detects GeneratorType + # and internally converts it to input_features_generator + generate_kwargs = { + k: v for k, v in first_inputs.items() + if k != "input_features" + } model.generate( - input_features_generator=input_features_gen(), + input_features=input_features_gen(), streamer=streamer, - **first_inputs, + **generate_kwargs, ) except Exception as e: logger.error(f"[voxtral-hf] generate error: {e}", exc_info=True) @@ -271,18 +278,20 @@ class VoxtralHFStreamingOnlineProcessor: if not self._generate_started: return - streamer = self._streamer - try: - for text_fragment in streamer: - if text_fragment: - with self._text_lock: - self._accumulated_text += text_fragment - self._n_text_tokens_received += 1 - # Check if more is immediately available (non-blocking) - if streamer.text_queue.empty(): - break - except StopIteration: - pass + text_queue = self._streamer.text_queue + while True: + try: + text_fragment = text_queue.get_nowait() + except queue.Empty: + break + # TextIteratorStreamer uses None as end-of-stream sentinel + if text_fragment is None: + self._generate_finished = True + break + if text_fragment: + with self._text_lock: + self._accumulated_text += text_fragment + self._n_text_tokens_received += 1 # ── Word extraction ── From f5eee67b11042c82896492d91786e0e08b05cf61 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 23:27:12 +0100 Subject: [PATCH 02/10] fix: silence double-counting bug, add metrics module and runtime instrumentation - Fix _begin_silence pushing same object reference as _end_silence, causing the consumer to process two ended events and double the silence duration. - Fix initial silence never cleared when VAC is disabled, causing the no-VAC path to enqueue zero audio. - Add sample-precise silence boundaries (at_sample parameter). - Add whisperlivekit/metrics.py with WER computation (word-level Levenshtein) and timestamp accuracy (greedy alignment). No external dependencies. - Add whisperlivekit/metrics_collector.py with SessionMetrics dataclass for per-session runtime observability. Instrumented at 6 points in AudioProcessor: init, process_audio, transcription_processor, _end_silence, results_formatter, cleanup. Emits SESSION_METRICS structured log line on session end. --- whisperlivekit/audio_processor.py | 111 ++++++++++++++++++-- whisperlivekit/metrics.py | 151 ++++++++++++++++++++++++++++ whisperlivekit/metrics_collector.py | 84 ++++++++++++++++ 3 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 whisperlivekit/metrics.py create mode 100644 whisperlivekit/metrics_collector.py diff --git a/whisperlivekit/audio_processor.py b/whisperlivekit/audio_processor.py index 37c6a44..3d43c03 100644 --- a/whisperlivekit/audio_processor.py +++ b/whisperlivekit/audio_processor.py @@ -9,6 +9,7 @@ import numpy as np from whisperlivekit.core import (TranscriptionEngine, online_diarization_factory, online_factory, online_translation_factory) +from whisperlivekit.metrics_collector import SessionMetrics from whisperlivekit.ffmpeg_manager import FFmpegManager, FFmpegState from whisperlivekit.silero_vad_iterator import FixedVADIterator, OnnxWrapper, load_jit_vad from whisperlivekit.timed_objects import (ASRToken, ChangeSpeaker, FrontData, @@ -118,6 +119,7 @@ class AudioProcessor: self.translation_task: Optional[asyncio.Task] = None self.watchdog_task: Optional[asyncio.Task] = None self.all_tasks_for_cleanup: List[asyncio.Task] = [] + self.metrics: SessionMetrics = SessionMetrics() self.transcription: Optional[Any] = None self.translation: Optional[Any] = None @@ -139,25 +141,43 @@ class AudioProcessor: if self.translation_queue: await self.translation_queue.put(self.current_silence) - async def _begin_silence(self) -> None: + async def _begin_silence(self, at_sample: Optional[int] = None) -> None: if self.current_silence: return - now = time() - self.beg_loop + # Use audio stream time (sample-precise) for accurate silence duration + if at_sample is not None: + audio_t = at_sample / self.sample_rate + else: + audio_t = self.total_pcm_samples / self.sample_rate if self.sample_rate else 0.0 self.current_silence = Silence( - is_starting=True, start=now + is_starting=True, start=audio_t ) - await self._push_silence_event() + # Push a separate start-only event so _end_silence won't mutate it + start_event = Silence(is_starting=True, start=audio_t) + if self.transcription_queue: + await self.transcription_queue.put(start_event) + if self.args.diarization and self.diarization_queue: + await self.diarization_queue.put(start_event) + if self.translation_queue: + await self.translation_queue.put(start_event) - async def _end_silence(self) -> None: + async def _end_silence(self, at_sample: Optional[int] = None) -> None: if not self.current_silence: return - now = time() - self.beg_loop - self.current_silence.end = now - self.current_silence.is_starting=False - self.current_silence.has_ended=True + if at_sample is not None: + audio_t = at_sample / self.sample_rate + else: + audio_t = self.total_pcm_samples / self.sample_rate if self.sample_rate else 0.0 + self.current_silence.end = audio_t + self.current_silence.is_starting = False + self.current_silence.has_ended = True self.current_silence.compute_duration() + self.metrics.n_silence_events += 1 + if self.current_silence.duration is not None: + self.metrics.total_silence_duration_s += self.current_silence.duration if self.current_silence.duration > MIN_DURATION_REAL_SILENCE: self.state.new_tokens.append(self.current_silence) + # Push the completed silence as the end event (separate from the start event) await self._push_silence_event() self.current_silence = None @@ -253,6 +273,34 @@ class AudioProcessor: if self.translation: await self.translation_queue.put(SENTINEL) + async def _finish_transcription(self) -> None: + """Call finish() on the online processor to flush remaining tokens.""" + if not self.transcription: + return + try: + if hasattr(self.transcription, 'finish'): + final_tokens, end_time = await asyncio.to_thread(self.transcription.finish) + else: + # SimulStreamingOnlineProcessor uses start_silence() → process_iter(is_last=True) + final_tokens, end_time = await asyncio.to_thread(self.transcription.start_silence) + + final_tokens = final_tokens or [] + if final_tokens: + logger.info(f"Finish flushed {len(final_tokens)} tokens") + _buffer_transcript = self.transcription.get_buffer() + async with self.lock: + self.state.tokens.extend(final_tokens) + self.state.buffer_transcription = _buffer_transcript + self.state.end_buffer = max(self.state.end_buffer, end_time) + self.state.new_tokens.extend(final_tokens) + self.state.new_tokens_buffer = _buffer_transcript + if self.translation_queue: + for token in final_tokens: + await self.translation_queue.put(token) + except Exception as e: + logger.warning(f"Error finishing transcription: {e}") + logger.debug(f"Traceback: {traceback.format_exc()}") + async def transcription_processor(self) -> None: """Process audio chunks for transcription.""" cumulative_pcm_duration_stream_time = 0.0 @@ -263,6 +311,7 @@ class AudioProcessor: item = await get_all_from_queue(self.transcription_queue) if item is SENTINEL: logger.debug("Transcription processor received sentinel. Finishing.") + await self._finish_transcription() break asr_internal_buffer_duration_s = len(getattr(self.transcription, 'audio_buffer', [])) / self.transcription.SAMPLING_RATE @@ -297,8 +346,13 @@ class AudioProcessor: cumulative_pcm_duration_stream_time += len(pcm_array) / self.sample_rate stream_time_end_of_current_pcm = cumulative_pcm_duration_stream_time self.transcription.insert_audio_chunk(pcm_array, stream_time_end_of_current_pcm) + _t0 = time() new_tokens, current_audio_processed_upto = await asyncio.to_thread(self.transcription.process_iter) + _dur = time() - _t0 + self.metrics.transcription_durations.append(_dur) + self.metrics.n_transcription_calls += 1 new_tokens = new_tokens or [] + self.metrics.n_tokens_produced += len(new_tokens) _buffer_transcript = self.transcription.get_buffer() buffer_text = _buffer_transcript.text @@ -433,6 +487,7 @@ class AudioProcessor: should_push = (response != self.last_response_content) if should_push: + self.metrics.n_responses_sent += 1 yield response self.last_response_content = response @@ -535,6 +590,10 @@ class AudioProcessor: logger.warning(f"Error stopping FFmpeg manager: {e}") if self.diarization: self.diarization.close() + + # Finalize session metrics + self.metrics.total_audio_duration_s = self.total_pcm_samples / self.sample_rate + self.metrics.log_summary() logger.info("AudioProcessor cleanup complete.") def _processing_tasks_done(self) -> bool: @@ -553,6 +612,7 @@ class AudioProcessor: if not self.beg_loop: self.beg_loop = time() + self.metrics.session_start = self.beg_loop self.current_silence = Silence(start=0.0, is_starting=True) self.tokens_alignment.beg_loop = self.beg_loop @@ -560,6 +620,10 @@ class AudioProcessor: logger.info("Empty audio message received, initiating stop sequence.") self.is_stopping = True + # Flush any remaining PCM data before signaling end-of-stream + if self.is_pcm_input and self.pcm_buffer: + await self._flush_remaining_pcm() + if self.transcription_queue: await self.transcription_queue.put(SENTINEL) @@ -572,6 +636,8 @@ class AudioProcessor: logger.warning("AudioProcessor is stopping. Ignoring incoming audio.") return + self.metrics.n_chunks_received += 1 + if self.is_pcm_input: self.pcm_buffer.extend(message) await self.handle_pcm_data() @@ -588,6 +654,11 @@ class AudioProcessor: logger.warning("Failed to write audio data to FFmpeg") async def handle_pcm_data(self) -> None: + # Without VAC, there's no speech detector to end the initial silence. + # Clear it on the first audio chunk so audio actually gets enqueued. + if not self.args.vac and self.current_silence: + await self._end_silence() + # Process when enough data if len(self.pcm_buffer) < self.bytes_per_sec: return @@ -616,7 +687,7 @@ class AudioProcessor: if res is not None: if "start" in res and self.current_silence: - await self._end_silence() + await self._end_silence(at_sample=res.get("start")) if "end" in res and not self.current_silence: pre_silence_chunk = self._slice_before_silence( @@ -624,7 +695,7 @@ class AudioProcessor: ) if pre_silence_chunk is not None and pre_silence_chunk.size > 0: await self._enqueue_active_audio(pre_silence_chunk) - await self._begin_silence() + await self._begin_silence(at_sample=res.get("end")) if not self.current_silence: await self._enqueue_active_audio(pcm_array) @@ -633,3 +704,21 @@ class AudioProcessor: if not self.args.transcription and not self.args.diarization: await asyncio.sleep(0.1) + + async def _flush_remaining_pcm(self) -> None: + """Flush whatever PCM data remains in the buffer, regardless of size threshold.""" + if not self.pcm_buffer: + return + aligned_size = (len(self.pcm_buffer) // self.bytes_per_sample) * self.bytes_per_sample + if aligned_size == 0: + return + pcm_array = self.convert_pcm_to_float(self.pcm_buffer[:aligned_size]) + self.pcm_buffer = self.pcm_buffer[aligned_size:] + + # End any active silence so the audio gets enqueued + if self.current_silence: + await self._end_silence(at_sample=self.total_pcm_samples) + + await self._enqueue_active_audio(pcm_array) + self.total_pcm_samples += len(pcm_array) + logger.info(f"Flushed remaining PCM buffer: {len(pcm_array)} samples ({len(pcm_array)/self.sample_rate:.2f}s)") diff --git a/whisperlivekit/metrics.py b/whisperlivekit/metrics.py new file mode 100644 index 0000000..09e9c12 --- /dev/null +++ b/whisperlivekit/metrics.py @@ -0,0 +1,151 @@ +"""Lightweight ASR evaluation metrics — no external dependencies. + +Provides WER (Word Error Rate) computation via word-level Levenshtein distance, +text normalization, and word-level timestamp accuracy metrics with greedy alignment. +""" + +import re +import unicodedata +from typing import Dict, List, Optional + + +def normalize_text(text: str) -> str: + """Normalize text for WER comparison: lowercase, strip punctuation, collapse whitespace.""" + text = text.lower() + # Normalize unicode (e.g., accented chars to composed form) + text = unicodedata.normalize("NFC", text) + # Remove punctuation (keep letters, numbers, spaces, hyphens within words) + text = re.sub(r"[^\w\s\-']", " ", text) + # Collapse whitespace + text = re.sub(r"\s+", " ", text).strip() + return text + + +def compute_wer(reference: str, hypothesis: str) -> Dict: + """Compute Word Error Rate using word-level Levenshtein edit distance. + + Args: + reference: Ground truth transcription. + hypothesis: Predicted transcription. + + Returns: + Dict with keys: wer, substitutions, insertions, deletions, ref_words, hyp_words. + WER can exceed 1.0 if there are more errors than reference words. + """ + ref_words = normalize_text(reference).split() + hyp_words = normalize_text(hypothesis).split() + + n = len(ref_words) + m = len(hyp_words) + + if n == 0: + return { + "wer": 0.0 if m == 0 else float(m), + "substitutions": 0, + "insertions": m, + "deletions": 0, + "ref_words": 0, + "hyp_words": m, + } + + # DP table: dp[i][j] = (edit_distance, substitutions, insertions, deletions) + dp = [[(0, 0, 0, 0) for _ in range(m + 1)] for _ in range(n + 1)] + + for i in range(1, n + 1): + dp[i][0] = (i, 0, 0, i) + for j in range(1, m + 1): + dp[0][j] = (j, 0, j, 0) + + for i in range(1, n + 1): + for j in range(1, m + 1): + if ref_words[i - 1] == hyp_words[j - 1]: + dp[i][j] = dp[i - 1][j - 1] + else: + sub = dp[i - 1][j - 1] + ins = dp[i][j - 1] + dele = dp[i - 1][j] + + sub_cost = (sub[0] + 1, sub[1] + 1, sub[2], sub[3]) + ins_cost = (ins[0] + 1, ins[1], ins[2] + 1, ins[3]) + del_cost = (dele[0] + 1, dele[1], dele[2], dele[3] + 1) + + dp[i][j] = min(sub_cost, del_cost, ins_cost, key=lambda x: x[0]) + + dist, subs, ins, dels = dp[n][m] + return { + "wer": dist / n, + "substitutions": subs, + "insertions": ins, + "deletions": dels, + "ref_words": n, + "hyp_words": m, + } + + +def compute_timestamp_accuracy( + predicted: List[Dict], + reference: List[Dict], +) -> Dict: + """Compute timestamp accuracy by aligning predicted words to reference words. + + Uses greedy left-to-right alignment on normalized text. For each matched pair, + computes the start-time delta (predicted - reference). + + Args: + predicted: List of dicts with keys: word, start, end. + reference: List of dicts with keys: word, start, end. + + Returns: + Dict with keys: mae_start, max_delta_start, median_delta_start, + n_matched, n_ref, n_pred. Returns None values if no matches found. + """ + if not predicted or not reference: + return { + "mae_start": None, + "max_delta_start": None, + "median_delta_start": None, + "n_matched": 0, + "n_ref": len(reference), + "n_pred": len(predicted), + } + + # Normalize words for matching + pred_norm = [normalize_text(p["word"]) for p in predicted] + ref_norm = [normalize_text(r["word"]) for r in reference] + + # Greedy left-to-right alignment + deltas_start = [] + ref_idx = 0 + for p_idx, p_word in enumerate(pred_norm): + if not p_word: + continue + # Scan forward in reference to find a match (allow small skips) + search_limit = min(ref_idx + 3, len(ref_norm)) + for r_idx in range(ref_idx, search_limit): + if ref_norm[r_idx] == p_word: + delta = predicted[p_idx]["start"] - reference[r_idx]["start"] + deltas_start.append(delta) + ref_idx = r_idx + 1 + break + + if not deltas_start: + return { + "mae_start": None, + "max_delta_start": None, + "median_delta_start": None, + "n_matched": 0, + "n_ref": len(reference), + "n_pred": len(predicted), + } + + abs_deltas = [abs(d) for d in deltas_start] + sorted_abs = sorted(abs_deltas) + + return { + "mae_start": sum(abs_deltas) / len(abs_deltas), + "max_delta_start": max(abs_deltas), + "median_delta_start": sorted_abs[len(sorted_abs) // 2], + "n_matched": len(deltas_start), + "n_ref": len(reference), + "n_pred": len(predicted), + } diff --git a/whisperlivekit/metrics_collector.py b/whisperlivekit/metrics_collector.py new file mode 100644 index 0000000..365f07a --- /dev/null +++ b/whisperlivekit/metrics_collector.py @@ -0,0 +1,84 @@ +"""Lightweight runtime metrics for AudioProcessor sessions. + +Zero external dependencies. Negligible overhead when not queried — +just integer increments and list appends during normal operation. +""" + +import logging +import time +from dataclasses import dataclass, field +from typing import Dict, List + +logger = logging.getLogger(__name__) + + +@dataclass +class SessionMetrics: + """Per-session metrics collected by AudioProcessor.""" + + session_start: float = 0.0 + total_audio_duration_s: float = 0.0 + total_processing_time_s: float = 0.0 + + # Chunk / call counters + n_chunks_received: int = 0 + n_transcription_calls: int = 0 + n_tokens_produced: int = 0 + n_responses_sent: int = 0 + + # Per-call ASR latency (seconds) + transcription_durations: List[float] = field(default_factory=list) + + # Silence + n_silence_events: int = 0 + total_silence_duration_s: float = 0.0 + + # --- Computed properties --- + + @property + def rtf(self) -> float: + """Real-time factor: processing_time / audio_duration.""" + if self.total_audio_duration_s <= 0: + return 0.0 + return self.total_processing_time_s / self.total_audio_duration_s + + @property + def avg_latency_ms(self) -> float: + """Average per-call ASR latency in milliseconds.""" + if not self.transcription_durations: + return 0.0 + return (sum(self.transcription_durations) / len(self.transcription_durations)) * 1000 + + @property + def p95_latency_ms(self) -> float: + """95th percentile per-call ASR latency in milliseconds.""" + if not self.transcription_durations: + return 0.0 + sorted_d = sorted(self.transcription_durations) + idx = int(len(sorted_d) * 0.95) + idx = min(idx, len(sorted_d) - 1) + return sorted_d[idx] * 1000 + + def to_dict(self) -> Dict: + """Serialize to a plain dict (JSON-safe).""" + return { + "session_start": self.session_start, + "total_audio_duration_s": round(self.total_audio_duration_s, 3), + "total_processing_time_s": round(self.total_processing_time_s, 3), + "rtf": round(self.rtf, 3), + "n_chunks_received": self.n_chunks_received, + "n_transcription_calls": self.n_transcription_calls, + "n_tokens_produced": self.n_tokens_produced, + "n_responses_sent": self.n_responses_sent, + "avg_latency_ms": round(self.avg_latency_ms, 2), + "p95_latency_ms": round(self.p95_latency_ms, 2), + "n_silence_events": self.n_silence_events, + "total_silence_duration_s": round(self.total_silence_duration_s, 3), + } + + def log_summary(self) -> None: + """Emit a structured log line summarising the session.""" + elapsed = time.time() - self.session_start if self.session_start else 0 + self.total_processing_time_s = elapsed + d = self.to_dict() + logger.info(f"SESSION_METRICS {d}") From 5a12c627b474f9af84f2acb89d38a8affbe1714e Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 23:27:40 +0100 Subject: [PATCH 03/10] feat: add 99-test unit test suite with zero model dependencies Test suite covering: - metrics.py: WER computation, timestamp accuracy, text normalization - config.py: defaults, .en model detection, policy aliases, from_namespace - timed_objects.py: ASRToken, Silence, Transcript, Segment, FrontData - hypothesis_buffer.py: insert, flush, LCP matching, pop_committed - silence_handling.py: state machine, double-counting regression test - audio_processor.py: async pipeline with MockOnlineProcessor All tests run in ~1.3s without downloading any ASR models. Add pytest and pytest-asyncio as optional test dependencies. Update .gitignore to allow tests/ directory. --- .gitignore | 6 +- pyproject.toml | 4 +- tests/__init__.py | 0 tests/conftest.py | 58 +++++++++ tests/test_audio_processor.py | 209 ++++++++++++++++++++++++++++++++ tests/test_config.py | 99 +++++++++++++++ tests/test_hypothesis_buffer.py | 172 ++++++++++++++++++++++++++ tests/test_metrics.py | 147 ++++++++++++++++++++++ tests/test_silence_handling.py | 99 +++++++++++++++ tests/test_timed_objects.py | 185 ++++++++++++++++++++++++++++ 10 files changed, 976 insertions(+), 3 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_audio_processor.py create mode 100644 tests/test_config.py create mode 100644 tests/test_hypothesis_buffer.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_silence_handling.py create mode 100644 tests/test_timed_objects.py diff --git a/.gitignore b/.gitignore index a015198..ecfdcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -119,9 +119,11 @@ run_*.sh *.pt # Debug & testing -test_*.py +/test_*.py +!test_backend_offline.py launch.json .DS_Store -test/* +/test/ +!tests/ nllb-200-distilled-600M-ctranslate2/* *.mp3 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 74ade12..9a79780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "whisperlivekit" -version = "0.2.18" +version = "0.2.19" description = "Real-time speech-to-text with speaker diarization using Whisper" readme = "README.md" authors = [ @@ -42,6 +42,7 @@ dependencies = [ ] [project.optional-dependencies] +test = ["pytest>=7.0", "pytest-asyncio>=0.21"] translation = ["nllw"] sentence_tokenizer = ["mosestokenizer", "wtpsplit"] voxtral-hf = ["transformers>=5.2.0", "mistral-common[audio]"] @@ -64,6 +65,7 @@ packages = [ "whisperlivekit.whisper.normalizers", "whisperlivekit.web", "whisperlivekit.local_agreement", + "whisperlivekit.voxtral_mlx", "whisperlivekit.silero_vad_models" ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1a26f33 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared pytest fixtures for WhisperLiveKit tests.""" + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from whisperlivekit.timed_objects import ASRToken, Silence, Transcript + + +AUDIO_TESTS_DIR = Path(__file__).parent.parent / "audio_tests" + + +@pytest.fixture +def sample_tokens(): + """A short sequence of ASRToken objects.""" + return [ + ASRToken(start=0.0, end=0.5, text="Hello"), + ASRToken(start=0.5, end=1.0, text=" world"), + ASRToken(start=1.0, end=1.5, text=" test."), + ] + + +@pytest.fixture +def sample_silence(): + """A completed silence event.""" + s = Silence(start=1.5, end=3.0, is_starting=False, has_ended=True) + s.compute_duration() + return s + + +@pytest.fixture +def mock_args(): + """Minimal args namespace for AudioProcessor tests.""" + return SimpleNamespace( + diarization=False, + transcription=True, + target_language="", + vac=False, + vac_chunk_size=0.04, + min_chunk_size=0.1, + pcm_input=True, + punctuation_split=False, + backend="faster-whisper", + backend_policy="localagreement", + vad=True, + ) + + +@pytest.fixture +def ground_truth_en(): + """Ground truth transcript for the 7s English audio (if available).""" + path = AUDIO_TESTS_DIR / "00_00_07_english_1_speaker.transcript.json" + if path.exists(): + with open(path) as f: + return json.load(f) + return None diff --git a/tests/test_audio_processor.py b/tests/test_audio_processor.py new file mode 100644 index 0000000..9286108 --- /dev/null +++ b/tests/test_audio_processor.py @@ -0,0 +1,209 @@ +"""Tests for AudioProcessor pipeline with mocked ASR backends. + +These tests verify the async audio processing pipeline works correctly +without requiring any real ASR models to be loaded. +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest + +from whisperlivekit.timed_objects import ASRToken, Transcript + + +# --------------------------------------------------------------------------- +# Mock ASR components +# --------------------------------------------------------------------------- + +class MockASR: + """Mock ASR model holder.""" + sep = " " + SAMPLING_RATE = 16000 + + def __init__(self): + self.transcribe_kargs = {} + self.original_language = "en" + self.backend_choice = "mock" + + def transcribe(self, audio): + return None + + +class MockOnlineProcessor: + """Mock online processor that returns canned tokens.""" + SAMPLING_RATE = 16000 + + def __init__(self, asr=None): + self.asr = asr or MockASR() + self.audio_buffer = np.array([], dtype=np.float32) + self.end = 0.0 + self._call_count = 0 + self._finished = False + + def insert_audio_chunk(self, audio, audio_stream_end_time): + self.audio_buffer = np.append(self.audio_buffer, audio) + self.end = audio_stream_end_time + + def process_iter(self, is_last=False): + self._call_count += 1 + # Emit a token on every call when we have audio + if len(self.audio_buffer) > 0: + t = self._call_count * 0.5 + return [ASRToken(start=t, end=t + 0.5, text=f"word{self._call_count}")], self.end + return [], self.end + + def get_buffer(self): + return Transcript(start=None, end=None, text="") + + def start_silence(self): + return [], self.end + + def end_silence(self, silence_duration, offset): + pass + + def new_speaker(self, change_speaker): + pass + + def finish(self): + self._finished = True + return [], self.end + + def warmup(self, audio, init_prompt=""): + pass + + +def _make_pcm_bytes(duration_s=0.1, sample_rate=16000): + """Generate silent PCM s16le bytes.""" + n_samples = int(duration_s * sample_rate) + audio = np.zeros(n_samples, dtype=np.float32) + return (audio * 32768).clip(-32768, 32767).astype(np.int16).tobytes() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_engine(): + """Create a mock TranscriptionEngine-like object.""" + engine = SimpleNamespace( + asr=MockASR(), + diarization_model=None, + translation_model=None, + args=SimpleNamespace( + diarization=False, + transcription=True, + target_language="", + vac=False, + vac_chunk_size=0.04, + min_chunk_size=0.1, + pcm_input=True, + punctuation_split=False, + backend="mock", + backend_policy="localagreement", + vad=True, + model_size="base", + lan="en", + ), + ) + return engine + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestPCMConversion: + """Test PCM byte conversion without needing the full pipeline.""" + + def test_s16le_roundtrip(self): + """Convert float32 → s16le → float32 and verify approximate roundtrip.""" + original = np.array([0.0, 0.5, -0.5, 1.0, -1.0], dtype=np.float32) + s16 = (original * 32768).clip(-32768, 32767).astype(np.int16) + pcm_bytes = s16.tobytes() + # Direct numpy conversion (same logic as AudioProcessor.convert_pcm_to_float) + recovered = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0 + + np.testing.assert_allclose(recovered, original, atol=1 / 32768) + + +@pytest.mark.asyncio +class TestPipelineBasics: + async def test_feed_audio_and_get_responses(self, mock_engine): + """Feed audio through the pipeline and verify we get responses.""" + from whisperlivekit.audio_processor import AudioProcessor + + with patch("whisperlivekit.audio_processor.online_factory", return_value=MockOnlineProcessor()): + processor = AudioProcessor(transcription_engine=mock_engine) + results_gen = await processor.create_tasks() + + responses = [] + + async def collect(): + async for resp in results_gen: + responses.append(resp) + + task = asyncio.create_task(collect()) + + # Feed 2 seconds of audio in 100ms chunks + for _ in range(20): + await processor.process_audio(_make_pcm_bytes(0.1)) + + # Signal EOF + await processor.process_audio(None) + + await asyncio.wait_for(task, timeout=10.0) + await processor.cleanup() + + # We should have gotten at least one response + assert len(responses) > 0 + + async def test_eof_terminates_pipeline(self, mock_engine): + """Sending None (EOF) should cleanly terminate the pipeline.""" + from whisperlivekit.audio_processor import AudioProcessor + + with patch("whisperlivekit.audio_processor.online_factory", return_value=MockOnlineProcessor()): + processor = AudioProcessor(transcription_engine=mock_engine) + results_gen = await processor.create_tasks() + + responses = [] + + async def collect(): + async for resp in results_gen: + responses.append(resp) + + task = asyncio.create_task(collect()) + + # Send a small amount of audio then EOF + await processor.process_audio(_make_pcm_bytes(0.5)) + await processor.process_audio(None) + + await asyncio.wait_for(task, timeout=10.0) + await processor.cleanup() + + # Pipeline should have terminated without error + assert task.done() + + async def test_empty_audio_no_crash(self, mock_engine): + """Sending EOF immediately (no audio) should not crash.""" + from whisperlivekit.audio_processor import AudioProcessor + + with patch("whisperlivekit.audio_processor.online_factory", return_value=MockOnlineProcessor()): + processor = AudioProcessor(transcription_engine=mock_engine) + results_gen = await processor.create_tasks() + + responses = [] + + async def collect(): + async for resp in results_gen: + responses.append(resp) + + task = asyncio.create_task(collect()) + await processor.process_audio(None) + + await asyncio.wait_for(task, timeout=10.0) + await processor.cleanup() + assert task.done() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..23f4c56 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,99 @@ +"""Tests for WhisperLiveKitConfig.""" + +import logging +from types import SimpleNamespace + +import pytest + +from whisperlivekit.config import WhisperLiveKitConfig + + +class TestDefaults: + def test_default_backend(self): + c = WhisperLiveKitConfig() + assert c.backend == "auto" + + def test_default_policy(self): + c = WhisperLiveKitConfig() + assert c.backend_policy == "simulstreaming" + + def test_default_language(self): + c = WhisperLiveKitConfig() + assert c.lan == "auto" + + def test_default_vac(self): + c = WhisperLiveKitConfig() + assert c.vac is True + + def test_default_model_size(self): + c = WhisperLiveKitConfig() + assert c.model_size == "base" + + def test_default_transcription(self): + c = WhisperLiveKitConfig() + assert c.transcription is True + assert c.diarization is False + + +class TestPostInit: + def test_en_model_forces_english(self): + c = WhisperLiveKitConfig(model_size="tiny.en") + assert c.lan == "en" + + def test_en_suffix_with_auto_language(self): + c = WhisperLiveKitConfig(model_size="base.en", lan="auto") + assert c.lan == "en" + + def test_non_en_model_keeps_language(self): + c = WhisperLiveKitConfig(model_size="base", lan="fr") + assert c.lan == "fr" + + def test_policy_alias_1(self): + c = WhisperLiveKitConfig(backend_policy="1") + assert c.backend_policy == "simulstreaming" + + def test_policy_alias_2(self): + c = WhisperLiveKitConfig(backend_policy="2") + assert c.backend_policy == "localagreement" + + def test_policy_no_alias(self): + c = WhisperLiveKitConfig(backend_policy="localagreement") + assert c.backend_policy == "localagreement" + + +class TestFromNamespace: + def test_known_keys(self): + ns = SimpleNamespace(backend="faster-whisper", lan="en", model_size="large-v3") + c = WhisperLiveKitConfig.from_namespace(ns) + assert c.backend == "faster-whisper" + assert c.lan == "en" + assert c.model_size == "large-v3" + + def test_ignores_unknown_keys(self): + ns = SimpleNamespace(backend="auto", unknown_key="value", another="x") + c = WhisperLiveKitConfig.from_namespace(ns) + assert c.backend == "auto" + assert not hasattr(c, "unknown_key") + + def test_preserves_defaults_for_missing(self): + ns = SimpleNamespace(backend="voxtral-mlx") + c = WhisperLiveKitConfig.from_namespace(ns) + assert c.lan == "auto" + assert c.vac is True + + +class TestFromKwargs: + def test_known_keys(self): + c = WhisperLiveKitConfig.from_kwargs(backend="mlx-whisper", lan="fr") + assert c.backend == "mlx-whisper" + assert c.lan == "fr" + + def test_warns_on_unknown_keys(self, caplog): + with caplog.at_level(logging.WARNING, logger="whisperlivekit.config"): + c = WhisperLiveKitConfig.from_kwargs(backend="auto", bogus="value") + assert c.backend == "auto" + assert "bogus" in caplog.text + + def test_post_init_runs(self): + c = WhisperLiveKitConfig.from_kwargs(model_size="small.en") + assert c.lan == "en" diff --git a/tests/test_hypothesis_buffer.py b/tests/test_hypothesis_buffer.py new file mode 100644 index 0000000..732090a --- /dev/null +++ b/tests/test_hypothesis_buffer.py @@ -0,0 +1,172 @@ +"""Tests for HypothesisBuffer — the core of LocalAgreement policy.""" + +import pytest + +from whisperlivekit.timed_objects import ASRToken +from whisperlivekit.local_agreement.online_asr import HypothesisBuffer + + +def make_tokens(words, start=0.0, step=0.5): + """Helper: create ASRToken list from word strings.""" + tokens = [] + t = start + for w in words: + tokens.append(ASRToken(start=t, end=t + step, text=w, probability=0.9)) + t += step + return tokens + + +class TestInsert: + def test_basic_insert(self): + buf = HypothesisBuffer() + tokens = make_tokens(["hello", "world"]) + buf.insert(tokens, offset=0.0) + assert len(buf.new) == 2 + assert buf.new[0].text == "hello" + + def test_insert_with_offset(self): + buf = HypothesisBuffer() + tokens = make_tokens(["hello"], start=0.0) + buf.insert(tokens, offset=5.0) + assert buf.new[0].start == pytest.approx(5.0) + + def test_insert_filters_old_tokens(self): + buf = HypothesisBuffer() + buf.last_committed_time = 10.0 + tokens = make_tokens(["old", "new"], start=5.0, step=3.0) + buf.insert(tokens, offset=0.0) + # "old" at 5.0 is before last_committed_time - 0.1 = 9.9 → filtered + # "new" at 8.0 is also before 9.9 → filtered + assert len(buf.new) == 0 + + def test_insert_deduplicates_committed(self): + buf = HypothesisBuffer() + # Commit "hello" + tokens1 = make_tokens(["hello", "world"]) + buf.insert(tokens1, offset=0.0) + buf.flush() # commits "hello" (buffer was empty, so nothing matches) + # Actually with empty buffer, flush won't commit anything + # Let's do it properly: two rounds + buf2 = HypothesisBuffer() + first = make_tokens(["hello", "world"]) + buf2.insert(first, offset=0.0) + buf2.flush() # buffer was empty → no commits, buffer = ["hello", "world"] + + second = make_tokens(["hello", "world", "test"]) + buf2.insert(second, offset=0.0) + committed = buf2.flush() + # LCP of ["hello", "world"] and ["hello", "world", "test"] = ["hello", "world"] + assert len(committed) == 2 + assert committed[0].text == "hello" + assert committed[1].text == "world" + + +class TestFlush: + def test_flush_empty(self): + buf = HypothesisBuffer() + committed = buf.flush() + assert committed == [] + + def test_flush_lcp_matching(self): + buf = HypothesisBuffer() + # Round 1: establish buffer + buf.insert(make_tokens(["hello", "world"]), offset=0.0) + buf.flush() # buffer = ["hello", "world"], committed = [] + + # Round 2: same prefix, new suffix + buf.insert(make_tokens(["hello", "world", "test"]), offset=0.0) + committed = buf.flush() + assert [t.text for t in committed] == ["hello", "world"] + + def test_flush_no_match(self): + buf = HypothesisBuffer() + # Round 1 + buf.insert(make_tokens(["hello", "world"]), offset=0.0) + buf.flush() + + # Round 2: completely different + buf.insert(make_tokens(["foo", "bar"]), offset=0.0) + committed = buf.flush() + assert committed == [] + + def test_flush_partial_match(self): + buf = HypothesisBuffer() + buf.insert(make_tokens(["hello", "world", "test"]), offset=0.0) + buf.flush() + + buf.insert(make_tokens(["hello", "earth", "again"]), offset=0.0) + committed = buf.flush() + assert len(committed) == 1 + assert committed[0].text == "hello" + + def test_flush_updates_last_committed(self): + buf = HypothesisBuffer() + buf.insert(make_tokens(["hello", "world"]), offset=0.0) + buf.flush() + + buf.insert(make_tokens(["hello", "world", "test"]), offset=0.0) + buf.flush() + assert buf.last_committed_word == "world" + assert buf.last_committed_time > 0 + + def test_flush_with_confidence_validation(self): + buf = HypothesisBuffer(confidence_validation=True) + high_conf = [ + ASRToken(start=0.0, end=0.5, text="sure", probability=0.99), + ASRToken(start=0.5, end=1.0, text="maybe", probability=0.5), + ] + buf.insert(high_conf, offset=0.0) + committed = buf.flush() + # "sure" has p>0.95 → committed immediately + assert len(committed) == 1 + assert committed[0].text == "sure" + + +class TestPopCommitted: + def test_pop_removes_old(self): + buf = HypothesisBuffer() + buf.committed_in_buffer = make_tokens(["a", "b", "c"], start=0.0, step=1.0) + # "a": end=1.0, "b": end=2.0, "c": end=3.0 + # pop_committed removes tokens with end <= time + buf.pop_committed(2.0) + # "a" (end=1.0) and "b" (end=2.0) removed, "c" (end=3.0) remains + assert len(buf.committed_in_buffer) == 1 + assert buf.committed_in_buffer[0].text == "c" + + def test_pop_nothing(self): + buf = HypothesisBuffer() + buf.committed_in_buffer = make_tokens(["a", "b"], start=5.0) + buf.pop_committed(0.0) + assert len(buf.committed_in_buffer) == 2 + + def test_pop_all(self): + buf = HypothesisBuffer() + buf.committed_in_buffer = make_tokens(["a", "b"], start=0.0, step=0.5) + buf.pop_committed(100.0) + assert len(buf.committed_in_buffer) == 0 + + +class TestStreamingSimulation: + """Multi-round insert/flush simulating real streaming behavior.""" + + def test_three_rounds(self): + buf = HypothesisBuffer() + all_committed = [] + + # Round 1: "this is" + buf.insert(make_tokens(["this", "is"]), offset=0.0) + all_committed.extend(buf.flush()) + + # Round 2: "this is a test" + buf.insert(make_tokens(["this", "is", "a", "test"]), offset=0.0) + all_committed.extend(buf.flush()) + + # Round 3: "this is a test today" + buf.insert(make_tokens(["this", "is", "a", "test", "today"]), offset=0.0) + all_committed.extend(buf.flush()) + + words = [t.text for t in all_committed] + assert "this" in words + assert "is" in words + assert "a" in words + assert "test" in words diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..365e168 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,147 @@ +"""Tests for whisperlivekit.metrics — WER, timestamp accuracy, normalization.""" + +import pytest + +from whisperlivekit.metrics import compute_wer, compute_timestamp_accuracy, normalize_text + + +class TestNormalizeText: + def test_lowercase(self): + assert normalize_text("Hello World") == "hello world" + + def test_strip_punctuation(self): + assert normalize_text("Hello, world!") == "hello world" + + def test_collapse_whitespace(self): + assert normalize_text(" hello world ") == "hello world" + + def test_keep_hyphens(self): + assert normalize_text("real-time") == "real-time" + + def test_keep_apostrophes(self): + assert normalize_text("don't") == "don't" + + def test_unicode_normalized(self): + # e + combining accent should be same as precomposed + assert normalize_text("caf\u0065\u0301") == normalize_text("caf\u00e9") + + def test_empty(self): + assert normalize_text("") == "" + + def test_only_punctuation(self): + assert normalize_text("...!?") == "" + + +class TestComputeWER: + def test_perfect_match(self): + result = compute_wer("hello world", "hello world") + assert result["wer"] == 0.0 + assert result["substitutions"] == 0 + assert result["insertions"] == 0 + assert result["deletions"] == 0 + + def test_case_insensitive(self): + result = compute_wer("Hello World", "hello world") + assert result["wer"] == 0.0 + + def test_punctuation_ignored(self): + result = compute_wer("Hello, world!", "hello world") + assert result["wer"] == 0.0 + + def test_one_substitution(self): + result = compute_wer("hello world", "hello earth") + assert result["wer"] == pytest.approx(0.5) + assert result["substitutions"] == 1 + + def test_one_insertion(self): + result = compute_wer("hello world", "hello big world") + assert result["wer"] == pytest.approx(0.5) + assert result["insertions"] == 1 + + def test_one_deletion(self): + result = compute_wer("hello big world", "hello world") + assert result["wer"] == pytest.approx(1 / 3) + assert result["deletions"] == 1 + + def test_completely_different(self): + result = compute_wer("the cat sat", "a dog ran") + assert result["wer"] == pytest.approx(1.0) + + def test_empty_reference(self): + result = compute_wer("", "hello") + assert result["wer"] == 1.0 # 1 insertion / 0 ref → treated as float(m) + assert result["ref_words"] == 0 + + def test_empty_hypothesis(self): + result = compute_wer("hello world", "") + assert result["wer"] == pytest.approx(1.0) + assert result["deletions"] == 2 + + def test_both_empty(self): + result = compute_wer("", "") + assert result["wer"] == 0.0 + + def test_ref_and_hyp_word_counts(self): + result = compute_wer("one two three", "one two three four") + assert result["ref_words"] == 3 + assert result["hyp_words"] == 4 + + +class TestComputeTimestampAccuracy: + def test_perfect_match(self): + words = [ + {"word": "hello", "start": 0.0, "end": 0.5}, + {"word": "world", "start": 0.5, "end": 1.0}, + ] + result = compute_timestamp_accuracy(words, words) + assert result["mae_start"] == 0.0 + assert result["max_delta_start"] == 0.0 + assert result["n_matched"] == 2 + + def test_constant_offset(self): + ref = [ + {"word": "hello", "start": 0.0, "end": 0.5}, + {"word": "world", "start": 0.5, "end": 1.0}, + ] + pred = [ + {"word": "hello", "start": 0.1, "end": 0.6}, + {"word": "world", "start": 0.6, "end": 1.1}, + ] + result = compute_timestamp_accuracy(pred, ref) + assert result["mae_start"] == pytest.approx(0.1) + assert result["max_delta_start"] == pytest.approx(0.1) + assert result["n_matched"] == 2 + + def test_mismatched_word_counts(self): + ref = [ + {"word": "hello", "start": 0.0, "end": 0.5}, + {"word": "beautiful", "start": 0.5, "end": 1.0}, + {"word": "world", "start": 1.0, "end": 1.5}, + ] + pred = [ + {"word": "hello", "start": 0.0, "end": 0.5}, + {"word": "world", "start": 1.1, "end": 1.6}, + ] + result = compute_timestamp_accuracy(pred, ref) + assert result["n_matched"] == 2 + assert result["n_ref"] == 3 + assert result["n_pred"] == 2 + + def test_empty_predicted(self): + ref = [{"word": "hello", "start": 0.0, "end": 0.5}] + result = compute_timestamp_accuracy([], ref) + assert result["mae_start"] is None + assert result["n_matched"] == 0 + + def test_empty_reference(self): + pred = [{"word": "hello", "start": 0.0, "end": 0.5}] + result = compute_timestamp_accuracy(pred, []) + assert result["mae_start"] is None + assert result["n_matched"] == 0 + + def test_case_insensitive_matching(self): + ref = [{"word": "Hello", "start": 0.0, "end": 0.5}] + pred = [{"word": "hello", "start": 0.1, "end": 0.6}] + result = compute_timestamp_accuracy(pred, ref) + assert result["n_matched"] == 1 + assert result["mae_start"] == pytest.approx(0.1) diff --git a/tests/test_silence_handling.py b/tests/test_silence_handling.py new file mode 100644 index 0000000..08028be --- /dev/null +++ b/tests/test_silence_handling.py @@ -0,0 +1,99 @@ +"""Tests for silence handling — state machine and double-counting regression.""" + +import pytest + +from whisperlivekit.timed_objects import Silence + + +class TestSilenceStateMachine: + """Test Silence object state transitions.""" + + def test_initial_state(self): + s = Silence(start=1.0, is_starting=True) + assert s.is_starting is True + assert s.has_ended is False + assert s.duration is None + assert s.end is None + + def test_end_silence(self): + s = Silence(start=1.0, is_starting=True) + s.end = 3.0 + s.is_starting = False + s.has_ended = True + s.compute_duration() + assert s.duration == pytest.approx(2.0) + + def test_very_short_silence(self): + s = Silence(start=1.0, end=1.01, is_starting=False, has_ended=True) + s.compute_duration() + assert s.duration == pytest.approx(0.01) + + def test_zero_duration_silence(self): + s = Silence(start=5.0, end=5.0) + s.compute_duration() + assert s.duration == pytest.approx(0.0) + + +class TestSilenceDoubleCounting: + """Regression tests for the silence double-counting bug. + + The bug: _begin_silence and _end_silence both pushed self.current_silence + to the queue. Since they were the same Python object, _end_silence's mutation + affected the already-queued start event. The consumer processed both as + ended silences, doubling the duration. + + Fix: _begin_silence now pushes a separate Silence object for the start event. + """ + + def test_start_and_end_are_separate_objects(self): + """Simulate the fix: start event and end event must be different objects.""" + # Simulate _begin_silence: creates start event as separate object + current_silence = Silence(start=1.0, is_starting=True) + start_event = Silence(start=1.0, is_starting=True) # separate copy + + # Simulate _end_silence: mutates current_silence + current_silence.end = 3.0 + current_silence.is_starting = False + current_silence.has_ended = True + current_silence.compute_duration() + + # start_event should NOT be affected by mutations to current_silence + assert start_event.is_starting is True + assert start_event.has_ended is False + assert start_event.end is None + + # current_silence (end event) has the final state + assert current_silence.has_ended is True + assert current_silence.duration == pytest.approx(2.0) + + def test_single_object_would_cause_double_counting(self): + """Demonstrate the bug: if same object is used for both events.""" + shared = Silence(start=1.0, is_starting=True) + queue = [shared] # start event queued + + # Mutate (simulates _end_silence) + shared.end = 3.0 + shared.is_starting = False + shared.has_ended = True + shared.compute_duration() + queue.append(shared) # end event queued + + # Both queue items point to the SAME mutated object + assert queue[0] is queue[1] # same reference + assert queue[0].has_ended is True # start event also shows ended! + + # This would cause double-counting: both items have has_ended=True + # and duration=2.0, so the consumer adds 2.0 twice = 4.0 + + +class TestConsecutiveSilences: + def test_multiple_silences(self): + """Multiple silence periods should have independent durations.""" + s1 = Silence(start=1.0, end=2.0) + s1.compute_duration() + s2 = Silence(start=5.0, end=8.0) + s2.compute_duration() + assert s1.duration == pytest.approx(1.0) + assert s2.duration == pytest.approx(3.0) + # Total silence should be sum, not accumulated on single object + assert s1.duration + s2.duration == pytest.approx(4.0) diff --git a/tests/test_timed_objects.py b/tests/test_timed_objects.py new file mode 100644 index 0000000..559a1c3 --- /dev/null +++ b/tests/test_timed_objects.py @@ -0,0 +1,185 @@ +"""Tests for whisperlivekit.timed_objects data classes.""" + +import pytest + +from whisperlivekit.timed_objects import ( + ASRToken, + FrontData, + Segment, + Silence, + TimedText, + Transcript, + format_time, +) + + +class TestFormatTime: + def test_zero(self): + assert format_time(0) == "0:00:00" + + def test_one_minute(self): + assert format_time(60) == "0:01:00" + + def test_one_hour(self): + assert format_time(3600) == "1:00:00" + + def test_fractional_truncated(self): + assert format_time(61.9) == "0:01:01" + + +class TestASRToken: + def test_with_offset(self): + t = ASRToken(start=1.0, end=2.0, text="hello") + shifted = t.with_offset(0.5) + assert shifted.start == pytest.approx(1.5) + assert shifted.end == pytest.approx(2.5) + assert shifted.text == "hello" + + def test_with_offset_preserves_fields(self): + t = ASRToken(start=0.0, end=1.0, text="hi", speaker=2, probability=0.95) + shifted = t.with_offset(1.0) + assert shifted.speaker == 2 + assert shifted.probability == 0.95 + + def test_is_silence_false(self): + t = ASRToken(start=0.0, end=1.0, text="hello") + assert t.is_silence() is False + + def test_bool_truthy(self): + t = ASRToken(start=0.0, end=1.0, text="hello") + assert bool(t) is True + + def test_bool_falsy(self): + t = ASRToken(start=0.0, end=1.0, text="") + assert bool(t) is False + + +class TestTimedText: + def test_has_punctuation_period(self): + t = TimedText(text="hello.") + assert t.has_punctuation() is True + + def test_has_punctuation_exclamation(self): + t = TimedText(text="wow!") + assert t.has_punctuation() is True + + def test_has_punctuation_question(self): + t = TimedText(text="really?") + assert t.has_punctuation() is True + + def test_has_punctuation_cjk(self): + t = TimedText(text="hello。") + assert t.has_punctuation() is True + + def test_no_punctuation(self): + t = TimedText(text="hello world") + assert t.has_punctuation() is False + + def test_duration(self): + t = TimedText(start=1.0, end=3.5) + assert t.duration() == pytest.approx(2.5) + + def test_contains_timespan(self): + outer = TimedText(start=0.0, end=5.0) + inner = TimedText(start=1.0, end=3.0) + assert outer.contains_timespan(inner) is True + assert inner.contains_timespan(outer) is False + + +class TestSilence: + def test_compute_duration(self): + s = Silence(start=1.0, end=3.5) + d = s.compute_duration() + assert d == pytest.approx(2.5) + assert s.duration == pytest.approx(2.5) + + def test_compute_duration_none_start(self): + s = Silence(start=None, end=3.5) + d = s.compute_duration() + assert d is None + + def test_compute_duration_none_end(self): + s = Silence(start=1.0, end=None) + d = s.compute_duration() + assert d is None + + def test_is_silence_true(self): + s = Silence() + assert s.is_silence() is True + + +class TestTranscript: + def test_from_tokens(self, sample_tokens): + t = Transcript.from_tokens(sample_tokens, sep="") + assert t.text == "Hello world test." + assert t.start == pytest.approx(0.0) + assert t.end == pytest.approx(1.5) + + def test_from_tokens_with_sep(self, sample_tokens): + t = Transcript.from_tokens(sample_tokens, sep="|") + assert t.text == "Hello| world| test." + + def test_from_empty_tokens(self): + t = Transcript.from_tokens([]) + assert t.text == "" + assert t.start is None + assert t.end is None + + def test_from_tokens_with_offset(self, sample_tokens): + t = Transcript.from_tokens(sample_tokens, offset=10.0) + assert t.start == pytest.approx(10.0) + assert t.end == pytest.approx(11.5) + + +class TestSegment: + def test_from_tokens(self, sample_tokens): + seg = Segment.from_tokens(sample_tokens) + assert seg is not None + assert seg.text == "Hello world test." + assert seg.start == pytest.approx(0.0) + assert seg.end == pytest.approx(1.5) + assert seg.speaker == -1 + + def test_from_silence_tokens(self): + silences = [ + Silence(start=1.0, end=2.0), + Silence(start=2.0, end=3.0), + ] + seg = Segment.from_tokens(silences, is_silence=True) + assert seg is not None + assert seg.speaker == -2 + assert seg.is_silence() is True + assert seg.text is None + + def test_from_empty_tokens(self): + seg = Segment.from_tokens([]) + assert seg is None + + def test_to_dict(self, sample_tokens): + seg = Segment.from_tokens(sample_tokens) + d = seg.to_dict() + assert "text" in d + assert "speaker" in d + assert "start" in d + assert "end" in d + + +class TestFrontData: + def test_to_dict_empty(self): + fd = FrontData() + d = fd.to_dict() + assert d["lines"] == [] + assert d["buffer_transcription"] == "" + assert "error" not in d + + def test_to_dict_with_error(self): + fd = FrontData(error="something broke") + d = fd.to_dict() + assert d["error"] == "something broke" + + def test_to_dict_with_lines(self, sample_tokens): + seg = Segment.from_tokens(sample_tokens) + fd = FrontData(lines=[seg]) + d = fd.to_dict() + assert len(d["lines"]) == 1 + assert d["lines"][0]["text"] == "Hello world test." From 83d0fa3fac515684994227cbb8150c0a930b8aa8 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 23:27:50 +0100 Subject: [PATCH 04/10] feat: benchmark suite with WER, timestamp accuracy, cross-backend comparison - Extend test_backend_offline.py with WER and timestamp accuracy metrics computed via whisperlivekit.metrics against ground truth transcripts. - Add --benchmark flag to auto-detect all installed backends and run each (backend, policy) combination in sequence. - Add --policy flag to override the streaming policy. - Add detect_available_backends() probing faster-whisper, mlx-whisper, voxtral-mlx, voxtral (HF), and openai-whisper. - Add print_cross_backend_comparison() with per-combo averages. - Add run_benchmark.py for comprehensive multi-model benchmarking. - Add BENCHMARK.md with full results on Apple M4: speed, WER, timestamp accuracy, VAC impact, and recommendations. - Add ground truth transcript JSON files for all audio test files. --- BENCHMARK.md | 159 ++++ ...00_00_07_english_1_speaker.transcript.json | 97 +++ .../00_00_16_french_1_speaker.transcript.json | 177 ++++ ...0_00_30_english_3_speakers.transcript.json | 382 +++++++++ audio_tests/generate_transcripts.py | 57 ++ run_benchmark.py | 291 +++++++ test_backend_offline.py | 783 ++++++++++++++++++ 7 files changed, 1946 insertions(+) create mode 100644 BENCHMARK.md create mode 100644 audio_tests/00_00_07_english_1_speaker.transcript.json create mode 100644 audio_tests/00_00_16_french_1_speaker.transcript.json create mode 100644 audio_tests/00_00_30_english_3_speakers.transcript.json create mode 100644 audio_tests/generate_transcripts.py create mode 100644 run_benchmark.py create mode 100644 test_backend_offline.py diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 0000000..9d27ab2 --- /dev/null +++ b/BENCHMARK.md @@ -0,0 +1,159 @@ +# WhisperLiveKit Benchmark Report + +Benchmark comparing all supported ASR backends and streaming policies on Apple Silicon, +using the full AudioProcessor pipeline (the same path audio takes in production via WebSocket). + +## Test Environment + +| Property | Value | +|----------|-------| +| Hardware | Apple M4, 32 GB RAM | +| OS | macOS 25.3.0 (arm64) | +| Python | 3.13 | +| faster-whisper | 1.2.1 | +| mlx-whisper | installed (via mlx) | +| Voxtral (HF) | transformers-based | +| Voxtral MLX | native MLX backend | +| Model size | `base` (default for whisper backends) | +| VAC (Silero VAD) | enabled unless noted | +| Chunk size | 100 ms | +| Pacing | no-realtime (as fast as possible) | + +## Audio Test Files + +| File | Duration | Language | Speakers | Description | +|------|----------|----------|----------|-------------| +| `00_00_07_english_1_speaker.wav` | 7.2 s | English | 1 | Short dictation with pauses | +| `00_00_16_french_1_speaker.wav` | 16.3 s | French | 1 | French speech with intentional silence gaps | +| `00_00_30_english_3_speakers.wav` | 30.0 s | English | 3 | Multi-speaker conversation about transcription | + +All files have hand-verified ground truth transcripts (`.transcript.json`) with per-word timestamps. + +--- + +## Results Overview + +### English - Short (7.2 s, 1 speaker) + +| Backend | Policy | RTF | WER | Timestamp MAE | +|---------|--------|-----|-----|---------------| +| faster-whisper | LocalAgreement | 0.20x | 21.1% | 0.080 s | +| faster-whisper | SimulStreaming | 0.14x | 0.0% | 0.239 s | +| mlx-whisper | LocalAgreement | 0.05x | 21.1% | 0.080 s | +| mlx-whisper | SimulStreaming | 0.14x | 10.5% | 0.245 s | +| voxtral-mlx | voxtral | 0.32x | 0.0% | 0.254 s | +| voxtral (HF) | voxtral | 1.29x | 0.0% | 1.876 s | + +### French (16.3 s, 1 speaker) + +| Backend | Policy | RTF | WER | Timestamp MAE | +|---------|--------|-----|-----|---------------| +| faster-whisper | LocalAgreement | 0.20x | 120.0% | 0.540 s | +| faster-whisper | SimulStreaming | 0.10x | 100.0% | 0.120 s | +| mlx-whisper | LocalAgreement | 0.31x | 1737.1% | 0.060 s | +| mlx-whisper | SimulStreaming | 0.08x | 94.3% | 0.120 s | +| voxtral-mlx | voxtral | 0.18x | 37.1% | 3.422 s | +| voxtral (HF) | voxtral | 0.63x | 28.6% | 4.040 s | + +Note: The whisper-based backends were run with `--lan en`, so they attempted to transcribe French +audio in English. This is expected to produce high WER. For a fair comparison, the whisper backends +should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect language. + +### English - Multi-speaker (30.0 s, 3 speakers) + +| Backend | Policy | RTF | WER | Timestamp MAE | +|---------|--------|-----|-----|---------------| +| faster-whisper | LocalAgreement | 0.24x | 44.7% | 0.235 s | +| faster-whisper | SimulStreaming | 0.10x | 5.3% | 0.398 s | +| mlx-whisper | LocalAgreement | 0.06x | 23.7% | 0.237 s | +| mlx-whisper | SimulStreaming | 0.11x | 5.3% | 0.395 s | +| voxtral-mlx | voxtral | 0.31x | 9.2% | 0.176 s | +| voxtral (HF) | voxtral | 1.00x | 32.9% | 1.034 s | + +--- + +## Key Findings + +### Speed (RTF = processing time / audio duration, lower is better) + +1. **mlx-whisper + LocalAgreement** is the fastest combo on Apple Silicon, reaching 0.05-0.06x RTF + on English audio. This means 30 seconds of audio is processed in under 2 seconds. +2. **SimulStreaming** is consistently faster than LocalAgreement for faster-whisper, but comparable + for mlx-whisper. +3. **voxtral-mlx** runs at 0.18-0.32x RTF, roughly 3-5x slower than mlx-whisper but well within + real-time requirements. +4. **voxtral (HF transformers)** is the slowest, hitting 1.0-1.3x RTF. On longer audio, it risks + falling behind real-time. On Apple Silicon, the MLX variant is strongly preferred. + +### Accuracy (WER = Word Error Rate, lower is better) + +1. **SimulStreaming** produces significantly better WER than LocalAgreement for whisper backends. + On the 30s English file: 5.3% vs 23.7-44.7%. +2. **voxtral-mlx** achieves strong accuracy (0% on short English, 9.2% on multi-speaker) and is + the only backend that auto-detects language, making it the best choice for multilingual use. +3. **LocalAgreement** tends to duplicate the last sentence, inflating WER. This is a known + artifact of the LCP (Longest Common Prefix) commit strategy at end-of-stream. +4. **Voxtral** backends handle French natively with 28-37% WER, while whisper backends + attempted English transcription of French audio (not a fair comparison for French). + +### Timestamp Accuracy (MAE = Mean Absolute Error on word start times, lower is better) + +1. **LocalAgreement** produces the most accurate timestamps (0.08s MAE on English), since it + processes overlapping audio windows and validates via prefix matching. +2. **SimulStreaming** timestamps are slightly less precise (0.24-0.40s MAE) but still usable + for most applications. +3. **voxtral-mlx** achieves excellent timestamps on English (0.18-0.25s MAE) but can drift on + audio with long silence gaps (3.4s MAE on the French file with 4-second pauses). +4. **voxtral (HF)** has the worst timestamp accuracy (1.0-4.0s MAE), likely due to the + additional overhead of the transformers pipeline. + +### VAC (Voice Activity Classification) Impact + +| Backend | Policy | VAC | 7s English WER | 30s English WER | +|---------|--------|-----|----------------|-----------------| +| faster-whisper | LocalAgreement | on | 21.1% | 44.7% | +| faster-whisper | LocalAgreement | off | 100.0% | 100.0% | +| voxtral-mlx | voxtral | on | 0.0% | 9.2% | +| voxtral-mlx | voxtral | off | 0.0% | 9.2% | + +- **Whisper backends require VAC** to function in streaming mode. Without it, the entire audio + is buffered as a single chunk and the LocalAgreement/SimulStreaming buffer logic breaks down. +- **Voxtral backends are VAC-independent** because they handle their own internal chunking and + produce identical results with or without VAC. VAC still reduces wasted compute on silence. + +--- + +## Recommendations + +| Use Case | Recommended Backend | Policy | Notes | +|----------|-------------------|--------|-------| +| Fastest English transcription (Apple Silicon) | mlx-whisper | SimulStreaming | 0.08-0.14x RTF, 5-10% WER | +| Fastest English transcription (Linux/GPU) | faster-whisper | SimulStreaming | 0.10-0.14x RTF, 0-5% WER | +| Multilingual / auto-detect (Apple Silicon) | voxtral-mlx | voxtral | Handles 100+ languages, 0.18-0.32x RTF | +| Multilingual / auto-detect (Linux/GPU) | voxtral (HF) | voxtral | Same model, slower on CPU, needs GPU | +| Best timestamp accuracy | faster-whisper | LocalAgreement | 0.08s MAE, good for subtitle alignment | +| Low latency, low memory | mlx-whisper (tiny) | SimulStreaming | Smallest footprint, fastest response | + +--- + +## Reproducing These Benchmarks + +```bash +# Install test dependencies +pip install -e ".[test]" + +# Single backend test +python test_backend_offline.py --backend faster-whisper --policy simulstreaming --no-realtime + +# Multi-backend auto-detect benchmark +python test_backend_offline.py --benchmark --no-realtime + +# Export to JSON for programmatic analysis +python test_backend_offline.py --benchmark --no-realtime --json results.json + +# Test with custom audio +python test_backend_offline.py --backend voxtral-mlx --audio your_file.wav --no-realtime +``` + +The benchmark harness computes WER and timestamp accuracy automatically when ground truth +`.transcript.json` files exist alongside the audio files. See `audio_tests/` for the format. diff --git a/audio_tests/00_00_07_english_1_speaker.transcript.json b/audio_tests/00_00_07_english_1_speaker.transcript.json new file mode 100644 index 0000000..43ca785 --- /dev/null +++ b/audio_tests/00_00_07_english_1_speaker.transcript.json @@ -0,0 +1,97 @@ +[ + { + "word": "This", + "start": 0.0, + "end": 0.24 + }, + { + "word": "is", + "start": 0.24, + "end": 0.56 + }, + { + "word": "a", + "start": 0.56, + "end": 0.76 + }, + { + "word": "transcription", + "start": 0.76, + "end": 1.32 + }, + { + "word": "test.", + "start": 1.32, + "end": 2.0 + }, + { + "word": "We", + "start": 2.4, + "end": 2.5 + }, + { + "word": "want", + "start": 2.5, + "end": 2.66 + }, + { + "word": "to", + "start": 2.66, + "end": 2.84 + }, + { + "word": "see", + "start": 2.84, + "end": 3.1 + }, + { + "word": "if", + "start": 3.1, + "end": 3.34 + }, + { + "word": "we", + "start": 3.34, + "end": 3.5 + }, + { + "word": "can", + "start": 3.5, + "end": 3.68 + }, + { + "word": "use", + "start": 3.68, + "end": 4.04 + }, + { + "word": "smaller", + "start": 4.04, + "end": 4.76 + }, + { + "word": "chunks.", + "start": 4.76, + "end": 5.16 + }, + { + "word": "What", + "start": 6.06, + "end": 6.32 + }, + { + "word": "do", + "start": 6.32, + "end": 6.44 + }, + { + "word": "you", + "start": 6.44, + "end": 6.58 + }, + { + "word": "think?", + "start": 6.58, + "end": 6.84 + } +] \ No newline at end of file diff --git a/audio_tests/00_00_16_french_1_speaker.transcript.json b/audio_tests/00_00_16_french_1_speaker.transcript.json new file mode 100644 index 0000000..07c0b31 --- /dev/null +++ b/audio_tests/00_00_16_french_1_speaker.transcript.json @@ -0,0 +1,177 @@ +[ + { + "word": "Ok,", + "start": 2.02, + "end": 2.38 + }, + { + "word": "là", + "start": 2.52, + "end": 2.58 + }, + { + "word": "c", + "start": 2.58, + "end": 2.74 + }, + { + "word": "'est", + "start": 2.74, + "end": 2.76 + }, + { + "word": "un", + "start": 2.76, + "end": 2.86 + }, + { + "word": "test,", + "start": 2.86, + "end": 3.2 + }, + { + "word": "on", + "start": 3.34, + "end": 3.34 + }, + { + "word": "veut", + "start": 3.34, + "end": 3.48 + }, + { + "word": "voir", + "start": 3.48, + "end": 3.86 + }, + { + "word": "si", + "start": 3.86, + "end": 4.14 + }, + { + "word": "ça", + "start": 4.14, + "end": 4.26 + }, + { + "word": "arrive", + "start": 4.26, + "end": 4.36 + }, + { + "word": "à", + "start": 4.36, + "end": 4.5 + }, + { + "word": "capté", + "start": 4.5, + "end": 4.78 + }, + { + "word": "le", + "start": 4.78, + "end": 4.9 + }, + { + "word": "silence.", + "start": 4.9, + "end": 5.44 + }, + { + "word": "Là", + "start": 9.24, + "end": 9.6 + }, + { + "word": "il", + "start": 9.6, + "end": 9.78 + }, + { + "word": "est", + "start": 9.78, + "end": 9.84 + }, + { + "word": "une", + "start": 9.84, + "end": 9.96 + }, + { + "word": "telle", + "start": 9.96, + "end": 10.12 + }, + { + "word": "seconde", + "start": 10.12, + "end": 10.38 + }, + { + "word": "de", + "start": 10.38, + "end": 10.48 + }, + { + "word": "silence", + "start": 10.48, + "end": 10.78 + }, + { + "word": "et", + "start": 10.78, + "end": 11.06 + }, + { + "word": "je", + "start": 11.06, + "end": 11.16 + }, + { + "word": "vous", + "start": 11.16, + "end": 11.32 + }, + { + "word": "parle.", + "start": 11.32, + "end": 11.68 + }, + { + "word": "Et", + "start": 13.28, + "end": 13.64 + }, + { + "word": "voilà,", + "start": 13.64, + "end": 13.96 + }, + { + "word": "allez", + "start": 14.36, + "end": 14.62 + }, + { + "word": "on", + "start": 14.62, + "end": 14.78 + }, + { + "word": "va", + "start": 14.78, + "end": 14.88 + }, + { + "word": "tester", + "start": 14.88, + "end": 15.06 + }, + { + "word": "ça.", + "start": 15.06, + "end": 15.36 + } +] \ No newline at end of file diff --git a/audio_tests/00_00_30_english_3_speakers.transcript.json b/audio_tests/00_00_30_english_3_speakers.transcript.json new file mode 100644 index 0000000..bb9d097 --- /dev/null +++ b/audio_tests/00_00_30_english_3_speakers.transcript.json @@ -0,0 +1,382 @@ +[ + { + "word": "Transcription", + "start": 0.0, + "end": 0.6 + }, + { + "word": "technology", + "start": 0.6, + "end": 1.24 + }, + { + "word": "has", + "start": 1.24, + "end": 1.5 + }, + { + "word": "improved", + "start": 1.5, + "end": 1.96 + }, + { + "word": "so", + "start": 1.96, + "end": 2.32 + }, + { + "word": "much", + "start": 2.32, + "end": 2.68 + }, + { + "word": "in", + "start": 2.68, + "end": 2.94 + }, + { + "word": "the", + "start": 2.94, + "end": 3.02 + }, + { + "word": "past", + "start": 3.02, + "end": 3.24 + }, + { + "word": "few", + "start": 3.24, + "end": 3.5 + }, + { + "word": "years.", + "start": 3.5, + "end": 3.96 + }, + { + "word": "Have", + "start": 4.56, + "end": 4.74 + }, + { + "word": "you", + "start": 4.74, + "end": 4.9 + }, + { + "word": "noticed", + "start": 4.9, + "end": 5.26 + }, + { + "word": "how", + "start": 5.26, + "end": 5.52 + }, + { + "word": "accurate", + "start": 5.52, + "end": 6.08 + }, + { + "word": "real", + "start": 6.08, + "end": 6.42 + }, + { + "word": "-time", + "start": 6.42, + "end": 6.74 + }, + { + "word": "speech", + "start": 6.74, + "end": 7.24 + }, + { + "word": "to", + "start": 7.24, + "end": 7.46 + }, + { + "word": "text", + "start": 7.46, + "end": 7.78 + }, + { + "word": "is", + "start": 7.78, + "end": 8.0 + }, + { + "word": "now?", + "start": 8.0, + "end": 8.3 + }, + { + "word": "Absolutely.", + "start": 8.7, + "end": 9.16 + }, + { + "word": "I", + "start": 10.04, + "end": 10.38 + }, + { + "word": "use", + "start": 10.38, + "end": 10.56 + }, + { + "word": "it", + "start": 10.56, + "end": 10.76 + }, + { + "word": "all", + "start": 10.76, + "end": 10.9 + }, + { + "word": "the", + "start": 10.9, + "end": 11.04 + }, + { + "word": "time", + "start": 11.04, + "end": 11.32 + }, + { + "word": "for", + "start": 11.32, + "end": 11.54 + }, + { + "word": "taking", + "start": 11.54, + "end": 11.86 + }, + { + "word": "notes", + "start": 11.86, + "end": 12.16 + }, + { + "word": "during", + "start": 12.16, + "end": 12.54 + }, + { + "word": "meetings.", + "start": 12.54, + "end": 12.94 + }, + { + "word": "It's", + "start": 13.6, + "end": 13.8 + }, + { + "word": "amazing", + "start": 13.8, + "end": 14.1 + }, + { + "word": "how", + "start": 14.1, + "end": 14.48 + }, + { + "word": "it", + "start": 14.48, + "end": 14.62 + }, + { + "word": "can", + "start": 14.62, + "end": 14.74 + }, + { + "word": "recognise", + "start": 14.74, + "end": 15.24 + }, + { + "word": "different", + "start": 15.24, + "end": 15.68 + }, + { + "word": "speakers", + "start": 15.68, + "end": 16.16 + }, + { + "word": "and", + "start": 16.16, + "end": 16.8 + }, + { + "word": "even", + "start": 16.8, + "end": 17.1 + }, + { + "word": "add", + "start": 17.1, + "end": 17.44 + }, + { + "word": "punctuation.", + "start": 17.44, + "end": 18.36 + }, + { + "word": "Yeah,", + "start": 18.88, + "end": 19.16 + }, + { + "word": "but", + "start": 19.36, + "end": 19.52 + }, + { + "word": "sometimes", + "start": 19.52, + "end": 20.16 + }, + { + "word": "noise", + "start": 20.16, + "end": 20.54 + }, + { + "word": "can", + "start": 20.54, + "end": 20.8 + }, + { + "word": "still", + "start": 20.8, + "end": 21.1 + }, + { + "word": "cause", + "start": 21.1, + "end": 21.44 + }, + { + "word": "mistakes.", + "start": 21.44, + "end": 21.94 + }, + { + "word": "Does", + "start": 22.68, + "end": 22.9 + }, + { + "word": "this", + "start": 22.9, + "end": 23.12 + }, + { + "word": "system", + "start": 23.12, + "end": 23.46 + }, + { + "word": "handle", + "start": 23.46, + "end": 23.88 + }, + { + "word": "that", + "start": 23.88, + "end": 24.12 + }, + { + "word": "well?", + "start": 24.12, + "end": 24.42 + }, + { + "word": "It", + "start": 24.42, + "end": 25.32 + }, + { + "word": "does", + "start": 25.32, + "end": 25.48 + }, + { + "word": "a", + "start": 25.48, + "end": 25.62 + }, + { + "word": "pretty", + "start": 25.62, + "end": 25.88 + }, + { + "word": "good", + "start": 25.88, + "end": 26.08 + }, + { + "word": "job", + "start": 26.08, + "end": 26.32 + }, + { + "word": "filtering", + "start": 26.32, + "end": 26.8 + }, + { + "word": "noise,", + "start": 26.8, + "end": 27.18 + }, + { + "word": "especially", + "start": 27.36, + "end": 28.0 + }, + { + "word": "with", + "start": 28.0, + "end": 28.28 + }, + { + "word": "models", + "start": 28.28, + "end": 28.62 + }, + { + "word": "that", + "start": 28.62, + "end": 28.94 + }, + { + "word": "use", + "start": 28.94, + "end": 29.22 + }, + { + "word": "voice", + "start": 29.22, + "end": 29.54 + }, + { + "word": "active.", + "start": 29.54, + "end": 29.9 + } +] \ No newline at end of file diff --git a/audio_tests/generate_transcripts.py b/audio_tests/generate_transcripts.py new file mode 100644 index 0000000..7eb180f --- /dev/null +++ b/audio_tests/generate_transcripts.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Generate word-level timestamped transcripts using faster-whisper (offline). + +Produces one JSON file per audio with: [{word, start, end}, ...] +""" + +import json +import os +from faster_whisper import WhisperModel + +AUDIO_DIR = os.path.dirname(os.path.abspath(__file__)) + +FILES = [ + ("00_00_07_english_1_speaker.wav", "en"), + ("00_00_16_french_1_speaker.wav", "fr"), + ("00_00_30_english_3_speakers.wav", "en"), +] + +def main(): + print("Loading faster-whisper model (base, cpu, float32)...") + model = WhisperModel("base", device="cpu", compute_type="float32") + + for filename, lang in FILES: + audio_path = os.path.join(AUDIO_DIR, filename) + out_path = os.path.join( + AUDIO_DIR, filename.rsplit(".", 1)[0] + ".transcript.json" + ) + + print(f"\n{'='*60}") + print(f"Transcribing: {filename} (language={lang})") + print(f"{'='*60}") + + segments, info = model.transcribe( + audio_path, word_timestamps=True, language=lang + ) + + words = [] + for segment in segments: + if segment.words: + for w in segment.words: + words.append({ + "word": w.word.strip(), + "start": round(w.start, 3), + "end": round(w.end, 3), + }) + print(f" {w.start:6.2f} - {w.end:6.2f} {w.word.strip()}") + + with open(out_path, "w", encoding="utf-8") as f: + json.dump(words, f, indent=2, ensure_ascii=False) + + print(f"\n -> {len(words)} words written to {os.path.basename(out_path)}") + + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/run_benchmark.py b/run_benchmark.py new file mode 100644 index 0000000..5a4e23b --- /dev/null +++ b/run_benchmark.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Comprehensive benchmark runner for WhisperLiveKit. + +Tests all available backend+policy combinations across multiple audio files, +model sizes, and VAC on/off configurations. Outputs structured JSON that +is consumed by the report generator. + +Usage: + python run_benchmark.py # full benchmark + python run_benchmark.py --quick # subset (tiny models, fewer combos) + python run_benchmark.py --json results.json # custom output path +""" + +import argparse +import asyncio +import gc +import json +import logging +import platform +import subprocess +import sys +import time +from dataclasses import asdict +from pathlib import Path + +logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("benchmark") +logger.setLevel(logging.INFO) + +# Re-use harness functions +sys.path.insert(0, str(Path(__file__).parent)) +from test_backend_offline import ( + AUDIO_TESTS_DIR, + SAMPLE_RATE, + TestResult, + create_engine, + discover_audio_files, + download_sample_audio, + load_audio, + run_test, +) + +CACHE_DIR = Path(__file__).parent / ".test_cache" + + +def get_system_info() -> dict: + """Collect system metadata for the report.""" + info = { + "platform": platform.platform(), + "machine": platform.machine(), + "processor": platform.processor(), + "python_version": platform.python_version(), + } + + # macOS: get chip info + try: + chip = subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"], text=True + ).strip() + info["cpu"] = chip + except Exception: + info["cpu"] = platform.processor() + + # RAM + try: + mem_bytes = int( + subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True).strip() + ) + info["ram_gb"] = round(mem_bytes / (1024**3)) + except Exception: + info["ram_gb"] = None + + # Backend versions + versions = {} + try: + import faster_whisper + versions["faster-whisper"] = faster_whisper.__version__ + except ImportError: + pass + try: + import mlx_whisper # noqa: F401 + versions["mlx-whisper"] = "installed" + except ImportError: + pass + try: + import mlx.core as mx + versions["mlx"] = mx.__version__ + except ImportError: + pass + try: + import transformers + versions["transformers"] = transformers.__version__ + except ImportError: + pass + try: + import torch + versions["torch"] = torch.__version__ + except ImportError: + pass + + info["backend_versions"] = versions + return info + + +def detect_combos(quick: bool = False) -> list: + """Build list of (backend, policy, model_size) combos to test.""" + combos = [] + + # Model sizes to test + model_sizes = ["tiny", "base", "small"] if not quick else ["tiny", "base"] + + # faster-whisper + try: + import faster_whisper # noqa: F401 + for model in model_sizes: + combos.append({"backend": "faster-whisper", "policy": "localagreement", "model": model}) + combos.append({"backend": "faster-whisper", "policy": "simulstreaming", "model": model}) + except ImportError: + pass + + # mlx-whisper + try: + import mlx_whisper # noqa: F401 + for model in model_sizes: + combos.append({"backend": "mlx-whisper", "policy": "localagreement", "model": model}) + combos.append({"backend": "mlx-whisper", "policy": "simulstreaming", "model": model}) + except ImportError: + pass + + # voxtral-mlx (single model, single policy) + try: + from whisperlivekit.voxtral_mlx import VoxtralMLXModel # noqa: F401 + combos.append({"backend": "voxtral-mlx", "policy": "voxtral", "model": ""}) + except ImportError: + pass + + # voxtral HF (single model, single policy) + try: + from transformers import AutoModelForSpeechSeq2Seq # noqa: F401 + combos.append({"backend": "voxtral", "policy": "voxtral", "model": ""}) + except ImportError: + pass + + return combos + + +def collect_audio_files() -> list: + """Collect all benchmark audio files.""" + files = [] + + # audio_tests/ directory + if AUDIO_TESTS_DIR.is_dir(): + files.extend(discover_audio_files(str(AUDIO_TESTS_DIR))) + + # JFK sample + jfk = CACHE_DIR / "jfk.wav" + if not jfk.exists(): + jfk = download_sample_audio() + if jfk.exists(): + files.append(jfk) + + return files + + +async def run_single_combo( + combo: dict, audio_files: list, vac: bool, lan: str, max_duration: float, +) -> list: + """Run one backend+policy+model combo across all audio files.""" + backend = combo["backend"] + policy = combo["policy"] + model = combo["model"] + + results = [] + try: + engine = create_engine( + backend=backend, + model_size=model, + lan=lan, + vac=vac, + policy=policy, + ) + + # Quiet noisy loggers + for mod in ( + "whisperlivekit.audio_processor", + "whisperlivekit.simul_whisper", + "whisperlivekit.tokens_alignment", + "whisperlivekit.simul_whisper.align_att_base", + "whisperlivekit.simul_whisper.simul_whisper", + ): + logging.getLogger(mod).setLevel(logging.WARNING) + + for audio_path in audio_files: + duration = len(load_audio(str(audio_path))) / SAMPLE_RATE + if duration > max_duration: + logger.info(f" Skipping {audio_path.name} ({duration:.0f}s > {max_duration:.0f}s)") + continue + + file_lan = lan + if "french" in audio_path.name.lower() and lan == "en": + file_lan = "fr" + + audio = load_audio(str(audio_path)) + result = await run_test( + engine, audio, chunk_ms=100, realtime=False, + audio_file=audio_path.name, backend=backend, + policy=policy, lan=file_lan, + ) + # Tag with extra metadata + result_dict = asdict(result) + result_dict["model_size"] = model + result_dict["vac"] = vac + results.append(result_dict) + + except Exception as e: + logger.error(f" FAILED: {e}") + import traceback + traceback.print_exc() + + return results + + +async def run_full_benchmark(combos, audio_files, max_duration=60.0): + """Run all combos with VAC on and off.""" + all_results = [] + total = len(combos) * 2 # x2 for VAC on/off + idx = 0 + + for combo in combos: + for vac in [True, False]: + idx += 1 + vac_str = "VAC=on" if vac else "VAC=off" + desc = f"{combo['backend']} / {combo['policy']}" + if combo["model"]: + desc += f" / {combo['model']}" + desc += f" / {vac_str}" + + print(f"\n{'='*70}") + print(f"[{idx}/{total}] {desc}") + print(f"{'='*70}") + + results = await run_single_combo( + combo, audio_files, vac=vac, lan="en", max_duration=max_duration, + ) + all_results.extend(results) + + # Free memory between combos + gc.collect() + + return all_results + + +def main(): + parser = argparse.ArgumentParser(description="Run comprehensive WhisperLiveKit benchmark") + parser.add_argument("--quick", action="store_true", help="Quick mode: fewer models and combos") + parser.add_argument("--json", default="benchmark_results.json", dest="json_output", help="Output JSON path") + parser.add_argument("--max-duration", type=float, default=60.0, help="Max audio duration in seconds") + args = parser.parse_args() + + system_info = get_system_info() + combos = detect_combos(quick=args.quick) + audio_files = collect_audio_files() + + print(f"System: {system_info.get('cpu', 'unknown')}, {system_info.get('ram_gb', '?')}GB RAM") + print(f"Backends: {list(system_info['backend_versions'].keys())}") + print(f"Combos to test: {len(combos)} x 2 (VAC on/off) = {len(combos)*2}") + print(f"Audio files: {[f.name for f in audio_files]}") + print() + + t0 = time.time() + all_results = asyncio.run( + run_full_benchmark(combos, audio_files, max_duration=args.max_duration) + ) + total_time = time.time() - t0 + + output = { + "system_info": system_info, + "benchmark_date": time.strftime("%Y-%m-%d %H:%M"), + "total_benchmark_time_s": round(total_time, 1), + "n_combos": len(combos) * 2, + "n_audio_files": len(audio_files), + "results": all_results, + } + + Path(args.json_output).write_text(json.dumps(output, indent=2, ensure_ascii=False)) + print(f"\nBenchmark complete in {total_time:.0f}s. Results: {args.json_output}") + + +if __name__ == "__main__": + main() diff --git a/test_backend_offline.py b/test_backend_offline.py new file mode 100644 index 0000000..486b715 --- /dev/null +++ b/test_backend_offline.py @@ -0,0 +1,783 @@ +#!/usr/bin/env python3 +""" +Offline test harness and benchmark suite for WhisperLiveKit backends. + +Simulates a client-server session by feeding audio files as PCM bytes through +the full AudioProcessor pipeline (the same path used by the WebSocket server), +without needing a browser or microphone. + +Computes WER (Word Error Rate) and timestamp accuracy when ground truth +transcript files (.transcript.json) are available alongside audio files. + +Usage: + # Test with a single audio file: + python test_backend_offline.py --backend faster-whisper --audio audio_tests/00_00_07_english_1_speaker.wav + + # Test all files in audio_tests/: + python test_backend_offline.py --backend faster-whisper --no-realtime + + # Override streaming policy: + python test_backend_offline.py --backend faster-whisper --policy simulstreaming --no-realtime + + # Multi-backend benchmark (auto-detects all installed backends): + python test_backend_offline.py --benchmark --no-realtime + + # Export results as JSON: + python test_backend_offline.py --benchmark --no-realtime --json results.json + + # Insert silence for testing silence handling: + python test_backend_offline.py --backend faster-whisper --insert-silence 3.0 2.0 +""" + +import argparse +import asyncio +import json +import logging +import sys +import time +import urllib.request +from pathlib import Path +from dataclasses import dataclass, asdict, field +from typing import List, Optional + +import numpy as np + +logging.basicConfig( + level=logging.WARNING, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger("test_offline") +logger.setLevel(logging.INFO) + +SAMPLE_RATE = 16000 +JFK_WAV_URL = "https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav" +CACHE_DIR = Path(__file__).parent / ".test_cache" +AUDIO_TESTS_DIR = Path(__file__).parent / "audio_tests" +AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".m4a"} + + +@dataclass +class WordTimestamp: + """Word with its start/end time.""" + word: str + start: float + end: float + + +@dataclass +class TestResult: + """Structured result from a single test run.""" + audio_file: str + audio_duration_s: float + backend: str + policy: str + language: str + chunk_ms: int + realtime_pacing: bool + # Timing + processing_time_s: float + rtf: float # real-time factor + # Transcription output + transcription: str + n_lines: int + n_responses: int + # WER metrics (None if no ground truth) + wer: Optional[float] = None + wer_details: Optional[dict] = None + # Timestamp accuracy (None if no ground truth) + timestamp_mae: Optional[float] = None + timestamp_max_delta: Optional[float] = None + timestamp_median_delta: Optional[float] = None + # Word-level timestamps + word_timestamps: List[WordTimestamp] = field(default_factory=list) + # Raw last response + last_response: Optional[dict] = None + + +def download_sample_audio() -> Path: + """Download the jfk.wav sample if not cached.""" + CACHE_DIR.mkdir(exist_ok=True) + path = CACHE_DIR / "jfk.wav" + if not path.exists(): + logger.info(f"Downloading sample audio to {path} ...") + urllib.request.urlretrieve(JFK_WAV_URL, path) + logger.info("Done.") + return path + + +def load_audio(path: str) -> np.ndarray: + """Load audio file as float32 mono 16kHz numpy array. + + Supports WAV, FLAC (via soundfile) and MP3, OGG, M4A (via librosa). + """ + ext = Path(path).suffix.lower() + if ext in (".mp3", ".ogg", ".m4a"): + import librosa + audio, _ = librosa.load(path, sr=SAMPLE_RATE, mono=True) + return audio.astype(np.float32) + + import soundfile as sf + audio, sr = sf.read(path, dtype="float32") + if audio.ndim > 1: + audio = audio.mean(axis=1) + if sr != SAMPLE_RATE: + import librosa + audio = librosa.resample(audio, orig_sr=sr, target_sr=SAMPLE_RATE) + return audio + + +def insert_silence(audio: np.ndarray, silence_sec: float, position_sec: float) -> np.ndarray: + """Insert silence into audio at a given position. + + Args: + audio: Float32 mono audio array at SAMPLE_RATE. + silence_sec: Duration of silence to insert in seconds. + position_sec: Position in seconds where silence starts. + Returns: + New audio array with silence inserted. + """ + pos_samples = int(position_sec * SAMPLE_RATE) + silence_samples = int(silence_sec * SAMPLE_RATE) + pos_samples = min(pos_samples, len(audio)) + silence = np.zeros(silence_samples, dtype=np.float32) + return np.concatenate([audio[:pos_samples], silence, audio[pos_samples:]]) + + +def float32_to_s16le_bytes(audio: np.ndarray) -> bytes: + """Convert float32 audio to s16le PCM bytes (what the browser sends).""" + return (audio * 32768).clip(-32768, 32767).astype(np.int16).tobytes() + + +def create_engine( + backend: str, model_size: str, lan: str, + diarization: bool = False, vac: bool = True, policy: str = "", +): + """Create a TranscriptionEngine with the given backend config.""" + import gc + from whisperlivekit.core import TranscriptionEngine + + # Reset singleton so we get a fresh instance + TranscriptionEngine._instance = None + TranscriptionEngine._initialized = False + gc.collect() + + kwargs = dict( + backend=backend, + lan=lan, + pcm_input=True, + vac=vac, + transcription=True, + diarization=diarization, + ) + if model_size: + kwargs["model_size"] = model_size + if policy: + kwargs["backend_policy"] = policy + + return TranscriptionEngine(**kwargs) + + +def _extract_text_from_response(response_dict: dict) -> str: + """Extract full transcription text from a FrontData dict.""" + segments = response_dict.get("lines", []) + full_text = " ".join( + seg.get("text", "").strip() + for seg in segments + if seg.get("text", "").strip() + ) + buf = response_dict.get("buffer_transcription", "").strip() + if buf: + full_text = f"{full_text} {buf}".strip() if full_text else buf + return full_text + + +async def run_test( + engine, audio: np.ndarray, chunk_ms: int, realtime: bool, + audio_file: str = "", backend: str = "", policy: str = "", lan: str = "", +) -> TestResult: + """ + Simulate a client session through the full AudioProcessor pipeline. + + 1. Create AudioProcessor (one per "client session") + 2. Start async pipeline (transcription_processor, results_formatter, etc.) + 3. Feed audio as PCM bytes in timed chunks + 4. Collect and display FrontData responses + 5. Signal EOF and cleanup + """ + from whisperlivekit.audio_processor import AudioProcessor + + chunk_samples = int(SAMPLE_RATE * chunk_ms / 1000) + total_samples = len(audio) + audio_duration = total_samples / SAMPLE_RATE + + logger.info( + f"Audio: {audio_duration:.2f}s | " + f"Chunk: {chunk_ms}ms ({chunk_samples} samples) | " + f"Steps: {total_samples // chunk_samples + 1} | " + f"Realtime: {realtime}" + ) + + # --- Server side: create processor and start pipeline --- + processor = AudioProcessor(transcription_engine=engine) + results_generator = await processor.create_tasks() + + # Collect results in background (like handle_websocket_results) + all_responses = [] + response_count = 0 + last_printed_text = "" + + async def collect_results(): + nonlocal response_count, last_printed_text + async for response in results_generator: + all_responses.append(response) + response_count += 1 + d = response.to_dict() + + # Only print when transcription text actually changes + current_text = _extract_text_from_response(d) + if current_text and current_text != last_printed_text: + buf = d.get("buffer_transcription", "").strip() + committed = current_text + if buf and committed.endswith(buf): + committed = committed[:-len(buf)].strip() + + # Show committed text + buffer separately + display = committed + if buf: + display = f"{committed} \033[90m{buf}\033[0m" if committed else f"\033[90m{buf}\033[0m" + print(f" > {display}", flush=True) + last_printed_text = current_text + + result_task = asyncio.create_task(collect_results()) + + # --- Client side: feed audio as PCM bytes --- + t_start = time.time() + + for offset in range(0, total_samples, chunk_samples): + chunk = audio[offset : offset + chunk_samples] + pcm_bytes = float32_to_s16le_bytes(chunk) + await processor.process_audio(pcm_bytes) + if realtime: + await asyncio.sleep(chunk_ms / 1000) + + feed_elapsed = time.time() - t_start + + logger.info(f"Audio fed in {feed_elapsed:.2f}s. Signaling EOF...") + + # Signal end of audio (like client disconnect / empty message) + await processor.process_audio(None) + + # Wait for pipeline to drain completely + try: + await asyncio.wait_for(result_task, timeout=120.0) + except asyncio.TimeoutError: + logger.warning("Timed out waiting for results. Proceeding with cleanup.") + result_task.cancel() + try: + await result_task + except asyncio.CancelledError: + pass + + # --- Capture word-level timestamps before cleanup --- + word_timestamps = [] + try: + state = await processor.get_current_state() + for token in state.tokens: + if hasattr(token, 'start') and hasattr(token, 'text') and token.text: + word_timestamps.append(WordTimestamp( + word=token.text.strip(), + start=round(token.start, 3), + end=round(token.end, 3), + )) + except Exception as e: + logger.warning(f"Could not capture word timestamps: {e}") + + # Cleanup + await processor.cleanup() + + total_elapsed = time.time() - t_start + + # --- Build result --- + transcription = "" + n_lines = 0 + last_response_dict = None + + if all_responses: + last = all_responses[-1].to_dict() + last_response_dict = last + n_lines = len(last.get("lines", [])) + transcription = _extract_text_from_response(last) + + # --- Compute WER and timestamp accuracy against ground truth --- + from whisperlivekit.metrics import compute_wer, compute_timestamp_accuracy + + wer_val = None + wer_details = None + ts_mae = None + ts_max_delta = None + ts_median_delta = None + + gt_path = Path(audio_file).with_suffix(".transcript.json") + if not gt_path.exists(): + gt_path = AUDIO_TESTS_DIR / gt_path + gt = None + if gt_path.exists(): + with open(gt_path) as f: + gt = json.load(f) + + # WER + gt_text = " ".join(w["word"] for w in gt) + wer_result = compute_wer(gt_text, transcription) + wer_val = round(wer_result["wer"], 4) + wer_details = wer_result + + # Timestamp accuracy + if word_timestamps: + pred_dicts = [{"word": wt.word, "start": wt.start, "end": wt.end} for wt in word_timestamps] + ts_result = compute_timestamp_accuracy(pred_dicts, gt) + ts_mae = ts_result["mae_start"] + ts_max_delta = ts_result["max_delta_start"] + ts_median_delta = ts_result["median_delta_start"] + + result = TestResult( + audio_file=audio_file, + audio_duration_s=round(audio_duration, 2), + backend=backend, + policy=policy, + language=lan, + chunk_ms=chunk_ms, + realtime_pacing=realtime, + processing_time_s=round(total_elapsed, 2), + rtf=round(total_elapsed / audio_duration, 2), + transcription=transcription, + n_lines=n_lines, + n_responses=response_count, + wer=wer_val, + wer_details=wer_details, + timestamp_mae=round(ts_mae, 3) if ts_mae is not None else None, + timestamp_max_delta=round(ts_max_delta, 3) if ts_max_delta is not None else None, + timestamp_median_delta=round(ts_median_delta, 3) if ts_median_delta is not None else None, + word_timestamps=word_timestamps, + last_response=last_response_dict, + ) + + # --- Print summary --- + print(f"\n{'=' * 60}") + print(f"RESULT: {audio_file}") + print(f"{'=' * 60}") + print(f"Transcription: {transcription}") + print(f"Lines: {n_lines} | Responses: {response_count}") + print(f"Audio: {audio_duration:.2f}s | Time: {total_elapsed:.2f}s | RTF: {result.rtf:.2f}x") + + if wer_val is not None: + print(f"WER: {wer_val:.2%} (S={wer_details['substitutions']} I={wer_details['insertions']} D={wer_details['deletions']})") + + # Print word timestamps if available + if word_timestamps: + print(f"\nWord timestamps ({len(word_timestamps)} words):") + for wt in word_timestamps: + print(f" [{wt.start:6.2f} - {wt.end:6.2f}] {wt.word}") + + # Detailed comparison with ground truth + if gt: + print(f"\n vs Ground truth ({len(gt)} words):") + max_words = max(len(word_timestamps), len(gt)) + for i in range(max_words): + pred = word_timestamps[i] if i < len(word_timestamps) else None + ref = gt[i] if i < len(gt) else None + p_str = f"[{pred.start:5.2f}-{pred.end:5.2f}] {pred.word:<15}" if pred else " " * 30 + r_str = f"[{ref['start']:5.2f}-{ref['end']:5.2f}] {ref['word']:<15}" if ref else "" + delta = "" + if pred and ref: + d = pred.start - ref['start'] + delta = f" Δstart={d:+.2f}" + print(f" {p_str} | {r_str}{delta}") + + if ts_mae is not None: + print(f"\n Timestamp stats: MAE={ts_mae:.3f}s max|Δ|={ts_max_delta:.3f}s median|Δ|={ts_median_delta:.3f}s") + + print(f"{'=' * 60}") + + return result + + +def discover_audio_files(directory: str) -> List[Path]: + """Find all supported audio files in directory.""" + d = Path(directory) + files = sorted( + p for p in d.iterdir() + if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS + ) + return files + + +async def run_all_tests( + engine, audio_files: List[Path], chunk_ms: int, realtime: bool, + backend: str, policy: str, lan: str, max_duration: float = 60.0, + silence_insertions: Optional[List[List[float]]] = None, +) -> List[TestResult]: + """Run tests on multiple audio files sequentially.""" + results = [] + for audio_path in audio_files: + # Detect language from filename if "french" in name + file_lan = lan + if "french" in audio_path.name.lower() and lan == "en": + file_lan = "fr" + logger.info(f"Auto-detected language 'fr' from filename") + + audio = load_audio(str(audio_path)) + + # Insert silence segments (applied in reverse position order to keep offsets valid) + if silence_insertions: + for secs, at_sec in sorted(silence_insertions, key=lambda x: x[1], reverse=True): + logger.info(f"Inserting {secs:.1f}s silence at {at_sec:.1f}s") + audio = insert_silence(audio, secs, at_sec) + + duration = len(audio) / SAMPLE_RATE + + if duration > max_duration: + logger.info(f"Skipping {audio_path.name} ({duration:.0f}s > {max_duration:.0f}s max)") + continue + + print(f"\n{'#' * 60}") + print(f"# Testing: {audio_path.name} ({duration:.1f}s)") + print(f"{'#' * 60}") + + result = await run_test( + engine, audio, chunk_ms, realtime, + audio_file=audio_path.name, backend=backend, policy=policy, lan=file_lan, + ) + results.append(result) + + return results + + +def print_benchmark_summary(results: List[TestResult]): + """Print a tabular summary of all test results.""" + print(f"\n{'=' * 110}") + print("BENCHMARK SUMMARY") + print(f"{'=' * 110}") + print( + f"{'File':<40} {'Duration':>8} {'Time':>8} {'RTF':>6} " + f"{'WER':>7} {'MAE(s)':>7} {'Lines':>5}" + ) + print(f"{'-' * 110}") + for r in results: + wer_str = f"{r.wer:.2%}" if r.wer is not None else " -" + mae_str = f"{r.timestamp_mae:.3f}" if r.timestamp_mae is not None else " -" + print( + f"{r.audio_file:<40} {r.audio_duration_s:>7.1f}s {r.processing_time_s:>7.1f}s " + f"{r.rtf:>5.2f}x {wer_str:>7} {mae_str:>7} {r.n_lines:>5}" + ) + print(f"{'-' * 110}") + total_audio = sum(r.audio_duration_s for r in results) + total_time = sum(r.processing_time_s for r in results) + avg_rtf = total_time / total_audio if total_audio > 0 else 0 + wer_vals = [r.wer for r in results if r.wer is not None] + avg_wer_str = f"{sum(wer_vals)/len(wer_vals):.2%}" if wer_vals else " -" + mae_vals = [r.timestamp_mae for r in results if r.timestamp_mae is not None] + avg_mae_str = f"{sum(mae_vals)/len(mae_vals):.3f}" if mae_vals else " -" + print( + f"{'TOTAL/AVG':<40} {total_audio:>7.1f}s {total_time:>7.1f}s " + f"{avg_rtf:>5.2f}x {avg_wer_str:>7} {avg_mae_str:>7}" + ) + print(f"{'=' * 110}") + + # Print transcription excerpts + print(f"\nTRANSCRIPTIONS:") + print(f"{'-' * 110}") + for r in results: + excerpt = r.transcription[:120] + "..." if len(r.transcription) > 120 else r.transcription + print(f" {r.audio_file}:") + print(f" {excerpt}") + print(f"{'=' * 110}") + + +def detect_available_backends() -> List[dict]: + """Probe which backends can be imported and return (backend, policy) combos. + + Returns list of dicts with keys: backend, policy, description. + """ + combos = [] + + # faster-whisper + try: + import faster_whisper # noqa: F401 + combos.append({"backend": "faster-whisper", "policy": "localagreement", "description": "faster-whisper + LocalAgreement"}) + combos.append({"backend": "faster-whisper", "policy": "simulstreaming", "description": "faster-whisper + SimulStreaming"}) + except ImportError: + pass + + # mlx-whisper (macOS only) + try: + import mlx_whisper # noqa: F401 + combos.append({"backend": "mlx-whisper", "policy": "localagreement", "description": "mlx-whisper + LocalAgreement"}) + combos.append({"backend": "mlx-whisper", "policy": "simulstreaming", "description": "mlx-whisper + SimulStreaming"}) + except ImportError: + pass + + # openai-whisper + try: + import whisper # noqa: F401 + combos.append({"backend": "whisper", "policy": "localagreement", "description": "openai-whisper + LocalAgreement"}) + combos.append({"backend": "whisper", "policy": "simulstreaming", "description": "openai-whisper + SimulStreaming"}) + except ImportError: + pass + + # voxtral-mlx + try: + from whisperlivekit.voxtral_mlx import VoxtralMLXModel # noqa: F401 + combos.append({"backend": "voxtral-mlx", "policy": "voxtral", "description": "voxtral-mlx (MLX)"}) + except ImportError: + pass + + # voxtral (HuggingFace) + try: + from transformers import AutoModelForSpeechSeq2Seq # noqa: F401 + combos.append({"backend": "voxtral", "policy": "voxtral", "description": "voxtral (HuggingFace)"}) + except ImportError: + pass + + return combos + + +def print_cross_backend_comparison(all_results: List[TestResult]): + """Print a comparison table across backends and policies.""" + print(f"\n{'=' * 110}") + print("CROSS-BACKEND BENCHMARK COMPARISON") + print(f"{'=' * 110}") + print( + f"{'Backend':<18} {'Policy':<16} {'File':<30} " + f"{'WER':>7} {'RTF':>6} {'MAE(s)':>7} {'MaxΔ(s)':>8}" + ) + print(f"{'-' * 110}") + + for r in all_results: + wer_str = f"{r.wer:.2%}" if r.wer is not None else " -" + rtf_str = f"{r.rtf:.2f}x" + mae_str = f"{r.timestamp_mae:.3f}" if r.timestamp_mae is not None else " -" + max_str = f"{r.timestamp_max_delta:.3f}" if r.timestamp_max_delta is not None else " -" + # Truncate filename for readability + fname = r.audio_file[:28] + ".." if len(r.audio_file) > 30 else r.audio_file + print( + f"{r.backend:<18} {r.policy:<16} {fname:<30} " + f"{wer_str:>7} {rtf_str:>6} {mae_str:>7} {max_str:>8}" + ) + + print(f"{'-' * 110}") + + # Per-backend averages + from collections import defaultdict + by_combo = defaultdict(list) + for r in all_results: + by_combo[(r.backend, r.policy)].append(r) + + print(f"\n{'Backend':<18} {'Policy':<16} {'Avg WER':>8} {'Avg RTF':>8} {'Avg MAE':>8} {'Files':>6}") + print(f"{'-' * 80}") + for (backend, policy), group in sorted(by_combo.items()): + wer_vals = [r.wer for r in group if r.wer is not None] + rtf_vals = [r.rtf for r in group] + mae_vals = [r.timestamp_mae for r in group if r.timestamp_mae is not None] + avg_wer = f"{sum(wer_vals)/len(wer_vals):.2%}" if wer_vals else " -" + avg_rtf = f"{sum(rtf_vals)/len(rtf_vals):.2f}x" + avg_mae = f"{sum(mae_vals)/len(mae_vals):.3f}" if mae_vals else " -" + print( + f"{backend:<18} {policy:<16} {avg_wer:>8} {avg_rtf:>8} {avg_mae:>8} {len(group):>6}" + ) + print(f"{'=' * 110}") + + +def _quiet_loggers(verbose: bool): + """Set internal module log levels to reduce noise.""" + if verbose: + logging.getLogger().setLevel(logging.DEBUG) + else: + for mod in ( + "whisperlivekit.audio_processor", "whisperlivekit.simul_whisper", + "whisperlivekit.tokens_alignment", "whisperlivekit.simul_whisper.align_att_base", + "whisperlivekit.simul_whisper.simul_whisper", + ): + logging.getLogger(mod).setLevel(logging.WARNING) + + +async def run_benchmark( + audio_files: List[Path], chunk_ms: int, realtime: bool, + model_size: str, lan: str, max_duration: float, vac: bool, + verbose: bool, +) -> List[TestResult]: + """Run benchmark across all available backend+policy combinations.""" + combos = detect_available_backends() + if not combos: + logger.error("No backends available. Install at least one ASR backend.") + return [] + + logger.info(f"Detected {len(combos)} backend+policy combinations:") + for c in combos: + logger.info(f" - {c['description']}") + + all_results = [] + for i, combo in enumerate(combos, 1): + backend = combo["backend"] + policy = combo["policy"] + desc = combo["description"] + + print(f"\n{'*' * 70}") + print(f"* BENCHMARK {i}/{len(combos)}: {desc}") + print(f"{'*' * 70}") + + try: + engine = create_engine( + backend, model_size, lan, vac=vac, policy=policy, + ) + _quiet_loggers(verbose) + + results = await run_all_tests( + engine, audio_files, chunk_ms, realtime, + backend=backend, policy=policy, lan=lan, + max_duration=max_duration, + ) + all_results.extend(results) + except Exception as e: + logger.error(f"Failed to run {desc}: {e}") + import traceback + traceback.print_exc() + + return all_results + + +def main(): + parser = argparse.ArgumentParser( + description="Offline backend test harness (AudioProcessor-level)" + ) + parser.add_argument( + "--backend", default="faster-whisper", + help="Backend: voxtral, voxtral-mlx, auto, faster-whisper, mlx-whisper, whisper.", + ) + parser.add_argument( + "--policy", default="", + help="Override backend policy: localagreement, simulstreaming, voxtral.", + ) + parser.add_argument( + "--audio", default=None, + help="Path to a single audio file (WAV, MP3, FLAC, etc.).", + ) + parser.add_argument( + "--audio-dir", default=None, + help="Directory of audio files to test. Defaults to audio_tests/ if neither --audio nor --audio-dir given.", + ) + parser.add_argument( + "--chunk-ms", type=int, default=100, + help="Chunk size in milliseconds (simulates real-time interval).", + ) + parser.add_argument( + "--model", default="", dest="model_size", + help="Model size or HF repo ID.", + ) + parser.add_argument("--lan", default="en", help="Language code.") + parser.add_argument( + "--no-realtime", action="store_true", + help="Skip real-time pacing between chunks (faster but less realistic).", + ) + parser.add_argument( + "--no-vac", action="store_true", + help="Disable Voice Activity Classification (send all audio without silence filtering).", + ) + parser.add_argument( + "--diarization", action="store_true", + help="Enable speaker diarization.", + ) + parser.add_argument( + "--benchmark", action="store_true", + help="Run benchmark across all detected backend+policy combinations.", + ) + parser.add_argument( + "--json", default=None, dest="json_output", + help="Write structured JSON results to this file.", + ) + parser.add_argument( + "--max-duration", type=float, default=60.0, + help="Skip audio files longer than this many seconds (default: 60).", + ) + parser.add_argument( + "--insert-silence", nargs=2, type=float, metavar=("SECS", "AT_SEC"), + action="append", default=[], + help="Insert SECS of silence at AT_SEC position. Can be repeated. " + "E.g.: --insert-silence 3.0 2.0 --insert-silence 5.0 7.0", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Show debug-level logs from all components.", + ) + args = parser.parse_args() + + realtime = not args.no_realtime + vac = not args.no_vac + + # Resolve audio file(s) + if args.audio: + audio_files = [Path(args.audio)] + elif args.audio_dir: + audio_files = discover_audio_files(args.audio_dir) + elif AUDIO_TESTS_DIR.is_dir(): + audio_files = discover_audio_files(str(AUDIO_TESTS_DIR)) + else: + # Fall back to jfk.wav download + audio_files = [download_sample_audio()] + + if not audio_files: + logger.error("No audio files found.") + sys.exit(1) + + logger.info(f"Audio files: {[f.name for f in audio_files]}") + + if args.benchmark: + # --- Multi-backend benchmark mode --- + all_results = asyncio.run( + run_benchmark( + audio_files, args.chunk_ms, realtime, + args.model_size, args.lan, args.max_duration, vac, + args.verbose, + ) + ) + if all_results: + print_cross_backend_comparison(all_results) + results = all_results + else: + # --- Single-backend mode --- + policy = args.policy + logger.info(f"Creating {args.backend} engine...") + engine = create_engine( + args.backend, args.model_size, args.lan, + diarization=args.diarization, vac=vac, policy=policy, + ) + logger.info("Engine ready.") + + _quiet_loggers(args.verbose) + + results = asyncio.run( + run_all_tests( + engine, audio_files, args.chunk_ms, realtime, + args.backend, policy, args.lan, + max_duration=args.max_duration, + silence_insertions=args.insert_silence or None, + ) + ) + + if len(results) > 1: + print_benchmark_summary(results) + + # JSON output + if args.json_output and results: + json_results = [] + for r in results: + d = asdict(r) + d.pop("last_response", None) # too verbose for summary + json_results.append(d) + Path(args.json_output).write_text( + json.dumps(json_results, indent=2, ensure_ascii=False) + ) + logger.info(f"Results written to {args.json_output}") + + +if __name__ == "__main__": + main() From 9b2c3ee844ea3b1da97fb7912e764e1a200ee1a9 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 23:27:57 +0100 Subject: [PATCH 05/10] docs: update README with voxtral backend, benchmarks, testing sections - Add Voxtral Backend section explaining voxtral-mlx and voxtral (HF). - Add Testing & Benchmarks section with commands to run tests/benchmarks. - Update --backend parameter docs to include voxtral-mlx and voxtral. - Update optional dependencies table with Voxtral entry. - Link to BENCHMARK.md for detailed performance comparisons. --- README.md | 55 +++++++++++++++++++++++++++++++++--- whisperlivekit/core.py | 10 ++++++- whisperlivekit/parse_args.py | 4 +-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 948f015..d434a25 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,35 @@ Go to `chrome-extension` for instructions. |-----------|-------------| | **Windows/Linux optimizations** | `faster-whisper` | | **Apple Silicon optimizations** | `mlx-whisper` | +| **Voxtral (multilingual, auto-detect)** | `transformers torch` (or use built-in `voxtral-mlx` on Apple Silicon) | | **Translation** | `nllw` | | **Speaker diarization** | `git+https://github.com/NVIDIA/NeMo.git@main#egg=nemo_toolkit[asr]` | | OpenAI API | `openai` | | *[Not recommanded]* Speaker diarization with Diart | `diart` | -See **Parameters & Configuration** below on how to use them. +See **Parameters & Configuration** below on how to use them. +See **[BENCHMARK.md](BENCHMARK.md)** for detailed performance comparisons across all backends. +### Voxtral Backend + +WhisperLiveKit supports [Voxtral Mini](https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602), +a 4B-parameter speech model from Mistral AI that natively handles 100+ languages with automatic +language detection. Unlike whisper-based backends, Voxtral does not require specifying `--language`. + +```bash +# Apple Silicon (native MLX, recommended) +wlk --backend voxtral-mlx + +# Linux/GPU (HuggingFace transformers) +pip install transformers torch +wlk --backend voxtral +``` + +Voxtral uses its own streaming policy and does not use LocalAgreement or SimulStreaming. +See [BENCHMARK.md](BENCHMARK.md) for performance numbers. + ### Usage Examples **Command-line Interface**: Start the transcription server with various options: @@ -92,8 +112,11 @@ See **Parameters & Configuration** below on how to use them. # Large model and translate from french to danish wlk --model large-v3 --language fr --target-language da -# Diarization and server listening on */80 +# Diarization and server listening on */80 wlk --host 0.0.0.0 --port 80 --model medium --diarization --language fr + +# Voxtral multilingual (auto-detects language) +wlk --backend voxtral-mlx ``` @@ -151,7 +174,7 @@ async def websocket_endpoint(websocket: WebSocket): | `--target-language` | If sets, translates using [NLLW](https://github.com/QuentinFuxa/NoLanguageLeftWaiting). [200 languages available](docs/supported_languages.md). If you want to translate to english, you can also use `--direct-english-translation`. The STT model will try to directly output the translation. | `None` | | `--diarization` | Enable speaker identification | `False` | | `--backend-policy` | Streaming strategy: `1`/`simulstreaming` uses AlignAtt SimulStreaming, `2`/`localagreement` uses the LocalAgreement policy | `simulstreaming` | -| `--backend` | Whisper implementation selector. `auto` picks MLX on macOS (if installed), otherwise Faster-Whisper, otherwise vanilla Whisper. You can also force `mlx-whisper`, `faster-whisper`, `whisper`, or `openai-api` (LocalAgreement only) | `auto` | +| `--backend` | ASR backend selector. `auto` picks MLX on macOS (if installed), otherwise Faster-Whisper, otherwise vanilla Whisper. Options: `mlx-whisper`, `faster-whisper`, `whisper`, `openai-api` (LocalAgreement only), `voxtral-mlx` (Apple Silicon), `voxtral` (HuggingFace) | `auto` | | `--no-vac` | Disable Voice Activity Controller. NOT ADVISED | `False` | | `--no-vad` | Disable Voice Activity Detection. NOT ADVISED | `False` | | `--warmup-file` | Audio file path for model warmup | `jfk.wav` | @@ -271,5 +294,29 @@ docker run --gpus all -p 8000:8000 --name wlk wlk --model large-v3 --language fr - `HF_PRECACHE_DIR="./.cache/"` - Pre-load a model cache for faster first-time start - `HF_TKN_FILE="./token"` - Add your Hugging Face Hub access token to download gated models -## 🔮 Use Cases +## Testing & Benchmarks + +WhisperLiveKit includes a unit test suite and an offline benchmark harness. + +```bash +# Install test dependencies +pip install -e ".[test]" + +# Run unit tests (no model download required) +pytest tests/ -v + +# Benchmark a single backend +python test_backend_offline.py --backend faster-whisper --no-realtime + +# Benchmark all installed backends +python test_backend_offline.py --benchmark --no-realtime + +# Export benchmark results as JSON +python test_backend_offline.py --benchmark --no-realtime --json results.json +``` + +See [BENCHMARK.md](BENCHMARK.md) for a full comparison of backends, policies, WER, speed, and +timestamp accuracy on Apple Silicon. + +## Use Cases Capture discussions in real-time for meeting transcription, help hearing-impaired users follow conversations through accessibility tools, transcribe podcasts or videos automatically for content creation, transcribe support calls with speaker identification for customer service... diff --git a/whisperlivekit/core.py b/whisperlivekit/core.py index 7cf1041..c306f52 100644 --- a/whisperlivekit/core.py +++ b/whisperlivekit/core.py @@ -92,7 +92,12 @@ class TranscriptionEngine: } if config.transcription: - if config.backend == "voxtral": + if config.backend == "voxtral-mlx": + from whisperlivekit.voxtral_mlx_asr import VoxtralMLXASR + self.tokenizer = None + self.asr = VoxtralMLXASR(**transcription_common_params) + logger.info("Using Voxtral MLX native backend") + elif config.backend == "voxtral": from whisperlivekit.voxtral_hf_streaming import VoxtralHFStreamingASR self.tokenizer = None self.asr = VoxtralHFStreamingASR(**transcription_common_params) @@ -169,6 +174,9 @@ class TranscriptionEngine: def online_factory(args, asr): + if getattr(args, 'backend', None) == "voxtral-mlx": + from whisperlivekit.voxtral_mlx_asr import VoxtralMLXOnlineProcessor + return VoxtralMLXOnlineProcessor(asr) if getattr(args, 'backend', None) == "voxtral": from whisperlivekit.voxtral_hf_streaming import VoxtralHFStreamingOnlineProcessor return VoxtralHFStreamingOnlineProcessor(asr) diff --git a/whisperlivekit/parse_args.py b/whisperlivekit/parse_args.py index d89aaca..94518d7 100644 --- a/whisperlivekit/parse_args.py +++ b/whisperlivekit/parse_args.py @@ -147,8 +147,8 @@ def parse_args(): "--backend", type=str, default="auto", - choices=["auto", "mlx-whisper", "faster-whisper", "whisper", "openai-api", "voxtral"], - help="Select the ASR backend implementation (auto: prefer MLX on macOS, otherwise Faster-Whisper, else Whisper). Use 'voxtral' for Voxtral streaming via HuggingFace Transformers (CUDA/CPU/MPS).", + choices=["auto", "mlx-whisper", "faster-whisper", "whisper", "openai-api", "voxtral", "voxtral-mlx"], + help="Select the ASR backend implementation (auto: prefer MLX on macOS, otherwise Faster-Whisper, else Whisper). Use 'voxtral' for HF Transformers Voxtral (CUDA/CPU/MPS). Use 'voxtral-mlx' for native MLX Voxtral on Apple Silicon.", ) parser.add_argument( "--no-vac", From a4da246ea5f8b7960c86c309e26ceaaab05b9d56 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 23:28:10 +0100 Subject: [PATCH 06/10] feat: add voxtral-mlx native backend for Apple Silicon Pure-MLX implementation of Voxtral Mini 4B Realtime for low-latency speech transcription on Apple Silicon. Avoids the transformers/torch overhead and runs at 0.18-0.32x real-time factor. - voxtral_mlx/model.py: MLX model with spectrogram, encoder, decoder - voxtral_mlx/loader.py: model loading with 6-bit quantized weights - voxtral_mlx/spectrogram.py: mel spectrogram computation in MLX - voxtral_mlx_asr.py: VoxtralASR adapter for the AudioProcessor pipeline --- whisperlivekit/voxtral_mlx/__init__.py | 6 + whisperlivekit/voxtral_mlx/loader.py | 282 ++++++++++++ whisperlivekit/voxtral_mlx/model.py | 534 ++++++++++++++++++++++ whisperlivekit/voxtral_mlx/spectrogram.py | 202 ++++++++ whisperlivekit/voxtral_mlx_asr.py | 521 +++++++++++++++++++++ 5 files changed, 1545 insertions(+) create mode 100644 whisperlivekit/voxtral_mlx/__init__.py create mode 100644 whisperlivekit/voxtral_mlx/loader.py create mode 100644 whisperlivekit/voxtral_mlx/model.py create mode 100644 whisperlivekit/voxtral_mlx/spectrogram.py create mode 100644 whisperlivekit/voxtral_mlx_asr.py diff --git a/whisperlivekit/voxtral_mlx/__init__.py b/whisperlivekit/voxtral_mlx/__init__.py new file mode 100644 index 0000000..d008c70 --- /dev/null +++ b/whisperlivekit/voxtral_mlx/__init__.py @@ -0,0 +1,6 @@ +"""Pure-MLX Voxtral Realtime backend for WhisperLiveKit.""" + +from .loader import load_voxtral_model +from .model import VoxtralMLXModel + +__all__ = ["load_voxtral_model", "VoxtralMLXModel"] diff --git a/whisperlivekit/voxtral_mlx/loader.py b/whisperlivekit/voxtral_mlx/loader.py new file mode 100644 index 0000000..486bd71 --- /dev/null +++ b/whisperlivekit/voxtral_mlx/loader.py @@ -0,0 +1,282 @@ +""" +Model weight loading for the MLX Voxtral Realtime backend. + +Supports two on-disk formats: + 1. **Converted** (``config.json`` + ``model.safetensors``): ready-to-load, + with optional quantisation metadata. + 2. **Original Mistral** (``params.json`` + ``consolidated.safetensors``): + requires weight renaming and conv-weight transposition. + +The public entry point is :func:`load_voxtral_model` which returns the +model, tokenizer, and raw config dict. +""" + +import json +import logging +import re +from pathlib import Path + +import mlx.core as mx +import mlx.nn as nn +from huggingface_hub import snapshot_download + +from .model import VoxtralMLXModel + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL_ID = "mlx-community/Voxtral-Mini-4B-Realtime-6bit" + +# --------------------------------------------------------------------------- +# Downloading +# --------------------------------------------------------------------------- + +_ALLOWED_PATTERNS = [ + "consolidated.safetensors", + "model*.safetensors", + "model.safetensors.index.json", + "params.json", + "config.json", + "tekken.json", +] + + +def download_weights(model_id: str = DEFAULT_MODEL_ID) -> Path: + """Download model files from HuggingFace Hub and return the local path.""" + return Path(snapshot_download(model_id, allow_patterns=_ALLOWED_PATTERNS)) + + +# --------------------------------------------------------------------------- +# Weight name remapping (Mistral → our naming) +# --------------------------------------------------------------------------- + +_NAME_RULES: list[tuple[str, str]] = [ + # Encoder convolutions + (r"whisper_encoder\.conv_layers\.0\.conv\.(.*)", r"encoder.conv1.\1"), + (r"whisper_encoder\.conv_layers\.1\.conv\.(.*)", r"encoder.conv2.\1"), + # Encoder transformer blocks + (r"whisper_encoder\.transformer\.layers\.(\d+)\.attention\.wq\.(.*)", + r"encoder.blocks.\1.self_attn.q_proj.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.attention\.wk\.(.*)", + r"encoder.blocks.\1.self_attn.k_proj.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.attention\.wv\.(.*)", + r"encoder.blocks.\1.self_attn.v_proj.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.attention\.wo\.(.*)", + r"encoder.blocks.\1.self_attn.out_proj.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.attention_norm\.(.*)", + r"encoder.blocks.\1.pre_attn_norm.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.feed_forward\.w1\.(.*)", + r"encoder.blocks.\1.ffn.gate.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.feed_forward\.w2\.(.*)", + r"encoder.blocks.\1.ffn.down.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.feed_forward\.w3\.(.*)", + r"encoder.blocks.\1.ffn.up.\2"), + (r"whisper_encoder\.transformer\.layers\.(\d+)\.ffn_norm\.(.*)", + r"encoder.blocks.\1.pre_ffn_norm.\2"), + (r"whisper_encoder\.transformer\.norm\.(.*)", r"encoder.final_norm.\1"), + # Adapter + (r"audio_language_projection\.0\.weight", r"adapter.linear1.weight"), + (r"audio_language_projection\.2\.weight", r"adapter.linear2.weight"), + # Decoder embedding + (r"tok_embeddings\.weight", r"decoder.token_embedding.weight"), + # Decoder blocks + (r"layers\.(\d+)\.attention\.wq\.weight", + r"decoder.blocks.\1.self_attn.q_proj.weight"), + (r"layers\.(\d+)\.attention\.wk\.weight", + r"decoder.blocks.\1.self_attn.k_proj.weight"), + (r"layers\.(\d+)\.attention\.wv\.weight", + r"decoder.blocks.\1.self_attn.v_proj.weight"), + (r"layers\.(\d+)\.attention\.wo\.weight", + r"decoder.blocks.\1.self_attn.out_proj.weight"), + (r"layers\.(\d+)\.attention_norm\.weight", + r"decoder.blocks.\1.pre_attn_norm.weight"), + (r"layers\.(\d+)\.feed_forward\.w1\.weight", + r"decoder.blocks.\1.ffn.gate.weight"), + (r"layers\.(\d+)\.feed_forward\.w2\.weight", + r"decoder.blocks.\1.ffn.down.weight"), + (r"layers\.(\d+)\.feed_forward\.w3\.weight", + r"decoder.blocks.\1.ffn.up.weight"), + (r"layers\.(\d+)\.ffn_norm\.weight", + r"decoder.blocks.\1.pre_ffn_norm.weight"), + (r"layers\.(\d+)\.ada_rms_norm_t_cond\.0\.weight", + r"decoder.blocks.\1.adaptive_scale.proj_in.weight"), + (r"layers\.(\d+)\.ada_rms_norm_t_cond\.2\.weight", + r"decoder.blocks.\1.adaptive_scale.proj_out.weight"), + # Decoder final norm + (r"norm\.weight", r"decoder.final_norm.weight"), +] + +_PREFIX_STRIP = re.compile( + r"^(mm_streams_embeddings\.embedding_module|mm_whisper_embeddings)\." +) + + +def _translate_weight_name(name: str) -> str | None: + name = _PREFIX_STRIP.sub("", name) + for pattern, replacement in _NAME_RULES: + result, n = re.subn(f"^{pattern}$", replacement, name) + if n: + return result + return None + + +def _is_conv_weight(name: str) -> bool: + return ("conv1.weight" in name or "conv2.weight" in name) and "bias" not in name + + +# --------------------------------------------------------------------------- +# Converted-format weight remapping (voxmlx names → our names) +# --------------------------------------------------------------------------- + +_CONVERTED_RULES: list[tuple[str, str]] = [ + # Adapter + (r"adapter\.w_in\.(.*)", r"adapter.linear1.\1"), + (r"adapter\.w_out\.(.*)", r"adapter.linear2.\1"), + # Encoder transformer blocks + (r"encoder\.layers\.(\d+)\.attention\.(.*)", r"encoder.blocks.\1.self_attn.\2"), + (r"encoder\.layers\.(\d+)\.attn_norm\.(.*)", r"encoder.blocks.\1.pre_attn_norm.\2"), + (r"encoder\.layers\.(\d+)\.mlp\.gate_proj\.(.*)", r"encoder.blocks.\1.ffn.gate.\2"), + (r"encoder\.layers\.(\d+)\.mlp\.down_proj\.(.*)", r"encoder.blocks.\1.ffn.down.\2"), + (r"encoder\.layers\.(\d+)\.mlp\.up_proj\.(.*)", r"encoder.blocks.\1.ffn.up.\2"), + (r"encoder\.layers\.(\d+)\.ffn_norm\.(.*)", r"encoder.blocks.\1.pre_ffn_norm.\2"), + (r"encoder\.norm\.(.*)", r"encoder.final_norm.\1"), + # Decoder embedding + (r"language_model\.embed_tokens\.(.*)", r"decoder.token_embedding.\1"), + # Decoder blocks + (r"language_model\.layers\.(\d+)\.attention\.(.*)", r"decoder.blocks.\1.self_attn.\2"), + (r"language_model\.layers\.(\d+)\.attn_norm\.(.*)", r"decoder.blocks.\1.pre_attn_norm.\2"), + (r"language_model\.layers\.(\d+)\.mlp\.gate_proj\.(.*)", r"decoder.blocks.\1.ffn.gate.\2"), + (r"language_model\.layers\.(\d+)\.mlp\.down_proj\.(.*)", r"decoder.blocks.\1.ffn.down.\2"), + (r"language_model\.layers\.(\d+)\.mlp\.up_proj\.(.*)", r"decoder.blocks.\1.ffn.up.\2"), + (r"language_model\.layers\.(\d+)\.ffn_norm\.(.*)", r"decoder.blocks.\1.pre_ffn_norm.\2"), + (r"language_model\.layers\.(\d+)\.ada_norm\.linear_in\.(.*)", + r"decoder.blocks.\1.adaptive_scale.proj_in.\2"), + (r"language_model\.layers\.(\d+)\.ada_norm\.linear_out\.(.*)", + r"decoder.blocks.\1.adaptive_scale.proj_out.\2"), + (r"language_model\.norm\.(.*)", r"decoder.final_norm.\1"), +] + +# Also remap o_proj → out_proj in both encoder and decoder +_POST_RENAME = [ + (r"\.o_proj\.", r".out_proj."), +] + + +def _remap_converted_name(name: str) -> str: + """Translate a converted-format weight name to our naming convention.""" + for pattern, replacement in _CONVERTED_RULES: + result, n = re.subn(f"^{pattern}$", replacement, name) + if n: + name = result + break + for pattern, replacement in _POST_RENAME: + name = re.sub(pattern, replacement, name) + return name + + +# --------------------------------------------------------------------------- +# Loading strategies +# --------------------------------------------------------------------------- + +def _has_converted_layout(path: Path) -> bool: + return (path / "config.json").exists() and not (path / "consolidated.safetensors").exists() + + +def _load_converted_weights(path: Path): + with open(path / "config.json") as f: + config = json.load(f) + + model = VoxtralMLXModel(config) + + quant = config.get("quantization") + if quant is not None: + gs = quant["group_size"] + nn.quantize( + model, + group_size=gs, + bits=quant["bits"], + class_predicate=lambda _p, m: ( + hasattr(m, "to_quantized") and m.weight.shape[-1] % gs == 0 + ), + ) + + index_file = path / "model.safetensors.index.json" + if index_file.exists(): + with open(index_file) as f: + shard_map = json.load(f) + shard_files = sorted(set(shard_map["weight_map"].values())) + weights = {} + for sf in shard_files: + weights.update(mx.load(str(path / sf))) + else: + weights = mx.load(str(path / "model.safetensors")) + + remapped = {_remap_converted_name(k): v for k, v in weights.items()} + model.load_weights(list(remapped.items())) + mx.eval(model.parameters()) + return model, config + + +def _load_original_weights(path: Path): + with open(path / "params.json") as f: + config = json.load(f) + + model = VoxtralMLXModel(config) + + raw = mx.load(str(path / "consolidated.safetensors")) + mapped: dict[str, mx.array] = {} + skipped: list[str] = [] + + for name, tensor in raw.items(): + if name == "output.weight": + continue + new_name = _translate_weight_name(name) + if new_name is None: + skipped.append(name) + continue + # Conv weights: PyTorch [C_out, C_in, K] → MLX [C_out, K, C_in] + if _is_conv_weight(new_name): + tensor = mx.swapaxes(tensor, 1, 2) + mapped[new_name] = tensor + + if skipped: + logger.warning("Skipped %d unrecognised weight keys (first 5: %s)", len(skipped), skipped[:5]) + + model.load_weights(list(mapped.items())) + mx.eval(model.parameters()) + return model, config + + +# --------------------------------------------------------------------------- +# Tokenizer +# --------------------------------------------------------------------------- + +def _load_tokenizer(model_dir: Path): + from mistral_common.tokens.tokenizers.tekken import Tekkenizer + return Tekkenizer.from_file(str(model_dir / "tekken.json")) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def load_voxtral_model(path_or_id: str = DEFAULT_MODEL_ID): + """Load a Voxtral Realtime model and its tokenizer. + + Args: + path_or_id: Local directory path **or** a HuggingFace model ID. + + Returns: + ``(model, tokenizer, config)`` + """ + p = Path(path_or_id) + if not p.exists(): + p = download_weights(path_or_id) + + if _has_converted_layout(p): + model, config = _load_converted_weights(p) + else: + model, config = _load_original_weights(p) + + tokenizer = _load_tokenizer(p) + logger.info("Voxtral MLX model loaded from %s", p) + return model, tokenizer, config diff --git a/whisperlivekit/voxtral_mlx/model.py b/whisperlivekit/voxtral_mlx/model.py new file mode 100644 index 0000000..0a637f8 --- /dev/null +++ b/whisperlivekit/voxtral_mlx/model.py @@ -0,0 +1,534 @@ +""" +Voxtral Realtime MLX model — encoder, decoder, adapter, and top-level model. + +Architecture: + audio → StreamingEncoder → EncoderToDecoderAdapter → TextDecoder → logits + with DelayEmbedding providing time-conditioning to the decoder. + +The model supports both batch inference (full audio) and incremental streaming +(one chunk at a time with cached encoder/decoder state). +""" + +import math + +import mlx.core as mx +import mlx.nn as nn + + +# --------------------------------------------------------------------------- +# KV Cache +# --------------------------------------------------------------------------- + + +class SlidingKVCache: + """Bounded key-value cache with rotating buffer for sliding-window attention. + + Uses in-place writes for single-token autoregressive steps and + concatenation for multi-token prefills. Pre-allocates in blocks of + ``alloc_step`` entries to reduce repeated allocation. + """ + + alloc_step = 256 + + def __init__(self, capacity: int): + self.capacity = capacity + self.keys = None + self.values = None + self._offset = 0 + self._write_idx = 0 + + @property + def offset(self) -> int: + return self._offset + + # -- helpers -- + + def _reorder(self, buf): + """Return *buf* in temporal order (unwrap the circular buffer).""" + if self._write_idx == buf.shape[2]: + return buf + if self._write_idx < self._offset: + return mx.concatenate( + [buf[..., self._write_idx:, :], buf[..., : self._write_idx, :]], + axis=2, + ) + return buf[..., : self._write_idx, :] + + def _drop_oldest(self, buf, n_drop, tail=None): + parts = [buf[..., n_drop:, :]] if n_drop > 0 else [buf] + if tail is not None: + parts.append(tail) + return mx.concatenate(parts, axis=2) + + # -- update strategies -- + + def _append_concat(self, k, v): + """Multi-token update via concatenation (used during prefill).""" + if self.keys is None: + self.keys, self.values = k, v + else: + self.keys = self._reorder(self.keys) + self.values = self._reorder(self.values) + self._write_idx = self.keys.shape[2] + overflow = self._write_idx - self.capacity + 1 + self.keys = self._drop_oldest(self.keys, overflow, k) + self.values = self._drop_oldest(self.values, overflow, v) + self._offset += k.shape[2] + self._write_idx = self.keys.shape[2] + return self.keys, self.values + + def _write_inplace(self, k, v): + """Single-token update via in-place write (autoregressive step).""" + B, n_heads, S, dim_k = k.shape + dim_v = v.shape[3] + prev = self._offset + + if self.keys is None or ( + prev >= self.keys.shape[2] and self.keys.shape[2] < self.capacity + ): + n_new = min(self.alloc_step, self.capacity - prev) + fresh_k = mx.zeros((B, n_heads, n_new, dim_k), k.dtype) + fresh_v = mx.zeros((B, n_heads, n_new, dim_v), v.dtype) + if self.keys is not None: + self.keys = mx.concatenate([self.keys, fresh_k], axis=2) + self.values = mx.concatenate([self.values, fresh_v], axis=2) + else: + self.keys, self.values = fresh_k, fresh_v + self._write_idx = prev + + overflow = self.keys.shape[2] - self.capacity + if overflow > 0: + self.keys = self._drop_oldest(self.keys, overflow) + self.values = self._drop_oldest(self.values, overflow) + self._write_idx = self.capacity + + if self._write_idx == self.capacity: + self._write_idx = 0 + + self.keys[..., self._write_idx : self._write_idx + S, :] = k + self.values[..., self._write_idx : self._write_idx + S, :] = v + self._offset += S + self._write_idx += S + + if self._offset < self.capacity: + return ( + self.keys[..., : self._offset, :], + self.values[..., : self._offset, :], + ) + return self.keys, self.values + + # -- public API -- + + def update_and_fetch(self, k, v): + if k.shape[2] == 1: + return self._write_inplace(k, v) + return self._append_concat(k, v) + + +# --------------------------------------------------------------------------- +# Encoder components +# --------------------------------------------------------------------------- + + +class CausalConv(nn.Module): + """1-D causal convolution (left-padded so no future leakage).""" + + def __init__(self, channels_in: int, channels_out: int, kernel: int, stride: int = 1): + super().__init__() + self.stride = stride + self.kernel = kernel + self.left_pad = kernel - stride + self.weight = mx.zeros((channels_out, kernel, channels_in)) + self.bias = mx.zeros((channels_out,)) + + def __call__(self, x: mx.array) -> mx.array: + if self.left_pad > 0: + x = mx.pad(x, [(0, 0), (self.left_pad, 0), (0, 0)]) + return mx.conv1d(x, self.weight, stride=self.stride) + self.bias + + +class _EncoderSelfAttention(nn.Module): + def __init__(self, dim: int, n_heads: int, head_dim: int, rope_theta: float): + super().__init__() + self.n_heads = n_heads + self.head_dim = head_dim + self.scale = head_dim**-0.5 + self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=True) + self.k_proj = nn.Linear(dim, n_heads * head_dim, bias=False) + self.v_proj = nn.Linear(dim, n_heads * head_dim, bias=True) + self.out_proj = nn.Linear(n_heads * head_dim, dim, bias=True) + self.rope_theta = rope_theta + + def __call__(self, x, mask, cache=None): + B, L, _ = x.shape + q = self.q_proj(x).reshape(B, L, self.n_heads, self.head_dim).transpose(0, 2, 1, 3) + k = self.k_proj(x).reshape(B, L, self.n_heads, self.head_dim).transpose(0, 2, 1, 3) + v = self.v_proj(x).reshape(B, L, self.n_heads, self.head_dim).transpose(0, 2, 1, 3) + + pos = cache.offset if cache is not None else 0 + q = mx.fast.rope(q, self.head_dim, traditional=True, base=self.rope_theta, scale=1.0, offset=pos) + k = mx.fast.rope(k, self.head_dim, traditional=True, base=self.rope_theta, scale=1.0, offset=pos) + + if cache is not None: + k, v = cache.update_and_fetch(k, v) + + out = mx.fast.scaled_dot_product_attention(q, k, v, scale=self.scale, mask=mask) + return self.out_proj(out.transpose(0, 2, 1, 3).reshape(B, L, -1)) + + +class _EncoderFFN(nn.Module): + """SwiGLU feed-forward for encoder layers.""" + + def __init__(self, dim: int, hidden: int): + super().__init__() + self.gate = nn.Linear(dim, hidden, bias=False) + self.up = nn.Linear(dim, hidden, bias=False) + self.down = nn.Linear(hidden, dim, bias=True) + + def __call__(self, x): + return self.down(nn.silu(self.gate(x)) * self.up(x)) + + +class _EncoderBlock(nn.Module): + def __init__(self, dim, n_heads, head_dim, hidden, rope_theta): + super().__init__() + self.pre_attn_norm = nn.RMSNorm(dim, eps=1e-5) + self.self_attn = _EncoderSelfAttention(dim, n_heads, head_dim, rope_theta) + self.pre_ffn_norm = nn.RMSNorm(dim, eps=1e-5) + self.ffn = _EncoderFFN(dim, hidden) + + def __call__(self, x, mask, cache=None): + x = x + self.self_attn(self.pre_attn_norm(x), mask, cache=cache) + x = x + self.ffn(self.pre_ffn_norm(x)) + return x + + +class StreamingEncoder(nn.Module): + """Causal Whisper-style encoder with two causal convolutions followed by + a stack of transformer blocks. Supports both full-sequence and + incremental (streaming) forward passes.""" + + def __init__( + self, + mel_channels: int = 128, + dim: int = 1280, + n_layers: int = 32, + n_heads: int = 32, + head_dim: int = 64, + hidden_dim: int = 5120, + rope_theta: float = 1e6, + sliding_window: int = 750, + ): + super().__init__() + self.conv1 = CausalConv(mel_channels, dim, kernel=3, stride=1) + self.conv2 = CausalConv(dim, dim, kernel=3, stride=2) + self.blocks = [ + _EncoderBlock(dim, n_heads, head_dim, hidden_dim, rope_theta) + for _ in range(n_layers) + ] + self.final_norm = nn.RMSNorm(dim, eps=1e-5) + self.sliding_window = sliding_window + + # -- full-sequence -- + + def _apply_convs(self, mel: mx.array) -> mx.array: + x = mel.T[None, :, :] # [1, T, mel_channels] + x = nn.gelu(self.conv1(x)) + x = nn.gelu(self.conv2(x)) + return x + + def forward(self, mel: mx.array) -> mx.array: + x = self._apply_convs(mel.astype(self.conv1.weight.dtype)) + for blk in self.blocks: + x = blk(x, mask="causal") + return self.final_norm(x) + + # -- incremental (streaming) -- + + def forward_conv_incremental(self, x_in, tail1, tail2): + """Process new mel frames through the two causal convs using cached tails. + + Args: + x_in: [1, N, mel_channels] + tail1: [1, pad1, mel_channels] or None (first call) + tail2: [1, pad2, dim] or None (first call) + + Returns: + (out, new_tail1, new_tail2) + """ + # Conv1 (kernel=3, stride=1 → left_pad=2) + if tail1 is not None: + c1_in = mx.concatenate([tail1, x_in], axis=1) + else: + c1_in = mx.pad(x_in, [(0, 0), (self.conv1.left_pad, 0), (0, 0)]) + new_tail1 = x_in[:, -self.conv1.left_pad :, :] + c1_out = nn.gelu( + mx.conv1d(c1_in, self.conv1.weight, stride=self.conv1.stride) + self.conv1.bias + ) + + # Conv2 (kernel=3, stride=2 → left_pad=1) + if tail2 is not None: + c2_in = mx.concatenate([tail2, c1_out], axis=1) + else: + c2_in = mx.pad(c1_out, [(0, 0), (self.conv2.left_pad, 0), (0, 0)]) + new_tail2 = c1_out[:, -self.conv2.left_pad :, :] + c2_out = nn.gelu( + mx.conv1d(c2_in, self.conv2.weight, stride=self.conv2.stride) + self.conv2.bias + ) + + return c2_out, new_tail1, new_tail2 + + def forward_transformer_incremental(self, x, cache_list): + """Run transformer blocks with per-layer KV caches.""" + for i, blk in enumerate(self.blocks): + x = blk(x, mask="causal", cache=cache_list[i]) + return self.final_norm(x) + + +# --------------------------------------------------------------------------- +# Decoder components +# --------------------------------------------------------------------------- + + +class _DecoderAttention(nn.Module): + """Grouped-query attention for the text decoder.""" + + def __init__(self, dim, n_heads, n_kv_heads, head_dim, rope_theta): + super().__init__() + self.n_heads = n_heads + self.n_kv_heads = n_kv_heads + self.head_dim = head_dim + self.scale = head_dim**-0.5 + self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=False) + self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False) + self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False) + self.out_proj = nn.Linear(n_heads * head_dim, dim, bias=False) + self.rope_theta = rope_theta + + def __call__(self, x, mask=None, cache=None): + B, L, _ = x.shape + q = self.q_proj(x).reshape(B, L, self.n_heads, self.head_dim).transpose(0, 2, 1, 3) + k = self.k_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim).transpose(0, 2, 1, 3) + v = self.v_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim).transpose(0, 2, 1, 3) + + pos = cache.offset if cache is not None else 0 + q = mx.fast.rope(q, self.head_dim, traditional=True, base=self.rope_theta, scale=1.0, offset=pos) + k = mx.fast.rope(k, self.head_dim, traditional=True, base=self.rope_theta, scale=1.0, offset=pos) + + if cache is not None: + k, v = cache.update_and_fetch(k, v) + + out = mx.fast.scaled_dot_product_attention(q, k, v, scale=self.scale, mask=mask) + return self.out_proj(out.transpose(0, 2, 1, 3).reshape(B, L, -1)) + + +class _DecoderFFN(nn.Module): + """SwiGLU feed-forward for decoder layers.""" + + def __init__(self, dim, hidden): + super().__init__() + self.gate = nn.Linear(dim, hidden, bias=False) + self.up = nn.Linear(dim, hidden, bias=False) + self.down = nn.Linear(hidden, dim, bias=False) + + def __call__(self, x): + return self.down(nn.silu(self.gate(x)) * self.up(x)) + + +class AdaptiveScaling(nn.Module): + """Small MLP that produces a multiplicative scale from the delay embedding, + used to condition the FFN on the streaming delay.""" + + def __init__(self, dim, bottleneck): + super().__init__() + self.proj_in = nn.Linear(dim, bottleneck, bias=False) + self.proj_out = nn.Linear(bottleneck, dim, bias=False) + + def __call__(self, cond): + return self.proj_out(nn.gelu(self.proj_in(cond))) + + +class _DecoderBlock(nn.Module): + def __init__(self, dim, n_heads, n_kv_heads, head_dim, hidden, rope_theta, cond_dim): + super().__init__() + self.pre_attn_norm = nn.RMSNorm(dim, eps=1e-5) + self.self_attn = _DecoderAttention(dim, n_heads, n_kv_heads, head_dim, rope_theta) + self.adaptive_scale = AdaptiveScaling(dim, cond_dim) + self.pre_ffn_norm = nn.RMSNorm(dim, eps=1e-5) + self.ffn = _DecoderFFN(dim, hidden) + + def __call__(self, x, delay_cond, mask=None, cache=None): + x = x + self.self_attn(self.pre_attn_norm(x), mask, cache) + scaled = self.pre_ffn_norm(x) * (1.0 + self.adaptive_scale(delay_cond)) + x = x + self.ffn(scaled) + return x + + +class TextDecoder(nn.Module): + """Mistral-style causal language model with adaptive time-conditioning.""" + + def __init__( + self, + dim: int = 3072, + n_layers: int = 26, + n_heads: int = 32, + n_kv_heads: int = 8, + head_dim: int = 128, + hidden_dim: int = 9216, + vocab_size: int = 131072, + rope_theta: float = 1e6, + cond_dim: int = 32, + ): + super().__init__() + self.token_embedding = nn.Embedding(vocab_size, dim) + self.blocks = [ + _DecoderBlock(dim, n_heads, n_kv_heads, head_dim, hidden_dim, rope_theta, cond_dim) + for _ in range(n_layers) + ] + self.final_norm = nn.RMSNorm(dim, eps=1e-5) + + def embed(self, token_ids: mx.array) -> mx.array: + return self.token_embedding(token_ids) + + def __call__(self, x, delay_cond, mask=None, cache=None): + delay_cond = delay_cond.astype(x.dtype) + for i, blk in enumerate(self.blocks): + blk_cache = cache[i] if cache is not None else None + x = blk(x, delay_cond, mask, blk_cache) + x = self.final_norm(x) + return self.token_embedding.as_linear(x) + + +# --------------------------------------------------------------------------- +# Adapter & embeddings +# --------------------------------------------------------------------------- + + +class EncoderToDecoderAdapter(nn.Module): + """Two-layer projection from encoder space to decoder space.""" + + def __init__(self, enc_dim: int, dec_dim: int): + super().__init__() + self.linear1 = nn.Linear(enc_dim, dec_dim, bias=False) + self.linear2 = nn.Linear(dec_dim, dec_dim, bias=False) + + def __call__(self, x): + return self.linear2(nn.gelu(self.linear1(x))) + + +class DelayEmbedding(nn.Module): + """Sinusoidal embedding that encodes the streaming delay as a conditioning + vector for the decoder's adaptive scaling.""" + + def __init__(self, dim: int = 3072, theta: float = 10000.0): + super().__init__() + self.dim = dim + half = dim // 2 + freqs = mx.exp(-math.log(theta) * mx.arange(half, dtype=mx.float32) / half) + self._freqs = freqs + + def __call__(self, delay: mx.array) -> mx.array: + t = delay.reshape(-1, 1).astype(mx.float32) + angles = t * self._freqs + return mx.concatenate([mx.cos(angles), mx.sin(angles)], axis=-1) + + +# --------------------------------------------------------------------------- +# Top-level model +# --------------------------------------------------------------------------- + + +class VoxtralMLXModel(nn.Module): + """Top-level Voxtral Realtime model wiring encoder, adapter, and decoder.""" + + def __init__(self, config: dict): + super().__init__() + + enc_cfg = config["multimodal"]["whisper_model_args"]["encoder_args"] + audio_cfg = enc_cfg["audio_encoding_args"] + ds_factor = config["multimodal"]["whisper_model_args"]["downsample_args"]["downsample_factor"] + + self.encoder = StreamingEncoder( + mel_channels=audio_cfg["num_mel_bins"], + dim=enc_cfg["dim"], + n_layers=enc_cfg["n_layers"], + n_heads=enc_cfg["n_heads"], + head_dim=enc_cfg["head_dim"], + hidden_dim=enc_cfg["hidden_dim"], + rope_theta=enc_cfg["rope_theta"], + sliding_window=enc_cfg["sliding_window"], + ) + + adapter_input_dim = enc_cfg["dim"] * ds_factor + decoder_dim = config["dim"] + cond_bottleneck = config.get("ada_rms_norm_t_cond_dim", 32) + + self.adapter = EncoderToDecoderAdapter(adapter_input_dim, decoder_dim) + + self.decoder = TextDecoder( + dim=decoder_dim, + n_layers=config["n_layers"], + n_heads=config["n_heads"], + n_kv_heads=config["n_kv_heads"], + head_dim=config["head_dim"], + hidden_dim=config["hidden_dim"], + vocab_size=config["vocab_size"], + rope_theta=config["rope_theta"], + cond_dim=cond_bottleneck, + ) + + self.delay_embedding = DelayEmbedding(dim=decoder_dim) + self.ds_factor = ds_factor + + # -- batch encode -- + + def encode(self, mel: mx.array) -> mx.array: + T = mel.shape[1] + if T % 2 != 0: + mel = mel[:, 1:] + + h = self.encoder.forward(mel) # [1, T/2, enc_dim] + h = h[0] + + n = h.shape[0] + trim = n % self.ds_factor + if trim: + h = h[trim:] + n = h.shape[0] + + h = h.reshape(n // self.ds_factor, -1) + return self.adapter(h) + + # -- incremental encode -- + + def encode_incremental(self, new_mel, conv_tail1, conv_tail2, enc_cache, ds_remainder): + """Incrementally encode new mel frames. + + Returns: + (audio_embeds | None, conv_tail1, conv_tail2, enc_cache, ds_remainder) + """ + x = new_mel.T[None, :, :].astype(self.encoder.conv1.weight.dtype) + + x, conv_tail1, conv_tail2 = self.encoder.forward_conv_incremental(x, conv_tail1, conv_tail2) + + if enc_cache is None: + enc_cache = [SlidingKVCache(100_000) for _ in range(len(self.encoder.blocks))] + + x = self.encoder.forward_transformer_incremental(x, enc_cache) + x = x[0] # [N, enc_dim] + + if ds_remainder is not None: + x = mx.concatenate([ds_remainder, x]) + + n_full = (x.shape[0] // self.ds_factor) * self.ds_factor + if n_full == 0: + return None, conv_tail1, conv_tail2, enc_cache, x + + leftover = x[n_full:] if x.shape[0] > n_full else None + x = x[:n_full].reshape(n_full // self.ds_factor, -1) + return self.adapter(x), conv_tail1, conv_tail2, enc_cache, leftover + + # -- decode -- + + def decode(self, embeddings, delay_cond, mask=None, cache=None): + return self.decoder(embeddings, delay_cond, mask, cache) diff --git a/whisperlivekit/voxtral_mlx/spectrogram.py b/whisperlivekit/voxtral_mlx/spectrogram.py new file mode 100644 index 0000000..0fdf463 --- /dev/null +++ b/whisperlivekit/voxtral_mlx/spectrogram.py @@ -0,0 +1,202 @@ +""" +Mel spectrogram computation for Voxtral Realtime. + +Provides both a full-audio function and an incremental streaming variant +that maintains overlap state between calls. The DFT is computed via +matrix multiplication in MLX — no external FFT dependency required. +""" + +import math + +import mlx.core as mx +import numpy as np + +# Audio / mel constants matching the Voxtral Realtime model expectations. +SAMPLE_RATE = 16_000 +WINDOW_SIZE = 400 # n_fft +HOP = 160 +MEL_BANDS = 128 +MEL_MAX = 1.5 # global log-mel normalisation ceiling +# Each output audio token spans: hop * conv_stride(2) * downsample_factor(4) +SAMPLES_PER_TOKEN = HOP * 2 * 4 # = 1280 samples = 80 ms + +# Padding tokens used by the model prompt structure. +LEFT_PAD_TOKENS = 32 +RIGHT_PAD_TOKENS = 17 + + +# --------------------------------------------------------------------------- +# Slaney mel filterbank +# --------------------------------------------------------------------------- + +def _build_slaney_filterbank( + sr: int = SAMPLE_RATE, + n_fft: int = WINDOW_SIZE, + n_mels: int = MEL_BANDS, + lo_hz: float = 0.0, + hi_hz: float = 8000.0, +) -> np.ndarray: + """Compute a Slaney-normalised triangular mel filterbank. + + Returns an array of shape ``[n_mels, n_fft//2 + 1]``. + """ + + def _hz2mel(f): + threshold = 1000.0 + base_mel = 15.0 + log_coeff = 27.0 / np.log(6.4) + mel = 3.0 * f / 200.0 + if isinstance(f, np.ndarray): + above = f >= threshold + mel[above] = base_mel + np.log(f[above] / threshold) * log_coeff + elif f >= threshold: + mel = base_mel + np.log(f / threshold) * log_coeff + return mel + + def _mel2hz(m): + threshold = 1000.0 + base_mel = 15.0 + log_coeff = np.log(6.4) / 27.0 + hz = 200.0 * m / 3.0 + above = m >= base_mel + hz[above] = threshold * np.exp(log_coeff * (m[above] - base_mel)) + return hz + + n_bins = n_fft // 2 + 1 + fft_hz = np.linspace(0, sr / 2, n_bins) + mel_lo, mel_hi = _hz2mel(lo_hz), _hz2mel(hi_hz) + mel_pts = np.linspace(mel_lo, mel_hi, n_mels + 2) + hz_pts = _mel2hz(mel_pts) + diffs = np.diff(hz_pts) + + slopes = np.expand_dims(hz_pts, 0) - np.expand_dims(fft_hz, 1) + rising = -slopes[:, :-2] / diffs[:-1] + falling = slopes[:, 2:] / diffs[1:] + fb = np.maximum(0.0, np.minimum(rising, falling)) + + # Slaney area normalisation + widths = 2.0 / (hz_pts[2 : n_mels + 2] - hz_pts[:n_mels]) + fb *= np.expand_dims(widths, 0) + return fb.T.astype(np.float32) + + +_CACHED_FILTERS: mx.array | None = None + + +def _mel_filters() -> mx.array: + global _CACHED_FILTERS + if _CACHED_FILTERS is None: + _CACHED_FILTERS = mx.array(_build_slaney_filterbank()) + return _CACHED_FILTERS + + +# --------------------------------------------------------------------------- +# DFT helpers +# --------------------------------------------------------------------------- + +def _hann_window() -> mx.array: + return mx.array(np.hanning(WINDOW_SIZE + 1)[:-1].astype(np.float32)) + + +def _dft_matrices(): + """Pre-compute the real / imaginary DFT basis matrices.""" + n_bins = WINDOW_SIZE // 2 + 1 + k = mx.arange(n_bins, dtype=mx.float32)[:, None] + n = mx.arange(WINDOW_SIZE, dtype=mx.float32)[None, :] + phase = -2.0 * math.pi * (k @ n) / WINDOW_SIZE + return mx.cos(phase), mx.sin(phase) + + +def _stft_frames(audio: mx.array, window: mx.array) -> mx.array: + """Frame *audio* using the Hann window and compute power spectrogram.""" + n_bins = WINDOW_SIZE // 2 + 1 + n_frames = 1 + (audio.shape[0] - WINDOW_SIZE) // HOP + if n_frames <= 0: + return mx.zeros((0, n_bins)) + + offsets = (mx.arange(n_frames) * HOP)[:, None] + indices = offsets + mx.arange(WINDOW_SIZE)[None, :] + windowed = audio[indices] * window[None, :] + + dft_re, dft_im = _dft_matrices() + real_part = windowed @ dft_re.T + imag_part = windowed @ dft_im.T + return real_part ** 2 + imag_part ** 2 + + +def _apply_mel_and_log(power: mx.array) -> mx.array: + """Convert a power spectrogram to log-mel and normalise.""" + mel = power @ _mel_filters().T + log_mel = mx.log10(mx.maximum(mel, 1e-10)) + log_mel = mx.maximum(log_mel, MEL_MAX - 8.0) + return (log_mel + 4.0) / 4.0 + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def compute_mel(audio: np.ndarray) -> mx.array: + """Compute log-mel spectrogram for a complete audio signal. + + Args: + audio: 1-D float32 numpy array at ``SAMPLE_RATE``. + + Returns: + ``[MEL_BANDS, T]`` MLX array. + """ + x = mx.array(audio) + pad = WINDOW_SIZE // 2 + x = mx.pad(x, [(pad, pad)]) + window = _hann_window() + + power = _stft_frames(x, window) + # Drop last frame to match reference STFT behaviour + power = power[:-1] + return _apply_mel_and_log(power).T + + +def compute_mel_streaming( + chunk: np.ndarray, + overlap: np.ndarray | None, +) -> tuple[mx.array, np.ndarray]: + """Incrementally compute log-mel for a new audio chunk. + + Args: + chunk: New audio samples (float32 numpy). + overlap: The last ``WINDOW_SIZE - HOP`` = 240 samples from the + previous call, or *None* on the first call (uses zero-padding). + + Returns: + ``(mel, new_overlap)`` where *mel* is ``[MEL_BANDS, N]`` and + *new_overlap* is the 240-sample tail for the next call. + """ + tail_len = WINDOW_SIZE - HOP # 240 + + if overlap is not None: + combined = np.concatenate([overlap, chunk]) + else: + combined = np.concatenate([np.zeros(WINDOW_SIZE // 2, dtype=np.float32), chunk]) + + new_overlap = combined[-tail_len:].copy() + + x = mx.array(combined) + window = _hann_window() + power = _stft_frames(x, window) + + if power.shape[0] == 0: + return mx.zeros((MEL_BANDS, 0)), new_overlap + + return _apply_mel_and_log(power).T, new_overlap + + +def pad_audio( + audio: np.ndarray, + n_left: int = LEFT_PAD_TOKENS, + n_right: int = RIGHT_PAD_TOKENS, +) -> np.ndarray: + """Pad audio with silence for batch (non-streaming) inference.""" + left = n_left * SAMPLES_PER_TOKEN + align = (SAMPLES_PER_TOKEN - (len(audio) % SAMPLES_PER_TOKEN)) % SAMPLES_PER_TOKEN + right = align + n_right * SAMPLES_PER_TOKEN + return np.pad(audio, (left, right)) diff --git a/whisperlivekit/voxtral_mlx_asr.py b/whisperlivekit/voxtral_mlx_asr.py new file mode 100644 index 0000000..4c62f80 --- /dev/null +++ b/whisperlivekit/voxtral_mlx_asr.py @@ -0,0 +1,521 @@ +""" +Pure-MLX Voxtral Realtime ASR backend for WhisperLiveKit. + +Provides ``VoxtralMLXASR`` (model holder) and ``VoxtralMLXOnlineProcessor`` +(streaming processor) that plug into WhisperLiveKit's audio processing +pipeline via ``insert_audio_chunk`` / ``process_iter`` / ``get_buffer`` etc. + +Unlike the HuggingFace backend, this runs the full inference loop in-process +(no background thread / queue) — MLX operations on Apple Silicon are fast +enough to run synchronously inside ``asyncio.to_thread(process_iter)``. +""" + +import logging +import sys +import time +from typing import List, Optional, Tuple + +import mlx.core as mx +import numpy as np +from mistral_common.tokens.tokenizers.base import SpecialTokenPolicy + +from whisperlivekit.timed_objects import ASRToken, Transcript +from whisperlivekit.voxtral_mlx.loader import load_voxtral_model, DEFAULT_MODEL_ID +from whisperlivekit.voxtral_mlx.model import SlidingKVCache +from whisperlivekit.voxtral_mlx.spectrogram import ( + SAMPLES_PER_TOKEN, + LEFT_PAD_TOKENS, + RIGHT_PAD_TOKENS, + compute_mel_streaming, +) + +logger = logging.getLogger(__name__) + +# Decoder sliding-window size (matches the model's training configuration). +_DECODER_WINDOW = 8192 + + +def _prompt_tokens(tokenizer, n_left_pad=LEFT_PAD_TOKENS, n_delay=6): + """Build the prompt token sequence and return ``(token_ids, n_delay)``.""" + pad_id = tokenizer.get_special_token("[STREAMING_PAD]") + ids = [tokenizer.bos_id] + [pad_id] * (n_left_pad + n_delay) + return ids, n_delay + + +# --------------------------------------------------------------------------- +# Model holder +# --------------------------------------------------------------------------- + + +class VoxtralMLXASR: + """Lightweight model holder — loads the MLX Voxtral model once and keeps + it alive for the lifetime of the server.""" + + sep = " " + SAMPLING_RATE = 16_000 + + def __init__(self, logfile=sys.stderr, **kwargs): + self.logfile = logfile + self.transcribe_kargs = {} + + lan = kwargs.get("lan", "auto") + self.original_language = None if lan == "auto" else lan + + model_path = kwargs.get("model_dir") or kwargs.get("model_path") + if not model_path: + model_size = kwargs.get("model_size", "") + if model_size and ("/" in model_size or model_size.startswith(".")): + model_path = model_size + else: + model_path = DEFAULT_MODEL_ID + + t0 = time.time() + logger.info("Loading Voxtral MLX model '%s' ...", model_path) + self.model, self.tokenizer, self.config = load_voxtral_model(model_path) + logger.info("Voxtral MLX model loaded in %.2fs", time.time() - t0) + + self.backend_choice = "voxtral-mlx" + + def transcribe(self, audio): + pass # all work happens in the online processor + + +# --------------------------------------------------------------------------- +# Online processor +# --------------------------------------------------------------------------- + + +class VoxtralMLXOnlineProcessor: + """Streaming processor that incrementally encodes audio and decodes text + using the MLX Voxtral model. + + Lifecycle (called by ``AudioProcessor.transcription_processor``): + + insert_audio_chunk(pcm, time) → process_iter() → get_buffer() + ... repeat ... + start_silence() / end_silence() + finish() + """ + + SAMPLING_RATE = 16_000 + + def __init__(self, asr: VoxtralMLXASR, logfile=sys.stderr): + self.asr = asr + self.logfile = logfile + self.end = 0.0 + self.buffer: list = [] + self.audio_buffer = np.array([], dtype=np.float32) + + self._model = asr.model + self._tokenizer = asr.tokenizer + + # Pre-compute prompt tokens and delay conditioning (constant across utterances). + self._prompt_ids, self._n_delay = _prompt_tokens(self._tokenizer) + self._prefix_len = len(self._prompt_ids) + + self._delay_cond = self._model.delay_embedding( + mx.array([self._n_delay], dtype=mx.float32) + ) + mx.eval(self._delay_cond) + + self._prompt_embeds = self._model.decoder.embed( + mx.array([self._prompt_ids]) + )[0] # [prefix_len, dim] + mx.eval(self._prompt_embeds) + + self._eos_id = self._tokenizer.eos_id + self._secs_per_token = SAMPLES_PER_TOKEN / self.SAMPLING_RATE + # The streaming model has an inherent delay: text for audio at position P + # is generated at decoder position P + n_delay. Compensate timestamps. + self._delay_secs = self._n_delay * self._secs_per_token + + self._reset_state() + + # -- state management -- + + def _reset_state(self): + """Reset all incremental state for a fresh utterance.""" + # Audio accumulation + self._pending = np.zeros(0, dtype=np.float32) + # Mel overlap + self._mel_overlap: np.ndarray | None = None + # Encoder incremental state + self._conv_tail1 = None + self._conv_tail2 = None + self._enc_cache = None + self._ds_remainder = None + # Audio embeddings not yet decoded + self._audio_embeds: mx.array | None = None + # Decoder state + self._dec_cache: list[SlidingKVCache] | None = None + self._last_token: mx.array | None = None + # Bookkeeping + self._samples_encoded = 0 + self._positions_decoded = 0 + self._prefilled = False + self._first_chunk = True + # Text state + self._full_text = "" + self._n_text_tokens = 0 + self._n_committed_words = 0 + self._time_offset = 0.0 + # Per-word audio position tracking: decoder position (relative to prefix) + # where each word in _full_text started and ended + self._word_audio_starts: list[int] = [] # audio pos where word i started + self._word_audio_ends: list[int] = [] # audio pos where word i last produced a token + self._current_word_pos: Optional[int] = None # audio pos of current (incomplete) word's first token + + # -- audio ingestion -- + + def insert_audio_chunk(self, audio: np.ndarray, audio_stream_end_time: float): + self.end = audio_stream_end_time + self._pending = np.append(self._pending, audio) + self.audio_buffer = self._pending + + # -- core processing -- + + def process_iter(self, is_last=False) -> Tuple[List[ASRToken], float]: + try: + return self._step(is_last) + except Exception as e: + logger.warning("[voxtral-mlx] process_iter error: %s", e, exc_info=True) + return [], self.end + + def _step(self, is_last: bool) -> Tuple[List[ASRToken], float]: + # 1. Encode any new audio + self._encode_pending() + + if self._audio_embeds is None: + return [], self.end + + # 2. Compute how many positions we can safely decode + total_safe = LEFT_PAD_TOKENS + self._samples_encoded // SAMPLES_PER_TOKEN + n_available = self._audio_embeds.shape[0] + n_decodable = min(n_available, total_safe - self._positions_decoded) + + if n_decodable <= 0: + return [], self.end + + # 3. Prefill if needed + if not self._prefilled: + if self._positions_decoded + n_available < self._prefix_len: + return [], self.end + self._do_prefill() + # Re-check after consuming prefix embeddings + n_available = self._audio_embeds.shape[0] if self._audio_embeds is not None else 0 + n_decodable = min(n_available, total_safe - self._positions_decoded) + + if n_decodable <= 0 or self._audio_embeds is None: + return [], self.end + + # 4. Decode available positions + hit_eos = self._decode_positions(n_decodable) + + if hit_eos: + # Flush words, reset for next utterance + words = self._flush_all_words() + logger.debug( + "[voxtral-mlx] EOS hit during stream: flushed %d words, " + "samples_encoded=%d (%.2fs), text='%s'", + len(words), self._samples_encoded, + self._samples_encoded / self.SAMPLING_RATE, + self._full_text[-60:] if self._full_text else "", + ) + saved_offset = self._time_offset + self._reset_state() + self._time_offset = saved_offset + return words, self.end + + # 5. Extract committed words (all but the last, which may still grow) + return self._extract_committed_words(), self.end + + def _encode_pending(self): + """Feed pending audio through the incremental encoder.""" + available = len(self._pending) + if available < SAMPLES_PER_TOKEN: + return + + if self._first_chunk: + # First chunk: prepend silence for left-padding + n_take = (available // SAMPLES_PER_TOKEN) * SAMPLES_PER_TOKEN + left_pad = np.zeros(LEFT_PAD_TOKENS * SAMPLES_PER_TOKEN, dtype=np.float32) + chunk = np.concatenate([left_pad, self._pending[:n_take]]) + self._pending = self._pending[n_take:] + self._samples_encoded += n_take + self._first_chunk = False + else: + n_take = (available // SAMPLES_PER_TOKEN) * SAMPLES_PER_TOKEN + chunk = self._pending[:n_take] + self._pending = self._pending[n_take:] + self._samples_encoded += n_take + + mel, self._mel_overlap = compute_mel_streaming(chunk, self._mel_overlap) + + embeds, self._conv_tail1, self._conv_tail2, self._enc_cache, self._ds_remainder = ( + self._model.encode_incremental( + mel, self._conv_tail1, self._conv_tail2, self._enc_cache, self._ds_remainder + ) + ) + + if embeds is not None: + mx.eval(embeds) + if self._audio_embeds is not None: + self._audio_embeds = mx.concatenate([self._audio_embeds, embeds]) + else: + self._audio_embeds = embeds + + self.audio_buffer = self._pending + + def _do_prefill(self): + """Run the decoder prefill pass over the prompt + first audio embeddings.""" + n_dec_layers = len(self._model.decoder.blocks) + self._dec_cache = [SlidingKVCache(_DECODER_WINDOW) for _ in range(n_dec_layers)] + + prefix_embeds = self._prompt_embeds + self._audio_embeds[: self._prefix_len] + prefix_embeds = prefix_embeds[None, :, :] # [1, prefix_len, dim] + + logits = self._model.decode(prefix_embeds, self._delay_cond, "causal", self._dec_cache) + mx.eval(logits, *[x for c in self._dec_cache for x in (c.keys, c.values)]) + + self._last_token = self._sample(logits) + mx.async_eval(self._last_token) + + # Remove consumed prefix embeddings + self._audio_embeds = self._audio_embeds[self._prefix_len :] + if self._audio_embeds.shape[0] == 0: + self._audio_embeds = None + self._positions_decoded = self._prefix_len + self._prefilled = True + + def _decode_positions(self, n: int) -> bool: + """Autoregressively decode *n* positions. Returns True on EOS.""" + base_pos = self._positions_decoded # absolute position before this batch + for i in range(n): + tok_embed = self._model.decoder.embed(self._last_token.reshape(1, 1))[0, 0] + combined = (self._audio_embeds[i] + tok_embed)[None, None, :] + logits = self._model.decode(combined, self._delay_cond, mask=None, cache=self._dec_cache) + next_tok = self._sample(logits) + mx.async_eval(next_tok) + + token_id = self._last_token.item() + if token_id == self._eos_id: + # Close the current word if one is being built + if self._current_word_pos is not None: + self._word_audio_ends.append(base_pos + i - self._prefix_len) + self._current_word_pos = None + self._trim_embeds(i) + self._positions_decoded += i + return True + + text = self._tokenizer.decode( + [token_id], special_token_policy=SpecialTokenPolicy.IGNORE + ) + + if text: + audio_pos = base_pos + i - self._prefix_len + + # Detect word boundary: new word starts with space or is the very first text + if text.lstrip() != text or not self._full_text: + # Close previous word if exists + if self._current_word_pos is not None: + self._word_audio_ends.append(audio_pos) + # Start new word + self._word_audio_starts.append(audio_pos) + self._current_word_pos = audio_pos + elif self._current_word_pos is None: + # First token of first word (no leading space) + self._word_audio_starts.append(audio_pos) + self._current_word_pos = audio_pos + + self._full_text += text + self._n_text_tokens += 1 + + if i > 0 and i % 256 == 0: + mx.clear_cache() + + self._last_token = next_tok + + self._positions_decoded += n + self._trim_embeds(n) + return False + + def _trim_embeds(self, n_consumed: int): + if self._audio_embeds is not None and self._audio_embeds.shape[0] > n_consumed: + self._audio_embeds = self._audio_embeds[n_consumed:] + else: + self._audio_embeds = None + + def _sample(self, logits: mx.array) -> mx.array: + return mx.argmax(logits[0, -1:], axis=-1).squeeze() + + # -- word extraction -- + + def _audio_pos_to_time(self, pos: int) -> float: + """Convert an audio position (relative to prefix end) to seconds.""" + return max(0.0, pos * self._secs_per_token - self._delay_secs + self._time_offset) + + def _word_time_range(self, word_idx: int, n_words: int) -> Tuple[float, float]: + """Compute (start, end) time for a word using tracked word positions.""" + starts = self._word_audio_starts + ends = self._word_audio_ends + + if not starts: + return self._time_offset, self._time_offset + + # Get start position for this word + if word_idx < len(starts): + t0 = self._audio_pos_to_time(starts[word_idx]) + else: + # Fallback: estimate from last known position + last_pos = ends[-1] if ends else starts[-1] + t0 = self._audio_pos_to_time(last_pos + 1) + + # Get end position: use the start of the next word, or the end of this word + if word_idx + 1 < len(starts): + t1 = self._audio_pos_to_time(starts[word_idx + 1]) + elif word_idx < len(ends): + t1 = self._audio_pos_to_time(ends[word_idx] + 1) + else: + # Last word, still being built: use last known position + 1 token + last_pos = starts[word_idx] if word_idx < len(starts) else (ends[-1] if ends else 0) + t1 = self._audio_pos_to_time(last_pos + 1) + + return t0, t1 + + def _extract_committed_words(self) -> List[ASRToken]: + """Return complete words (all except the last which may still grow).""" + if not self._full_text: + return [] + words = self._full_text.split() + tokens: List[ASRToken] = [] + n_total = max(len(words), 1) + + while len(words) > self._n_committed_words + 1: + w = words[self._n_committed_words] + idx = self._n_committed_words + t0, t1 = self._word_time_range(idx, n_total) + label = w if idx == 0 else " " + w + tokens.append(ASRToken(start=t0, end=t1, text=label)) + self._n_committed_words += 1 + + return tokens + + def _flush_all_words(self) -> List[ASRToken]: + """Flush every word including the last partial one.""" + if not self._full_text: + return [] + words = self._full_text.split() + tokens: List[ASRToken] = [] + n_total = max(len(words), 1) + + while self._n_committed_words < len(words): + w = words[self._n_committed_words] + idx = self._n_committed_words + t0, t1 = self._word_time_range(idx, n_total) + label = w if idx == 0 else " " + w + tokens.append(ASRToken(start=t0, end=t1, text=label)) + self._n_committed_words += 1 + + return tokens + + # -- interface methods -- + + def get_buffer(self) -> Transcript: + if not self._full_text: + return Transcript(start=None, end=None, text="") + words = self._full_text.split() + remaining = words[self._n_committed_words :] + if remaining: + return Transcript(start=self.end, end=self.end, text=" ".join(remaining)) + return Transcript(start=None, end=None, text="") + + def start_silence(self) -> Tuple[List[ASRToken], float]: + words = self._flush_all_words() + logger.info("[voxtral-mlx] start_silence: flushed %d words", len(words)) + return words, self.end + + def end_silence(self, silence_duration: float, offset: float): + self._time_offset += silence_duration + self.end += silence_duration + + def new_speaker(self, change_speaker): + self.start_silence() + + def warmup(self, audio, init_prompt=""): + pass + + def finish(self) -> Tuple[List[ASRToken], float]: + logger.debug( + "[voxtral-mlx] finish: pending=%d samples, audio_embeds=%s, " + "samples_encoded=%d, positions_decoded=%d, prefilled=%s, text so far='%s'", + len(self._pending), + self._audio_embeds.shape if self._audio_embeds is not None else None, + self._samples_encoded, + self._positions_decoded, + self._prefilled, + self._full_text[-80:] if self._full_text else "", + ) + + # Align pending audio to SAMPLES_PER_TOKEN boundary so nothing is lost + remainder = len(self._pending) % SAMPLES_PER_TOKEN + if remainder > 0: + align_pad = SAMPLES_PER_TOKEN - remainder + else: + align_pad = 0 + + # Add alignment + right-padding silence + total_pad = align_pad + RIGHT_PAD_TOKENS * SAMPLES_PER_TOKEN + if total_pad > 0: + self._pending = np.append( + self._pending, np.zeros(total_pad, dtype=np.float32) + ) + + # Encode remaining audio (including right-padding) + self._encode_pending() + + logger.debug( + "[voxtral-mlx] finish after encode: audio_embeds=%s, pending=%d", + self._audio_embeds.shape if self._audio_embeds is not None else None, + len(self._pending), + ) + + hit_eos = False + + # Decode everything that's left from right-padding + if self._audio_embeds is not None and self._prefilled: + hit_eos = self._decode_positions(self._audio_embeds.shape[0]) + logger.debug( + "[voxtral-mlx] finish decode: hit_eos=%s, text='%s'", + hit_eos, self._full_text[-80:] if self._full_text else "", + ) + + # Flush last token if it wasn't EOS + if self._last_token is not None: + tid = self._last_token.item() + if tid != self._eos_id: + text = self._tokenizer.decode( + [tid], special_token_policy=SpecialTokenPolicy.IGNORE + ) + if text: + last_pos = self._positions_decoded - self._prefix_len + # Check if this starts a new word + if text.lstrip() != text or not self._full_text: + if self._current_word_pos is not None: + self._word_audio_ends.append(last_pos) + self._word_audio_starts.append(last_pos) + self._current_word_pos = last_pos + elif self._current_word_pos is None: + self._word_audio_starts.append(last_pos) + self._current_word_pos = last_pos + self._full_text += text + self._n_text_tokens += 1 + + # Close the last word if still open + if self._current_word_pos is not None: + last_pos = self._positions_decoded - self._prefix_len + self._word_audio_ends.append(last_pos) + self._current_word_pos = None + + words = self._flush_all_words() + logger.info("[voxtral-mlx] finish: flushed %d words", len(words)) + return words, self.end From 4b2377c243d63b582ef89b9171310b59208394e7 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Sun, 22 Feb 2026 23:38:04 +0100 Subject: [PATCH 07/10] fix: correct false auto-detect claim, median bug, RTF inflation - BENCHMARK.md: whisper also supports --language auto, voxtral is not the only one. Fixed mlx-whisper speed comparison (LA is actually faster than SS for mlx-whisper, not comparable). - metrics.py: median calculation was wrong for even-length lists (took upper middle instead of averaging the two middle values). - metrics_collector.py: RTF was inflated because log_summary() used wall-clock elapsed time instead of sum of actual ASR call durations. - README.md: clarified that whisper also supports auto language detection, voxtral just does it better. - Added 2 new median tests (even + odd length). --- BENCHMARK.md | 21 +++++++++-------- README.md | 3 ++- tests/test_metrics.py | 36 +++++++++++++++++++++++++++++ whisperlivekit/metrics.py | 7 +++++- whisperlivekit/metrics_collector.py | 4 ++-- 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/BENCHMARK.md b/BENCHMARK.md index 9d27ab2..df1293f 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -77,24 +77,25 @@ should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect ### Speed (RTF = processing time / audio duration, lower is better) 1. **mlx-whisper + LocalAgreement** is the fastest combo on Apple Silicon, reaching 0.05-0.06x RTF - on English audio. This means 30 seconds of audio is processed in under 2 seconds. -2. **SimulStreaming** is consistently faster than LocalAgreement for faster-whisper, but comparable - for mlx-whisper. + on English audio. 30 seconds of audio processed in under 2 seconds. +2. For **faster-whisper**, SimulStreaming is consistently faster than LocalAgreement. + For **mlx-whisper**, it is the opposite: LocalAgreement (0.05-0.06x) is faster than SimulStreaming (0.11-0.14x). 3. **voxtral-mlx** runs at 0.18-0.32x RTF, roughly 3-5x slower than mlx-whisper but well within real-time requirements. -4. **voxtral (HF transformers)** is the slowest, hitting 1.0-1.3x RTF. On longer audio, it risks +4. **voxtral (HF transformers)** is the slowest at 1.0-1.3x RTF. On longer audio it risks falling behind real-time. On Apple Silicon, the MLX variant is strongly preferred. ### Accuracy (WER = Word Error Rate, lower is better) 1. **SimulStreaming** produces significantly better WER than LocalAgreement for whisper backends. On the 30s English file: 5.3% vs 23.7-44.7%. -2. **voxtral-mlx** achieves strong accuracy (0% on short English, 9.2% on multi-speaker) and is - the only backend that auto-detects language, making it the best choice for multilingual use. +2. **voxtral-mlx** has good accuracy (0% on short English, 9.2% on multi-speaker). + Whisper also supports `--language auto`, but Voxtral's language detection is more + reliable and does not bias towards English the way Whisper's auto mode tends to. 3. **LocalAgreement** tends to duplicate the last sentence, inflating WER. This is a known artifact of the LCP (Longest Common Prefix) commit strategy at end-of-stream. 4. **Voxtral** backends handle French natively with 28-37% WER, while whisper backends - attempted English transcription of French audio (not a fair comparison for French). + were run with `--lan en` here (not a fair comparison for French). ### Timestamp Accuracy (MAE = Mean Absolute Error on word start times, lower is better) @@ -102,10 +103,10 @@ should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect processes overlapping audio windows and validates via prefix matching. 2. **SimulStreaming** timestamps are slightly less precise (0.24-0.40s MAE) but still usable for most applications. -3. **voxtral-mlx** achieves excellent timestamps on English (0.18-0.25s MAE) but can drift on +3. **voxtral-mlx** has good timestamp accuracy on English (0.18-0.25s MAE) but drifts on audio with long silence gaps (3.4s MAE on the French file with 4-second pauses). -4. **voxtral (HF)** has the worst timestamp accuracy (1.0-4.0s MAE), likely due to the - additional overhead of the transformers pipeline. +4. **voxtral (HF)** has the worst timestamp accuracy (1.0-4.0s MAE). This is likely related to + differences in the transformers-based decoding pipeline rather than model quality. ### VAC (Voice Activity Classification) Impact diff --git a/README.md b/README.md index d434a25..d35d569 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ See **[BENCHMARK.md](BENCHMARK.md)** for detailed performance comparisons across WhisperLiveKit supports [Voxtral Mini](https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602), a 4B-parameter speech model from Mistral AI that natively handles 100+ languages with automatic -language detection. Unlike whisper-based backends, Voxtral does not require specifying `--language`. +language detection. Whisper also supports auto-detection (`--language auto`), but Voxtral's per-chunk +detection is more reliable and does not bias towards English. ```bash # Apple Silicon (native MLX, recommended) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 365e168..4412b32 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -145,3 +145,39 @@ class TestComputeTimestampAccuracy: result = compute_timestamp_accuracy(pred, ref) assert result["n_matched"] == 1 assert result["mae_start"] == pytest.approx(0.1) + + def test_median_even_count(self): + """Median with even number of matched words should average the two middle values.""" + ref = [ + {"word": "a", "start": 0.0, "end": 0.2}, + {"word": "b", "start": 0.5, "end": 0.7}, + {"word": "c", "start": 1.0, "end": 1.2}, + {"word": "d", "start": 1.5, "end": 1.7}, + ] + pred = [ + {"word": "a", "start": 0.1, "end": 0.3}, # delta 0.1 + {"word": "b", "start": 0.7, "end": 0.9}, # delta 0.2 + {"word": "c", "start": 1.3, "end": 1.5}, # delta 0.3 + {"word": "d", "start": 1.9, "end": 2.1}, # delta 0.4 + ] + result = compute_timestamp_accuracy(pred, ref) + assert result["n_matched"] == 4 + # sorted abs deltas: [0.1, 0.2, 0.3, 0.4] -> median = (0.2 + 0.3) / 2 = 0.25 + assert result["median_delta_start"] == pytest.approx(0.25) + + def test_median_odd_count(self): + """Median with odd number of matched words takes the middle value.""" + ref = [ + {"word": "a", "start": 0.0, "end": 0.2}, + {"word": "b", "start": 0.5, "end": 0.7}, + {"word": "c", "start": 1.0, "end": 1.2}, + ] + pred = [ + {"word": "a", "start": 0.1, "end": 0.3}, # delta 0.1 + {"word": "b", "start": 0.8, "end": 1.0}, # delta 0.3 + {"word": "c", "start": 1.2, "end": 1.4}, # delta 0.2 + ] + result = compute_timestamp_accuracy(pred, ref) + assert result["n_matched"] == 3 + # sorted abs deltas: [0.1, 0.2, 0.3] -> median = 0.2 + assert result["median_delta_start"] == pytest.approx(0.2) diff --git a/whisperlivekit/metrics.py b/whisperlivekit/metrics.py index 09e9c12..8bbd9af 100644 --- a/whisperlivekit/metrics.py +++ b/whisperlivekit/metrics.py @@ -140,11 +140,16 @@ def compute_timestamp_accuracy( abs_deltas = [abs(d) for d in deltas_start] sorted_abs = sorted(abs_deltas) + n = len(sorted_abs) + if n % 2 == 1: + median = sorted_abs[n // 2] + else: + median = (sorted_abs[n // 2 - 1] + sorted_abs[n // 2]) / 2 return { "mae_start": sum(abs_deltas) / len(abs_deltas), "max_delta_start": max(abs_deltas), - "median_delta_start": sorted_abs[len(sorted_abs) // 2], + "median_delta_start": median, "n_matched": len(deltas_start), "n_ref": len(reference), "n_pred": len(predicted), diff --git a/whisperlivekit/metrics_collector.py b/whisperlivekit/metrics_collector.py index 365f07a..03db5dc 100644 --- a/whisperlivekit/metrics_collector.py +++ b/whisperlivekit/metrics_collector.py @@ -78,7 +78,7 @@ class SessionMetrics: def log_summary(self) -> None: """Emit a structured log line summarising the session.""" - elapsed = time.time() - self.session_start if self.session_start else 0 - self.total_processing_time_s = elapsed + self.total_processing_time_s = sum(self.transcription_durations) d = self.to_dict() + d["session_elapsed_s"] = round(time.time() - self.session_start, 3) if self.session_start else 0 logger.info(f"SESSION_METRICS {d}") From c76b2ef2c64503b04f857237f6f8ea0f8839c713 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Mon, 23 Feb 2026 10:16:34 +0100 Subject: [PATCH 08/10] docs: rewrite benchmark with base/small comparison, proper French results - Re-ran all whisper benchmarks with --lan fr for the French file (previously ran with --lan en which made the results meaningless) - Added small model results alongside base for all backends - Added model size comparison table (base vs small tradeoffs) - Added benchmark chart (30s English, WER + RTF by backend) - Added caveats section about dataset size and RTF variance - Key findings: SimulStreaming saturates at 5.3% WER on base already, small model mainly helps LocalAgreement and French timestamps - mlx-whisper LA base is unstable on French (hallucination loops) --- BENCHMARK.md | 182 +++++++++++++++++++++++++------------------- benchmark_chart.png | Bin 0 -> 70338 bytes 2 files changed, 104 insertions(+), 78 deletions(-) create mode 100644 benchmark_chart.png diff --git a/BENCHMARK.md b/BENCHMARK.md index df1293f..3fcb30a 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -1,7 +1,7 @@ # WhisperLiveKit Benchmark Report -Benchmark comparing all supported ASR backends and streaming policies on Apple Silicon, -using the full AudioProcessor pipeline (the same path audio takes in production via WebSocket). +Benchmark comparing all supported ASR backends, streaming policies, and model sizes on Apple Silicon. +All tests run through the full AudioProcessor pipeline (same code path as production WebSocket). ## Test Environment @@ -12,9 +12,8 @@ using the full AudioProcessor pipeline (the same path audio takes in production | Python | 3.13 | | faster-whisper | 1.2.1 | | mlx-whisper | installed (via mlx) | -| Voxtral (HF) | transformers-based | | Voxtral MLX | native MLX backend | -| Model size | `base` (default for whisper backends) | +| Voxtral (HF) | transformers-based | | VAC (Silero VAD) | enabled unless noted | | Chunk size | 100 ms | | Pacing | no-realtime (as fast as possible) | @@ -25,50 +24,80 @@ using the full AudioProcessor pipeline (the same path audio takes in production |------|----------|----------|----------|-------------| | `00_00_07_english_1_speaker.wav` | 7.2 s | English | 1 | Short dictation with pauses | | `00_00_16_french_1_speaker.wav` | 16.3 s | French | 1 | French speech with intentional silence gaps | -| `00_00_30_english_3_speakers.wav` | 30.0 s | English | 3 | Multi-speaker conversation about transcription | +| `00_00_30_english_3_speakers.wav` | 30.0 s | English | 3 | Multi-speaker conversation | -All files have hand-verified ground truth transcripts (`.transcript.json`) with per-word timestamps. +Ground truth transcripts (`.transcript.json`) with per-word timestamps are hand-verified. --- -## Results Overview +## Results -### English - Short (7.2 s, 1 speaker) +### English -- Short (7.2 s, 1 speaker) -| Backend | Policy | RTF | WER | Timestamp MAE | -|---------|--------|-----|-----|---------------| -| faster-whisper | LocalAgreement | 0.20x | 21.1% | 0.080 s | -| faster-whisper | SimulStreaming | 0.14x | 0.0% | 0.239 s | -| mlx-whisper | LocalAgreement | 0.05x | 21.1% | 0.080 s | -| mlx-whisper | SimulStreaming | 0.14x | 10.5% | 0.245 s | -| voxtral-mlx | voxtral | 0.32x | 0.0% | 0.254 s | -| voxtral (HF) | voxtral | 1.29x | 0.0% | 1.876 s | +| Backend | Policy | Model | RTF | WER | Timestamp MAE | +|---------|--------|-------|-----|-----|---------------| +| faster-whisper | LocalAgreement | base | 0.20x | 21.1% | 0.080 s | +| faster-whisper | SimulStreaming | base | 0.14x | 0.0% | 0.239 s | +| faster-whisper | LocalAgreement | small | 0.59x | 21.1% | 0.089 s | +| faster-whisper | SimulStreaming | small | 0.39x | 0.0% | 0.221 s | +| mlx-whisper | LocalAgreement | base | 0.05x | 21.1% | 0.080 s | +| mlx-whisper | SimulStreaming | base | 0.14x | 10.5% | 0.245 s | +| mlx-whisper | LocalAgreement | small | 0.16x | 21.1% | 0.089 s | +| mlx-whisper | SimulStreaming | small | 0.20x | 10.5% | 0.226 s | +| voxtral-mlx | voxtral | 4B | 0.32x | 0.0% | 0.254 s | +| voxtral (HF) | voxtral | 4B | 1.29x | 0.0% | 1.876 s | -### French (16.3 s, 1 speaker) +### English -- Multi-speaker (30.0 s, 3 speakers) -| Backend | Policy | RTF | WER | Timestamp MAE | -|---------|--------|-----|-----|---------------| -| faster-whisper | LocalAgreement | 0.20x | 120.0% | 0.540 s | -| faster-whisper | SimulStreaming | 0.10x | 100.0% | 0.120 s | -| mlx-whisper | LocalAgreement | 0.31x | 1737.1% | 0.060 s | -| mlx-whisper | SimulStreaming | 0.08x | 94.3% | 0.120 s | -| voxtral-mlx | voxtral | 0.18x | 37.1% | 3.422 s | -| voxtral (HF) | voxtral | 0.63x | 28.6% | 4.040 s | +| Backend | Policy | Model | RTF | WER | Timestamp MAE | +|---------|--------|-------|-----|-----|---------------| +| faster-whisper | LocalAgreement | base | 0.24x | 44.7% | 0.235 s | +| faster-whisper | SimulStreaming | base | 0.10x | 5.3% | 0.398 s | +| faster-whisper | LocalAgreement | small | 0.59x | 25.0% | 0.226 s | +| faster-whisper | SimulStreaming | small | 0.26x | 5.3% | 0.387 s | +| mlx-whisper | LocalAgreement | base | 0.06x | 23.7% | 0.237 s | +| mlx-whisper | SimulStreaming | base | 0.11x | 5.3% | 0.395 s | +| mlx-whisper | LocalAgreement | small | 0.13x | 25.0% | 0.226 s | +| mlx-whisper | SimulStreaming | small | 0.20x | 5.3% | 0.394 s | +| voxtral-mlx | voxtral | 4B | 0.31x | 9.2% | 0.176 s | +| voxtral (HF) | voxtral | 4B | 1.00x | 32.9% | 1.034 s | -Note: The whisper-based backends were run with `--lan en`, so they attempted to transcribe French -audio in English. This is expected to produce high WER. For a fair comparison, the whisper backends -should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect language. +

+Benchmark comparison on 30s English +

-### English - Multi-speaker (30.0 s, 3 speakers) +### French (16.3 s, 1 speaker, `--language fr`) -| Backend | Policy | RTF | WER | Timestamp MAE | -|---------|--------|-----|-----|---------------| -| faster-whisper | LocalAgreement | 0.24x | 44.7% | 0.235 s | -| faster-whisper | SimulStreaming | 0.10x | 5.3% | 0.398 s | -| mlx-whisper | LocalAgreement | 0.06x | 23.7% | 0.237 s | -| mlx-whisper | SimulStreaming | 0.11x | 5.3% | 0.395 s | -| voxtral-mlx | voxtral | 0.31x | 9.2% | 0.176 s | -| voxtral (HF) | voxtral | 1.00x | 32.9% | 1.034 s | +| Backend | Policy | Model | RTF | WER | Timestamp MAE | +|---------|--------|-------|-----|-----|---------------| +| faster-whisper | LocalAgreement | base | 0.22x | 25.7% | 3.460 s | +| faster-whisper | SimulStreaming | base | 0.10x | 31.4% | 3.660 s | +| faster-whisper | LocalAgreement | small | 0.76x | 42.9% | 0.051 s | +| faster-whisper | SimulStreaming | small | 0.29x | 25.7% | 0.219 s | +| mlx-whisper | LocalAgreement | base | 0.09x | ~45%* | ~5.0 s* | +| mlx-whisper | SimulStreaming | base | 0.09x | 40.0% | 3.540 s | +| mlx-whisper | LocalAgreement | small | 0.14x | 25.7% | 0.083 s | +| mlx-whisper | SimulStreaming | small | 0.17x | 31.4% | 0.203 s | +| voxtral-mlx | voxtral | 4B | 0.18x | 37.1% | 3.422 s | +| voxtral (HF) | voxtral | 4B | 0.63x | 28.6% | 4.040 s | + +\* mlx-whisper + LocalAgreement + base is unstable on this French file (WER fluctuates 34-1037% across runs due to hallucination loops). The `small` model does not have this problem. + +**Timestamp note:** The base model produces very high timestamp MAE (3.4-3.7s) on this French file because it misaligns words around the silence gaps. The small model handles this much better (0.05-0.22s MAE). Voxtral also drifts on the silence gaps. + +--- + +## Model Size Comparison (base vs small) + +| | base | small | Observation | +|--|------|-------|-------------| +| **RTF** | 0.05-0.24x | 0.13-0.76x | small is 2-3x slower | +| **English WER (SS)** | 0-5.3% | 0-5.3% | No improvement: SimulStreaming already saturates on base | +| **English WER (LA)** | 21-44.7% | 21-25% | small reduces LA errors on longer audio | +| **French WER** | 25-40% | 25-43% | Mixed: depends on backend/policy combo | +| **French timestamps** | 3.4-5.0s MAE | 0.05-0.22s MAE | small is dramatically better for French timestamps | + +In short: **base + SimulStreaming** gives the best speed/accuracy tradeoff for English. The small model only helps if you need LocalAgreement (for subtitle-grade timestamps) or non-English languages. --- @@ -76,37 +105,25 @@ should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect ### Speed (RTF = processing time / audio duration, lower is better) -1. **mlx-whisper + LocalAgreement** is the fastest combo on Apple Silicon, reaching 0.05-0.06x RTF - on English audio. 30 seconds of audio processed in under 2 seconds. -2. For **faster-whisper**, SimulStreaming is consistently faster than LocalAgreement. - For **mlx-whisper**, it is the opposite: LocalAgreement (0.05-0.06x) is faster than SimulStreaming (0.11-0.14x). -3. **voxtral-mlx** runs at 0.18-0.32x RTF, roughly 3-5x slower than mlx-whisper but well within - real-time requirements. -4. **voxtral (HF transformers)** is the slowest at 1.0-1.3x RTF. On longer audio it risks - falling behind real-time. On Apple Silicon, the MLX variant is strongly preferred. +1. **mlx-whisper + LocalAgreement + base** is the fastest combo on Apple Silicon: 0.05-0.06x RTF on English. 30 seconds of audio in under 2 seconds. +2. For **faster-whisper**, SimulStreaming is faster than LocalAgreement. For **mlx-whisper**, it is the opposite: LocalAgreement (0.05-0.06x) outperforms SimulStreaming (0.11-0.14x) on speed. +3. **voxtral-mlx** runs at 0.18-0.32x RTF -- 3-5x slower than mlx-whisper base, but well within real-time. +4. **voxtral (HF transformers)** hits 1.0-1.3x RTF. At the real-time boundary on Apple Silicon. Use the MLX variant instead. +5. The **small** model is 2-3x slower than base across all backends. ### Accuracy (WER = Word Error Rate, lower is better) -1. **SimulStreaming** produces significantly better WER than LocalAgreement for whisper backends. - On the 30s English file: 5.3% vs 23.7-44.7%. -2. **voxtral-mlx** has good accuracy (0% on short English, 9.2% on multi-speaker). - Whisper also supports `--language auto`, but Voxtral's language detection is more - reliable and does not bias towards English the way Whisper's auto mode tends to. -3. **LocalAgreement** tends to duplicate the last sentence, inflating WER. This is a known - artifact of the LCP (Longest Common Prefix) commit strategy at end-of-stream. -4. **Voxtral** backends handle French natively with 28-37% WER, while whisper backends - were run with `--lan en` here (not a fair comparison for French). +1. **SimulStreaming** gives dramatically lower WER than LocalAgreement on the whisper backends. On the 30s English file: 5.3% vs 23-44%. +2. **voxtral-mlx** hits 0% on short English and 9.2% on multi-speaker. It auto-detects language natively. Whisper also supports `--language auto`, but tends to bias towards English on short segments. +3. **LocalAgreement** tends to repeat the last sentence at end-of-stream (a known LCP artifact), inflating WER. This is visible in the 21% WER on the 7s file -- the same 4 extra words appear in every LA run. +4. On **French** with the correct `--language fr`, whisper base achieves 25-40% WER -- comparable to Voxtral's 28-37%. The small model does not consistently improve French WER. -### Timestamp Accuracy (MAE = Mean Absolute Error on word start times, lower is better) +### Timestamps (MAE = Mean Absolute Error on word start times) -1. **LocalAgreement** produces the most accurate timestamps (0.08s MAE on English), since it - processes overlapping audio windows and validates via prefix matching. -2. **SimulStreaming** timestamps are slightly less precise (0.24-0.40s MAE) but still usable - for most applications. -3. **voxtral-mlx** has good timestamp accuracy on English (0.18-0.25s MAE) but drifts on - audio with long silence gaps (3.4s MAE on the French file with 4-second pauses). -4. **voxtral (HF)** has the worst timestamp accuracy (1.0-4.0s MAE). This is likely related to - differences in the transformers-based decoding pipeline rather than model quality. +1. **LocalAgreement** gives the best timestamps on English (0.08-0.09s MAE). +2. **SimulStreaming** is less precise (0.22-0.40s MAE) but good enough for most applications. +3. On French with silence gaps, **base model timestamps are unreliable** (3.4-5s MAE). The **small model fixes this** (0.05-0.22s MAE). This is the strongest argument for using `small` over `base`. +4. **voxtral-mlx** has good timestamps on English (0.18-0.25s MAE) but drifts on audio with long silence gaps (3.4s MAE on the French file). ### VAC (Voice Activity Classification) Impact @@ -117,23 +134,29 @@ should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect | voxtral-mlx | voxtral | on | 0.0% | 9.2% | | voxtral-mlx | voxtral | off | 0.0% | 9.2% | -- **Whisper backends require VAC** to function in streaming mode. Without it, the entire audio - is buffered as a single chunk and the LocalAgreement/SimulStreaming buffer logic breaks down. -- **Voxtral backends are VAC-independent** because they handle their own internal chunking and - produce identical results with or without VAC. VAC still reduces wasted compute on silence. +- **Whisper backends need VAC** to work in streaming mode. Without it the buffer logic breaks down and you get empty or garbage output. +- **Voxtral is unaffected by VAC** since it handles its own internal chunking. Identical results with or without. VAC still saves compute on silent segments. --- ## Recommendations -| Use Case | Recommended Backend | Policy | Notes | -|----------|-------------------|--------|-------| -| Fastest English transcription (Apple Silicon) | mlx-whisper | SimulStreaming | 0.08-0.14x RTF, 5-10% WER | -| Fastest English transcription (Linux/GPU) | faster-whisper | SimulStreaming | 0.10-0.14x RTF, 0-5% WER | -| Multilingual / auto-detect (Apple Silicon) | voxtral-mlx | voxtral | Handles 100+ languages, 0.18-0.32x RTF | -| Multilingual / auto-detect (Linux/GPU) | voxtral (HF) | voxtral | Same model, slower on CPU, needs GPU | -| Best timestamp accuracy | faster-whisper | LocalAgreement | 0.08s MAE, good for subtitle alignment | -| Low latency, low memory | mlx-whisper (tiny) | SimulStreaming | Smallest footprint, fastest response | +| Use Case | Backend | Policy | Model | Notes | +|----------|---------|--------|-------|-------| +| Fastest English (Apple Silicon) | mlx-whisper | SimulStreaming | base | 0.11x RTF, 5.3% WER | +| Fastest English (Linux/GPU) | faster-whisper | SimulStreaming | base | 0.10x RTF, 5.3% WER | +| Best accuracy, English | faster-whisper | SimulStreaming | small | 0.26x RTF, 5.3% WER, still fast | +| Multilingual / auto-detect | voxtral-mlx | voxtral | 4B | 100+ languages, 0.18-0.32x RTF | +| Best timestamps | any | LocalAgreement | small | 0.05-0.09s MAE, good for subtitles | +| Low memory / embedded | mlx-whisper | SimulStreaming | base | Smallest footprint, fastest response | + +--- + +## Caveats + +- **3 test files, ~53 seconds total.** Results give relative rankings between backends but should not be taken as definitive WER numbers. Run on your own data for production decisions. +- **RTF varies between runs** (up to +/-30%) depending on thermal state, background processes, and model caching. The numbers above are single sequential runs on a warm machine. +- **Only base and small tested.** Medium and large-v3 would likely improve WER at the cost of higher RTF. We did not test them here because they are slow on Apple Silicon without GPU. --- @@ -144,15 +167,18 @@ should be run with `--lan fr` or `--lan auto`. The Voxtral backends auto-detect pip install -e ".[test]" # Single backend test -python test_backend_offline.py --backend faster-whisper --policy simulstreaming --no-realtime +python test_backend_offline.py --backend faster-whisper --policy simulstreaming --model base --no-realtime + +# With a specific language +python test_backend_offline.py --backend mlx-whisper --policy simulstreaming --model small --lan fr --no-realtime # Multi-backend auto-detect benchmark python test_backend_offline.py --benchmark --no-realtime -# Export to JSON for programmatic analysis +# Export to JSON python test_backend_offline.py --benchmark --no-realtime --json results.json -# Test with custom audio +# Test with your own audio python test_backend_offline.py --backend voxtral-mlx --audio your_file.wav --no-realtime ``` diff --git a/benchmark_chart.png b/benchmark_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..20123bd15001786bf4b184d9a9d44bfe4ff1f8ce GIT binary patch literal 70338 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 wB_2Lv?%X)}yZf1HTxn1ONa4 literal 0 HcmV?d00001 From 10c4e5f7303624526f3a93c98a46d8537a9d01c6 Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Mon, 23 Feb 2026 10:27:53 +0100 Subject: [PATCH 09/10] docs: add speed vs accuracy scatter plot to benchmark and README WER vs RTF scatter plot showing all backend/policy/model combos on the 30s English file. Sweet spot zone highlights the best tradeoffs. Added to both BENCHMARK.md and README.md. --- BENCHMARK.md | 4 ++++ README.md | 7 ++++++- benchmark_scatter.png | Bin 0 -> 97315 bytes 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 benchmark_scatter.png diff --git a/BENCHMARK.md b/BENCHMARK.md index 3fcb30a..7dad3b4 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -66,6 +66,10 @@ Ground truth transcripts (`.transcript.json`) with per-word timestamps are hand- Benchmark comparison on 30s English

+

+Speed vs Accuracy tradeoff +

+ ### French (16.3 s, 1 speaker, `--language fr`) | Backend | Policy | Model | RTF | WER | Timestamp MAE | diff --git a/README.md b/README.md index d35d569..9853eba 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,12 @@ Go to `chrome-extension` for instructions. | *[Not recommanded]* Speaker diarization with Diart | `diart` | See **Parameters & Configuration** below on how to use them. -See **[BENCHMARK.md](BENCHMARK.md)** for detailed performance comparisons across all backends. + +

+Speed vs Accuracy tradeoff +

+ +See **[BENCHMARK.md](BENCHMARK.md)** for the full benchmark with tables, model size comparison, and more. diff --git a/benchmark_scatter.png b/benchmark_scatter.png new file mode 100644 index 0000000000000000000000000000000000000000..9f62bf385c516b547d382a4e97c984233af3b165 GIT binary patch literal 97315 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

0YqNNF!in4cRi=Ob!2;WC}wvHC*Bo4 z{qU3}>%#h1V7KzkUEkwVy=CQOoioe)!Vi$#{FNXAMClC;xeB(7n~CW%C=+6l+jo;* zz*<5VV&WgXqntFCE*J6I9_hdD4ei6fHJ^YFDH?+m(^90#rnBG{iguz! zSl}!dg@8M6Fj(%aR*Qkq43<4c)d7+WDa7wcMagJJrIxG47GFM{?bR0TGUU1Io-$Ku zi&HFf>odv3mRTTXbIuBbyKr)q@dfoKW_7`_$4>6Hk>>PDoG?<{J{5 z=i@T(LT>Cj2EHO1QPN`%Sy})D9kFPz+EE~aeOxRx>&XY8CP@IFc@G0{#-c$sv5!4; zK-Y8I{?z5Pt52rqp!QIAfqRwB%3gy(S0>w3c|#1j+`$Cw$V7+NxkXrnuKP$um2`D6 zBUgR*TU|u8uISXxgAZ;3!rKBZCl7bUE-*{)#k?ixf$A9-t1`8a2p~k;i*4?g%zr#( z(3@|tu+neU1U2lrk}5S~1e<>BC^lPD17jTama1%Pp=rj0nmfVydu*b?z}*NjMd%kV zsCwx*W1}efOx`4`s?MH&2_IB5P)sLREo;CTmw;{-#P!^#zyJwVOOZosU}-*J6l9ij zpJb-nZJH#H^1x`J%HSlYMO8HaJDaJYs;UIyS77v!1~$~y^>L*YKr7`p>CMkDqBcuKmn@~6KhI^zV}ogE?2;RK{}I8>-yv*pqL*FkoH zpK}e)q5<4je9;hYf0=qZ5Tz_nug;O`Mu48Z^M=tB@_4(6_F5^Lg!k;Z@$ob_>Ld9t zvU7O5&@OsVmVd55#&9Ap?NwVDq$g-ZW|N;It??59h0TcG`fI2SN)KOWFuASLR}zcl z6FBN0W7${PWVQTWVw+<6xA>ilUb@c4msf{w=Bu4Cnc_Wscwmu+*1{tViUWPk!Vc5M zxly^4)*JC(L$bNr%G+9{IZWm6gGHk(k6gMHZ<-d~8j#38==7fb<79X4+)E;K66o08 zYubit+WM^H)_k374MhS@R;x2AL!u(sS)8P=9hs$Owfbd+*CCr70|K>r%DQV~+P?Sd z#mTBSYO!LhIl~elDjMWAX3SW8DMdAisqMYQn*}g{XDzJu78r|Dk7-9o!#FX2UZ=s3C?qn&nn6V z4v*`aC^k~Xe2_80O1A%S^wnhDI`Go2+?$%nh=3$ekq@>m?lKmnvpMq(w9d7am_#193n9=ab(3Kp$2()Bp>7SXu7l(a{US0Dr4&Wm z3GsxxUZds~1#_ABt=>JyVtS$3D$?;9wNMHEbBT86aBC;NF|F^#9WDODq7_J{L||W=kv(je!^EF`wNXK9lw!Hq+l_Yd zfa+{X335Fbr1u(9ey3NruDUR2AW;$M$M<5P-w8b8`v664JpgfPcwC&8 zaV+%Ubv$;~jmi+W@2V3be5Sos_Oeg;-S9YgC}@8RvWn~k9mzhaR`++irBgXg-7cTv zU|}g->BN|*v>-mWymbZ%G0WyE`*GV)MCZ@VEh;+Ku5(lE#~cXQua$cyBi==YOB3_P z`Ptr)BHLF%CoLsHQK*5`E~}j4e6^z1yC{$Yz@NJRF(|JIs$LJ4=7#jZ5-~vXU4Xw1 z@o4S<(4ieRVMu8Ff&kiI3aQ9JD`x`m5h;Q(1HEj zF}fb>24*qOE$Wf}YKuBc-bO8~ZpLT;N&0z?1s~eh|3nLze z!e#lI^uVW@89tKbJDOicEN`lX3bUfsqnzT(>P1_^$|_=u3kT(~c6~`~O+uyK z+e_add*LkZcr#u+8eBl>2Q=YuIDtW(cuW2BWRn4B*_8GR12hiyQ8L&o6pR|xzCz9X4Nyl-o^Dp@g&R8U{1r(8{UY6KC z_E*q&xNQ($sOJWyHGEM-9pV=G&*rgDKyzJs)TzFGvyg}snUW-@wiUW&YZXO5eR^_} zrV~=}y;0OZW0jH_lD-GfOqkzcL!Z@W6l5`?n zN*J?N)jfWR{s_4xRLVH_Zk|L;A1gNuLm~COMXr~3b{fBR^|lft)n VdsMw0AI=C$?uw#xj?^8W{{hA{J*xl! literal 0 HcmV?d00001 From b1fc23807a91d9d322e21bd865dbcda4fedeefcd Mon Sep 17 00:00:00 2001 From: Quentin Fuxa Date: Mon, 23 Feb 2026 10:37:22 +0100 Subject: [PATCH 10/10] docs: add benchmark collaboration call, voxtral in powered-by section --- BENCHMARK.md | 15 +++++++++++++++ README.md | 2 ++ 2 files changed, 17 insertions(+) diff --git a/BENCHMARK.md b/BENCHMARK.md index 7dad3b4..81239f0 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -188,3 +188,18 @@ python test_backend_offline.py --backend voxtral-mlx --audio your_file.wav --no- The benchmark harness computes WER and timestamp accuracy automatically when ground truth `.transcript.json` files exist alongside the audio files. See `audio_tests/` for the format. + +--- + +## Help Us Benchmark on More Hardware + +These results are from a single Apple M4 machine. We'd love to see numbers from other setups: Linux with CUDA GPUs, older Macs, different CPU architectures, cloud instances, etc. + +If you run the benchmark on your hardware, please open an issue or PR with your results and we will add them here. The more data points we have, the better the recommendations get. + +What we are especially interested in: +- **NVIDIA GPUs** (RTX 3090, 4090, A100, T4, etc.) with faster-whisper +- **Older Apple Silicon** (M1, M2, M3) with mlx-whisper and voxtral-mlx +- **Medium and large-v3 models** (we only tested base and small so far) +- **Longer audio files** or domain-specific audio (medical, legal, call center) +- **Other languages** beyond English and French diff --git a/README.md b/README.md index 9853eba..f97ba06 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - [WhisperStreaming](https://github.com/ufal/whisper_streaming) (SOTA 2023) - Low latency transcription using [LocalAgreement policy](https://www.isca-archive.org/interspeech_2020/liu20s_interspeech.pdf) - [Streaming Sortformer](https://arxiv.org/abs/2507.18446) (SOTA 2025) - Advanced real-time speaker diarization - [Diart](https://github.com/juanmc2005/diart) (SOTA 2021) - Real-time speaker diarization +- [Voxtral Mini](https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602) (2025) - 4B-parameter multilingual speech model by Mistral AI - [Silero VAD](https://github.com/snakers4/silero-vad) (2024) - Enterprise-grade Voice Activity Detection @@ -88,6 +89,7 @@ See **Parameters & Configuration** below on how to use them.

See **[BENCHMARK.md](BENCHMARK.md)** for the full benchmark with tables, model size comparison, and more. +We are actively looking for benchmark results on other hardware (NVIDIA GPUs, different Apple Silicon chips, cloud instances). If you run the benchmarks on your machine, please share your results via an issue or PR!