feat(speechify): add Speechify TTS plugin#1968
Conversation
🦋 Changeset detectedLatest commit: cb85f94 The changes in this PR will be included in the next version bump. This PR includes changesets to release 37 packages
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 |
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).
6869087 to
26fede2
Compare
… 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.
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.
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.
|
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 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.
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.
| this.#timeoutInSeconds = | ||
| connOptions?.timeoutMs !== undefined ? connOptions.timeoutMs / 1000 : undefined; |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
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/apiSDK.There is currently no Speechify plugin in agents-js; this adds one following the existing plugin conventions (mirrors the
cartesiapackage layout).Details
Speechify's word-level speech marks are only returned by the batch
/audio/speechendpoint, soSynthesizeStreamchunks streamed input into per-sentence sequential/audio/speechcalls, emitting audio and aligned word timestamps as each sentence completes — near-streaming time-to-first-audio plusalignedTranscript.capabilities: { streaming: true, alignedTranscript: true }SynthesizeStream(sentence-chunked, cumulative word-mark offsets) +ChunkedStream(one-shot)@speechify/apivendor SDKturbo.jsonenv allowlist (SPEECHIFY_API_KEY)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) succeedspnpm lint(eslint/prettier/turbo) cleantsc --noEmitcleanHappy to adjust to match maintainer preferences.