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
1 change: 1 addition & 0 deletions examples/avatar_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ These providers work with pre-configured avatars using unique avatar identifiers
- **[BitHuman](./bithuman/)** (Cloud mode) - [Platform](https://bithuman.ai/) | [Integration Guide](https://sdk.docs.bithuman.ai/#/preview/livekit-cloud-plugin)
- **[LemonSlice](./lemonslice/)** - [Platform](https://www.lemonslice.com/) | [Integration Guide](https://lemonslice.com/docs/self-managed/livekit-agent-integration)
- **[LiveAvatar](./liveavatar/)** - [Platform](https://www.liveavatar.com/)
- **[Protoface](./protoface/)** - [Platform](https://protoface.com/)
- **[Simli](./simli/)** - [Platform](https://app.simli.com/)
- **[Tavus](./tavus/)** - [Platform](https://www.tavus.io/)
- **[TruGen](./trugen/)** - [Platform](https://app.trugen.ai/)
Expand Down
28 changes: 28 additions & 0 deletions examples/avatar_agents/protoface/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# LiveKit Protoface Avatar Agent

This example demonstrates how to create a realtime [Protoface](https://protoface.com/)
avatar with LiveKit Agents.

## Usage

- Update the environment:

```bash
# Protoface config. PROTOFACE_AVATAR_ID is optional and defaults to av_stock_001.
export PROTOFACE_API_KEY="..."
export PROTOFACE_AVATAR_ID="av_stock_001"

# Google config
export GOOGLE_API_KEY="..."

# LiveKit config
export LIVEKIT_API_KEY="..."
export LIVEKIT_API_SECRET="..."
export LIVEKIT_URL="..."
```

- Start the agent worker:

```bash
python examples/avatar_agents/protoface/agent_worker.py dev
```
36 changes: 36 additions & 0 deletions examples/avatar_agents/protoface/agent_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import logging
import os

from dotenv import load_dotenv

from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
from livekit.plugins import google, protoface

logger = logging.getLogger("protoface-avatar-example")
logger.setLevel(logging.INFO)

load_dotenv()

server = AgentServer()


@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
llm=google.realtime.RealtimeModel(voice="Charon"),
resume_false_interruption=False,
)

avatar = protoface.AvatarSession(
avatar_id=os.getenv("PROTOFACE_AVATAR_ID", protoface.DEFAULT_STOCK_AVATAR_ID),
)
await avatar.start(session, room=ctx.room)

await session.start(
agent=Agent(instructions="Talk to me!"),
room=ctx.room,
)


if __name__ == "__main__":
cli.run_app(server)
1 change: 1 addition & 0 deletions livekit-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ nltk = ["livekit-plugins-nltk>=1.6.3"]
nvidia = ["livekit-plugins-nvidia>=1.6.3"]
openai = ["livekit-plugins-openai>=1.6.3"]
perplexity = ["livekit-plugins-perplexity>=1.6.3"]
protoface = ["livekit-plugins-protoface>=1.6.3"]
resemble = ["livekit-plugins-resemble>=1.6.3"]
respeecher = ["livekit-plugins-respeecher>=1.6.3"]
rime = ["livekit-plugins-rime>=1.6.3"]
Expand Down
15 changes: 15 additions & 0 deletions livekit-plugins/livekit-plugins-protoface/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Protoface plugin for LiveKit Agents

Support for the [Protoface](https://protoface.com/) virtual avatar.

See the [Protoface docs](https://docs.protoface.com/) for more information.

## Installation

```bash
pip install livekit-plugins-protoface
```

## Pre-requisites

You'll need an API key from Protoface. It can be set as an environment variable: `PROTOFACE_API_KEY`
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Protoface avatar plugin for LiveKit Agents."""

from .avatar import DEFAULT_STOCK_AVATAR_ID, AvatarSession
from .errors import ProtofaceException
from .version import __version__

__all__ = [
"DEFAULT_STOCK_AVATAR_ID",
"AvatarSession",
"ProtofaceException",
"__version__",
]

from livekit.agents import Plugin

from .log import logger


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


Plugin.register_plugin(ProtofacePlugin())
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
from __future__ import annotations

import asyncio
import os
from typing import Any

import aiohttp

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

from .errors import ProtofaceException
from .log import logger
from .version import __version__

DEFAULT_API_URL = "https://api.protoface.com"
_USER_AGENT = f"livekit-plugins-protoface/{__version__}"


class ProtofaceAPI:
"""Async client for the Protoface session API."""

def __init__(
self,
*,
api_key: NotGivenOr[str | None] = NOT_GIVEN,
api_url: NotGivenOr[str | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Create a Protoface API client.

Args:
api_key: Protoface API key. Defaults to the `PROTOFACE_API_KEY`
environment variable.
api_url: Protoface API base URL. Defaults to `PROTOFACE_API_URL` or
`https://api.protoface.com`.
conn_options: Timeout and retry settings for API calls.
session: Optional caller-owned HTTP session. When omitted, the
client uses LiveKit's shared HTTP session.

Raises:
ProtofaceException: If no API key is passed and `PROTOFACE_API_KEY`
is not set.
"""
self._api_key = _resolve_optional_string(api_key, "PROTOFACE_API_KEY")
if not self._api_key:
raise ProtofaceException(
"api_key must be set by passing it to ProtofaceAPI or "
"setting the PROTOFACE_API_KEY environment variable"
)

api_url_value = _resolve_optional_string(api_url, "PROTOFACE_API_URL")
self._api_url = (api_url_value or DEFAULT_API_URL).rstrip("/")
self._conn_options = conn_options
self._session = session

async def start_session(
self,
*,
avatar_id: str,
transport: dict[str, Any],
max_duration_seconds: NotGivenOr[int | None] = NOT_GIVEN,
) -> dict[str, Any]:
"""Create a hosted Protoface avatar session.

Args:
avatar_id: Protoface avatar ID to render.
transport: Protoface transport configuration. The LiveKit Agents
plugin uses `audio_source="data_stream"`.
max_duration_seconds: Optional maximum session duration. Protoface
applies the lower of this value and the account plan limit.

Returns:
The decoded Protoface session object.

Raises:
APIConnectionError: If a retryable API or network error persists
after all retry attempts.
APIStatusError: If Protoface returns a non-retryable error response.
"""
body: dict[str, Any] = {"avatar_id": avatar_id, "transport": transport}
if utils.is_given(max_duration_seconds) and max_duration_seconds is not None:
body["max_duration_seconds"] = max_duration_seconds

return await self._json("POST", "/v1/sessions", json=body)

async def end_session(self, session_id: str) -> dict[str, Any]:
"""Request a graceful end for a hosted Protoface session.

Args:
session_id: Protoface session ID returned by `start_session()`.

Returns:
The decoded Protoface response body.

Raises:
APIConnectionError: If a retryable API or network error persists
after all retry attempts.
APIStatusError: If Protoface returns a non-retryable error response.
"""
return await self._json("POST", f"/v1/sessions/{session_id}/end")

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

async def _json(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
request_headers = {
"Authorization": f"Bearer {self._api_key}",
"User-Agent": _USER_AGENT,
"Accept": "application/json",
**(headers or {}),
}
url = f"{self._api_url}{path}"
error: Exception | None = None

for attempt in range(self._conn_options.max_retry + 1):
try:
async with self._ensure_http_session().request(
method,
url,
json=json,
headers=request_headers,
timeout=aiohttp.ClientTimeout(total=self._conn_options.timeout),
) as response:
payload = await _read_payload(response)
if response.ok:
if not isinstance(payload, dict):
raise APIStatusError(
"Protoface API returned a non-object JSON response",
status_code=response.status,
body=payload,
retryable=False,
)
return payload

raise APIStatusError(
"Protoface API returned an error",
status_code=response.status,
body=payload,
)
except asyncio.TimeoutError as exc:
error = APITimeoutError()
error.__cause__ = exc
except aiohttp.ClientError as exc:
error = APIConnectionError()
error.__cause__ = exc
except APIStatusError as exc:
if not exc.retryable:
raise
error = exc

if attempt == self._conn_options.max_retry:
break

logger.warning(
"protoface api request failed, retrying",
extra={"attempt": attempt + 1, "method": method, "path": path},
)
await asyncio.sleep(self._conn_options._interval_for_retry(attempt))

raise APIConnectionError("Failed to call Protoface API after all retries.") from error


def _resolve_optional_string(value: NotGivenOr[str | None], env_name: str) -> str | None:
if utils.is_given(value) and value is not None:
return value
return os.getenv(env_name)


async def _read_payload(response: aiohttp.ClientResponse) -> object:
text = await response.text()
if not text:
return {}

try:
return await response.json(content_type=None)
except ValueError:
return {"raw": text}


__all__ = ["DEFAULT_API_URL", "ProtofaceAPI"]
Loading