Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions livekit-plugins/livekit-plugins-avatario/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Avatario virtual avatar plugin for LiveKit Agents

Support for the [Avatario](https://avatario.ai/) virtual avatar.


Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .api import AvatarioException
from .avatar import AvatarSession
from .version import __version__

__all__ = [
"AvatarioException",
"AvatarSession",
"__version__",
]

from livekit.agents import Plugin

from .log import logger


class AvatarioPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)


Plugin.register_plugin(AvatarioPlugin())
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import asyncio
import os
from typing import Any, cast

import aiohttp

from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectionError,
APIConnectOptions,
APIStatusError,
NotGivenOr,
utils,
)

from .log import logger


class AvatarioException(Exception):
"""Exception for Avatario errors"""


DEFAULT_API_URL = "https://avatario.ai/api/sdk"


class AvatarioAPI:
def __init__(
self,
api_key: NotGivenOr[str] = NOT_GIVEN,
avatar_id: NotGivenOr[str] = NOT_GIVEN,
video_info: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
session: aiohttp.ClientSession | None = None,
) -> None:
if not avatar_id:
raise AvatarioException("avatar_id must be set")
self._avatar_id = avatar_id
self._video_info = video_info
self._api_key = api_key or os.getenv("AVATARIO_API_KEY")
if self._api_key is None:
raise AvatarioException("AVATARIO_API_KEY must be set")
self._api_key = cast(str, self._api_key)

self._conn_options = conn_options
self._session = session or aiohttp.ClientSession()

async def start_session(
self,
*,
livekit_agent_identity: NotGivenOr[str] = NOT_GIVEN,
properties: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> None:
if not livekit_agent_identity:
raise AvatarioException(
"the identity of agent needs to be provided "
"to ensure its proper communication with avatario backend"
)

properties = properties or {}

payload = {
"avatario_face_id": self._avatar_id,
"agent_id": livekit_agent_identity,
"livekit": properties,
}

if utils.is_given(self._video_info):
payload.update(self._video_info)
await self._post(payload)

async def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
"""
Make a POST request to the Avatario API with retry logic.

Args:
endpoint: API endpoint path (without leading slash)
payload: JSON payload for the request

Returns:
Response data as a dictionary

Raises:
APIConnectionError: If the request fails after all retries
"""
for i in range(self._conn_options.max_retry):
try:
async with self._session.post(
f"{DEFAULT_API_URL}/start-session",
headers={
"Content-Type": "application/json",
"x-api-key": self._api_key,
},
json=payload,
timeout=self._conn_options.timeout,
) as response:
if not response.ok:
text = await response.text()
raise APIStatusError(
"Server returned an error", status_code=response.status, body=text
)
return await response.json()
except Exception as e:
if isinstance(e, APIConnectionError):
logger.warning("failed to call Avatario API", extra={"error": str(e)})
else:
logger.exception("failed to call avatario api")

if i < self._conn_options.max_retry - 1:
await asyncio.sleep(self._conn_options.retry_interval)

raise APIConnectionError("Failed to call Avatario API after all retries")
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from __future__ import annotations

import os
from dataclasses import asdict, dataclass

import aiohttp

from livekit import api, rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
AgentSession,
APIConnectOptions,
NotGivenOr,
utils,
)
from livekit.agents.voice.avatar import DataStreamAudioOutput
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF

from .api import AvatarioAPI, AvatarioException
from .log import logger

SAMPLE_RATE = 24000
_AVATAR_AGENT_IDENTITY = "avatario-avatar-agent"
_AVATAR_AGENT_NAME = "avatario-avatar-agent"


class AvatarSession:
"""An Avatario avatar session"""

@dataclass
class VideoInfo:
video_height: int = 720
video_width: int = 1280
custom_background_url: str | None = None

def __init__(
self,
*,
avatar_id: NotGivenOr[str] = NOT_GIVEN,
video_info: NotGivenOr[VideoInfo] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> None:
self._http_session: aiohttp.ClientSession | None = None
self._conn_options = conn_options
video_info = video_info if utils.is_given(video_info) else self.VideoInfo()

self._api = AvatarioAPI(
api_key=api_key,
video_info=asdict(video_info),
avatar_id=avatar_id,
conn_options=conn_options,
session=self._ensure_http_session(),
)

self._avatar_participant_identity = avatar_participant_identity or _AVATAR_AGENT_IDENTITY
self._avatar_participant_name = avatar_participant_name or _AVATAR_AGENT_NAME

def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._http_session is None:
self._http_session = utils.http_context.http_session()

return self._http_session

async def start(
self,
agent_session: AgentSession,
room: rtc.Room,
*,
livekit_url: NotGivenOr[str] = NOT_GIVEN,
livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
) -> None:
livekit_url = livekit_url or os.getenv("LIVEKIT_URL")
livekit_api_key = livekit_api_key or os.getenv("LIVEKIT_API_KEY")
livekit_api_secret = livekit_api_secret or os.getenv("LIVEKIT_API_SECRET")
if not livekit_url or not livekit_api_key or not livekit_api_secret:
raise AvatarioException(
"livekit_url, livekit_api_key, and livekit_api_secret must be set "
"by arguments or environment variables"
)
livekit_token = (
api.AccessToken(api_key=livekit_api_key, api_secret=livekit_api_secret)
.with_kind("agent")
.with_identity(self._avatar_participant_identity)
.with_name(self._avatar_participant_name)
.with_grants(api.VideoGrants(room_join=True, room=room.name))
# allow the avatar agent to publish audio and video on behalf of your local agent
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: room.local_participant.identity})
.to_jwt()
)

await self._api.start_session(
livekit_agent_identity=room.local_participant.identity,
properties={
"url": livekit_url,
"token": livekit_token,
},
)

logger.debug("waiting for avatar agent to join the room")
await utils.wait_for_participant(room=room, identity=self._avatar_participant_identity)

agent_session.output.audio = DataStreamAudioOutput(
room=room,
destination_identity=self._avatar_participant_identity,
sample_rate=SAMPLE_RATE,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import logging

logger = logging.getLogger("livekit.plugins.avatario")
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

__version__ = "1.3.5"
39 changes: 39 additions & 0 deletions livekit-plugins/livekit-plugins-avatario/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "livekit-plugins-avatario"
dynamic = ["version"]
description = "Agent Framework plugin for Avatario"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9.0"
authors = [{ name = "LiveKit", email = "[email protected]" }]
keywords = ["voice", "ai", "realtime", "audio", "video", "livekit", "webrtc"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
]
dependencies = ["livekit-agents>=1.3.5"]

[project.urls]
Documentation = "https://docs.livekit.io"
Website = "https://livekit.io/"
Source = "https://github.com/livekit/agents"

[tool.hatch.version]
path = "livekit/plugins/avatario/version.py"

[tool.hatch.build.targets.wheel]
packages = ["livekit"]

[tool.hatch.build.targets.sdist]
include = ["/livekit"]
Loading