Add Synthesia interactive-avatar plugin - #7216
Conversation
Adds livekit-plugins-synthesia: attach a Synthesia interactive avatar to a LiveKit voice agent, mirroring the bey/tavus/hedra avatar plugins. Registers the package as a uv workspace member and as a livekit-agents[synthesia] extra, and adds a combined unit-test module under tests/, following the plugin(name) test-category convention.
There was a problem hiding this comment.
Note
This report is out of date. Scroll down for Devin Review's latest report on this PR.
Devin Review found 8 potential issues.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| audio = agent_session.output.audio | ||
| if isinstance(audio, DataStreamAudioOutput): | ||
| agent_session.output.audio = None | ||
| if hasattr(audio, "aclose"): | ||
| await audio.aclose() |
There was a problem hiding this comment.
🔴 Join timeout leaks audio transport
When wait_for_join times out, _discard_partial_start cannot close the real DataStreamAudioOutput. The transport has no aclose, leaving its participant-wait task and room listener alive.
Learn more
A connected room starts DataStreamAudioOutput._start_task immediately. That task waits indefinitely for the avatar participant and video track. If Synthesia never joins, wait_for_join times out and this cleanup detaches the transport, but the production transport has no aclose method. Its task and connection_state_changed listener therefore retain the room and transport after start() returns an error.
Example: Synthesia accepts a session request but its worker never joins. After 30 seconds, AvatarSession.start() raises TIMEOUT, while the transport still waits for that worker. Repeating the start creates another waiting task and listener.
Recommended fix: Add a real lifecycle close operation to DataStreamAudioOutput that unregisters its room listener, cancels _start_atask, closes any stream writer, cancels tracked tasks and timers, and removes registered RPC handlers. Store the created transport explicitly and always invoke that close operation during partial-start cleanup.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
DataStreamAudioOutput has no aclose() at all today — confirmed in the framework source, which carries a maintainer TODO acknowledging exactly this ("this class should be encapsuled somewhere else to allow for a clean close"). Let me know if you feel differently about this.
| agent_session = self._agent_session | ||
| if agent_session is not None and isinstance( | ||
| agent_session.output.audio, DataStreamAudioOutput | ||
| ): | ||
| audio = agent_session.output.audio | ||
| agent_session.output.audio = None | ||
| if hasattr(audio, "aclose"): | ||
| await audio.aclose() |
There was a problem hiding this comment.
🟡 Closed avatar sink remains installed
When aclose() runs, agent_session.output keeps the Synthesia sink installed. Clearing _audio_output leaves later agent speech routed to a closed or departed avatar.
Learn more
The avatar sink is part of the agent's active audio chain. Closing that sink does not detach it from the chain, and clearing _audio_output only removes the plugin's ownership reference. This occurs after normal close, an unexpected avatar departure, and startup failures after sink installation. The same stale reference remains when wrappers hold the sink through an audio proxy.
Example: An avatar joins, then disconnects while the AgentSession remains active. Teardown closes the Synthesia sink, but the next TTS response still traverses the existing output chain into that sink instead of using no output or the prior output.
Recommended fix: Retain enough chain state to detach or replace the exact Synthesia tail during both aclose() and _discard_partial_start(). Restore the displaced tail when possible; otherwise use a framework API that removes the tail while preserving wrappers. Add coverage that sends audio after teardown and verifies it cannot reach the Synthesia sink.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Same issue as above for close. We might need a framework level close method for this.
| except Exception as e: | ||
| raise SynthesiaError(f"avatar swap RPC failed: {e}", type=ErrorType.CONNECTION) from e |
There was a problem hiding this comment.
body is a documented field on the base APIError class itself ("The API response body, if available"). This seems like an established repo-wide pattern. Happy to change if you feel strongly.
| avatar_id_result = response.get("avatar_id") if isinstance(response, dict) else None | ||
| if ( | ||
| not isinstance(response, dict) | ||
| or response.get("error") | ||
| or not isinstance(avatar_id_result, str) | ||
| ): | ||
| detail = response.get("error") if isinstance(response, dict) else None | ||
| raise SynthesiaError(f"avatar swap failed: {detail or raw}") |
There was a problem hiding this comment.
Same as above — body is a documented APIError field, and every other plugin attaches raw provider responses to it the same way. Not specific to this plugin's swap_avatar path. Again happy to change if you feel strongly.
| message = _body_message(body) or ( | ||
| f"Synthesia request failed ({code or f'HTTP {resp.status}'})" | ||
| ) | ||
| request_id = _body_request_id(body) | ||
| retry_after = ( | ||
| _parse_retry_after(resp, body) | ||
| if error_type in (ErrorType.RATE_LIMITED, ErrorType.CONCURRENCY_LIMIT) | ||
| else None | ||
| ) | ||
| return SynthesiaError( | ||
| message, | ||
| type=error_type, | ||
| retry_after=retry_after, | ||
| body=body, | ||
| status=resp.status, | ||
| request_id=request_id, |
There was a problem hiding this comment.
Same as the other body= findings — this is the documented APIError.body contract, used identically across every plugin in the repo (deepgram, cartesia, etc.), not something unique to this HTTP client.
| raise SynthesiaError( | ||
| _exhausted_message(conn_options.max_retry + 1, last_status, last_body), | ||
| type=ErrorType.CONNECTION, | ||
| body=last_body, | ||
| status=last_status, | ||
| request_id=_body_request_id(last_body), | ||
| ) from last_exc |
There was a problem hiding this comment.
Same as the other body= findings — retry exhaustion attaching the last response body/status/request_id to APIError.body matches the documented, repo-wide contract rather than being a plugin-specific issue.
…tion Match bey/tavus and use replace_audio_tail() to install the avatar's audio output, so wrappers AgentSession.start() adds (TranscriptSynchronizer, RecorderAudioOutput) survive. Teardown now closes the exact sink this session created, tracked in self._audio_output, rather than inferring ownership from isinstance(agent_session.output.audio, DataStreamAudioOutput). The old check broke once the sink was wrapped, and could not tell a caller's pre-existing DataStreamAudioOutput from this session's own.
|
Verified against production Synthesia + LiveKit infrastructure Ran an internal smoke test that drives the exact public path an integrator would use: construct Confirms auth, worker dispatch, room join, and video publish all work end-to-end against production, using the code currently in this PR. |
…hesia/avatar.py Co-authored-by: Tina Nguyen <72938484+tinalenguyen@users.noreply.github.com>
get_job_context was referenced but never imported, and JobContext has no local_participant attribute — only local_participant_identity, which bey/tavus already use for the same reason: it doesn't depend on the room having finished connecting. Tests now fake the job context via an autouse fixture, since get_job_context() raises outside a real job entrypoint.
tinalenguyen
left a comment
There was a problem hiding this comment.
tested it and it works well for me, added some small docs comments!
| Documentation = "https://docs.synthesia.io" | ||
| Website = "https://www.synthesia.io/" | ||
| Source = "https://github.com/livekit/agents" |
There was a problem hiding this comment.
| Documentation = "https://docs.synthesia.io" | |
| Website = "https://www.synthesia.io/" | |
| Source = "https://github.com/livekit/agents" | |
| Documentation = "https://docs.livekit.io" | |
| Website = "https://livekit.io/" | |
| Source = "https://github.com/livekit/agents" |
| @@ -0,0 +1,56 @@ | |||
| # Synthesia plugin for LiveKit Agents | |||
|
|
|||
| Attach a Synthesia interactive avatar to a LiveKit voice agent. The avatar joins | |||
There was a problem hiding this comment.
| Attach a Synthesia interactive avatar to a LiveKit voice agent. The avatar joins | |
| Attach a [Synthesia](https://www.synthesia.io/) interactive avatar to a LiveKit voice agent. The avatar joins |
| Attach a Synthesia interactive avatar to a LiveKit voice agent. The avatar joins | ||
| the room and lip-syncs the agent's speech in real time. | ||
|
|
||
| See the [Synthesia integration docs](https://docs.synthesia.io/reference/interactive-avatars) for more information. |
There was a problem hiding this comment.
| See the [Synthesia integration docs](https://docs.synthesia.io/reference/interactive-avatars) for more information. | |
| See the [Synthesia integration docs](https://docs.livekit.io/agents/models/avatar/plugins/synthesia/) for more information. |
…ekit.io _mint_token now calls get_job_context(required=False) and falls back to room.local_participant.identity when there's no job context, so callers who construct and connect an rtc.Room directly (the base AvatarSession contract's supported standalone mode) can still start. Also point pyproject.toml's Documentation/Website and the README's docs link at docs.livekit.io, matching bey/tavus, and link the Synthesia mention in the README to synthesia.io.
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| job_ctx = get_job_context(required=False) | ||
| agent_identity = ( | ||
| job_ctx.local_participant_identity | ||
| if job_ctx is not None | ||
| else room.local_participant.identity | ||
| ) |
There was a problem hiding this comment.
🟡 Disconnected standalone startup leaks SDK error
Without a job context, start() reads room.local_participant even when the room is disconnected. The RTC RuntimeError escapes instead of the plugin’s SynthesiaError.
Learn more
A LiveKit room creates its local participant during connection. The base avatar session accepts disconnected rooms because it can wait for a later connection, but token minting needs the agent identity immediately. Without a job context, that identity is only available from an already connected room. Accessing it earlier raises the RTC SDK's RuntimeError, and the broad startup cleanup handler re-raises that exception unchanged.
Example: A standalone script constructs rtc.Room() and calls avatar.start(session, room) before room.connect(...). Startup raises an RTC RuntimeError; callers expecting SynthesiaError cannot handle it through the plugin's error API.
Recommended fix: Check room.isconnected() before reading room.local_participant.identity. Raise SynthesiaError with the existing connection guidance when no job context and no connected room are available.
| job_ctx = get_job_context(required=False) | |
| agent_identity = ( | |
| job_ctx.local_participant_identity | |
| if job_ctx is not None | |
| else room.local_participant.identity | |
| ) | |
| job_ctx = get_job_context(required=False) | |
| if job_ctx is not None: | |
| agent_identity = job_ctx.local_participant_identity | |
| elif room.isconnected(): | |
| agent_identity = room.local_participant.identity | |
| else: | |
| raise SynthesiaError( | |
| "connect the room before starting the avatar session outside a job context" | |
| ) |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Adds
livekit-plugins-synthesia, a plugin that attaches a Synthesia interactive avatar to a LiveKit voice agent:synthesia.AvatarSessionextendslivekit.agents.voice.avatar.AvatarSession. Callstart()beforeAgentSession.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.SynthesiaError(anAPIError) with atype: ErrorTypefield identifying the failure (auth, unknown avatar, quota, rate limit, concurrency limit, timeout, connection, etc.), plusretryable,retry_after,status, andrequest_id.What's included
livekit-plugins/livekit-plugins-synthesia/— the plugin packageuvworkspace member (pyproject.toml) and as alivekit-agents[synthesia]extra (livekit-agents/pyproject.toml), withuv.lockregenerated accordinglytests/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), taggedpytest.mark.unitandpytest.mark.plugin("synthesia")per the existing test-category conventionTest plan
ruff check/ruff format --checkcleanmypy(uv run mypy -p livekit.plugins.synthesia) clean under the repo's strict configpytest tests/test_plugin_synthesia.py— 165 passedpytest --list-categoriesthat the new test module is picked up under bothunitandplugincategories, so it runs in the standardmake unit-testsCI gateuv sync --all-extras --devsucceeds with the new package in the workspace