Skip to content

perf(tts): pipeline sync per-sentence synthesis with playback - #74301

Closed
MahdiHedhli wants to merge 1 commit into
NousResearch:mainfrom
MahdiHedhli:feat/sync-tts-sentence-pipeline
Closed

MahdiHedhli wants to merge 1 commit into
NousResearch:mainfrom
MahdiHedhli:feat/sync-tts-sentence-pipeline

Conversation

@MahdiHedhli

Copy link
Copy Markdown
Contributor

What does this PR do?

Removes the per-sentence dead air from voice-mode / wake-word replies for every provider without a chunked streaming API.

stream_tts_to_speaker's universal sync fallback ran strictly serially per sentence — synthesize, play, and only then start synthesizing the next sentence — so every sentence boundary added a full synthesis-time of silence. The chunked streamers (elevenlabs/openai/gemini/xai) already avoid this; edge, piper, and plugin providers all paid it on every reply. For local model providers the cost dominates the conversation: at real-time-factor ≈ 1, the reply is silent between sentences for about as long as it speaks.

This replaces the serial _speak_via_sync with _SyncSentencePipeline: one single-threaded synthesis worker (sentences stay FIFO — providers never see concurrent calls from this loop, same effective concurrency as before) feeding one playback worker through a small bounded queue. While sentence n plays, sentence n+1 is already synthesizing.

Behavior preserved deliberately:

  • Ordering — single synth worker + single playback worker, both FIFO.
  • stop_event — short-circuits both stages; queued items are skipped, not played.
  • Failure isolation — one sentence's synthesis failure doesn't kill the reply.
  • Temp-file hygiene — always unlinked, including on stop/failure.
  • tts_done_event semantics — the finally block flushes the pipeline before the event fires, so continuous voice mode never reopens the mic over its own voice.
  • Testabilitysynthesize/play are resolved late (module global / import in worker), so the existing monkeypatch-based tests pass unchanged.

Related Issue

No open issue found — searched open+merged PRs and issues for sentence/pipeline/TTS-latency phrasings per CONTRIBUTING's search-first rule; closest prior art is the chunked-streamer work this composes with (streamer path untouched).

Type of Change

  • ⚡ Performance and robustness (category 4 in the contribution priorities)

Changes Made

  • tools/tts_tool.py — add module-level _SyncSentencePipeline; construct it in stream_tts_to_speaker when no chunked streamer resolves; route _speak_sentence's sync branch through it; retire the serial _speak_via_sync; flush the pipeline in finally before tts_done_event.set().
  • tests/tools/test_tts_streaming.py — 4 new tests: timestamp-proven overlap (synth of sentence 2 starts before playback of sentence 1 ends), order preservation + per-sentence failure isolation, stop-event skipping queued playback, temp-file cleanup.

How to Test

  1. pytest tests/tools/test_tts_streaming.py -q — 34 pass here (the pre-existing test_streamer_path_writes_pcm_to_output failure on macOS is unrelated: the Darwin TCC guard forces output_stream = None, so that test can only pass on Linux CI).
  2. Neighboring suites (test_tts_streaming_e2e.py, test_voice_mode.py, test_tts_speed.py, test_tts_plugin_dispatch.py): 193 passed / 8 failed — the identical 8 (WSL2/PowerShell) fail on unpatched main on macOS; zero regressions.
  3. Real-world timing, real synthesis (OmniVoice plugin provider on Apple Silicon — a local model at RTF ≈ 1.2), same 3-sentence reply, playback simulated at the produced clips' exact durations, serial vs pipelined interleaved best-of-2 under identical load:
serial (main) pipelined (this PR)
time to first word 10.8 s 4.4 s
mid-reply silence 11.2 s 1.8 s (second gap 0.03 s)
full-reply wall 33.2 s 17.0 s

Fast cloud providers benefit less in absolute terms but the boundary stall (network round-trip per sentence) still disappears behind playback.

Checklist

  • Tests pass locally (see platform caveats above)
  • I've tested on my platform: macOS 15 (Apple Silicon), Python 3.11
  • Documentation — N/A (internal path; behavior contract of stream_tts_to_speaker unchanged)
  • cli-config.yaml.example — N/A (no new config keys)
  • Cross-platform: pure stdlib threading (ThreadPoolExecutor, queue, threading) — no platform-specific code; Windows/macOS/Linux identical
  • Tool descriptions/schemas — N/A

@alt-glitch alt-glitch added type/perf Performance improvement or optimization P3 Low — cosmetic, nice to have tool/tts Text-to-speech and transcription labels Jul 29, 2026
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for the focused performance improvement. The premise is confirmed on current main: tools/tts_tool.py:3437-3440 selects the sync fallback, and _speak_via_sync at tools/tts_tool.py:3479-3485 completes synthesis and playback serially. PR commit 77d20942d3b3913464b2f86e8e9ab3aa1254e690 replaces that branch with a bounded single-synthesis-worker/single-playback-worker FIFO pipeline and flushes it before the done event.

No verified correctness or design-fit problems found in the reviewed diff. The production file has not changed since the PR base; only older tests were pruned from tests/tools/test_tts_streaming.py, so salvage should be largely mechanical.

This is an automated hermes-sweeper review.

The universal sync fallback in stream_tts_to_speaker ran strictly serially
per sentence — synthesize, play, and only then start synthesizing the next
sentence — so every sentence boundary added a full synthesis-time of dead
air. Chunked streamers (elevenlabs/openai/gemini/xai) already avoid this;
every other provider (edge, piper, plugin providers) paid it on each reply
in voice mode and the wake-word loop.

_SyncSentencePipeline overlaps the two: one single-threaded synthesis
worker (sentences stay FIFO; providers never see concurrent calls from
this loop — same effective concurrency as before) feeds one playback
worker through a small bounded queue, so sentence n+1 synthesizes while
sentence n plays. Lookahead is bounded (backpressure + at most a couple of
temp files), stop_event short-circuits both stages, synthesis failures are
isolated per sentence, temp files are always unlinked, and the finally
block flushes the pipeline BEFORE tts_done_event fires so continuous voice
mode never reopens the mic over its own voice. synthesize/play are
resolved late so existing monkeypatch-based tests work unchanged.

Measured with a real local model provider (OmniVoice plugin, Apple
Silicon), same 3-sentence reply, playback simulated at the produced clips'
true durations, best-of-2 interleaved runs under identical load:

                     serial   pipelined
  time to first word  10.8s        4.4s
  mid-reply dead air  11.2s        1.8s   (second gap: 0.03s)
  full reply wall     33.2s       17.0s

Tests: 4 new (timestamp-proven overlap, order + per-sentence failure
isolation, stop skips queued playback, temp-file hygiene); the existing
sync-fallback and display-callback tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MahdiHedhli
MahdiHedhli force-pushed the feat/sync-tts-sentence-pipeline branch from 77d2094 to 178c0d6 Compare July 30, 2026 17:49
@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026
@kshitijk4poor

Copy link
Copy Markdown
Contributor

Thanks @MahdiHedhli — the measurement was right and the gap is real: main's prefetch rework covers only chunked streamers, so edge/piper/plugin providers still paid full per-sentence dead air on the serial sync path. Salvaged into #77355 with your authorship preserved via cherry-pick; conflict resolution kept all of main's prefetch machinery intact, and the ordering/backpressure/shutdown semantics of your pipeline passed a dedicated audit (single-worker FIFO + bounded queue + drain-then-shutdown close, running before tts_done_event.set). 245 tts tests green. Closing in favor of the salvage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform tool/tts Text-to-speech and transcription type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants