Skip to content

feat(speechify): streaming TTS with word-level timestamps via official SDK#6327

Draft
luke-speechify wants to merge 5 commits into
livekit:mainfrom
luke-speechify:lo/speechify-streaming-tts
Draft

feat(speechify): streaming TTS with word-level timestamps via official SDK#6327
luke-speechify wants to merge 5 commits into
livekit:mainfrom
luke-speechify:lo/speechify-streaming-tts

Conversation

@luke-speechify

@luke-speechify luke-speechify commented Jul 6, 2026

Copy link
Copy Markdown

Summary

Upgrades the livekit-plugins-speechify TTS plugin to stream audio with word-level timestamps, using Speechify's official speechify-api SDK.

Previously the plugin was non-streaming (streaming=False) and used raw aiohttp against /audio/stream (binary audio only, no timing metadata). Speechify's word-level speech marks are only returned by the batch /audio/speech endpoint, so this implementation chunks streamed input into per-sentence sequential /audio/speech calls, emitting audio and aligned word timestamps as each sentence completes — giving near-streaming time-to-first-audio and aligned_transcript support.

Changes

  • TTSCapabilities(streaming=True, aligned_transcript=True)
  • SynthesizeStream: sentence-chunked sequential synthesis with cumulative word-mark time offsets
  • ChunkedStream: one-shot synthesis with word marks
  • Uses the official speechify-api vendor SDK instead of hand-rolled HTTP
  • Provider-ownership metadata (following the livekit-plugins-gnani precedent): Repository/Issues point at Speechify's maintenance repo

Maintenance & distribution

This plugin is maintained by Speechify and distributed by LiveKit as part of livekit/agents. Speechify keeps a mirror of the plugin source at Speechify-AI/livekit-plugin-python for maintenance and issue triage, and proposes changes upstream here. The canonical, user-installable package remains livekit-plugins-speechify, published by LiveKit — there is no separate Speechify-published package.

Verification

  • ruff check + ruff format --check clean
  • mypy clean
  • Live synthesis verified against the Speechify API (synthesize + stream paths); audio transcribes back at WER 0.0 via the canonical tests/test_tts.py round-trip
  • Word-mark timestamps verified monotonic across sentence-chunk boundaries

Happy to adjust to match maintainer preferences.

@luke-speechify luke-speechify requested a review from a team as a code owner July 6, 2026 12:36
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment on lines +280 to +296
async def _synthesize() -> None:
offset = 0.0
async for ev in sent_stream:
if not (text := ev.token.strip()):
continue
response = await self._tts._client.audio.speech(
**_request_kwargs(text, self._opts),
request_options={"timeout_in_seconds": int(self._conn_options.timeout)},
)
audio = base64.b64decode(response.audio_data)
timed = _timed_transcript(response.speech_marks, offset)
if timed:
output_emitter.push_timed_transcript(timed)
output_emitter.push(audio)
offset += len(audio) / (2 * SAMPLE_RATE * NUM_CHANNELS)

output_emitter.end_segment()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Streaming synthesis never emits performance metrics because the timer is never started

The streaming synthesis path never records its start time (_mark_started() is never called in SynthesizeStream._run at livekit-plugins/livekit-plugins-speechify/livekit/plugins/speechify/tts.py:280), so the metrics-collection callback always exits early.

Impact: Operators get no time-to-first-byte or duration metrics for Speechify streaming TTS, making performance monitoring impossible.

Metrics guard in the base class silently discards all data

The base class SynthesizeStream._metrics_monitor_task (livekit-agents/livekit/agents/tts/tts.py:573-577) checks if not self._started_time before emitting any metrics. _started_time is initialized to 0 (livekit-agents/livekit/agents/tts/tts.py:439), and the only way to set it to a non-zero value is by calling self._mark_started() (livekit-agents/livekit/agents/tts/tts.py:561-564). Other TTS plugins (e.g., Cartesia) call self._mark_started() before sending data to the provider. The Speechify SynthesizeStream._run never calls it, so _started_time remains 0, not 0 evaluates to True, and _emit_metrics returns without emitting anything.

