-
Notifications
You must be signed in to change notification settings - Fork 276
feat(liveavatar): port plugin from python with video_quality param #1324
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
Open
toubatbrian
wants to merge
6
commits into
main
Choose a base branch
from
claude/jolly-lovelace-5PHvh
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
df624a8
feat(liveavatar): port plugin from python with video_quality param
claude 40ba52c
Update plugins/liveavatar/README.md
toubatbrian e71d5e8
Update .changeset/liveavatar-plugin.md
toubatbrian af8b8ed
review: lift QueueAudioOutput to core, harden mainTask, fix retry sem…
claude 651e3de
review: fix onClearBuffer race and stale-frame leak after interrupt
claude f518785
review: write AudioSegmentEnd from clearBuffer to unstick interruptDr…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| '@livekit/agents-plugin-liveavatar': minor | ||
| '@livekit/agents': minor | ||
|
toubatbrian marked this conversation as resolved.
Outdated
|
||
| --- | ||
|
|
||
| Port the `liveavatar` plugin from the Python `livekit-agents` repo, including the new `videoQuality` parameter from livekit/agents#5552. | ||
|
|
||
| The new `@livekit/agents-plugin-liveavatar` package adds a LiveAvatar `AvatarSession` that mirrors the Python plugin: it brings up a LiveAvatar streaming session, opens the realtime websocket, captures the agent's audio output through a queue-based `AudioOutput`, resamples to 24 kHz mono, and forwards base64-encoded chunks (~600 ms first chunk, ~1 s subsequent) to the LiveAvatar service. Inbound websocket events drive playback start/finish notifications back into the `AgentSession`. | ||
|
|
||
| Also exports `voice.AudioOutput` (and its companion `AudioOutputCapabilities` / `PlaybackFinishedEvent` / `PlaybackStartedEvent` types) from `@livekit/agents` so plugin authors can subclass the abstract audio sink. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| <!-- | ||
| SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
|
|
||
| SPDX-License-Identifier: Apache-2.0 | ||
| --> | ||
|
|
||
| # LiveAvatar plugin for LiveKit Agents | ||
|
|
||
| Support for [LiveAvatar](https://www.liveavatar.com) interactive avatars. | ||
|
|
||
| This is the JS/TS port of the Python `livekit-plugins-liveavatar` plugin. See [https://docs.livekit.io/agents/integrations/avatar/](https://docs.livekit.io/agents/integrations/avatar/) for more information. | ||
|
toubatbrian marked this conversation as resolved.
Outdated
|
||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| npm install @livekit/agents-plugin-liveavatar | ||
| ``` | ||
|
|
||
| or | ||
|
|
||
| ```bash | ||
| pnpm add @livekit/agents-plugin-liveavatar | ||
| ``` | ||
|
|
||
| ## Pre-requisites | ||
|
|
||
| Create a developer API key from the LiveAvatar dashboard and set the `LIVEAVATAR_API_KEY` environment variable with it: | ||
|
|
||
| ```bash | ||
| export LIVEAVATAR_API_KEY=<your-liveavatar-api-key> | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```typescript | ||
| import { AvatarSession } from '@livekit/agents-plugin-liveavatar'; | ||
| import { AgentSession } from '@livekit/agents'; | ||
|
|
||
| const avatarSession = new AvatarSession({ | ||
| avatarId: 'your-avatar-id', // or via LIVEAVATAR_AVATAR_ID | ||
| apiKey: process.env.LIVEAVATAR_API_KEY, | ||
| videoQuality: 'high', // optional: 'very_high' | 'high' | 'medium' | 'low' | ||
| }); | ||
|
|
||
| await avatarSession.start(agentSession, room, { | ||
| livekitUrl: process.env.LIVEKIT_URL, | ||
| livekitApiKey: process.env.LIVEKIT_API_KEY, | ||
| livekitApiSecret: process.env.LIVEKIT_API_SECRET, | ||
| }); | ||
| ``` | ||
|
|
||
| ## API | ||
|
|
||
| ### `AvatarSession` | ||
|
|
||
| #### Constructor Options | ||
|
|
||
| - `avatarId?: string` — The LiveAvatar avatar id. Falls back to `LIVEAVATAR_AVATAR_ID`. | ||
| - `apiUrl?: string` — Override the LiveAvatar API base URL. | ||
| - `apiKey?: string` — Your LiveAvatar API key. Falls back to `LIVEAVATAR_API_KEY`. | ||
| - `isSandbox?: boolean` — Use the LiveAvatar sandbox (1 minute connection limit). Defaults to `false`. | ||
| - `videoQuality?: 'very_high' | 'high' | 'medium' | 'low'` — Avatar video quality requested from the service. When omitted, the LiveAvatar service decides. | ||
| - `avatarParticipantIdentity?: string` — Identity for the avatar participant. Defaults to `'liveavatar-avatar-agent'`. | ||
| - `avatarParticipantName?: string` — Display name for the avatar participant. Defaults to `'liveavatar-avatar-agent'`. | ||
| - `connOptions?: APIConnectOptions` — API retry/timeout options. | ||
|
|
||
| #### Methods | ||
|
|
||
| ##### `start(agentSession, room, options?)` | ||
|
|
||
| Starts the avatar session, brings up a LiveAvatar streaming session, opens the realtime websocket, and routes the agent's audio output through to the avatar. | ||
|
|
||
| **StartOptions:** | ||
|
|
||
| - `livekitUrl?: string` — Falls back to `LIVEKIT_URL`. | ||
| - `livekitApiKey?: string` — Falls back to `LIVEKIT_API_KEY`. | ||
| - `livekitApiSecret?: string` — Falls back to `LIVEKIT_API_SECRET`. | ||
|
|
||
| ## License | ||
|
|
||
| Apache 2.0 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| { | ||
| "name": "@livekit/agents-plugin-liveavatar", | ||
| "version": "1.3.0", | ||
| "description": "LiveAvatar avatar plugin for LiveKit Node Agents", | ||
| "main": "dist/index.js", | ||
| "require": "dist/index.cjs", | ||
| "types": "dist/index.d.ts", | ||
| "exports": { | ||
| "import": { | ||
| "types": "./dist/index.d.ts", | ||
| "default": "./dist/index.js" | ||
| }, | ||
| "require": { | ||
| "types": "./dist/index.d.cts", | ||
| "default": "./dist/index.cjs" | ||
| } | ||
| }, | ||
| "author": "LiveKit", | ||
| "type": "module", | ||
| "repository": "git@github.com:livekit/agents-js.git", | ||
| "license": "Apache-2.0", | ||
| "files": [ | ||
| "dist", | ||
| "src", | ||
| "README.md" | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup --onSuccess \"pnpm build:types\"", | ||
| "build:types": "tsc --declaration --emitDeclarationOnly && node ../../scripts/copyDeclarationOutput.js", | ||
| "clean": "rm -rf dist", | ||
| "clean:build": "pnpm clean && pnpm build", | ||
| "lint": "eslint -f unix \"src/**/*.{ts,js}\"", | ||
| "api:check": "api-extractor run --typescript-compiler-folder ../../node_modules/typescript", | ||
| "api:update": "api-extractor run --local --typescript-compiler-folder ../../node_modules/typescript --verbose" | ||
| }, | ||
| "devDependencies": { | ||
| "@livekit/agents": "workspace:*", | ||
| "@livekit/rtc-node": "catalog:", | ||
| "@microsoft/api-extractor": "^7.35.0", | ||
| "@types/ws": "catalog:", | ||
| "pino": "^8.19.0", | ||
| "tsup": "^8.3.5", | ||
| "typescript": "^5.0.0" | ||
| }, | ||
| "dependencies": { | ||
| "livekit-server-sdk": "^2.13.3", | ||
| "ws": "catalog:" | ||
| }, | ||
| "peerDependencies": { | ||
| "@livekit/agents": "workspace:*", | ||
| "@livekit/rtc-node": "catalog:" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { | ||
| type APIConnectOptions, | ||
| APIConnectionError, | ||
| APIStatusError, | ||
| DEFAULT_API_CONNECT_OPTIONS, | ||
| } from '@livekit/agents'; | ||
| import { log } from './log.js'; | ||
|
|
||
| export const DEFAULT_API_URL = 'https://api.liveavatar.com/v1/sessions'; | ||
|
|
||
| // Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/avatar.py - 41 line | ||
| export type VideoQuality = 'very_high' | 'high' | 'medium' | 'low'; | ||
|
|
||
| /** | ||
| * Exception thrown when the LiveAvatar plugin or the LiveAvatar service errors. | ||
| */ | ||
| export class LiveAvatarException extends Error { | ||
| constructor(message: string) { | ||
| super(message); | ||
| this.name = 'LiveAvatarException'; | ||
| } | ||
| } | ||
|
|
||
| export interface CreateStreamingSessionOptions { | ||
| livekitUrl: string; | ||
| livekitToken: string; | ||
| roomName: string; | ||
| avatarId: string; | ||
| isSandbox?: boolean; | ||
| // Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py - 62 line | ||
| videoQuality?: VideoQuality | null; | ||
| } | ||
|
|
||
| export interface SessionResponse { | ||
| data: { | ||
| session_id: string; | ||
| session_token: string; | ||
| [key: string]: unknown; | ||
| }; | ||
| code: number; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| export interface StartSessionResponse { | ||
| data: { | ||
| ws_url: string; | ||
| [key: string]: unknown; | ||
| }; | ||
| code: number; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| export interface StopSessionResponse { | ||
| data: Record<string, unknown>; | ||
| code: number; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| export interface LiveAvatarAPIOptions { | ||
| apiKey?: string; | ||
| apiUrl?: string; | ||
| connOptions?: APIConnectOptions; | ||
| } | ||
|
|
||
| /** | ||
| * Thin client for the LiveAvatar HTTP API. | ||
| * | ||
| * Mirrors `livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py`. | ||
| */ | ||
| export class LiveAvatarAPI { | ||
| private apiKey: string; | ||
| private apiUrl: string; | ||
| private connOptions: APIConnectOptions; | ||
|
|
||
| #logger = log(); | ||
|
|
||
| constructor(options: LiveAvatarAPIOptions = {}) { | ||
| const apiKey = options.apiKey ?? process.env.LIVEAVATAR_API_KEY ?? ''; | ||
| if (!apiKey) { | ||
| throw new LiveAvatarException('api_key or LIVEAVATAR_API_KEY must be set'); | ||
| } | ||
| this.apiKey = apiKey; | ||
| this.apiUrl = options.apiUrl || DEFAULT_API_URL; | ||
| this.connOptions = options.connOptions || DEFAULT_API_CONNECT_OPTIONS; | ||
| } | ||
|
|
||
| /** | ||
| * Create a new streaming session, returning the session id and session token. | ||
| * | ||
| * Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py - 51-87 lines | ||
| */ | ||
| async createStreamingSession(opts: CreateStreamingSessionOptions): Promise<SessionResponse> { | ||
| const livekitConfig = { | ||
| livekit_room: opts.roomName, | ||
| livekit_url: opts.livekitUrl, | ||
| livekit_client_token: opts.livekitToken, | ||
| }; | ||
|
|
||
| const payload: Record<string, unknown> = { | ||
| mode: 'LITE', | ||
| avatar_id: opts.avatarId, | ||
| is_sandbox: opts.isSandbox ?? false, | ||
| livekit_config: livekitConfig, | ||
| }; | ||
|
|
||
| // Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py - 75-76 lines | ||
| if (opts.videoQuality != null) { | ||
| payload.video_quality = opts.videoQuality; | ||
| } | ||
|
|
||
| const headers = { | ||
| accept: 'application/json', | ||
| 'content-type': 'application/json', | ||
| 'X-API-KEY': this.apiKey, | ||
| }; | ||
| return (await this.post('/token', payload, headers)) as SessionResponse; | ||
| } | ||
|
|
||
| /** | ||
| * Start a previously created streaming session. | ||
| * | ||
| * Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py - 92-97 lines | ||
| */ | ||
| async startStreamingSession( | ||
| sessionId: string, | ||
| sessionToken: string, | ||
| ): Promise<StartSessionResponse> { | ||
| const payload = { session_id: sessionId }; | ||
| const headers = { | ||
| 'content-type': 'application/json', | ||
| Authorization: `Bearer ${sessionToken}`, | ||
| }; | ||
| return (await this.post('/start', payload, headers)) as StartSessionResponse; | ||
| } | ||
|
|
||
| /** | ||
| * Stop a running streaming session. | ||
| * | ||
| * Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py - 99-107 lines | ||
| */ | ||
| async stopStreamingSession( | ||
| sessionId: string, | ||
| sessionToken: string, | ||
| ): Promise<StopSessionResponse> { | ||
| const payload = { session_id: sessionId, reason: 'USER_DISCONNECTED' }; | ||
| const headers = { | ||
| 'content-type': 'application/json', | ||
| Authorization: `Bearer ${sessionToken}`, | ||
| }; | ||
| return (await this.post('/stop', payload, headers)) as StopSessionResponse; | ||
| } | ||
|
|
||
| /** | ||
| * POST helper with the same retry/backoff semantics as the Python plugin. | ||
| * | ||
| * Ref: python livekit-plugins/livekit-plugins-liveavatar/livekit/plugins/liveavatar/api.py - 109-138 lines | ||
| */ | ||
| private async post( | ||
| endpoint: string, | ||
| payload: Record<string, unknown>, | ||
| headers: Record<string, string>, | ||
| ): Promise<unknown> { | ||
| const url = this.apiUrl + endpoint; | ||
| const maxRetry = this.connOptions.maxRetry; | ||
| for (let i = 0; i < maxRetry; i++) { | ||
|
toubatbrian marked this conversation as resolved.
Outdated
|
||
| try { | ||
| const response = await fetch(url, { | ||
| method: 'POST', | ||
| headers, | ||
| body: JSON.stringify(payload), | ||
| signal: AbortSignal.timeout(this.connOptions.timeoutMs), | ||
| }); | ||
| if (!response.ok) { | ||
| const text = await response.text(); | ||
| throw new APIStatusError({ | ||
| message: `Server returned an error for ${url}: ${response.status}`, | ||
| options: { statusCode: response.status, body: { error: text } }, | ||
| }); | ||
| } | ||
| return await response.json(); | ||
| } catch (e) { | ||
| if (e instanceof APIStatusError && !e.retryable) { | ||
| throw e; | ||
| } | ||
| this.#logger.warn( | ||
| { error: String(e), url, attempt: i }, | ||
| `API request to ${url} failed on attempt ${i}`, | ||
| ); | ||
| } | ||
|
|
||
| if (i < maxRetry - 1) { | ||
| await new Promise((resolve) => setTimeout(resolve, this.connOptions.retryIntervalMs)); | ||
| } | ||
| } | ||
| throw new APIConnectionError({ | ||
| message: `Failed to call LiveAvatar API after ${maxRetry} retries`, | ||
| }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.