Skip to content

feat(speechify): add Speechify TTS plugin#1968

Open
luke-speechify wants to merge 13 commits into
livekit:mainfrom
luke-speechify:lo/add-speechify-plugin
Open

feat(speechify): add Speechify TTS plugin#1968
luke-speechify wants to merge 13 commits into
livekit:mainfrom
luke-speechify:lo/add-speechify-plugin

Conversation

@luke-speechify

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

Copy link
Copy Markdown

Summary

Adds @livekit/agents-plugin-speechify — a Speechify TTS plugin for Agents JS with streaming audio and word-level timestamps, built on Speechify's official @speechify/api SDK.

There is currently no Speechify plugin in agents-js; this adds one following the existing plugin conventions (mirrors the cartesia package layout).

Details

Speechify's word-level speech marks are only returned by the batch /audio/speech endpoint, so SynthesizeStream chunks streamed input into per-sentence sequential /audio/speech calls, emitting audio and aligned word timestamps as each sentence completes — near-streaming time-to-first-audio plus alignedTranscript.

  • capabilities: { streaming: true, alignedTranscript: true }
  • SynthesizeStream (sentence-chunked, cumulative word-mark offsets) + ChunkedStream (one-shot)
  • Uses the official @speechify/api vendor SDK
  • Added to the README plugin table and turbo.json env allowlist (SPEECHIFY_API_KEY)
  • Changeset included

Maintenance & distribution

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

Verification

  • pnpm build (ESM + CJS + type declarations) succeeds
  • pnpm lint (eslint/prettier/turbo) clean
  • tsc --noEmit clean
  • Live synthesis verified against the Speechify API; streaming produces valid 24 kHz PCM frames with monotonic word timestamps

Happy to adjust to match maintainer preferences.

@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cb85f94

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 37 packages
Name Type
@livekit/agents-plugin-speechify Patch
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch
@livekit/agents-plugins-test Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@CLAassistant

CLAassistant commented Jul 6, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

@luke-speechify
luke-speechify marked this pull request as draft July 6, 2026 12:56
Add @livekit/agents-plugin-speechify: streaming text-to-speech with
word-level timestamps via the official @speechify/api SDK. Chunks
streamed input into sequential /audio/speech calls, emitting audio and
timed transcripts per sentence (streaming + alignedTranscript). Maintained
by Speechify (Speechify-AI/livekit-plugin-js).
@luke-speechify
luke-speechify force-pushed the lo/add-speechify-plugin branch from 6869087 to 26fede2 Compare July 6, 2026 13:24
@luke-speechify
luke-speechify marked this pull request as ready for review July 13, 2026 20:27
@luke-speechify
luke-speechify requested a review from a team as a code owner July 13, 2026 20:27
devin-ai-integration[bot]

This comment was marked as resolved.

… drop model cast)

The SDK v3.0.0 renamed the auth constructor option from apiKey to token, and
v3.0.1's GetSpeechRequest.Model union now includes simba-3.0 and simba-3.2
natively so the plugin no longer needs to widen it via a cast.

- plugins/speechify/package.json: @speechify/api ^2.0.0 -> ^3.0.1
- plugins/speechify/src/tts.ts:
  - rename public TTSOptions.apiKey -> token; pass through to
    new SpeechifyClient({ token, baseUrl })
  - update JSDoc + local variable + Omit-union for updateOptions
  - drop the `opts.model as Speechify.GetSpeechRequest.Model` cast + comment;
    v3.0.1's Model union already covers simba-english / simba-multilingual /
    simba-3.0 / simba-3.2
- pnpm-lock.yaml: refreshed
Addresses Devin review feedback on livekit#1968.

The base class SynthesizeStream.monitorMetrics gates all TTS metric
emission on this.#startedHrTime being set, which only happens via
markStarted(). Without it, TTFB, duration, and character-count metrics
were silently dropped for all Speechify streaming synthesis, and
USERDATA_TTS_STARTED_TIME never got stamped on frames so downstream
transcription timing was degraded.

Matches the elevenlabs / cartesia / resemble / deepgram plugin pattern.
devin-ai-integration[bot]

This comment was marked as resolved.

Addresses Devin review findings on livekit#1968 that both
Chunked and Synthesize streams close `this.queue` in their run()
catch blocks before re-throwing. The base class already closes the
queue in its own `finally` after run() returns, and it retries run()
on retryable APIErrors - so closing on error made the next retry's
`queue.put(...)` throw 'Queue is closed', turning transient network
errors into permanent failures.

Matches the guard the minimax plugin documents on its own catch.
devin-ai-integration[bot]

This comment was marked as resolved.

Addresses Devin review finding on livekit#1968: calling
`sentenceStream.close()` immediately after `endInput()` closes the
tokenizer's output queue before its internal async loop has a chance
to flush the last buffered sentence, so the final `queue.put(token)`
throws 'Queue is closed' and consume() never sees the tail. Result:
the last sentence of a TTS response could be silently dropped.

Matches the neuphonic plugin pattern: only call endInput(), let the
tokenizer close its own output queue when its input drains.
@luke-speechify

Copy link
Copy Markdown
Author

hey @tinalenguyen! on this and livekit/agents#6327 was there anything else I needed to do? I noticed we had a new release go out with updates to a lot of vendors, but neither PR was included despite the changeset saying it would be

- align auth arg with sibling plugins (was `token`, now `apiKey`)
- drop `client?: SpeechifyClient` option and `get client()` getter
- move the SDK call into an `@internal` `_synthesize` on TTS that returns
  `{ audio, timed }` in LiveKit-owned types
- revert `package.json` author to LiveKit
- drop "maintained by Speechify" paragraph from the plugin README

verified `dist/tts.d.ts` no longer references any `Speechify*` SDK types.
devin-ai-integration[bot]

This comment was marked as resolved.

Devin re-review (livekit#1968):

- TTS.synthesize and ChunkedStream now accept + forward `abortSignal` to
  super() so external cancels reach the one-shot request path (matches
  the abstract signature in agents/src/tts/tts.ts and the cartesia plugin)
- SynthesizeStream now emits `final: true` exactly once, on the last
  frame of the whole reply (previously per-sentence, which dropped
  sentences 2..N from the base-class metrics because flush() only
  records one pending metric text per reply). Matches elevenlabs/cartesia.
devin-ai-integration[bot]

This comment was marked as resolved.

The buffer-one deferral in the previous commit meant `cumulativeDuration`
lagged one frame behind actual produced audio, so every sentence after the
first got a slightly early offset for its word marks (Devin re-review).

Track the total emitted duration independently: advance `offsetSeconds` by
the entire sentence's frame duration right after synthesis, before the
deferral loop. The deferral still governs when `final: true` fires, but
timestamps no longer share state with 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 1 new potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +190 to +191
this.#timeoutInSeconds =
connOptions?.timeoutMs !== undefined ? connOptions.timeoutMs / 1000 : undefined;

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.

🟡 One-shot synthesis requests can hang without honoring the configured timeout

The per-request timeout for one-shot synthesis is derived only from an explicitly passed value (connOptions?.timeoutMs at plugins/speechify/src/tts.ts:190-191) instead of the resolved default, so when synthesis is invoked without connection options the request runs with no timeout at all.
Impact: A stalled one-shot text-to-speech request can hang indefinitely instead of failing after the intended 10-second limit.

How the default timeout is dropped in the chunked path

The base ChunkedStream constructor defaults connOptions to DEFAULT_API_CONNECT_OPTIONS (agents/src/tts/tts.ts:610, timeoutMs=10000 per agents/src/types.ts:38-42) for its retry logic, but the private _connOptions isn't exposed to the plugin. The plugin instead computes #timeoutInSeconds from the raw constructor argument (plugins/speechify/src/tts.ts:190-191), which is undefined when tts.synthesize(text) is called with no connOptions. That undefined is then passed as timeoutInSeconds to the Speechify SDK call (plugins/speechify/src/tts.ts:199-202), so no timeout is applied. By contrast SynthesizeStream uses this.connOptions.timeoutMs / 1000 (plugins/speechify/src/tts.ts:279), which always reflects the default, making the two paths inconsistent.

Prompt for agents
In plugins/speechify/src/tts.ts, the ChunkedStream computes #timeoutInSeconds from the optional connOptions constructor argument, which is undefined when tts.synthesize(text) is called without connection options. This means the Speechify request gets no timeout in that case, unlike SynthesizeStream which uses the resolved this.connOptions.timeoutMs. Make the ChunkedStream fall back to the framework default timeout (DEFAULT_API_CONNECT_OPTIONS.timeoutMs, 10000ms) when connOptions is not provided, so the configured/default timeout is honored consistently across both paths.
Open in Devin Review

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

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