Suggested change
async def _synthesize() -> None:
offset = 0.0
async for ev in sent_stream:
if not (text := ev.token.strip()):
continue
response = await self._tts._client.audio.speech(
**_request_kwargs(text, self._opts),
request_options={"timeout_in_seconds": int(self._conn_options.timeout)},
)
audio = base64.b64decode(response.audio_data)
timed = _timed_transcript(response.speech_marks, offset)
if timed:
output_emitter.push_timed_transcript(timed)
output_emitter.push(audio)
offset += len(audio) / (2 * SAMPLE_RATE * NUM_CHANNELS)
output_emitter.end_segment()
async def _synthesize() -> None:
offset = 0.0
async for ev in sent_stream:
if not (text := ev.token.strip()):
continue
self._mark_started()
response = await self._tts._client.audio.speech(
**_request_kwargs(text, self._opts),
request_options={"timeout_in_seconds": int(self._conn_options.timeout)},
)
audio = base64.b64decode(response.audio_data)
timed = _timed_transcript(response.speech_marks, offset)
if timed:
output_emitter.push_timed_transcript(timed)
output_emitter.push(audio)
offset += len(audio) / (2 * SAMPLE_RATE * NUM_CHANNELS)
output_emitter.end_segment()
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 1745975. SynthesizeStream._run now calls self._mark_started() before the first synthesis request, matching the Cartesia plugin, so streaming TTS metrics are emitted.

Comment on lines +24 to 35
from .models import TTSModels
from .tts import DEFAULT_VOICE_ID, TTS, ChunkedStream, SynthesizeStream
from .version import __version__

__all__ = [
"TTS",
"Voice",
"TTSEncoding",
"ChunkedStream",
"SynthesizeStream",
"TTSModels",
"DEFAULT_VOICE_ID",
"__version__",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Breaking public API: Voice class and TTSEncoding removed from exports

The old __init__.py exported Voice and TTSEncoding in __all__. The new version removes both and replaces them with ChunkedStream and SynthesizeStream. Any downstream code doing from livekit.plugins.speechify import Voice or from livekit.plugins.speechify import TTSEncoding will break at import time. The Voice dataclass (which held voice metadata like id, display_name, gender, models, locale) and the list_voices() method on TTS are also removed entirely, so there's no migration path for users who relied on voice enumeration. This may be intentional given the SDK migration, but should be documented in release notes.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored list_voices() on the TTS class (now backed by the official SDK's voices.list()), and re-exported GetVoice from the package, so voice enumeration has a migration path. The old TTSEncoding is intentionally dropped — synthesis is now fixed to 24 kHz PCM (the SDK's stream accept-set no longer exposes container/rate selection); noted for release notes.

Comment on lines +213 to +223
def _raise_from(e: Exception) -> None:
if isinstance(e, ApiError):
raise APIStatusError(
message=str(e.body) if e.body is not None else "Speechify API error",
status_code=e.status_code or -1,
request_id=None,
body=None,
) from None
if isinstance(e, asyncio.TimeoutError):
raise APITimeoutError() from None
raise APIConnectionError() from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Error wrapping catches non-API exceptions and misclassifies them

The except Exception as e: _raise_from(e) pattern at lines 249-250 and 304-305 catches every exception type. If a non-API exception occurs (e.g., base64.binascii.Error from malformed audio_data, AttributeError from an unexpected response shape, or RuntimeError from the emitter), _raise_from falls through to its default branch and wraps it as APIConnectionError. This misclassifies the error — a decode failure isn't a connection problem — and the APIConnectionError has retryable=True by default (livekit-agents/livekit/agents/_exceptions.py:106), so the framework will retry a request that will deterministically fail again. Other plugins in the repo (e.g., Deepgram, Cartesia) use more targeted exception handling. Consider adding an early check like if isinstance(e, APIError): raise before the wrapping logic.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _raise_from now re-raises existing APIError subclasses unchanged (if isinstance(e, APIError): raise e) before the fallback, so decode/attribute errors are no longer misclassified as retryable APIConnectionError. Matches the targeted handling in the other plugins.

@luke-speechify luke-speechify marked this pull request as draft July 6, 2026 12:56
…l SDK

Upgrade the Speechify plugin to stream audio with aligned word-level
timestamps. Synthesis now uses the official speechify-api SDK and chunks
streamed input into sequential /audio/speech calls, emitting audio and
timed transcripts per sentence (streaming + aligned_transcript
capabilities). Route maintenance to the Speechify-owned repository.
@luke-speechify luke-speechify force-pushed the lo/speechify-streaming-tts branch from 1745975 to c008b99 Compare July 6, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants