Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions stackvox/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.

Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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]:
Expand Down
35 changes: 28 additions & 7 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading