-
Notifications
You must be signed in to change notification settings - Fork 269
feat(voice): port AvatarSession base class and transcript sync asymmetric detach warning #1280
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,9 @@ | ||
| --- | ||
| '@livekit/agents': patch | ||
| '@livekit/agents-plugin-anam': patch | ||
| '@livekit/agents-plugin-bey': patch | ||
| '@livekit/agents-plugin-lemonslice': patch | ||
| '@livekit/agents-plugin-trugen': patch | ||
| --- | ||
|
|
||
| Add `voice.AvatarSession` base class and port the asymmetric-detach warning from the Python `TranscriptSynchronizer`. The new base class registers `aclose` as a job shutdown callback and warns when an avatar session is started after `AgentSession.start()` has already wired an audio output. The transcript synchronizer now tracks `_audioAttached` / `_textAttached` via `onAttached` / `onDetached` and logs a one-shot warning when audio or text is detached asymmetrically (covering external avatars and manual `session.output.audio` / `.transcription` replacement). Existing avatar plugins (anam, bey, lemonslice, trugen) now inherit from `voice.AvatarSession` and call `super.start(agentSession, room)` first. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import type { Room } from '@livekit/rtc-node'; | ||
| import { getJobContext } from '../../job.js'; | ||
| import { log } from '../../log.js'; | ||
| import type { AgentSession } from '../agent_session.js'; | ||
|
|
||
| // Ref: python livekit-agents/livekit/agents/voice/avatar/_types.py - 53-78 lines | ||
| /** | ||
| * Base class for avatar plugin sessions. | ||
| * | ||
| * Plugin implementations should extend this class and call `super.start(agentSession, room)` | ||
| * first in their own `start()` method. The base: | ||
| * - Registers {@link AvatarSession.aclose} as a job shutdown callback, so avatar resources | ||
| * are released when the job shuts down. | ||
| * - Warns when the avatar session is started after {@link AgentSession.start} — in that | ||
| * case the existing audio output will be replaced by the avatar's. | ||
| */ | ||
| export class AvatarSession { | ||
| #logger = log(); | ||
|
|
||
| /** | ||
| * Start the avatar session. | ||
| * | ||
| * Subclasses should override this method and call `super.start(agentSession, room)` at the | ||
| * top of their implementation. Subclasses may widen the return type (e.g. returning a | ||
| * session id), matching the `# type: ignore[override]` escape hatch used in Python. | ||
| */ | ||
| async start(agentSession: AgentSession, _room: Room): Promise<unknown> { | ||
| const jobCtx = getJobContext(false); | ||
| if (jobCtx !== undefined) { | ||
| jobCtx.addShutdownCallback(() => this.aclose()); | ||
| } else { | ||
| this.#logger.debug( | ||
| 'AvatarSession started outside a job context; call aclose() manually to ' + | ||
| 'release resources when the job shuts down', | ||
| ); | ||
| } | ||
|
|
||
| const audioOutput = agentSession.output.audio; | ||
| if (agentSession._started && audioOutput !== null) { | ||
| this.#logger.warn( | ||
| { audioOutput: audioOutput.constructor.name }, | ||
| 'AvatarSession.start() was called after AgentSession.start(); ' + | ||
| 'the existing audio output may be replaced by the avatar. ' + | ||
| 'Please start the avatar session before AgentSession.start() to avoid this.', | ||
| ); | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Release any resources owned by this avatar session. Default implementation is a no-op; | ||
| * subclasses can override to perform cleanup. | ||
| */ | ||
| async aclose(): Promise<void> {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| // SPDX-FileCopyrightText: 2025 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| export * from './avatar_session.js'; | ||
| export * from './datastream_io.js'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,7 +96,7 @@ export interface StartOptions { | |
| * await avatar.start(agentSession, room); | ||
| * ``` | ||
| */ | ||
| export class AvatarSession { | ||
| export class AvatarSession extends voice.AvatarSession { | ||
|
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. 🟡 Missing Same issue as in the anam plugin. The class declaration change to Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| private avatarId: string; | ||
| private apiUrl: string; | ||
| private apiKey: string; | ||
|
|
@@ -113,6 +113,7 @@ export class AvatarSession { | |
| * @throws BeyException if BEY_API_KEY is not set | ||
| */ | ||
| constructor(options: AvatarSessionOptions = {}) { | ||
| super(); | ||
| this.avatarId = options.avatarId || STOCK_AVATAR_ID; | ||
| this.apiUrl = options.apiUrl || process.env.BEY_API_URL || DEFAULT_API_URL; | ||
| this.apiKey = options.apiKey || process.env.BEY_API_KEY || ''; | ||
|
|
@@ -147,6 +148,9 @@ export class AvatarSession { | |
| room: Room, | ||
| options: StartOptions = {}, | ||
| ): Promise<void> { | ||
| // Ref: python livekit-plugins/livekit-plugins-bey/livekit/plugins/bey/avatar.py - 78 lines | ||
| await super.start(agentSession, room); | ||
|
|
||
| const livekitUrl = options.livekitUrl || process.env.LIVEKIT_URL; | ||
| const livekitApiKey = options.livekitApiKey || process.env.LIVEKIT_API_KEY; | ||
| const livekitApiSecret = options.livekitApiSecret || process.env.LIVEKIT_API_SECRET; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -122,7 +122,7 @@ export interface StartOptions { | |
| * await avatar.start(agentSession, room); | ||
| * ``` | ||
| */ | ||
| export class AvatarSession { | ||
| export class AvatarSession extends voice.AvatarSession { | ||
|
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. 🟡 Missing Same issue as in the anam plugin. The class declaration change to Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| private agentId: string | null; | ||
| private agentImageUrl: string | null; | ||
| private agentPrompt: string | null; | ||
|
|
@@ -143,6 +143,7 @@ export class AvatarSession { | |
| * @throws LemonSliceException if invalid agentId or agentImageUrl is provided, or if LemonSlice API key is not set | ||
| */ | ||
| constructor(options: AvatarSessionOptions = {}) { | ||
| super(); | ||
| this.agentId = options.agentId ?? null; | ||
| this.agentImageUrl = options.agentImageUrl ?? null; | ||
|
|
||
|
|
@@ -191,6 +192,9 @@ export class AvatarSession { | |
| room: Room, | ||
| options: StartOptions = {}, | ||
| ): Promise<string> { | ||
| // Ref: python livekit-plugins/livekit-plugins-lemonslice/livekit/plugins/lemonslice/avatar.py - 68 lines | ||
| await super.start(agentSession, room); | ||
|
|
||
| const livekitUrl = options.livekitUrl || process.env.LIVEKIT_URL; | ||
| const livekitApiKey = options.livekitApiKey || process.env.LIVEKIT_API_KEY; | ||
| const livekitApiSecret = options.livekitApiSecret || process.env.LIVEKIT_API_SECRET; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,7 +64,7 @@ export interface StartOptions { | |
| * Routing agent audio output to the avatar for visual representation. | ||
| * @public | ||
| */ | ||
| export class AvatarSession { | ||
| export class AvatarSession extends voice.AvatarSession { | ||
|
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. 🟡 Missing Same issue as in the anam plugin. The class declaration change to Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| private avatarId: string; | ||
| private apiUrl: string; | ||
| private apiKey: string; | ||
|
|
@@ -81,6 +81,7 @@ export class AvatarSession { | |
| * @throws TrugenException if TRUGEN_API_KEY is not set | ||
| */ | ||
| constructor(options: AvatarSessionOptions = {}) { | ||
| super(); | ||
| this.avatarId = options.avatarId || DEFAULT_AVATAR_ID; | ||
| this.apiUrl = options.apiUrl || process.env.TRUGEN_API_URL || DEFAULT_API_URL; | ||
| this.apiKey = options.apiKey || process.env.TRUGEN_API_KEY || ''; | ||
|
|
@@ -112,6 +113,9 @@ export class AvatarSession { | |
| room: Room, | ||
| options: StartOptions = {}, | ||
| ): Promise<void> { | ||
| // Ref: python livekit-plugins/livekit-plugins-trugen/livekit/plugins/trugen/avatar.py - 83 lines | ||
| await super.start(agentSession, room); | ||
|
|
||
| const livekitUrl = options.livekitUrl || process.env.LIVEKIT_URL; | ||
| const livekitApiKey = options.livekitApiKey || process.env.LIVEKIT_API_KEY; | ||
| const livekitApiSecret = options.livekitApiSecret || process.env.LIVEKIT_API_SECRET; | ||
|
|
||
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.
🟡 Missing
// Refcomment onextends voice.AvatarSessionclass declaration (anam)Per the CLAUDE.md porting rule, every JS change that corresponds to a Python change must carry an inline
// Ref: python <path> - <line-range> linescomment directly above the relevant line(s). The class declaration change toextends voice.AvatarSessionis a direct port from the Python plugin (where the class similarly extends the baseAvatarSession), but it lacks the required reference comment. Thesuper()call atplugins/anam/src/avatar.ts:53is also a ported change without a// Ref.Was this helpful? React with 👍 or 👎 to provide feedback.