-
Notifications
You must be signed in to change notification settings - Fork 366
feat(synthesia): add interactive avatar plugin #2486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@livekit/agents-plugin-synthesia': minor | ||
| '@livekit/agents': patch | ||
| --- | ||
|
|
||
| Add Synthesia interactive avatar sessions with precomputed mid-session avatar swaps. | ||
|
|
||
| Add audio output tail replacement and data-stream output cleanup for avatar session lifecycle. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,9 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| private lock = new Mutex(); | ||
| private startTask?: Task<void>; | ||
| private firstFrameEmitted: boolean = false; | ||
| private closed: boolean = false; | ||
| private playbackFinishedHandler = (data: RpcInvocationData) => this.handlePlaybackFinished(data); | ||
| private playbackStartedHandler = (data: RpcInvocationData) => this.handlePlaybackStarted(data); | ||
|
|
||
| #logger = log(); | ||
|
|
||
|
|
@@ -73,23 +76,20 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| this.waitRemoteTrack = waitRemoteTrack; | ||
| this.waitPlaybackStart = waitPlaybackStart ?? false; | ||
|
|
||
| const onRoomConnected = async () => { | ||
| if (this.startTask) return; | ||
|
|
||
| await this.roomConnectedFuture.await; | ||
|
|
||
| const onRoomConnected = () => { | ||
| if (this.startTask || !this.room.isConnected || this.closed) return; | ||
| // register the rpc method right after the room is connected | ||
| DataStreamAudioOutput.registerPlaybackFinishedRpc({ | ||
| room, | ||
| callerIdentity: this.destinationIdentity, | ||
| handler: (data) => this.handlePlaybackFinished(data), | ||
| handler: this.playbackFinishedHandler, | ||
| }); | ||
|
|
||
| if (this.waitPlaybackStart) { | ||
| DataStreamAudioOutput.registerPlaybackStartedRpc({ | ||
| room, | ||
| callerIdentity: this.destinationIdentity, | ||
| handler: (data) => this.handlePlaybackStarted(data), | ||
| handler: this.playbackStartedHandler, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -98,11 +98,13 @@ export class DataStreamAudioOutput extends AudioOutput { | |
|
|
||
| this.roomConnectedFuture = new Future<void>(); | ||
|
|
||
| this.room.on(RoomEvent.ConnectionStateChanged, (_) => { | ||
| this.onRoomConnectionStateChanged = () => { | ||
| if (room.isConnected && !this.roomConnectedFuture.done) { | ||
| this.roomConnectedFuture.resolve(undefined); | ||
| } | ||
| }); | ||
| onRoomConnected(); | ||
| }; | ||
| this.room.on(RoomEvent.ConnectionStateChanged, this.onRoomConnectionStateChanged); | ||
|
|
||
| if (this.room.isConnected) { | ||
| this.roomConnectedFuture.resolve(undefined); | ||
|
|
@@ -111,7 +113,9 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| onRoomConnected(); | ||
| } | ||
|
|
||
| private async _start(_abortSignal: AbortSignal) { | ||
| private onRoomConnectionStateChanged: () => void; | ||
|
|
||
| private async _start(abortSignal: AbortSignal) { | ||
| const unlock = await this.lock.lock(); | ||
|
|
||
| try { | ||
|
|
@@ -129,6 +133,7 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| await waitForParticipant({ | ||
| room: this.room, | ||
| identity: this.destinationIdentity, | ||
| signal: abortSignal, | ||
| }); | ||
|
|
||
| if (this.waitRemoteTrack) { | ||
|
|
@@ -144,6 +149,7 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| room: this.room, | ||
| identity: this.destinationIdentity, | ||
| kind: this.waitRemoteTrack, | ||
| signal: abortSignal, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -161,6 +167,7 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| } | ||
|
|
||
| async captureFrame(frame: AudioFrame): Promise<void> { | ||
| if (this.closed) throw new Error('DataStreamAudioOutput is closed'); | ||
| if (!this.startTask) { | ||
| this.startTask = Task.from(({ signal }) => this._start(signal)); | ||
| } | ||
|
|
@@ -219,6 +226,30 @@ export class DataStreamAudioOutput extends AudioOutput { | |
| }); | ||
| } | ||
|
|
||
| /** Release resources owned by this data-stream output. */ | ||
| async aclose(): Promise<void> { | ||
| if (this.closed) return; | ||
| this.closed = true; | ||
| this.room.off(RoomEvent.ConnectionStateChanged, this.onRoomConnectionStateChanged); | ||
| this.startTask?.cancel(); | ||
| if (this.streamWriter) { | ||
| await this.streamWriter.close(); | ||
| this.streamWriter = undefined; | ||
| } | ||
| if ( | ||
| DataStreamAudioOutput._playbackFinishedHandlers[this.destinationIdentity] === | ||
| this.playbackFinishedHandler | ||
| ) { | ||
| delete DataStreamAudioOutput._playbackFinishedHandlers[this.destinationIdentity]; | ||
|
Comment on lines
+235
to
+243
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Stream failure aborts avatar cleanup When Learn moreStream closure can reject during a disconnect or transport failure. The handler deletions follow the awaited close without a Example: The avatar's data connection drops while a byte stream is open. Recommended fix: Make each cleanup stage independent with Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| if ( | ||
| DataStreamAudioOutput._playbackStartedHandlers[this.destinationIdentity] === | ||
| this.playbackStartedHandler | ||
| ) { | ||
| delete DataStreamAudioOutput._playbackStartedHandlers[this.destinationIdentity]; | ||
| } | ||
| } | ||
|
|
||
| private handlePlaybackFinished(data: RpcInvocationData): string { | ||
| if (data.callerIdentity !== this.destinationIdentity) { | ||
| this.#logger.warn( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,15 +136,16 @@ export abstract class AudioOutput extends EventEmitter { | |
| ) { | ||
| super(); | ||
| this.capabilities = capabilities; | ||
| this.attachNextInChainListeners(); | ||
| } | ||
|
|
||
| if (this.nextInChain) { | ||
| this.nextInChain.on(AudioOutput.EVENT_PLAYBACK_STARTED, (ev: PlaybackStartedEvent) => | ||
| this.onPlaybackStarted(ev.createdAt), | ||
| ); | ||
| this.nextInChain.on(AudioOutput.EVENT_PLAYBACK_FINISHED, (ev: PlaybackFinishedEvent) => | ||
| this.onPlaybackFinished(ev), | ||
| ); | ||
| } | ||
| private onNextPlaybackStarted = (ev: PlaybackStartedEvent) => | ||
| this.onPlaybackStarted(ev.createdAt); | ||
| private onNextPlaybackFinished = (ev: PlaybackFinishedEvent) => this.onPlaybackFinished(ev); | ||
|
|
||
| private attachNextInChainListeners(): void { | ||
| this.nextInChain?.on(AudioOutput.EVENT_PLAYBACK_STARTED, this.onNextPlaybackStarted); | ||
| this.nextInChain?.on(AudioOutput.EVENT_PLAYBACK_FINISHED, this.onNextPlaybackFinished); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -451,6 +452,42 @@ export class AgentOutput { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Replace the leaf audio sink while retaining recorder and transcription wrappers. | ||
| * Falls back to replacing the whole output when no wrapper chain is installed. | ||
| */ | ||
| replaceAudioTail(sink: AudioOutput): void { | ||
| let current = this._audioSink; | ||
| while (current) { | ||
| const internals = current as unknown as { | ||
| nextInChain?: AudioOutput; | ||
| onNextPlaybackStarted: (event: PlaybackStartedEvent) => void; | ||
| onNextPlaybackFinished: (event: PlaybackFinishedEvent) => void; | ||
| attachNextInChainListeners: () => void; | ||
| _capturing: boolean; | ||
| }; | ||
| const next = internals.nextInChain; | ||
| if (next && !(next as unknown as { nextInChain?: AudioOutput }).nextInChain) { | ||
| next.off(AudioOutput.EVENT_PLAYBACK_STARTED, internals.onNextPlaybackStarted); | ||
| next.off(AudioOutput.EVENT_PLAYBACK_FINISHED, internals.onNextPlaybackFinished); | ||
| if (current.pendingPlayoutSegments > 0) { | ||
| if (internals._capturing) next.flush(); | ||
| next.clearBuffer(); | ||
| } | ||
| if (this._audioEnabled) next.onDetached(); | ||
| internals.nextInChain = sink; | ||
| internals.attachNextInChainListeners(); | ||
| if (this._audioEnabled) sink.onAttached(); | ||
| if (current.pendingPlayoutSegments > 0) { | ||
| current.onPlaybackFinished({ playbackPosition: 0, interrupted: true }); | ||
| } | ||
|
Comment on lines
+481
to
+483
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Tail swap strands queued segments With multiple pending segments, Learn more
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. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| return; | ||
| } | ||
| current = next ?? null; | ||
| } | ||
| this.audio = sink; | ||
| } | ||
|
|
||
| get transcription(): TextOutput | null { | ||
| return this._transcriptionSink; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # Synthesia plugin for LiveKit Agents | ||
|
|
||
| Attach a [Synthesia](https://www.synthesia.io/) 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.livekit.io/agents/models/avatar/plugins/synthesia/) for more information. | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| npm install @livekit/agents-plugin-synthesia | ||
| ``` | ||
|
|
||
| ## Pre-requisites | ||
|
|
||
| You'll need an API key from Synthesia. It can be set as the `SYNTHESIA_API_KEY` environment variable. | ||
|
|
||
| ## Usage | ||
|
|
||
| ```typescript | ||
| import { voice } from '@livekit/agents'; | ||
| import * as synthesia from '@livekit/agents-plugin-synthesia'; | ||
|
|
||
| const session = new voice.AgentSession(/* ... */); | ||
| const avatar = new synthesia.AvatarSession( | ||
| new synthesia.AvatarConfig({ | ||
| avatarIds: ['03cee7ec-ac90-45ec-8c20-74a399cf3dc4'], | ||
| }), | ||
| ); | ||
|
|
||
| await avatar.start(session, ctx.room); // before session.start() | ||
| await session.start({ agent: new Agent(/* ... */), room: ctx.room }); | ||
| ``` | ||
|
|
||
| Set your Synthesia workspace API key in `SYNTHESIA_API_KEY`, or pass `apiKey`. `avatarIds` takes one to five gallery IDs available to your workspace. The first is active and the rest are precomputed so `swapAvatar()` can switch to them during the session. An inaccessible ID raises `SynthesiaError` with `type` set to `ErrorType.UNKNOWN_AVATAR`. | ||
|
|
||
| ```typescript | ||
| await avatar.swapAvatar('<another-id-from-avatarIds>'); | ||
| await avatar.swapAvatar('default'); | ||
| ``` | ||
|
|
||
| ## Parameters | ||
|
|
||
| | Parameter | Default | Description | | ||
| | --------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------- | | ||
| | `avatarParticipantIdentity` | `"synthesia-avatar-agent"` | The LiveKit identity the avatar joins under. It must be unique per concurrent avatar in a room. | | ||
| | `avatarParticipantName` | `"Synthesia avatar"` | The LiveKit display name the avatar joins under. | |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| /** | ||
| * Config file for API Extractor. For more info, visit https://api-extractor.com. | ||
| */ | ||
| { | ||
| "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", | ||
| "extends": "../../api-extractor-shared.json", | ||
| "mainEntryPointFilePath": "./dist/index.d.ts" | ||
| } |
There was a problem hiding this comment.
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()leavesstartTaskwaiting forever.roomConnectedFutureignores cancellation after its resolving listener is removed.Learn more
A capture made before room connection creates
startTask, and_startfirst 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, callcaptureFrame(), then callaclose()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.Was this helpful? React with 👍 or 👎 to provide feedback.