Skip to content

feat(tts): add FallbackAdapter for TTS failover support#1022

Merged
toubatbrian merged 31 commits intolivekit:mainfrom
gokuljs:fallBackAdapter-for-tts
Feb 17, 2026
Merged

feat(tts): add FallbackAdapter for TTS failover support#1022
toubatbrian merged 31 commits intolivekit:mainfrom
gokuljs:fallBackAdapter-for-tts

Conversation

@gokuljs
Copy link
Contributor

@gokuljs gokuljs commented Feb 4, 2026

This adds a FallbackAdapter for TTS that lets you configure multiple TTS providers and automatically switches to the next one if the current one fails. It handles both connection errors and silent failures where the TTS connects but doesn't return any audio. Failed providers are automatically tested in the background and restored when they come back online. It also normalizes sample rates across different providers so you can mix and match TTS services without worrying about audio format differences.

Summary by CodeRabbit

  • New Features

    • Adds a TTS fallback adapter that orchestrates multiple TTS providers with unified streaming and chunked synthesis, per-provider resampling when needed, availability-change events, and public controls to get streaming instances, synthesize, stream, and close adapters.
  • Bug Fixes

    • More robust error handling, improved shutdown/cleanup, and per-provider health tracking with configurable recovery retries and delays to improve streaming resilience.

@changeset-bot
Copy link

changeset-bot bot commented Feb 4, 2026

🦋 Changeset detected

Latest commit: 2b95856

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

This PR includes changesets to release 19 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-xai 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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Adds an exported FallbackAdapter TTS that orchestrates multiple TTS providers with per-provider availability tracking and recovery, unified sample-rate selection and optional per-provider resampling, and public APIs: synthesize, stream, getStreamingInstance, close, plus an AvailabilityChangedEvent type. (48 words)

Changes

Cohort / File(s) Summary
FallbackAdapter Implementation
agents/src/tts/fallback_adapter.ts
Adds exported FallbackAdapter, FallbackAdapterOptions, and AvailabilityChangedEvent. Implements prioritized multi-provider fallback for synthesize and stream, per-TTS availability state and recovery scheduling (maxRetryPerTTS, recoveryDelayMs), streaming adapters for non-streaming providers, optional per-TTS resampling, buffering/fallback logic, aggregated error handling, and close() cleanup.
TTS Module Exports
agents/src/tts/index.ts
Re-exports FallbackAdapter and type AvailabilityChangedEvent from ./fallback_adapter.js, exposing the new adapter in the public TTS surface.

Sequence Diagram

sequenceDiagram
    participant Client
    participant FallbackAdapter
    participant TTS1 as TTS_Instance_1
    participant TTS2 as TTS_Instance_2
    participant Resampler
    participant OutputStream

    Client->>FallbackAdapter: synthesize(text) / stream(options)
    activate FallbackAdapter

    FallbackAdapter->>TTS1: request synthesis / start stream
    activate TTS1
    alt TTS1 returns audio
        TTS1-->>FallbackAdapter: audio chunks
    else TTS1 errors/unavailable
        TTS1-->>FallbackAdapter: error
        FallbackAdapter->>FallbackAdapter: mark TTS1 unavailable & schedule recovery
        FallbackAdapter->>TTS2: attempt synthesis / start stream (fallback)
        activate TTS2
        TTS2-->>FallbackAdapter: audio chunks or error
        deactivate TTS2
    end
    deactivate TTS1

    alt resampling required
        FallbackAdapter->>Resampler: resample chunks to target sample rate/channels
        Resampler-->>FallbackAdapter: resampled chunks
    end

    FallbackAdapter->>OutputStream: write audio chunks
    OutputStream-->>Client: deliver audio
    deactivate FallbackAdapter
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hop through voices when one falls low,
I stitch the streams where missed notes go.
I try, retry, and mend each tune,
A tiny rabbit chasing moon. 🎧🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description explains what the FallbackAdapter does but does not follow the required template structure with sections like 'Description', 'Changes Made', 'Pre-Review Checklist', and 'Testing'. Update the description to follow the repository's template, including sections for Description, Changes Made, Pre-Review Checklist completion, Testing details, and any Additional Notes.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(tts): add FallbackAdapter for TTS failover support' is clear and directly describes the main addition—a FallbackAdapter class that enables TTS provider failover.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

🧹 Recent nitpick comments
agents/src/tts/fallback_adapter.ts (1)

134-136: Expose status as readonly to prevent external mutation.
This avoids callers accidentally flipping availability or tasks.

♻️ Proposed tweak
-  get status(): TTSStatus[] {
-    return this._status;
-  }
+  get status(): ReadonlyArray<Readonly<TTSStatus>> {
+    return this._status;
+  }
📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ac02687 and dd82074.

📒 Files selected for processing (1)
  • agents/src/tts/fallback_adapter.ts
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.cursor/rules/agent-core.mdc)

Add SPDX-FileCopyrightText and SPDX-License-Identifier headers to all newly added files with '// SPDX-FileCopyrightText: 2025 LiveKit, Inc.' and '// SPDX-License-Identifier: Apache-2.0'

Files:

  • agents/src/tts/fallback_adapter.ts
**/*.{ts,tsx}?(test|example|spec)

📄 CodeRabbit inference engine (.cursor/rules/agent-core.mdc)

When testing inference LLM, always use full model names from agents/src/inference/models.ts (e.g., 'openai/gpt-4o-mini' instead of 'gpt-4o-mini')

Files:

  • agents/src/tts/fallback_adapter.ts
**/*.{ts,tsx}?(test|example)

📄 CodeRabbit inference engine (.cursor/rules/agent-core.mdc)

Initialize logger before using any LLM functionality with initializeLogger({ pretty: true }) from '@livekit/agents'

Files:

  • agents/src/tts/fallback_adapter.ts
🧠 Learnings (1)
📚 Learning: 2026-01-16T14:33:39.551Z
Learnt from: CR
Repo: livekit/agents-js PR: 0
File: .cursor/rules/agent-core.mdc:0-0
Timestamp: 2026-01-16T14:33:39.551Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Add SPDX-FileCopyrightText and SPDX-License-Identifier headers to all newly added files with '// SPDX-FileCopyrightText: 2025 LiveKit, Inc.' and '// SPDX-License-Identifier: Apache-2.0'

Applied to files:

  • agents/src/tts/fallback_adapter.ts
🔇 Additional comments (9)
agents/src/tts/fallback_adapter.ts (9)

93-118: Solid initialization & capability aggregation.
Validations and derived sample rate/capabilities look consistent.


120-129: Event forwarding is clean and centralized.


138-145: Streaming adapter selection looks right.


172-215: Recovery task honors abort and avoids retry on shutdown.


218-228: Availability transitions & recovery kickoff are consistent.


233-256: Factory methods cleanly wrap fallback streams.


262-286: Close() covers timeouts, tasks, listeners, and instances.


307-387: Chunked fallback + silent-failure detection look solid.


153-161: AudioResampler constructor usage is correct.
The argument order matches the @livekit/rtc-node API: inputRate (tts.sampleRate), outputRate (this.sampleRate), and channels (tts.numChannels).

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as resolved.

@gokuljs
Copy link
Contributor Author

gokuljs commented Feb 4, 2026

#868

coderabbitai[bot]

This comment was marked as resolved.

@gokuljs
Copy link
Contributor Author

gokuljs commented Feb 4, 2026

@toubatbrian This is ready for review . Please let me know anything needs to be changed.

coderabbitai[bot]

This comment was marked as resolved.

@toubatbrian toubatbrian self-requested a review February 4, 2026 20:14
@toubatbrian
Copy link
Contributor

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac026870e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 331 to 334
// Use cached resampler for this TTS instance
const resampler = status.resampler;
if (resampler) {
for (const frame of resampler.push(audio.frame)) {

Choose a reason for hiding this comment

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

P2 Badge Create a resampler per stream to avoid cross-talk

The cached resampler is stored per TTS instance and then reused by every FallbackChunkedStream/FallbackSynthesizeStream call. AudioResampler is stateful (it buffers samples until flush()), so if two syntheses run concurrently on the same TTS instance, their frames will be interleaved in the shared resampler and the flush() from one stream can drain buffered audio from the other. This leads to corrupted or missing audio when the adapter is used for parallel syntheses (a common pattern with multiple speakers/requests). Consider instantiating a new resampler per stream or per call instead of sharing the instance from status.

Useful? React with 👍 / 👎.

…e createResamplerForTTS method for better management of audio resampling
@gokuljs
Copy link
Contributor Author

gokuljs commented Feb 4, 2026

@toubatbrian added a fix for that edge case

@gokuljs
Copy link
Contributor Author

gokuljs commented Feb 9, 2026

@toubatbrian and update in this pr?

const processOutputPromise = processOutput();
let outputError: unknown = null;
try {
await processOutputPromise;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we wait for both promise here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@toubatbrian Actually, we should not wait for both to finish, because that would add delay when switching to the new TTS. We would be waiting for the entire text to be generated. I think we should handle it independently and start receiving text from the LLM as it arrives.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will fix it

…esizeStream for improved stream management and error handling
Copy link
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 8 additional findings in Devin Review.

Open in Devin Review

return tts;
}
// Wrap non-streaming TTS with StreamAdapter
return new StreamAdapter(tts, new basic.SentenceTokenizer());
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 New StreamAdapter with leaked event listeners created on every fallback attempt for non-streaming TTS

getStreamingInstance() at line 144 creates a new StreamAdapter wrapping the underlying TTS instance every time it is called for a non-streaming TTS. Each StreamAdapter constructor adds metrics_collected and error event listeners to the underlying TTS instance (agents/src/tts/stream_adapter.ts:24-29) that are never removed.

Root Cause

Each call to getStreamingInstance(i) for a non-streaming TTS (line 144) does:

return new StreamAdapter(tts, new basic.SentenceTokenizer());

The StreamAdapter constructor at agents/src/tts/stream_adapter.ts:24-29 adds listeners:

this.#tts.on('metrics_collected', (metrics) => {
  this.emit('metrics_collected', metrics);
});
this.#tts.on('error', (error) => {
  this.emit('error', error);
});

These listeners are never removed because the StreamAdapter instance is ephemeral and not tracked. Over many fallback attempts (e.g., repeated failures and retries across multiple stream() calls), listeners accumulate on the underlying TTS instance, eventually triggering Node.js's MaxListenersExceededWarning and causing a memory leak.

Impact: Memory leak and potential MaxListenersExceededWarning in long-running applications with non-streaming TTS providers that experience repeated failures.

Prompt for agents
Cache the StreamAdapter instances per TTS index instead of creating new ones each time. In the FallbackAdapter class, add a private field like `private _streamAdapters: Map<number, StreamAdapter> = new Map();` and modify `getStreamingInstance` to check the cache first:

getStreamingInstance(index: number): TTS {
  const tts = this.ttsInstances[index]!;
  if (tts.capabilities.streaming) {
    return tts;
  }
  let adapter = this._streamAdapters.get(index);
  if (!adapter) {
    adapter = new StreamAdapter(tts, new basic.SentenceTokenizer());
    this._streamAdapters.set(index, adapter);
  }
  return adapter;
}

Also make sure to close the cached StreamAdapters in the close() method.
Open in Devin Review

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

…tream to prevent unhandled promise rejections
Copy link
Contributor

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

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 13 additional findings in Devin Review.

Open in Devin Review

Comment on lines +279 to +282
for (const tts of this.ttsInstances) {
tts.removeAllListeners('metrics_collected');
tts.removeAllListeners('error');
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 close() uses removeAllListeners which strips listeners added by other code on shared TTS instances

The close() method calls tts.removeAllListeners('metrics_collected') and tts.removeAllListeners('error') on each underlying TTS instance. This removes all listeners for those events, not just the ones registered by the FallbackAdapter.

Detailed Explanation

At lines 279-282, the close() method does:

for (const tts of this.ttsInstances) {
  tts.removeAllListeners('metrics_collected');
  tts.removeAllListeners('error');
}

If the TTS instances are shared with other parts of the application (e.g., used directly elsewhere, or wrapped by another adapter), their listeners for metrics_collected and error will be silently removed. Additionally, the StreamAdapter instances created by getStreamingInstance (agents/src/tts/fallback_adapter.ts:144) also register listeners on the original TTS instances — these are correctly cleaned up, but any listeners registered by the TTS provider itself or other consumers are also removed.

Impact: Other code that registered metrics_collected or error listeners on the TTS instances will stop receiving events after the FallbackAdapter is closed.

Fix: Store references to the specific listener functions added in setupEventForwarding and use tts.off(event, listener) to remove only those specific listeners.

Prompt for agents
In the FallbackAdapter class, store references to the specific listener functions created in setupEventForwarding(), then in close() use tts.off() to remove only those specific listeners instead of removeAllListeners.

For example, add a private field:
private _eventListeners: Map<TTS, { metrics: Function; error: Function }> = new Map();

In setupEventForwarding(), store the listeners:
const metricsListener = (metrics) => this.emit('metrics_collected', metrics);
const errorListener = (error) => this.emit('error', error);
tts.on('metrics_collected', metricsListener);
tts.on('error', errorListener);
this._eventListeners.set(tts, { metrics: metricsListener, error: errorListener });

In close(), remove only the specific listeners:
for (const [tts, listeners] of this._eventListeners) {
  tts.off('metrics_collected', listeners.metrics);
  tts.off('error', listeners.error);
}
this._eventListeners.clear();
Open in Devin Review

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

…ucing streamOutputCompleted flag to manage TTS stream completion
@gokuljs gokuljs requested a review from toubatbrian February 15, 2026 03:38
…of FallbackSynthesizeStream to improve robustness and prevent unhandled errors
… FallbackSynthesizeStream for improved maintainability
devin-ai-integration[bot]

This comment was marked as resolved.

… audio verification to prevent silent failures
@gokuljs
Copy link
Contributor Author

gokuljs commented Feb 17, 2026

@toubatbrian, any update here

@toubatbrian toubatbrian merged commit f29d308 into livekit:main Feb 17, 2026
4 checks passed
@gokuljs
Copy link
Contributor Author

gokuljs commented Feb 17, 2026

@toubatbrian Thanks man

@github-actions github-actions bot mentioned this pull request Feb 17, 2026
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

Comments