Skip to content

feat(synthesia): add interactive avatar plugin - #2486

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
gyrated-blowers-nurture
Open

feat(synthesia): add interactive avatar plugin#2486
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
gyrated-blowers-nurture

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports livekit/agents#7216 to Node.js by adding @livekit/agents-plugin-synthesia.

  • starts hosted Synthesia interactive avatars and routes agent speech over data streams
  • supports up to five precomputed avatars and mid-session swapAvatar() calls
  • exposes the source error taxonomy, retry metadata, lifecycle cleanup, and disconnect logging
  • adds the audio-tail replacement and data-stream close infrastructure required for lifecycle parity

Source diff coverage

File-by-file classification for livekit/agents#7216
  • livekit-agents/pyproject.toml: Not applicable. agents-js consumers install independently published plugins; it has no Python-style package extras.
  • livekit-plugins/livekit-plugins-synthesia/README.md: Adapted to plugins/synthesia/README.md; npm installation and TypeScript usage replace pip/Python syntax.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/__init__.py: Adapted to plugins/synthesia/src/index.ts; exports and plugin registration use agents-js conventions.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/api.py: Ported to plugins/synthesia/src/api.ts; fetch and millisecond connection options replace aiohttp and Python seconds.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/avatar.py: Ported to plugins/synthesia/src/avatar.ts; rtc-node events/RPC, livekit-server-sdk tokens, and agents-js avatar/data-stream APIs replace Python equivalents. Required output-chain and close support is adapted in agents/src/voice/io.ts, agents/src/voice/transcription/synchronizer.ts, and agents/src/voice/avatar/datastream_io.ts.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/errors.py: Ported to plugins/synthesia/src/errors.ts; ErrorType and SynthesiaError extends APIError retain the source metadata and retryability behavior.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/log.py: Ported to plugins/synthesia/src/log.ts using the agents-js child logger.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/py.typed: Not applicable. The TypeScript package emits native declaration files.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/types.py: Ported to plugins/synthesia/src/types.ts; Python timeout seconds are adapted to standard JS milliseconds.
  • livekit-plugins/livekit-plugins-synthesia/livekit/plugins/synthesia/version.py: Adapted to plugins/synthesia/package.json and the build-time package version.
  • livekit-plugins/livekit-plugins-synthesia/pyproject.toml: Adapted to plugins/synthesia/package.json, tsconfig.json, tsup.config.ts, and api-extractor.json following neighboring avatar packages.
  • pyproject.toml: Not applicable. pnpm-workspace.yaml already includes plugins/* automatically.
  • tests/test_plugin_synthesia.py: Adapted to plugins/synthesia/src/api.test.ts, avatar.test.ts, errors.test.ts, and index.test.ts; registration/config, error taxonomy, HTTP mapping/retries, tokens, swaps, lifecycle, cleanup, and README usage remain covered. Required output-tail infrastructure cases are adapted in agents/src/voice/io.test.ts.
  • uv.lock: Adapted to the plugins/synthesia importer in pnpm-lock.yaml using dependency versions already locked by agents-js.

Validation

  • pnpm test agents: 2613 passed, 5 skipped
  • pnpm test plugins/synthesia: 89 passed
  • pnpm build: 41/41 packages passed
  • pnpm format:check: passed
  • pnpm --filter @livekit/agents-plugin-synthesia lint: passed
  • pnpm --filter @livekit/agents api:check: passed
  • pnpm --filter @livekit/agents-plugin-synthesia api:check: passed
  • pnpm lint: blocked by unrelated plugins/openai/src/ws/llm.ts:127 (@typescript-eslint/no-misused-promises); agents lint otherwise exits with existing warnings and Synthesia is clean
  • cue-cli: attempted against the configured LiveKit project, but the worker was rejected with HTTP 401 before registration, so framework-event runtime validation could not run

Source: livekit/agents#7216


Ported from livekit/agents#7216

Original PR description

Summary

Adds livekit-plugins-synthesia, a plugin that attaches a Synthesia interactive avatar to a LiveKit voice agent:

  • synthesia.AvatarSession extends livekit.agents.voice.avatar.AvatarSession. Call start() before AgentSession.start() to dispatch the hosted avatar worker into the room and wire the agent's speech to it over a data stream.
  • swap_avatar() switches between up to five precomputed avatars mid-session.
  • Errors surface as a single SynthesiaError (an APIError) with a type: ErrorType field identifying the failure (auth, unknown avatar, quota, rate limit, concurrency limit, timeout, connection, etc.), plus retryable, retry_after, status, and request_id.
  • Lifecycle events (session end, unexpected avatar drop) are logged rather than emitted, since no other avatar plugin exposes a custom event API.

What's included

  • livekit-plugins/livekit-plugins-synthesia/ — the plugin package
  • Registered as a uv workspace member (pyproject.toml) and as a livekit-agents[synthesia] extra (livekit-agents/pyproject.toml), with uv.lock regenerated accordingly
  • tests/test_plugin_synthesia.py — a single combined unit-test module (plugin registration, config validation, error taxonomy, the HTTP client, the avatar session lifecycle, and a README-mirroring usage example), tagged pytest.mark.unit and pytest.mark.plugin("synthesia") per the existing test-category convention

Test plan

  • ruff check / ruff format --check clean
  • mypy (uv run mypy -p livekit.plugins.synthesia) clean under the repo's strict config
  • pytest tests/test_plugin_synthesia.py — 165 passed
  • Confirmed via pytest --list-categories that the new test module is picked up under both unit and plugin categories, so it runs in the standard make unit-tests CI gate
  • uv sync --all-extras --dev succeeds with the new package in the workspace

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner September 12, 2026 00:25
@changeset-bot

changeset-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48c1513

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

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

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

@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 7 potential issues.

4 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +240 to +242
const audioOutput = this.audioOutput;
this.audioOutput = undefined;
await audioOutput?.aclose();

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.

🔴 Closed avatar tail breaks speech

When aclose() runs before the agent session ends, audioOutput remains installed after closing. Pending playout stalls, and later speech fails against the closed output.

Learn more

The Synthesia output becomes the leaf of agentSession.output.audio, either directly or beneath recorder and transcription wrappers. Closing the DataStreamAudioOutput does not remove that leaf or settle its counted playback segments. Its captureFrame rejects every later frame once closed, while waitForPlayout() can remain blocked because no further playback RPC reaches the deleted handler. This path runs during explicit avatar closure and automatic teardown after the avatar disconnects.

Example: An avatar disconnects while one reply is playing. Automatic teardown closes the data-stream output. The current reply remains in playout, and the next generated reply rejects its first frame instead of continuing without avatar audio.

Recommended fix: Retain enough ownership information to remove or restore the exact tail during teardown. Before closing it, detach it from AgentOutput, settle interrupted segments through every retained wrapper, and ensure subsequent captures no longer route to the closed sink.

Devin Review

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

Comment on lines +233 to +234
this.room.off(RoomEvent.ConnectionStateChanged, this.onRoomConnectionStateChanged);
this.startTask?.cancel();

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.

🔴 Pre-connect capture never cancels

Closing after a disconnected-room captureFrame() leaves startTask waiting forever. roomConnectedFuture ignores cancellation after its resolving listener is removed.

Learn more

A capture made before room connection creates startTask, and _start first awaits roomConnectedFuture. The abort signal is only passed to later participant and track waits. Closing removes the connection-state listener before aborting, so a disconnected room can no longer resolve that future. The pending capture retains the task, output, and room indefinitely.

Example: Construct the output with room.isConnected === false, call captureFrame(), then call aclose() before connection. aclose() returns, but the capture promise never resolves or rejects.

Recommended fix: Make the room-connection wait abortable and use cancelAndWait() during closure. Remove the room listener only after cancellation has released the wait, or explicitly reject the connection future on close.

Devin Review

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

Comment on lines +235 to +243
if (this.streamWriter) {
await this.streamWriter.close();
this.streamWriter = undefined;
}
if (
DataStreamAudioOutput._playbackFinishedHandlers[this.destinationIdentity] ===
this.playbackFinishedHandler
) {
delete DataStreamAudioOutput._playbackFinishedHandlers[this.destinationIdentity];

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.

🔴 Stream failure aborts avatar cleanup

When streamWriter.close() rejects, aclose() skips RPC-handler cleanup. The rejection also prevents closeImpl() from removing lifecycle listeners and the avatar participant.

Learn more

Stream closure can reject during a disconnect or transport failure. The handler deletions follow the awaited close without a finally, so they do not run after rejection. The caller closeImpl similarly awaits this method before unregistering its room listeners and calling the base avatar cleanup. One stream error therefore skips every later cleanup stage.

Example: The avatar's data connection drops while a byte stream is open. streamWriter.close() rejects. The Synthesia room listeners remain registered, and the base session never attempts to remove the avatar participant.

Recommended fix: Make each cleanup stage independent with try/finally or Promise.allSettled. Always clear the writer reference and RPC handlers, and make AvatarSession.closeImpl() run listener removal and super.aclose() even when output closure fails; preserve the first error after cleanup finishes.

Devin Review

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

Comment thread agents/src/voice/io.ts
Comment on lines +481 to +483
if (current.pendingPlayoutSegments > 0) {
current.onPlaybackFinished({ playbackPosition: 0, interrupted: true });
}

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.

🔴 Tail swap strands queued segments

With multiple pending segments, replaceAudioTail() settles only one after clearing the old leaf. Remaining segments lose their completion source, so playout waits never finish.

Learn more

pendingPlayoutSegments is a count, not a boolean. A wrapper can have several flushed segments still queued at its leaf. Clearing and detaching that leaf prevents all of their real finish events, but the replacement emits only one synthetic finish. The wrapper's playback count therefore remains behind its capture count.

Example: A wrapper has two flushed segments pending when an avatar starts late. The swap clears both from the previous sink but reports one interruption. A caller waiting for the second segment remains blocked because the detached sink can no longer report it.

Recommended fix: Snapshot the pending count before mutation and reconcile every abandoned segment in order. Use wrapper-specific settlement paths where required so transcription and recorder queues receive one interrupted completion per abandoned segment.

Devin Review

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

Comment on lines +207 to +222
throw new SynthesiaError(`avatar swap RPC failed: ${String(cause)}`, {
type: ErrorType.CONNECTION,
cause,
});
}

let response: unknown;
try {
response = JSON.parse(raw) as unknown;
} catch (cause) {
throw new SynthesiaError('avatar swap returned a malformed response', { cause });
}
const result = isRecord(response) ? response.avatar_id : undefined;
if (!isRecord(response) || response.error || typeof result !== 'string') {
const detail = isRecord(response) ? response.error : undefined;
throw new SynthesiaError(`avatar swap failed: ${detail || raw}`);

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.

🟨 Swap errors expose provider content

A failed swapAvatar() embeds the RPC error or raw response in SynthesiaError. Automatic error logging can expose unredactable provider content and cause chains.

Devin Review

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

private beginTeardown() {
if (this.teardownPromise || this.state === State.CLOSED) return;
this.teardownPromise = this.aclose();
void this.teardownPromise.catch((error) => log().error({ error }, 'avatar teardown failed'));

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.

🟨 Teardown logs untagged exception content

Background teardown logs error and its cause chain under an untagged key. Swap and transport exceptions can contain provider payloads or credentials.

Devin Review

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

Comment on lines +83 to +87
const response = await this.fetch(url, {
method: 'POST',
headers: { Authorization: this.#apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(connOptions.timeoutMs),

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.

🟨 Custom API URL permits plaintext credentials

An http:// apiUrl sends the Synthesia key and minted LiveKit token without transport encryption. Environment or configuration mistakes expose both credentials in transit.

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.

0 participants