livekit-plugins-chatfx 0.1.0__py3-none-any.whl

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,37 @@
1
+ """ChatFX Avatar for LiveKit Agents.
2
+
3
+ A real-time talking face for your voice agent. The avatar joins your agent's LiveKit room as one more participant, takes
4
+ your agent's voice and speaks it with its lips in time, shown as your agent.
5
+
6
+ from livekit.plugins import chatfx
7
+
8
+ avatar = chatfx.AvatarSession(avatar_id="chatfx-lois") # your key in CHATFX_AVATAR_API_KEY
9
+ await avatar.start(session, room=ctx.room)
10
+
11
+ Docs: https://chatfx.com/support/avatar-connect/
12
+ """
13
+
14
+ from livekit.agents import Plugin
15
+
16
+ from .avatar import AvatarSession, ChatFXException, warm
17
+ from .log import logger
18
+ from .version import __version__
19
+
20
+ __all__ = ["AvatarSession", "ChatFXException", "warm", "__version__"]
21
+
22
+
23
+ class ChatFXPlugin(Plugin):
24
+ def __init__(self) -> None:
25
+ super().__init__(__name__, __version__, __package__, logger)
26
+
27
+
28
+ Plugin.register_plugin(ChatFXPlugin())
29
+
30
+ # Cleanup docs of unexported modules
31
+ _module = dir()
32
+ NOT_IN_ALL = [m for m in _module if m not in __all__]
33
+
34
+ __pdoc__ = {}
35
+
36
+ for n in NOT_IN_ALL:
37
+ __pdoc__[n] = False
@@ -0,0 +1,624 @@
1
+ """The ChatFX Avatar session: sends a talking face into your agent's LiveKit room.
2
+
3
+ Everything about how the avatar behaves in a call lives here, so every customer gets it by adding two lines:
4
+
5
+ 1. A waiting picture, with no work on your page. The moment the call starts, your agent shows the avatar's picture as its own
6
+ video, with "Video starting..." on it, and it comes down when the live face is ready. If the face never comes, the
7
+ picture stays, without the words, with your agent's voice over it. Any page that shows the agent's video gets this.
8
+ 2. Face first. A waking GPU takes about 11 seconds, so start() holds your agent back for up to face_first_seconds (10 by
9
+ default) and the agent greets with its face.
10
+ 3. Voice if the face is late. After that wait your agent starts talking with its own voice, and the voice moves to the face
11
+ between replies when it arrives, so a sentence is never split between two voices.
12
+ 4. The call never depends on the face. If the face can't come, or leaves mid-call, your agent carries on by voice.
13
+ 5. It keeps trying while a GPU wakes or is busy, for up to start_timeout seconds, and stops at once on a refusal more tries
14
+ won't fix (a wrong key, no minutes left).
15
+ 6. Your page can follow along. The agent's participant attribute "chatfx-face" says "waking", "voice", "on" or "off", so
16
+ you can show a photo or a message while the face is on its way.
17
+ 7. No call runs up the meter by accident. It ends after 2 minutes with nobody talking, 2 minutes after the visitor leaves
18
+ (long enough for a page reload), or at 1 hour. Each of these is a setting.
19
+ 8. Your agent can't go deaf. It learns the avatar has finished a reply from a message the face sends; if that's late, the
20
+ plugin ends the reply itself once the sound has run out, so the next question isn't ignored.
21
+ 9. Optional head start: warm(), called from your own server the moment a visitor is about to start a call.
22
+
23
+ How it connects: a LiveKit pass (access token) is made here with your own LiveKit key and secret, for the participant
24
+ "chatfx-avatar", to join this one room only, shown as your agent (lk.publish_on_behalf), valid for 5 hours. Your LiveKit key
25
+ and secret never leave your server. POST https://api.chatfx.com/v1/sessions starts the avatar; your agent's voice goes to it
26
+ as a LiveKit data stream; a check-in every 20 seconds keeps it going; aclose() (run when the job shuts down) ends it, so
27
+ billing stops.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import io
34
+ import os
35
+ import time
36
+ from datetime import timedelta
37
+ from typing import Any, Literal
38
+
39
+ import aiohttp
40
+
41
+ from livekit import api, rtc
42
+ from livekit.agents import AgentSession, get_job_context, utils
43
+ from livekit.agents.voice.avatar import AvatarSession as _AvatarSession, DataStreamAudioOutput
44
+ from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
45
+
46
+ from .log import logger
47
+
48
+ DEFAULT_API_URL = "https://api.chatfx.com"
49
+ AVATAR_IDENTITY = "chatfx-avatar"
50
+ AVATAR_NAME = "ChatFX"
51
+ KEEPALIVE_SECONDS = 20
52
+ # Our API accepts passes that expire within 6 hours.
53
+ PASS_TTL = timedelta(hours=5)
54
+ # Where the face is, for your page: the attribute on your agent's participant.
55
+ FACE_ATTRIBUTE = "chatfx-face"
56
+ FaceStatus = Literal["waking", "voice", "on", "off"]
57
+ # How long the GPU has, once it said yes, to put the picture in the room.
58
+ PICTURE_SECONDS = 8.0
59
+ # The API can hold a start while a GPU wakes (up to 45 seconds).
60
+ START_REQUEST_SECONDS = 60
61
+ REQUEST_SECONDS = 15
62
+ RETRY_SECONDS = 3.0
63
+ # How often the call is checked for the idle rules and stuck speech.
64
+ WATCH_SECONDS = 1.0
65
+ # Past the end of its own sound, the avatar isn't talking, whatever the face has reported.
66
+ SOUND_GRACE_SECONDS = 5.0
67
+ # The waiting picture is the same frame over and over, so a few a second is plenty.
68
+ STILL_FPS = 5
69
+ STILL_TRACK = "chatfx-waiting"
70
+ STILL_LONGEST_SIDE = 1280
71
+
72
+
73
+ class ChatFXException(Exception):
74
+ """The ChatFX Avatar API said no. code is its error code (for example invalid_api_key or out_of_minutes)."""
75
+
76
+ def __init__(self, message: str, *, code: str = "error", status: int = 0) -> None:
77
+ super().__init__(message)
78
+ self.code = code
79
+ self.status = status
80
+
81
+
82
+ def _settings(api_key: str | None, api_url: str | None) -> tuple[str, str]:
83
+ key = api_key or os.environ.get("CHATFX_AVATAR_API_KEY")
84
+ if not key:
85
+ raise ChatFXException("Set CHATFX_AVATAR_API_KEY, or pass api_key. Create a key on the API keys page.", code="missing_api_key")
86
+ return key, (api_url or os.environ.get("CHATFX_AVATAR_API_URL") or DEFAULT_API_URL).rstrip("/")
87
+
88
+
89
+ async def warm(*, api_key: str | None = None, api_url: str | None = None) -> bool:
90
+ """Starts waking a GPU ahead of a call, so the face is ready sooner. Optional, free, and safe to call often.
91
+
92
+ Call it from your own server the moment a visitor is about to start a call (never from a web page, where your key would
93
+ show), and again every 20 seconds while they get ready. True when the API took the request.
94
+ """
95
+ key, url = _settings(api_key, api_url)
96
+ try:
97
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=REQUEST_SECONDS)) as http:
98
+ async with http.post(url + "/v1/warm", headers={"Authorization": f"Bearer {key}"}) as response:
99
+ await response.read()
100
+ return response.status == 200
101
+ except (aiohttp.ClientError, asyncio.TimeoutError):
102
+ logger.debug("chatfx head start didn't go through", exc_info=True)
103
+ return False
104
+
105
+
106
+ def _still_frame(picture: bytes, text: str | None) -> rtc.VideoFrame:
107
+ """The avatar's picture as a video frame, with the words in a soft band near the bottom."""
108
+ from PIL import Image, ImageDraw, ImageFont
109
+
110
+ image = Image.open(io.BytesIO(picture)).convert("RGBA")
111
+ image.thumbnail((STILL_LONGEST_SIDE, STILL_LONGEST_SIDE))
112
+ # Video encoders need even sizes.
113
+ image = image.crop((0, 0, image.width - image.width % 2, image.height - image.height % 2))
114
+ if text:
115
+ size = max(14, round(min(image.width, image.height) * 0.05))
116
+ font = ImageFont.load_default(size=size)
117
+ band = Image.new("RGBA", image.size, (0, 0, 0, 0))
118
+ draw = ImageDraw.Draw(band)
119
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
120
+ x, y = (image.width - (right - left)) // 2, image.height - size * 3
121
+ pad = size // 2
122
+ draw.rounded_rectangle((x - pad * 2, y - pad + top, x + right - left + pad * 2, y + bottom + pad), radius=size, fill=(12, 10, 40, 170))
123
+ draw.text((x - left, y), text, font=font, fill=(255, 255, 255, 255))
124
+ image = Image.alpha_composite(image, band)
125
+ return rtc.VideoFrame(image.width, image.height, rtc.VideoBufferType.RGBA, image.tobytes())
126
+
127
+
128
+ class _WaitingPicture:
129
+ """The avatar's picture, published as the agent's own video until the live face is ready."""
130
+
131
+ def __init__(self, room: rtc.Room, picture: bytes, text: str | None) -> None:
132
+ self._room = room
133
+ self._with_words = _still_frame(picture, text)
134
+ self._plain = _still_frame(picture, None) if text else self._with_words
135
+ self._frame = self._with_words
136
+ self._source = rtc.VideoSource(self._frame.width, self._frame.height)
137
+ self._publication: Any = None
138
+ self._task: asyncio.Task[None] | None = None
139
+
140
+ async def show(self, words: bool = True) -> None:
141
+ self.words(words)
142
+ track = rtc.LocalVideoTrack.create_video_track(STILL_TRACK, self._source)
143
+ self._publication = await self._room.local_participant.publish_track(
144
+ track, rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA, simulcast=False)
145
+ )
146
+ self._task = asyncio.create_task(self._keep_sending())
147
+
148
+ def words(self, on: bool) -> None:
149
+ self._frame = self._with_words if on else self._plain
150
+
151
+ async def _keep_sending(self) -> None:
152
+ while True:
153
+ self._source.capture_frame(self._frame)
154
+ await asyncio.sleep(1 / STILL_FPS)
155
+
156
+ async def hide(self) -> None:
157
+ if self._task is not None:
158
+ await utils.aio.cancel_and_wait(self._task)
159
+ self._task = None
160
+ publication, self._publication = self._publication, None
161
+ if publication is not None:
162
+ try:
163
+ await self._room.local_participant.unpublish_track(publication.sid)
164
+ except Exception:
165
+ logger.debug("couldn't take down the waiting picture", exc_info=True)
166
+
167
+
168
+ class _FaceVoice(DataStreamAudioOutput):
169
+ """The agent's voice going to the face, counting the sound sent so the plugin knows when a reply really ends."""
170
+
171
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
172
+ super().__init__(*args, **kwargs)
173
+ self.sound_ends_at = 0.0
174
+
175
+ async def capture_frame(self, frame: rtc.AudioFrame) -> None:
176
+ self.sound_ends_at = max(self.sound_ends_at, time.monotonic()) + frame.duration
177
+ await super().capture_frame(frame)
178
+
179
+
180
+ class AvatarSession(_AvatarSession):
181
+ """A ChatFX avatar for one call.
182
+
183
+ Args:
184
+ avatar_id: the avatar's id from the dashboard (for example "chatfx-lois").
185
+ api_key: your ChatFX Avatar API key. Defaults to the CHATFX_AVATAR_API_KEY setting.
186
+ api_url: defaults to https://api.chatfx.com (or the CHATFX_AVATAR_API_URL setting).
187
+ avatar_participant_identity: the avatar's identity in the room. Defaults to "chatfx-avatar".
188
+ avatar_participant_name: the avatar's name in the room. Defaults to "ChatFX".
189
+ face_first_seconds: how long start() waits for the face before your agent starts by voice. Defaults to 10.
190
+ start_timeout: seconds to keep trying while a GPU wakes up or is busy. Defaults to 300.
191
+ quiet_seconds: end the call after this long with nobody talking. Defaults to 120. None turns it off.
192
+ visitor_gone_seconds: end the call this long after the last visitor leaves. Defaults to 120. None turns it off.
193
+ longest_call_seconds: end the call at this length. Defaults to 3600. None turns it off.
194
+ waiting_picture: show the avatar's picture as your agent's video until the live face is ready. Defaults to True.
195
+ waiting_text: the words on it while the face is on its way. Defaults to "Video starting...". None: no words.
196
+ end_call_when_idle: also close the LiveKit room when one of those rules ends the call, which stops your own meters
197
+ too. Defaults to True. False: only the avatar leaves, and your agent carries on by voice.
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ *,
203
+ avatar_id: str,
204
+ api_key: str | None = None,
205
+ api_url: str | None = None,
206
+ avatar_participant_identity: str = AVATAR_IDENTITY,
207
+ avatar_participant_name: str = AVATAR_NAME,
208
+ face_first_seconds: float = 10.0,
209
+ start_timeout: float = 300.0,
210
+ quiet_seconds: float | None = 120.0,
211
+ visitor_gone_seconds: float | None = 120.0,
212
+ longest_call_seconds: float | None = 3600.0,
213
+ end_call_when_idle: bool = True,
214
+ waiting_picture: bool = True,
215
+ waiting_text: str | None = "Video starting...",
216
+ ) -> None:
217
+ super().__init__()
218
+ if not avatar_id:
219
+ raise ChatFXException("avatar_id is required. Copy one from the Avatars page.", code="invalid_request")
220
+ self._avatar_id = avatar_id
221
+ self._api_key, self._api_url = _settings(api_key, api_url)
222
+ self._identity = avatar_participant_identity
223
+ self._name = avatar_participant_name
224
+ self._face_first_seconds = face_first_seconds
225
+ self._start_timeout = start_timeout
226
+ self._session_id: str | None = None
227
+ self._keepalive_task: asyncio.Task[None] | None = None
228
+ self._face_task: asyncio.Task[None] | None = None
229
+ self._own_http: aiohttp.ClientSession | None = None
230
+ self._face_on = asyncio.Event()
231
+ self._face_status: FaceStatus | None = None
232
+ self._failure: ChatFXException | None = None
233
+ self._quiet_seconds = quiet_seconds
234
+ self._visitor_gone_seconds = visitor_gone_seconds
235
+ self._longest_call_seconds = longest_call_seconds
236
+ self._end_call_when_idle = end_call_when_idle
237
+ self._waiting_picture = waiting_picture
238
+ self._waiting_text = waiting_text
239
+ self._still: _WaitingPicture | None = None
240
+ self._still_task: asyncio.Task[None] | None = None
241
+ self._early_wake: asyncio.Task[bool] | None = None
242
+ self._watch_task: asyncio.Task[None] | None = None
243
+ self._face_voice: _FaceVoice | None = None
244
+ self._voice_output: Any = None
245
+ self._on_face = False
246
+ self._closing = False
247
+ self._began = 0.0
248
+ self._last_talk = 0.0
249
+ self._visitor_left_at: float | None = None
250
+ # Starts the GPU waking now, while the rest of the call gets ready.
251
+ try:
252
+ self._early_wake = asyncio.get_running_loop().create_task(warm(api_key=self._api_key, api_url=self._api_url))
253
+ except RuntimeError:
254
+ pass
255
+
256
+ @property
257
+ def avatar_identity(self) -> str:
258
+ return self._identity
259
+
260
+ @property
261
+ def provider(self) -> str:
262
+ return "chatfx"
263
+
264
+ @property
265
+ def session_id(self) -> str | None:
266
+ """The ChatFX session's id once the avatar has started."""
267
+ return self._session_id
268
+
269
+ @property
270
+ def face_status(self) -> FaceStatus | None:
271
+ """Where the face is: "waking", "voice", "on" or "off"."""
272
+ return self._face_status
273
+
274
+ @property
275
+ def failure(self) -> ChatFXException | None:
276
+ """Why there's no face, when the API refused (a wrong key, no minutes left). The call carries on by voice."""
277
+ return self._failure
278
+
279
+ async def start(
280
+ self,
281
+ agent_session: AgentSession,
282
+ room: rtc.Room,
283
+ *,
284
+ livekit_url: str | None = None,
285
+ livekit_api_key: str | None = None,
286
+ livekit_api_secret: str | None = None,
287
+ ) -> None:
288
+ """Sends the avatar into the room, waiting up to face_first_seconds for it. Call before session.start().
289
+
290
+ Returns with the face on, or with the face still on its way (your agent starts by voice and the voice moves over
291
+ when the face arrives), or with no face (your agent carries on by voice; see failure and the log).
292
+ """
293
+ url = livekit_url or os.environ.get("LIVEKIT_URL")
294
+ key = livekit_api_key or os.environ.get("LIVEKIT_API_KEY")
295
+ secret = livekit_api_secret or os.environ.get("LIVEKIT_API_SECRET")
296
+ if not url or not key or not secret:
297
+ raise ChatFXException("Set LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET, or pass them to start().", code="invalid_request")
298
+
299
+ await super().start(agent_session, room)
300
+ # The pass names the room, and the status goes on the agent's participant: both need the room joined.
301
+ job_ctx = get_job_context(required=False)
302
+ if job_ctx is not None and not room.isconnected():
303
+ await job_ctx.connect()
304
+ room.on("participant_disconnected", self._on_participant_left)
305
+ room.on("participant_connected", self._on_participant_joined)
306
+ agent_session.on("user_input_transcribed", self._on_heard)
307
+ agent_session.on("agent_state_changed", self._on_agent_state)
308
+ self._began = self._last_talk = time.monotonic()
309
+ self._watch_task = asyncio.create_task(self._watch_call())
310
+
311
+ await self._say_face("waking")
312
+ if self._waiting_picture:
313
+ self._still_task = asyncio.create_task(self._show_waiting_picture())
314
+ self._face_task = asyncio.create_task(self._bring_face(url, self._pass(room, key, secret)))
315
+ face_on = asyncio.create_task(self._face_on.wait())
316
+ try:
317
+ await asyncio.wait({face_on, self._face_task}, timeout=self._face_first_seconds, return_when=asyncio.FIRST_COMPLETED)
318
+ finally:
319
+ face_on.cancel()
320
+ if not self._face_on.is_set() and not self._face_task.done():
321
+ await self._say_face("voice")
322
+
323
+ async def aclose(self) -> None:
324
+ """Ends the ChatFX session (billing stops) and leaves the room. Runs by itself when the job shuts down."""
325
+ if self._closing:
326
+ return
327
+ self._closing = True
328
+ for task in (self._face_task, self._watch_task, self._still_task, self._early_wake):
329
+ # The watcher itself closes the call when an idle rule is met.
330
+ if task is not None and task is not asyncio.current_task():
331
+ await utils.aio.cancel_and_wait(task)
332
+ self._face_task = self._watch_task = self._still_task = self._early_wake = None
333
+ if self._still is not None:
334
+ await self._still.hide()
335
+ self._still = None
336
+ if self._room is not None:
337
+ self._room.off("participant_disconnected", self._on_participant_left)
338
+ self._room.off("participant_connected", self._on_participant_joined)
339
+ if self._agent_session is not None:
340
+ self._agent_session.off("user_input_transcribed", self._on_heard)
341
+ self._agent_session.off("agent_state_changed", self._on_agent_state)
342
+ await self._end_session()
343
+ if self._own_http is not None:
344
+ await self._own_http.close()
345
+ self._own_http = None
346
+ await super().aclose()
347
+
348
+ # The pass that lets the avatar join this one room as your agent.
349
+ def _pass(self, room: rtc.Room, key: str, secret: str) -> str:
350
+ agent_identity = _agent_identity(room)
351
+ return (
352
+ api.AccessToken(api_key=key, api_secret=secret)
353
+ .with_kind("agent")
354
+ .with_identity(self._identity)
355
+ .with_name(self._name)
356
+ .with_grants(api.VideoGrants(room_join=True, room=room.name))
357
+ .with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: agent_identity})
358
+ .with_ttl(PASS_TTL)
359
+ .to_jwt()
360
+ )
361
+
362
+ async def _bring_face(self, livekit_url: str, token: str) -> None:
363
+ body = {"avatar_id": self._avatar_id, "livekit_url": livekit_url, "livekit_token": token}
364
+ deadline = time.monotonic() + self._start_timeout
365
+ while self._in_call() and time.monotonic() < deadline:
366
+ wait = RETRY_SECONDS
367
+ try:
368
+ status, data, wait = await self._call("POST", "/v1/sessions", body, seconds=START_REQUEST_SECONDS)
369
+ except (aiohttp.ClientError, asyncio.TimeoutError):
370
+ logger.debug("chatfx avatar api not reachable yet", exc_info=True)
371
+ status, data = 0, {}
372
+ if status == 201 and data.get("id"):
373
+ self._session_id = str(data["id"])
374
+ self._keepalive_task = asyncio.create_task(self._keep_alive())
375
+ if await self._wait_for_picture():
376
+ logger.info("chatfx avatar on", extra={"session_id": self._session_id, "avatar_id": self._avatar_id})
377
+ self._face_on.set()
378
+ await self._move_voice_to_face()
379
+ await self._say_face("on")
380
+ await self._hide_waiting_picture()
381
+ return
382
+ logger.warning("chatfx gpu took the call but sent no picture; trying again")
383
+ # Ended before asking again, so the try that failed stops counting.
384
+ await self._end_session()
385
+ elif status != 0 and status != 503 and status < 500 and not (status == 429 and data.get("error", {}).get("code") == "rate_limited"):
386
+ error = data.get("error") or {}
387
+ self._failure = ChatFXException(error.get("message") or f"ChatFX Avatar answered {status}.", code=error.get("code", "error"), status=status)
388
+ logger.error("chatfx avatar refused: %s (%s); the agent carries on by voice", self._failure, self._failure.code)
389
+ await self._say_face("off")
390
+ return
391
+ await asyncio.sleep(wait)
392
+ logger.warning("no chatfx face on this call; the agent carries on by voice")
393
+ await self._say_face("off")
394
+
395
+ async def _show_waiting_picture(self, words: bool = True) -> None:
396
+ try:
397
+ if self._still is None:
398
+ async with self._http().get(
399
+ f"{self._api_url}/v1/avatars/{self._avatar_id}/picture",
400
+ headers={"Authorization": f"Bearer {self._api_key}"},
401
+ timeout=aiohttp.ClientTimeout(total=REQUEST_SECONDS),
402
+ ) as response:
403
+ if response.status != 200:
404
+ logger.debug("no waiting picture: the api answered %s", response.status)
405
+ return
406
+ picture = await response.read()
407
+ if self._face_on.is_set() or self._room is None:
408
+ return
409
+ self._still = _WaitingPicture(self._room, picture, self._waiting_text)
410
+ await self._still.show(words)
411
+ except asyncio.CancelledError:
412
+ raise
413
+ except Exception:
414
+ logger.debug("couldn't show the waiting picture", exc_info=True)
415
+
416
+ async def _hide_waiting_picture(self) -> None:
417
+ if self._still_task is not None:
418
+ await utils.aio.cancel_and_wait(self._still_task)
419
+ self._still_task = None
420
+ if self._still is not None:
421
+ await self._still.hide()
422
+
423
+ def _in_call(self) -> bool:
424
+ return not self._closing and self._room is not None and self._room.isconnected()
425
+
426
+ async def _wait_for_picture(self) -> bool:
427
+ ends = time.monotonic() + PICTURE_SECONDS
428
+ while time.monotonic() < ends and self._in_call():
429
+ assert self._room is not None
430
+ avatar = self._room.remote_participants.get(self._identity)
431
+ if avatar and any(p.kind == rtc.TrackKind.KIND_VIDEO for p in avatar.track_publications.values()):
432
+ return True
433
+ await asyncio.sleep(0.1)
434
+ return False
435
+
436
+ # The voice moves between replies, so a sentence is never split between two voices.
437
+ async def _move_voice_to_face(self) -> None:
438
+ session = self._agent_session
439
+ if session is None:
440
+ return
441
+ if session.agent_state == "initializing":
442
+ # Not started yet: move it the moment it starts, before its first words. It has to have started first, so
443
+ # that LiveKit makes the agent's own voice output, which is what the call falls back on if the face leaves.
444
+ started = asyncio.Event()
445
+
446
+ def on_state(event: Any) -> None:
447
+ if event.new_state != "initializing":
448
+ self._voice_to_face(session)
449
+ started.set()
450
+
451
+ session.on("agent_state_changed", on_state)
452
+ try:
453
+ while not started.is_set() and self._in_call():
454
+ try:
455
+ await asyncio.wait_for(started.wait(), 0.5)
456
+ except asyncio.TimeoutError:
457
+ pass
458
+ finally:
459
+ session.off("agent_state_changed", on_state)
460
+ return
461
+ while session.agent_state == "speaking" and self._in_call():
462
+ await asyncio.sleep(0.1)
463
+ if self._in_call():
464
+ self._voice_to_face(session)
465
+
466
+ def _voice_to_face(self, session: AgentSession) -> None:
467
+ if self._on_face or self._room is None:
468
+ return
469
+ self._voice_output = session.output.audio
470
+ self._face_voice = _FaceVoice(
471
+ self._room,
472
+ destination_identity=self._identity,
473
+ wait_remote_track=rtc.TrackKind.KIND_VIDEO,
474
+ )
475
+ session.output.audio = self._face_voice
476
+ self._on_face = True
477
+
478
+ def _on_participant_joined(self, participant: rtc.RemoteParticipant) -> None:
479
+ if _is_visitor(participant, self._identity):
480
+ self._visitor_left_at = None
481
+
482
+ def _on_heard(self, event: Any) -> None:
483
+ if event.is_final:
484
+ self._last_talk = time.monotonic()
485
+
486
+ def _on_agent_state(self, event: Any) -> None:
487
+ if "speaking" in (event.old_state, event.new_state):
488
+ self._last_talk = time.monotonic()
489
+ # Cut off by the visitor, the rest of the sound will never play.
490
+ if event.old_state == "speaking" and self._face_voice is not None:
491
+ self._face_voice.sound_ends_at = 0.0
492
+
493
+ async def _watch_call(self) -> None:
494
+ while not self._closing:
495
+ await asyncio.sleep(WATCH_SECONDS)
496
+ session = self._agent_session
497
+ if session is None:
498
+ return
499
+ now = time.monotonic()
500
+ voice = self._face_voice
501
+ if self._on_face and voice is not None and session.agent_state == "speaking" and 0 < voice.sound_ends_at < now - SOUND_GRACE_SECONDS:
502
+ logger.warning("the chatfx avatar didn't report the end of a reply; ending it so the agent can listen")
503
+ voice.sound_ends_at = 0.0
504
+ session.interrupt(force=True)
505
+ reason = self._idle_reason(session, now)
506
+ if reason:
507
+ await self._end_idle_call(reason)
508
+ return
509
+
510
+ def _idle_reason(self, session: AgentSession, now: float) -> str | None:
511
+ if self._longest_call_seconds is not None and now - self._began >= self._longest_call_seconds:
512
+ return "it reached its longest length"
513
+ if self._visitor_gone_seconds is not None and self._visitor_left_at is not None and now - self._visitor_left_at >= self._visitor_gone_seconds:
514
+ return "the visitor left"
515
+ if self._quiet_seconds is not None and session.agent_state != "speaking" and now - self._last_talk >= self._quiet_seconds:
516
+ return "nobody was talking"
517
+ return None
518
+
519
+ async def _end_idle_call(self, reason: str) -> None:
520
+ logger.info("ending the chatfx avatar call: %s", reason)
521
+ job_ctx = get_job_context(required=False)
522
+ session = self._agent_session
523
+ if not self._end_call_when_idle and session is not None and self._on_face and self._voice_output is not None:
524
+ session.output.audio = self._voice_output
525
+ self._on_face = False
526
+ await self._say_face("off")
527
+ await self.aclose()
528
+ if self._end_call_when_idle and job_ctx is not None:
529
+ job_ctx.delete_room()
530
+
531
+ # The face left mid-call (a GPU problem, or the plan's minutes ran out): the call carries on by voice.
532
+ def _on_participant_left(self, participant: rtc.RemoteParticipant) -> None:
533
+ if _is_visitor(participant, self._identity):
534
+ if self._room is not None and not any(_is_visitor(p, self._identity) for p in self._room.remote_participants.values()):
535
+ self._visitor_left_at = time.monotonic()
536
+ return
537
+ if participant.identity != self._identity or self._closing or not self._face_on.is_set():
538
+ return
539
+ logger.warning("the chatfx avatar left the call; the agent carries on by voice")
540
+ session = self._agent_session
541
+ if session is not None and self._on_face and self._voice_output is not None:
542
+ session.output.audio = self._voice_output
543
+ self._on_face = False
544
+ asyncio.create_task(self._say_face("off"))
545
+ # Back to the picture, without the words, with the agent's voice over it.
546
+ if self._waiting_picture:
547
+ self._still_task = asyncio.create_task(self._show_waiting_picture(words=False))
548
+
549
+ async def _say_face(self, status: FaceStatus) -> None:
550
+ self._face_status = status
551
+ # No face is coming: the picture stays, without "Video starting".
552
+ if status == "off" and self._still is not None:
553
+ self._still.words(False)
554
+ try:
555
+ assert self._room is not None
556
+ await self._room.local_participant.set_attributes({FACE_ATTRIBUTE: status})
557
+ except Exception:
558
+ logger.debug("couldn't tell the page where the face is", exc_info=True)
559
+
560
+ async def _keep_alive(self) -> None:
561
+ while self._session_id:
562
+ await asyncio.sleep(KEEPALIVE_SECONDS)
563
+ session_id = self._session_id
564
+ if not session_id:
565
+ return
566
+ try:
567
+ status, data, _ = await self._call("POST", f"/v1/sessions/{session_id}/keepalive")
568
+ except (aiohttp.ClientError, asyncio.TimeoutError):
569
+ logger.warning("chatfx check-in didn't go through; trying again", exc_info=True)
570
+ continue
571
+ if status != 200:
572
+ # Ended (409), gone (404) or out of minutes (402): nothing left to keep alive.
573
+ logger.warning("chatfx avatar session ended", extra={"status": status, "error": data.get("error")})
574
+ self._session_id = None
575
+ return
576
+
577
+ async def _end_session(self) -> None:
578
+ if self._keepalive_task is not None:
579
+ await utils.aio.cancel_and_wait(self._keepalive_task)
580
+ self._keepalive_task = None
581
+ session_id, self._session_id = self._session_id, None
582
+ if session_id:
583
+ try:
584
+ await self._call("POST", f"/v1/sessions/{session_id}/end")
585
+ except Exception:
586
+ logger.warning("could not end the chatfx session; it ends by itself a minute after its last check-in", exc_info=True)
587
+
588
+ async def _call(self, method: str, path: str, body: dict[str, Any] | None = None, seconds: float = REQUEST_SECONDS) -> tuple[int, dict[str, Any], float]:
589
+ async with self._http().request(
590
+ method,
591
+ self._api_url + path,
592
+ json=body,
593
+ headers={"Authorization": f"Bearer {self._api_key}"},
594
+ timeout=aiohttp.ClientTimeout(total=seconds),
595
+ ) as response:
596
+ try:
597
+ data = await response.json(content_type=None)
598
+ except Exception:
599
+ data = {}
600
+ try:
601
+ retry_after = float(response.headers.get("Retry-After", str(RETRY_SECONDS)))
602
+ except ValueError:
603
+ retry_after = RETRY_SECONDS
604
+ return response.status, data if isinstance(data, dict) else {}, max(0.5, min(retry_after, 30.0))
605
+
606
+ def _http(self) -> aiohttp.ClientSession:
607
+ try:
608
+ return utils.http_context.http_session()
609
+ except RuntimeError:
610
+ # Outside a LiveKit job: our own connection pool, closed by aclose().
611
+ if self._own_http is None or self._own_http.closed:
612
+ self._own_http = aiohttp.ClientSession()
613
+ return self._own_http
614
+
615
+
616
+ def _is_visitor(participant: Any, avatar_identity: str) -> bool:
617
+ return participant.identity != avatar_identity and participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
618
+
619
+
620
+ def _agent_identity(room: rtc.Room) -> str:
621
+ job_ctx = get_job_context(required=False)
622
+ if job_ctx is not None:
623
+ return job_ctx.local_participant_identity
624
+ return room.local_participant.identity
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.chatfx")
File without changes
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: livekit-plugins-chatfx
3
+ Version: 0.1.0
4
+ Summary: ChatFX Avatar for LiveKit Agents: a real-time talking face for your voice agent
5
+ Project-URL: Homepage, https://chatfx.com/avatar/
6
+ Project-URL: Documentation, https://chatfx.com/support/avatar-connect/
7
+ Author-email: "ChatRep, Inc." <service@chatfx.com>
8
+ License-Expression: Apache-2.0
9
+ Keywords: agents,avatar,chatfx,livekit,realtime,voice
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Video
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: aiohttp>=3.9
16
+ Requires-Dist: livekit-agents>=1.2
17
+ Requires-Dist: pillow>=10.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # ChatFX Avatar for LiveKit Agents
21
+
22
+ A real-time talking face for your LiveKit voice agent. The avatar joins your agent's LiveKit Cloud room as one more
23
+ participant, takes your agent's voice and speaks it with its lips in time, shown as your agent. You keep your own
24
+ listening, AI and voice; ChatFX adds the face, billed by the minute.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install livekit-plugins-chatfx
30
+ ```
31
+
32
+ Requires Python 3.10+ and `livekit-agents` 1.2 or newer.
33
+
34
+ ## Add your key
35
+
36
+ Create a key under **API keys** at [avatar.chatfx.com](https://avatar.chatfx.com). Then add it to your voice agent's secrets
37
+ at LiveKit, named exactly `CHATFX_AVATAR_API_KEY`. In your agent's folder:
38
+
39
+ ```bash
40
+ lk agent update-secrets --secrets "CHATFX_AVATAR_API_KEY=cfx_..."
41
+ ```
42
+
43
+ Running your agent on your own server instead? Add the key to that server's environment variables, with the same name.
44
+
45
+ Your agent also needs `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`, which it already has. They never leave your
46
+ server.
47
+
48
+ ## Use it
49
+
50
+ In your agent's entrypoint, after you make the `AgentSession` and before `session.start()`:
51
+
52
+ ```python
53
+ from livekit.plugins import chatfx
54
+
55
+ avatar = chatfx.AvatarSession(avatar_id="chatfx-lois") # copy the id from the Avatars page
56
+ await avatar.start(session, room=ctx.room)
57
+
58
+ await session.start(agent=MyAgent(), room=ctx.room)
59
+ ```
60
+
61
+ That's all the code. When the job ends, the session ends too, so billing stops. To end it earlier, call
62
+ `await avatar.aclose()`.
63
+
64
+ ## What it does for you
65
+
66
+ - **Shows a waiting picture, with nothing to add to your page.** The moment a call starts, your agent shows the avatar's picture
67
+ as its own video, with "Video starting..." on it, and the live face takes over when it's ready. Any page that shows the
68
+ agent's video gets this.
69
+ - **Greets with the face.** `start()` waits up to 10 seconds for the face, so your agent's first words come with it.
70
+ - **Starts by voice, over the picture, if the face is late.** The voice moves to the face between replies, so a sentence is never split.
71
+ - **Never depends on the face.** If the face can't come, or leaves mid-call, your agent carries on by voice.
72
+ - **Keeps trying** while a GPU wakes or is busy, for up to 5 minutes, and stops at once on a refusal more tries won't fix.
73
+ - **Stops forgotten calls**, so they don't use your minutes: after 2 minutes with nobody talking, 2 minutes after the visitor
74
+ leaves (long enough for a page reload), or at 1 hour.
75
+ - **Can't go deaf.** If the face's "finished talking" message is late, the plugin ends the reply once the sound has run out,
76
+ so your agent hears the next question.
77
+
78
+ ## Options
79
+
80
+ ```python
81
+ chatfx.AvatarSession(
82
+ avatar_id="chatfx-lois",
83
+ api_key=None, # defaults to CHATFX_AVATAR_API_KEY
84
+ api_url=None, # defaults to https://api.chatfx.com
85
+ avatar_participant_identity="chatfx-avatar",
86
+ avatar_participant_name="ChatFX",
87
+ face_first_seconds=10.0, # how long start() waits for the face
88
+ start_timeout=300.0, # how long to keep trying while a GPU wakes
89
+ quiet_seconds=120.0, # end the call after this long with nobody talking (None: never)
90
+ visitor_gone_seconds=120.0, # end the call this long after the last visitor leaves (None: never)
91
+ longest_call_seconds=3600.0, # end the call at this length (None: never)
92
+ end_call_when_idle=True, # False: only the avatar leaves, and your agent carries on by voice
93
+ waiting_picture=True, # show the avatar's picture until the live face is ready
94
+ waiting_text="Video starting...", # the words on it, in your language (None: no words)
95
+ )
96
+ ```
97
+
98
+ `avatar.start()` also takes `livekit_url`, `livekit_api_key` and `livekit_api_secret` if you'd rather not use the settings.
99
+
100
+ We recommend `"interruption": {"min_words": 1}` in your session's turn handling. The avatar's sound can't pause and carry on,
101
+ so a false alarm would otherwise cut a reply off for good.
102
+
103
+ ## Optional: make the face appear sooner
104
+
105
+ The plugin starts waking the GPU as soon as a call begins, which takes about 11 seconds. To start even earlier, call `warm()` from your website's server when a visitor
106
+ is about to start a call, and again every 20 seconds while they get ready. It's free. Your website's server then needs the key
107
+ in its environment variables too. Never put the key in a web page.
108
+
109
+ ```python
110
+ await chatfx.warm()
111
+ ```
112
+
113
+ Or without Python:
114
+
115
+ ```bash
116
+ curl -X POST https://api.chatfx.com/v1/warm -H "Authorization: Bearer $CHATFX_AVATAR_API_KEY"
117
+ ```
118
+
119
+ ## If you build your own call screen
120
+
121
+ Your agent's participant attribute `chatfx-face` says where the face is: `waking`, `voice`, `on` or `off`. In the agent,
122
+ `avatar.face_status` says the same. You don't need either to get the waiting picture.
123
+
124
+ ## When there's no face
125
+
126
+ A refusal doesn't stop the call. The log says why, and `avatar.failure` is a `chatfx.ChatFXException` with the API's `code`
127
+ (for example `invalid_api_key`, `avatar_not_found`, `premium_avatar`, `out_of_minutes`) and a plain message. A missing key or
128
+ missing LiveKit setting raises right away, so you find it while setting up.
129
+
130
+ ## Docs
131
+
132
+ [Connecting your agent](https://chatfx.com/support/avatar-connect/) · [Building avatars](https://chatfx.com/support/avatar-builder/) · [Minutes and plans](https://chatfx.com/support/avatar-billing/)
@@ -0,0 +1,8 @@
1
+ livekit/plugins/chatfx/__init__.py,sha256=6MymqvylS1XFooKMXYnUmgBHXrgFknPGEvbRQ351wIU,1036
2
+ livekit/plugins/chatfx/avatar.py,sha256=kSKvt0Ysn9exBMBywykG1-xUDwoZvtlSWSeJndMKfjU,30930
3
+ livekit/plugins/chatfx/log.py,sha256=rtpkiLFJoPR2ieFPOIpWhpqgYHN525DmVkPxefLilR8,69
4
+ livekit/plugins/chatfx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ livekit/plugins/chatfx/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
6
+ livekit_plugins_chatfx-0.1.0.dist-info/METADATA,sha256=r9lOa136glsGOCO1GyOA4M7TPmoCdcVx9hN5KwQnZtA,6193
7
+ livekit_plugins_chatfx-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ livekit_plugins_chatfx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any