From c0bf011ed30f9b02402c79578638a179ce1c2204 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Fri, 10 Jul 2026 06:56:24 +0100 Subject: [PATCH] fix: smoother streaming start via one persistent output stream --- stackvox/engine.py | 56 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_engine.py | 35 +++++++++++++++++++++------ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/stackvox/engine.py b/stackvox/engine.py index b736970..9d07683 100644 --- a/stackvox/engine.py +++ b/stackvox/engine.py @@ -22,6 +22,12 @@ DEFAULT_SPEED = 1.0 DEFAULT_LANG = "en-us" +# Streaming playback smoothing: play a touch of silence before the first word so +# output-device warm-up (notably a Bluetooth codec/profile switch) lands during +# silence, and ramp the opening samples up from zero to avoid an onset click. +_PRIME_SILENCE_SECONDS = 0.12 +_FADE_IN_SECONDS = 0.008 + _MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx" _VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin" @@ -82,6 +88,16 @@ def _split_sentences(text: str) -> list[str]: return [part.strip() for part in _SENTENCE_BOUNDARY.split(text.strip()) if part.strip()] +def _fade_in(samples: np.ndarray, sample_rate: int) -> np.ndarray: + """Ramp the opening samples up from zero so playback doesn't begin with a click.""" + ramp_len = min(len(samples), int(sample_rate * _FADE_IN_SECONDS)) + if ramp_len <= 0: + return samples + faded = np.array(samples, dtype="float32", copy=True) + faded[:ramp_len] *= np.linspace(0.0, 1.0, ramp_len, dtype="float32") + return faded + + class Stackvox: """Reusable TTS engine. Load the model once, speak many times. @@ -104,6 +120,8 @@ def __init__( self._kokoro = Kokoro(str(model_path), str(voices_path)) self._stop_event = threading.Event() self._play_thread: threading.Thread | None = None + self._stream: sd.OutputStream | None = None + self._stream_lock = threading.Lock() def synthesize( self, @@ -210,6 +228,7 @@ def _produce() -> None: producer = threading.Thread(target=_produce, daemon=True, name="stackvox-synth") producer.start() error: Exception | None = None + stream: sd.OutputStream | None = None try: while not stop_event.is_set(): item = chunks.get() @@ -219,9 +238,36 @@ def _produce() -> None: error = item break samples, sample_rate = item - sd.play(samples, sample_rate) - sd.wait() + samples = np.ascontiguousarray(samples, dtype="float32") + if stream is None: + # One stream for the whole utterance: writing chunks into it is + # gapless and avoids the per-sentence open/close that crackles at + # the start. Prime with silence so device warm-up (e.g. a + # Bluetooth codec switch) lands before the first word, and fade + # the opening samples in to avoid an onset click. + stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32") + stream.start() + with self._stream_lock: + self._stream = stream + prime = int(sample_rate * _PRIME_SILENCE_SECONDS) + if prime > 0: + stream.write(np.zeros(prime, dtype="float32")) + samples = _fade_in(samples, sample_rate) + try: + stream.write(samples) + except Exception: + if stop_event.is_set(): + break # aborted by stop(); expected + raise finally: + with self._stream_lock: + self._stream = None + if stream is not None: + try: + stream.stop() + stream.close() + except Exception: + logger.debug("stream teardown failed", exc_info=True) # Stop the producer and keep draining so it can never stay parked on # a full queue — that's what guarantees the synth thread always exits. stop_event.set() @@ -236,6 +282,12 @@ def _produce() -> None: def stop(self) -> None: """Stop any in-progress playback started with blocking=False.""" self._stop_event.set() + with self._stream_lock: + if self._stream is not None: + try: + self._stream.abort() + except Exception: + logger.debug("stream abort failed", exc_info=True) sd.stop() def voices(self) -> list[str]: diff --git a/tests/test_engine.py b/tests/test_engine.py index 2d98929..0cfe097 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -35,7 +35,8 @@ def fake_kokoro(mocker, fake_ensure_models): @pytest.fixture def fake_audio(mocker): - """Mock the sounddevice surface used by Stackvox.""" + """Mock the sounddevice surface used by Stackvox (streaming + speak_sequence).""" + mocker.patch.object(engine.sd, "OutputStream") mocker.patch.object(engine.sd, "play") mocker.patch.object(engine.sd, "wait") mocker.patch.object(engine.sd, "stop") @@ -105,15 +106,35 @@ def test_blocking_synthesizes_and_plays_each_sentence(self, fake_kokoro, fake_au engine.Stackvox().speak("One sentence. Two sentence. Three here.") - # One synth + play cycle per sentence — that's what enables early audio. + # One synth per sentence (early audio), all streamed through a single + # output stream: a prime-silence write plus one write per sentence. assert fake_kokoro.return_value.create.call_count == 3 - assert engine.sd.play.call_count == 3 - assert engine.sd.wait.call_count == 3 + engine.sd.OutputStream.assert_called_once() + stream = engine.sd.OutputStream.return_value + assert stream.write.call_count == 4 # 1 prime + 3 sentences + stream.close.assert_called_once() def test_single_sentence_plays_once(self, fake_kokoro, fake_audio): fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000) engine.Stackvox().speak("Just one sentence with no terminal punctuation") - assert engine.sd.play.call_count == 1 + engine.sd.OutputStream.assert_called_once() + assert engine.sd.OutputStream.return_value.write.call_count == 2 # prime + 1 sentence + + def test_primes_with_silence_before_first_audio(self, fake_kokoro, fake_audio): + # The lead-in silence lets the output device (e.g. Bluetooth) warm up + # before the first word, rather than crackling into it. + fake_kokoro.return_value.create.return_value = (np.ones(10, dtype=np.float32), 24000) + engine.Stackvox().speak("hello") + prime = engine.sd.OutputStream.return_value.write.call_args_list[0].args[0] + assert not prime.any() + assert len(prime) == int(24000 * engine._PRIME_SILENCE_SECONDS) + + def test_first_audio_fades_in_from_zero(self, fake_kokoro, fake_audio): + fake_kokoro.return_value.create.return_value = (np.ones(1000, dtype=np.float32), 24000) + engine.Stackvox().speak("hello") + first_audio = engine.sd.OutputStream.return_value.write.call_args_list[1].args[0] + assert first_audio[0] == 0.0 # ramp starts at silence + assert first_audio[-1] == 1.0 # ramp completes, body untouched def test_non_blocking_plays_in_background_thread(self, fake_kokoro, fake_audio): fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000) @@ -123,7 +144,7 @@ def test_non_blocking_plays_in_background_thread(self, fake_kokoro, fake_audio): assert tts._play_thread is not None tts._play_thread.join(timeout=5) - engine.sd.play.assert_called() + engine.sd.OutputStream.assert_called() def test_passes_engine_defaults_to_synthesis(self, fake_kokoro, fake_audio): fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000) @@ -177,7 +198,7 @@ def test_speak_after_stop_still_plays(self, fake_kokoro, fake_audio): tts = engine.Stackvox() tts.stop() tts.speak("hello world") - engine.sd.play.assert_called() + engine.sd.OutputStream.assert_called() class TestSplitSentences: