Wyoming speech-to-text server for Home Assistant using NVIDIA Parakeet

Loads parakeet-mlx in-process (no HTTP hop) and serves it over the Wyoming
protocol. On an M4 Mac mini this transcribes typical voice commands in ~110ms
versus ~1150ms for a whisper.cpp large-v3 setup, with identical accuracy on a
ten-command benchmark.

Two behaviours matter beyond speed: silence returns an empty string rather
than whisper's "Thank you." hallucination, and there is no decoder context
carried between requests.

Notable implementation details, all covered by mutation-checked regression
tests:

- MLX streams are thread-local, so the model is loaded and evaluated on a
  single dedicated worker thread. Splitting those raises
  "There is no Stream(cpu, 1) in current thread".
- parakeet_mlx.load_audio() shells out to ffmpeg, which is unnecessary here
  since Wyoming delivers 16kHz mono PCM. The mel is built directly via
  get_logmel(), whose input must be float32 -- it views the complex STFT
  output as the input dtype, so anything narrower doubles the mel bin count.
- Wyoming's run loop has no except clause, so an exception escaping
  handle_event closes the connection without sending a Transcript and Home
  Assistant waits indefinitely. Failures are caught and returned as an empty
  transcript instead.

Defaults to parakeet-tdt-0.6b-v2 rather than the newer multilingual v3
because v2 emits digits ("21 degrees") where v3 spells numbers out, and
Home Assistant's local intent matching expects digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 03:04:48 +01:00
co-authored by Claude Opus 5
commit 4286e88344
18 changed files with 1168 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
"""Tests for ParakeetEngine.
The model itself is mocked throughout -- these cover the audio marshalling
and threading around it, which is where the real bugs were.
"""
import threading
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from wyoming_parakeet.engine import MIN_SAMPLES, SAMPLE_RATE, ParakeetEngine
def make_engine(text="turn off the kitchen lights"):
engine = ParakeetEngine("fake/model")
result = MagicMock()
result.text = text
engine.model = MagicMock()
engine.model.generate.return_value = [result]
return engine
def test_returns_model_text(pcm):
engine = make_engine("set a timer for 12 minutes")
with patch("wyoming_parakeet.engine.get_logmel"):
assert engine._transcribe(pcm(SAMPLE_RATE)) == "set a timer for 12 minutes"
def test_short_audio_short_circuits_without_touching_model(pcm):
"""A clipped VAD flush must not reach the model at all."""
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel") as get_logmel:
assert engine._transcribe(pcm(MIN_SAMPLES - 1)) == ""
engine.model.generate.assert_not_called()
get_logmel.assert_not_called()
def test_audio_at_threshold_is_processed(pcm):
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel"):
assert engine._transcribe(pcm(MIN_SAMPLES)) != ""
engine.model.generate.assert_called_once()
def test_empty_audio_returns_empty_string():
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel"):
assert engine._transcribe(b"") == ""
def test_no_results_returns_empty_string(pcm):
"""Silence legitimately decodes to nothing; don't IndexError on it."""
engine = make_engine()
engine.model.generate.return_value = []
with patch("wyoming_parakeet.engine.get_logmel"):
assert engine._transcribe(pcm(SAMPLE_RATE)) == ""
def test_audio_is_float32_not_bfloat16(pcm):
"""Regression: get_logmel views the complex STFT output as the input
dtype, so anything narrower than float32 silently doubles the mel bin
count and the downstream matmul fails."""
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel") as get_logmel:
engine._transcribe(pcm(SAMPLE_RATE))
samples = get_logmel.call_args[0][0]
assert np.asarray(samples).dtype == np.float32
def test_pcm_is_scaled_to_unit_range(pcm):
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel") as get_logmel:
engine._transcribe(pcm(SAMPLE_RATE, value=16384))
samples = np.asarray(get_logmel.call_args[0][0])
assert samples.shape == (SAMPLE_RATE,)
assert np.allclose(samples, 0.5)
def test_full_scale_pcm_stays_within_unit_range(pcm):
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel") as get_logmel:
engine._transcribe(pcm(SAMPLE_RATE, value=-32768))
samples = np.asarray(get_logmel.call_args[0][0])
assert np.abs(samples).max() <= 1.0
def test_preprocessor_config_is_passed_through(pcm):
engine = make_engine()
with patch("wyoming_parakeet.engine.get_logmel") as get_logmel:
engine._transcribe(pcm(SAMPLE_RATE))
assert get_logmel.call_args[0][1] is engine.model.preprocessor_config
@pytest.mark.asyncio
async def test_load_and_inference_share_one_thread():
"""Regression: MLX streams are thread-local, so a model loaded on one
thread cannot be evaluated from another -- mx.eval() raises
'There is no Stream(cpu, 1) in current thread'."""
import asyncio
engine = ParakeetEngine("fake/model")
threads = []
lock = threading.Lock()
def record():
with lock:
threads.append(threading.get_ident())
# Hold the worker. Sequential calls can coincidentally reuse a single
# thread out of a multi-worker pool, so overlap them -- a pool wider
# than one will hand these to different threads and fail the assert.
threading.Event().wait(0.05)
engine._load = record
engine._transcribe = lambda _pcm: (record(), "")[1]
await engine.start()
await asyncio.gather(*(engine.transcribe(b"") for _ in range(4)))
assert len(threads) == 5
assert len(set(threads)) == 1, "load and inference must share one thread"
assert threads[0] != threading.get_ident(), "must not run on the event loop"
@pytest.mark.asyncio
async def test_requests_are_serialised(pcm):
"""A single worker means overlapping satellites queue rather than
racing the ANE."""
import asyncio
engine = ParakeetEngine("fake/model")
concurrent = 0
peak = 0
def slow(_pcm):
nonlocal concurrent, peak
concurrent += 1
peak = max(peak, concurrent)
threading.Event().wait(0.05)
concurrent -= 1
return "ok"
engine._transcribe = slow
await asyncio.gather(*(engine.transcribe(pcm(SAMPLE_RATE)) for _ in range(4)))
assert peak == 1
def test_transcription_carries_no_state_between_calls(pcm):
"""Regression guard for the bug class that motivated leaving whisper.cpp:
its server reused decoder context across requests and would return the
previous utterance. Each call here must stand alone."""
engine = make_engine()
audio = pcm(SAMPLE_RATE)
with patch("wyoming_parakeet.engine.get_logmel") as get_logmel:
engine._transcribe(pcm(SAMPLE_RATE * 2, value=4096))
engine._transcribe(audio)
first = np.asarray(get_logmel.call_args[0][0])
engine._transcribe(pcm(SAMPLE_RATE // 2, value=-2048))
engine._transcribe(audio)
second = np.asarray(get_logmel.call_args[0][0])
assert np.array_equal(first, second)
# generate() must be handed only the current mel, with no prompt/context.
assert engine.model.generate.call_args[0][0] is get_logmel.return_value
assert engine.model.generate.call_args.kwargs == {}
@pytest.mark.asyncio
async def test_start_warms_the_model():
"""The first inference JITs Metal kernels. If warm-up is dropped, the
first voice command after every reboot pays that cost."""
engine = ParakeetEngine("fake/model")
model = MagicMock()
model.generate.return_value = []
with patch("parakeet_mlx.from_pretrained", return_value=model) as load:
with patch("wyoming_parakeet.engine.get_logmel"):
await engine.start()
load.assert_called_once_with("fake/model")
assert model.generate.call_count == 1, "start() should run one warm-up pass"
@pytest.mark.asyncio
async def test_engine_survives_a_failed_request(pcm):
"""One bad utterance must not poison the worker for later ones."""
engine = make_engine("recovered")
with patch("wyoming_parakeet.engine.get_logmel", side_effect=[RuntimeError("boom"), MagicMock()]):
with pytest.raises(RuntimeError):
await engine.transcribe(pcm(SAMPLE_RATE))
assert await engine.transcribe(pcm(SAMPLE_RATE)) == "recovered"