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.
- livekit/plugins/chatfx/__init__.py +37 -0
- livekit/plugins/chatfx/avatar.py +624 -0
- livekit/plugins/chatfx/log.py +3 -0
- livekit/plugins/chatfx/py.typed +0 -0
- livekit/plugins/chatfx/version.py +1 -0
- livekit_plugins_chatfx-0.1.0.dist-info/METADATA +132 -0
- livekit_plugins_chatfx-0.1.0.dist-info/RECORD +8 -0
- livekit_plugins_chatfx-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
|
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,,
|