livekit-plugins-anva 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .npmrc
2
+ dist/
3
+ node_modules/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .venv/
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-anva
3
+ Version: 0.1.0
4
+ Summary: Anva avatar plugin for LiveKit Agents: a lip-synced video avatar for your voice agent.
5
+ Project-URL: Homepage, https://anva.ai
6
+ Project-URL: Documentation, https://anva.ai/docs#livekit
7
+ Project-URL: Source, https://github.com/Anva-avatars/anva-sdk
8
+ Author-email: Anva <anva.ai.2026@gmail.com>
9
+ License: MIT
10
+ Keywords: agents,anva,avatar,livekit,voice,webrtc
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: aiohttp>=3.9
18
+ Requires-Dist: livekit-agents>=1.8.2
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Anva avatars for LiveKit Agents
22
+
23
+ Give your LiveKit voice agent a face. The agent keeps its own STT, LLM and
24
+ TTS; Anva joins the room as a second participant and speaks the agent's words
25
+ with the lips in time, video and audio published together.
26
+
27
+ ```sh
28
+ pip install livekit-plugins-anva
29
+ ```
30
+
31
+ ```python
32
+ from livekit import agents, rtc
33
+ from livekit.agents import Agent, AgentSession
34
+ from livekit.plugins import anva, deepgram, openai, silero
35
+
36
+ async def entrypoint(ctx: agents.JobContext):
37
+ session = AgentSession(
38
+ stt=deepgram.STT(), llm=openai.LLM(), tts=openai.TTS(), vad=silero.VAD.load(),
39
+ )
40
+ avatar = anva.AvatarSession(avatar_id="av_...") # or preset_id="..."
41
+ await avatar.start(session, room=ctx.room)
42
+ await session.start(agent=Agent(instructions="You are a friendly guide."), room=ctx.room)
43
+ ```
44
+
45
+ Set `ANVA_API_KEY` (from anva.ai → API keys) and the usual `LIVEKIT_URL`,
46
+ `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`. `ANVA_AVATAR_ID` can stand in for
47
+ `avatar_id`.
48
+
49
+ ## What happens
50
+
51
+ 1. `start()` mints a room token for the avatar participant (kind `agent`,
52
+ attribute `lk.publish_on_behalf` naming your agent) and asks Anva to create
53
+ an `avatar_only` session that joins your room with it. Anva answers once the
54
+ avatar is in the room with its video and audio tracks published.
55
+ 2. The agent's audio output is replaced by LiveKit's `DataStreamAudioOutput`,
56
+ so speech goes to the avatar over the room's data stream rather than
57
+ straight to the room. Interruptions and playback receipts use LiveKit's
58
+ standard `lk.clear_buffer`, `lk.playback_started` and
59
+ `lk.playback_finished` RPCs.
60
+ 3. `aclose()` (called for you when the job shuts down) ends the Anva session.
61
+
62
+ Billing is Anva's `avatar_only` rate per connected minute. The audio can be any
63
+ sample rate and channel count; Anva converts it to what its lip-sync takes.
64
+
65
+ ## Options
66
+
67
+ | Argument | Meaning |
68
+ |---|---|
69
+ | `avatar_id` / `preset_id` | Exactly one. A preset's voice and prompt do not apply: the agent speaks. |
70
+ | `api_key`, `api_url` | Anva API key and base URL (`https://anva.ai`). |
71
+ | `avatar_participant_identity`, `avatar_participant_name` | Defaults `anva-avatar`, `Anva avatar`. |
72
+ | `max_duration_seconds` | End the Anva session this long after it goes live. |
73
+ | `metadata` | Echoed on Anva's session and webhooks. |
74
+ | `conn_options` | LiveKit `APIConnectOptions`: retries and timeouts for the Anva call. |
75
+
76
+ A `400` from Anva (wrong mode, bad avatar, plan limits) raises `AnvaException`
77
+ without retrying; a `503 no_capacity` is retried after Anva's `Retry-After`.
78
+
79
+ ## Front end
80
+
81
+ Your front end sees two agent participants. LiveKit's components pick the
82
+ avatar's tracks automatically; by hand, it is the participant whose
83
+ `lk.publish_on_behalf` attribute equals your agent's identity.
84
+
85
+ ## Tests
86
+
87
+ ```sh
88
+ livekit-server --dev &
89
+ LIVEKIT_TEST_URL=ws://127.0.0.1:7880 python -m pytest tests
90
+ ```
@@ -0,0 +1,70 @@
1
+ # Anva avatars for LiveKit Agents
2
+
3
+ Give your LiveKit voice agent a face. The agent keeps its own STT, LLM and
4
+ TTS; Anva joins the room as a second participant and speaks the agent's words
5
+ with the lips in time, video and audio published together.
6
+
7
+ ```sh
8
+ pip install livekit-plugins-anva
9
+ ```
10
+
11
+ ```python
12
+ from livekit import agents, rtc
13
+ from livekit.agents import Agent, AgentSession
14
+ from livekit.plugins import anva, deepgram, openai, silero
15
+
16
+ async def entrypoint(ctx: agents.JobContext):
17
+ session = AgentSession(
18
+ stt=deepgram.STT(), llm=openai.LLM(), tts=openai.TTS(), vad=silero.VAD.load(),
19
+ )
20
+ avatar = anva.AvatarSession(avatar_id="av_...") # or preset_id="..."
21
+ await avatar.start(session, room=ctx.room)
22
+ await session.start(agent=Agent(instructions="You are a friendly guide."), room=ctx.room)
23
+ ```
24
+
25
+ Set `ANVA_API_KEY` (from anva.ai → API keys) and the usual `LIVEKIT_URL`,
26
+ `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`. `ANVA_AVATAR_ID` can stand in for
27
+ `avatar_id`.
28
+
29
+ ## What happens
30
+
31
+ 1. `start()` mints a room token for the avatar participant (kind `agent`,
32
+ attribute `lk.publish_on_behalf` naming your agent) and asks Anva to create
33
+ an `avatar_only` session that joins your room with it. Anva answers once the
34
+ avatar is in the room with its video and audio tracks published.
35
+ 2. The agent's audio output is replaced by LiveKit's `DataStreamAudioOutput`,
36
+ so speech goes to the avatar over the room's data stream rather than
37
+ straight to the room. Interruptions and playback receipts use LiveKit's
38
+ standard `lk.clear_buffer`, `lk.playback_started` and
39
+ `lk.playback_finished` RPCs.
40
+ 3. `aclose()` (called for you when the job shuts down) ends the Anva session.
41
+
42
+ Billing is Anva's `avatar_only` rate per connected minute. The audio can be any
43
+ sample rate and channel count; Anva converts it to what its lip-sync takes.
44
+
45
+ ## Options
46
+
47
+ | Argument | Meaning |
48
+ |---|---|
49
+ | `avatar_id` / `preset_id` | Exactly one. A preset's voice and prompt do not apply: the agent speaks. |
50
+ | `api_key`, `api_url` | Anva API key and base URL (`https://anva.ai`). |
51
+ | `avatar_participant_identity`, `avatar_participant_name` | Defaults `anva-avatar`, `Anva avatar`. |
52
+ | `max_duration_seconds` | End the Anva session this long after it goes live. |
53
+ | `metadata` | Echoed on Anva's session and webhooks. |
54
+ | `conn_options` | LiveKit `APIConnectOptions`: retries and timeouts for the Anva call. |
55
+
56
+ A `400` from Anva (wrong mode, bad avatar, plan limits) raises `AnvaException`
57
+ without retrying; a `503 no_capacity` is retried after Anva's `Retry-After`.
58
+
59
+ ## Front end
60
+
61
+ Your front end sees two agent participants. LiveKit's components pick the
62
+ avatar's tracks automatically; by hand, it is the participant whose
63
+ `lk.publish_on_behalf` attribute equals your agent's identity.
64
+
65
+ ## Tests
66
+
67
+ ```sh
68
+ livekit-server --dev &
69
+ LIVEKIT_TEST_URL=ws://127.0.0.1:7880 python -m pytest tests
70
+ ```
@@ -0,0 +1,26 @@
1
+ """Anva avatars for LiveKit Agents.
2
+
3
+ from livekit.plugins import anva
4
+
5
+ avatar = anva.AvatarSession(avatar_id="av_...")
6
+ await avatar.start(session, room=ctx.room)
7
+
8
+ The avatar joins the room as its own participant and speaks whatever the
9
+ agent says, with the lips in time. The agent keeps its own STT, LLM and TTS.
10
+ """
11
+ from .avatar import AnvaException, AvatarSession
12
+ from .version import __version__
13
+
14
+ __all__ = ["AvatarSession", "AnvaException", "__version__"]
15
+
16
+ from livekit.agents import Plugin
17
+
18
+ from .log import logger
19
+
20
+
21
+ class AnvaPlugin(Plugin):
22
+ def __init__(self) -> None:
23
+ super().__init__(__name__, __version__, __package__, logger)
24
+
25
+
26
+ Plugin.register_plugin(AnvaPlugin())
@@ -0,0 +1,237 @@
1
+ """An Anva avatar as a LiveKit Agents avatar session.
2
+
3
+ The agent does what it already does: listens, thinks, speaks. Its speech goes
4
+ to the avatar participant over the room's data stream instead of straight to
5
+ the room, and the avatar publishes the face and the voice together, in sync.
6
+ On Anva's side that is an ``avatar_only`` session: Anva supplies no voice of
7
+ its own, only the lip-synced video for the audio it is given.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ from typing import Any
15
+
16
+ import aiohttp
17
+
18
+ from livekit import api, rtc
19
+ from livekit.agents import (
20
+ DEFAULT_API_CONNECT_OPTIONS,
21
+ NOT_GIVEN,
22
+ AgentSession,
23
+ APIConnectionError,
24
+ APIConnectOptions,
25
+ APIStatusError,
26
+ NotGivenOr,
27
+ get_job_context,
28
+ utils,
29
+ )
30
+ from livekit.agents.voice.avatar import AvatarSession as BaseAvatarSession, DataStreamAudioOutput
31
+ from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
32
+
33
+ from .log import logger
34
+
35
+ DEFAULT_API_URL = "https://anva.ai"
36
+ DEFAULT_AVATAR_IDENTITY = "anva-avatar"
37
+ DEFAULT_AVATAR_NAME = "Anva avatar"
38
+
39
+
40
+ class AnvaException(Exception):
41
+ """Anva could not start or run the avatar."""
42
+
43
+
44
+ class AvatarSession(BaseAvatarSession):
45
+ """An Anva avatar session.
46
+
47
+ Exactly one of ``avatar_id`` (an avatar from your Anva account) or
48
+ ``preset_id`` (a saved preset, whose voice and prompt are ignored here
49
+ because the agent speaks) is required. The API key comes from
50
+ ``api_key`` or ``ANVA_API_KEY``.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ avatar_id: NotGivenOr[str] = NOT_GIVEN,
57
+ preset_id: NotGivenOr[str] = NOT_GIVEN,
58
+ api_url: NotGivenOr[str] = NOT_GIVEN,
59
+ api_key: NotGivenOr[str] = NOT_GIVEN,
60
+ avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
61
+ avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
62
+ max_duration_seconds: NotGivenOr[int] = NOT_GIVEN,
63
+ metadata: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
64
+ conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
65
+ ) -> None:
66
+ super().__init__()
67
+ self._avatar_id = avatar_id or os.getenv("ANVA_AVATAR_ID") or None
68
+ self._preset_id = preset_id or None
69
+ if bool(self._avatar_id) == bool(self._preset_id):
70
+ raise AnvaException("give exactly one of avatar_id (or ANVA_AVATAR_ID) and preset_id")
71
+ self._api_url = (api_url or os.getenv("ANVA_API_URL", DEFAULT_API_URL)).rstrip("/")
72
+ self._api_key = api_key or os.getenv("ANVA_API_KEY")
73
+ if not self._api_key:
74
+ raise AnvaException("api_key is required: pass it or set ANVA_API_KEY")
75
+ self._avatar_participant_identity = avatar_participant_identity or DEFAULT_AVATAR_IDENTITY
76
+ self._avatar_participant_name = avatar_participant_name or DEFAULT_AVATAR_NAME
77
+ self._max_duration_seconds = max_duration_seconds or None
78
+ self._metadata = metadata or None
79
+ self._conn_options = conn_options
80
+ self._http_session: aiohttp.ClientSession | None = None
81
+ self._owns_http_session = False
82
+ self._session_id: str | None = None
83
+
84
+ @property
85
+ def avatar_identity(self) -> str:
86
+ return self._avatar_participant_identity
87
+
88
+ @property
89
+ def provider(self) -> str:
90
+ return "anva"
91
+
92
+ @property
93
+ def session_id(self) -> str | None:
94
+ """The Anva session behind this avatar, once started."""
95
+ return self._session_id
96
+
97
+ def _ensure_http_session(self) -> aiohttp.ClientSession:
98
+ if self._http_session is None:
99
+ try:
100
+ self._http_session = utils.http_context.http_session()
101
+ except RuntimeError:
102
+ # Outside a job (a script, a test) there is no shared session.
103
+ self._http_session = aiohttp.ClientSession()
104
+ self._owns_http_session = True
105
+ return self._http_session
106
+
107
+ async def start(
108
+ self,
109
+ agent_session: AgentSession,
110
+ room: rtc.Room,
111
+ *,
112
+ livekit_url: NotGivenOr[str] = NOT_GIVEN,
113
+ livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
114
+ livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
115
+ ) -> None:
116
+ await super().start(agent_session, room)
117
+
118
+ livekit_url = livekit_url or (os.getenv("LIVEKIT_URL") or NOT_GIVEN)
119
+ livekit_api_key = livekit_api_key or (os.getenv("LIVEKIT_API_KEY") or NOT_GIVEN)
120
+ livekit_api_secret = livekit_api_secret or (os.getenv("LIVEKIT_API_SECRET") or NOT_GIVEN)
121
+ if not livekit_url or not livekit_api_key or not livekit_api_secret:
122
+ raise AnvaException(
123
+ "livekit_url, livekit_api_key and livekit_api_secret must be set, "
124
+ "by argument or by the LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET variables"
125
+ )
126
+
127
+ job_ctx = get_job_context(required=False)
128
+ local_identity = (
129
+ job_ctx.local_participant_identity if job_ctx is not None else room.local_participant.identity
130
+ )
131
+ # The avatar joins as an agent participant publishing on behalf of this
132
+ # agent: that attribute is how LiveKit front ends pair the two.
133
+ livekit_token = (
134
+ api.AccessToken(api_key=livekit_api_key, api_secret=livekit_api_secret)
135
+ .with_kind("agent")
136
+ .with_identity(self._avatar_participant_identity)
137
+ .with_name(self._avatar_participant_name)
138
+ .with_grants(api.VideoGrants(room_join=True, room=room.name))
139
+ .with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: local_identity})
140
+ .to_jwt()
141
+ )
142
+
143
+ logger.debug("starting Anva avatar session", extra={"room": room.name})
144
+ await self._create_session(livekit_url, livekit_token)
145
+
146
+ # The agent's speech now goes to the avatar, and the agent waits for
147
+ # the avatar's video before it speaks its first word.
148
+ agent_session.output.replace_audio_tail(
149
+ DataStreamAudioOutput(
150
+ room=room,
151
+ destination_identity=self._avatar_participant_identity,
152
+ wait_remote_track=rtc.TrackKind.KIND_VIDEO,
153
+ )
154
+ )
155
+
156
+ def _session_body(self, livekit_url: str, livekit_token: str) -> dict[str, Any]:
157
+ body: dict[str, Any] = {
158
+ "service_mode": "avatar_only",
159
+ "livekit": {"url": livekit_url, "token": livekit_token},
160
+ }
161
+ if self._avatar_id:
162
+ body["avatar_id"] = self._avatar_id
163
+ else:
164
+ body["preset_id"] = self._preset_id
165
+ if self._max_duration_seconds:
166
+ body["max_duration_seconds"] = self._max_duration_seconds
167
+ if self._metadata:
168
+ body["metadata"] = self._metadata
169
+ return body
170
+
171
+ async def _create_session(self, livekit_url: str, livekit_token: str) -> None:
172
+ """Ask Anva to join the room. Anva answers once the avatar is in it."""
173
+ assert self._api_key is not None
174
+ body = self._session_body(livekit_url, livekit_token)
175
+ last_error: Exception | None = None
176
+ for attempt in range(self._conn_options.max_retry):
177
+ try:
178
+ async with self._ensure_http_session().post(
179
+ f"{self._api_url}/api/v2/sessions",
180
+ headers={"Authorization": f"Bearer {self._api_key}"},
181
+ json=body,
182
+ timeout=aiohttp.ClientTimeout(total=90, sock_connect=self._conn_options.timeout),
183
+ ) as response:
184
+ if response.status == 201:
185
+ data = await response.json()
186
+ self._session_id = data.get("session_id")
187
+ logger.info(
188
+ "Anva avatar joined the room",
189
+ extra={"session_id": self._session_id, "livekit": data.get("livekit")},
190
+ )
191
+ return
192
+ text = await response.text()
193
+ # 4xx other than capacity is about the request; say so at once.
194
+ if 400 <= response.status < 500 and response.status not in (408, 429):
195
+ raise AnvaException(f"Anva refused the session ({response.status}): {text}")
196
+ retry_after = response.headers.get("Retry-After")
197
+ last_error = APIStatusError(
198
+ "Anva could not start the session", status_code=response.status, body=text
199
+ )
200
+ logger.warning(
201
+ "Anva session not started yet",
202
+ extra={"status": response.status, "retry_after": retry_after, "body": text[:300]},
203
+ )
204
+ if attempt < self._conn_options.max_retry - 1:
205
+ await asyncio.sleep(
206
+ float(retry_after) if retry_after and retry_after.isdigit()
207
+ else self._conn_options.retry_interval
208
+ )
209
+ continue
210
+ except AnvaException:
211
+ raise
212
+ except Exception as e: # noqa: BLE001
213
+ last_error = e
214
+ logger.warning("could not reach the Anva API", extra={"error": str(e)})
215
+ if attempt < self._conn_options.max_retry - 1:
216
+ await asyncio.sleep(self._conn_options.retry_interval)
217
+ raise APIConnectionError(f"failed to start the Anva avatar session: {last_error}")
218
+
219
+ async def aclose(self) -> None:
220
+ # Ending the Anva session is what stops its billing; leaving the room
221
+ # alone would stop it too, a reconnect grace later.
222
+ if self._session_id and self._api_key:
223
+ try:
224
+ async with self._ensure_http_session().delete(
225
+ f"{self._api_url}/api/v2/sessions/{self._session_id}",
226
+ headers={"Authorization": f"Bearer {self._api_key}"},
227
+ timeout=aiohttp.ClientTimeout(total=15),
228
+ ) as response:
229
+ if response.status >= 400:
230
+ logger.debug("Anva session already ended", extra={"status": response.status})
231
+ except Exception: # noqa: BLE001
232
+ logger.debug("could not end the Anva session", exc_info=True)
233
+ self._session_id = None
234
+ if self._owns_http_session and self._http_session is not None:
235
+ await self._http_session.close()
236
+ self._http_session = None
237
+ await super().aclose()
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.anva")
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livekit-plugins-anva"
7
+ dynamic = ["version"]
8
+ description = "Anva avatar plugin for LiveKit Agents: a lip-synced video avatar for your voice agent."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Anva", email = "anva.ai.2026@gmail.com" }]
13
+ keywords = ["livekit", "agents", "avatar", "anva", "webrtc", "voice"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Video",
20
+ ]
21
+ dependencies = ["livekit-agents>=1.8.2", "aiohttp>=3.9"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://anva.ai"
25
+ Documentation = "https://anva.ai/docs#livekit"
26
+ Source = "https://github.com/Anva-avatars/anva-sdk"
27
+
28
+ [tool.hatch.version]
29
+ path = "livekit/plugins/anva/version.py"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["livekit"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["/livekit", "/README.md"]