pipecat-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,6 @@
1
+ """ChatFX Avatar for Pipecat: a real-time talking face for your Pipecat bot. See ChatFXVideoService."""
2
+
3
+ from .version import __version__
4
+ from .video import ChatFXException, ChatFXVideoService
5
+
6
+ __all__ = ["ChatFXVideoService", "ChatFXException", "__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,658 @@
1
+ """ChatFX Avatar for Pipecat: a real-time talking face for your Pipecat bot.
2
+
3
+ Put ChatFXVideoService right after your TTS service. Your bot's voice goes to the avatar, and the avatar's face and voice,
4
+ drawn together so the lips match, come back into your pipeline as your bot's video and audio. Your transport stays as it
5
+ is (Daily, SmallWebRTC, anything Pipecat supports), and so does your page: it shows the bot's video as before.
6
+
7
+ How it connects (the way Pipecat's own Tavus service does): the avatar and your bot meet in a private Daily room in your
8
+ Daily account, made for the call with your Daily API key and removed when it ends. POST https://api.chatfx.com/v1/sessions
9
+ starts the avatar there with a meeting token for it alone. Your bot sends it each reply's sound as Daily app messages and
10
+ takes its face and voice back. A check-in every 20 seconds keeps the session going, and the end of your pipeline ends it,
11
+ so billing stops.
12
+
13
+ The call never depends on the face: until the avatar is in (a few seconds), or if it can't come or leaves, your bot speaks
14
+ with its own voice as if the avatar weren't there.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import base64
21
+ import io
22
+ import os
23
+ import time
24
+ import uuid
25
+ from typing import Any
26
+
27
+ import aiohttp
28
+ from loguru import logger
29
+ from pipecat.frames.frames import (
30
+ BotStartedSpeakingFrame,
31
+ CancelFrame,
32
+ EndFrame,
33
+ Frame,
34
+ InterruptionFrame,
35
+ OutputImageRawFrame,
36
+ OutputTransportReadyFrame,
37
+ SpeechOutputAudioRawFrame,
38
+ StartFrame,
39
+ TTSAudioRawFrame,
40
+ TTSStartedFrame,
41
+ TTSStoppedFrame,
42
+ )
43
+ from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup
44
+ from pipecat.services.ai_service import AIService
45
+ from pipecat.services.settings import ServiceSettings
46
+
47
+ DEFAULT_API_URL = "https://api.chatfx.com"
48
+ DAILY_API_URL = "https://api.daily.co/v1"
49
+ BOT_NAME = "chatfx-pipecat-bot"
50
+ AVATAR_NAME = "ChatFX"
51
+ KEEPALIVE_SECONDS = 20
52
+ REQUEST_SECONDS = 15
53
+ # The API holds a start while a GPU gets ready; past that it says when to try again (a 503 with Retry-After).
54
+ START_REQUEST_SECONDS = 60
55
+ RETRY_SECONDS = 3.0
56
+ # After a start, the avatar's face arrives within seconds; if it doesn't, that try is ended (so it isn't billed) and
57
+ # another is made.
58
+ FACE_SECONDS = 30.0
59
+ # The room and its tokens last this long (the API takes tokens that expire within 6 hours).
60
+ ROOM_SECONDS = 5 * 3600
61
+ # A reply's sound goes to the avatar in pieces this long, as Pipecat's Tavus service sends a replica its voice.
62
+ PIECE_SECONDS = 0.1
63
+ # At the end of a call, the avatar's last words get this long to finish before it hangs up.
64
+ LAST_WORDS_SECONDS = 15.0
65
+ # When the visitor talks over the avatar, what was already on its way from it is dropped for this long, so it stops at once.
66
+ CUT_SECONDS = 0.5
67
+ # The avatar's voice starts at full quality instead of ramping up from Daily's usual 1 Mbps.
68
+ RECEIVE_BITRATE = 2_500_000
69
+ # Until the live face is in, your bot's video is the avatar's picture, sent this many times a second.
70
+ STILL_FPS = 5
71
+ STILL_LONGEST_SIDE = 912
72
+
73
+
74
+ class ChatFXException(Exception):
75
+ """A ChatFX refusal (wrong key, no minutes left, no such avatar) or a missing setting, with the API's code."""
76
+
77
+ def __init__(self, message: str, *, code: str = "error", status: int = 0) -> None:
78
+ super().__init__(message)
79
+ self.code = code
80
+ self.status = status
81
+
82
+
83
+ def still_frame(picture: bytes, text: str | None) -> OutputImageRawFrame:
84
+ """The avatar's picture as a video frame, with the words in a soft band near the bottom."""
85
+ from PIL import Image, ImageDraw, ImageFont
86
+
87
+ image = Image.open(io.BytesIO(picture)).convert("RGBA")
88
+ image.thumbnail((STILL_LONGEST_SIDE, STILL_LONGEST_SIDE))
89
+ # Video encoders need even sizes.
90
+ image = image.crop((0, 0, image.width - image.width % 2, image.height - image.height % 2))
91
+ if text:
92
+ size = max(14, round(min(image.width, image.height) * 0.05))
93
+ font = ImageFont.load_default(size=size)
94
+ band = Image.new("RGBA", image.size, (0, 0, 0, 0))
95
+ draw = ImageDraw.Draw(band)
96
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
97
+ x, y = (image.width - (right - left)) // 2, image.height - size * 3
98
+ pad = size // 2
99
+ draw.rounded_rectangle((x - pad * 2, y - pad + top, x + right - left + pad * 2, y + bottom + pad), radius=size, fill=(12, 10, 40, 170))
100
+ draw.text((x - left, y), text, font=font, fill=(255, 255, 255, 255))
101
+ image = Image.alpha_composite(image, band)
102
+ image = image.convert("RGB")
103
+ return OutputImageRawFrame(image=image.tobytes(), size=image.size, format="RGB")
104
+
105
+
106
+ def daily_library():
107
+ """Daily's Python library (pipecat-ai[daily]), started once for the program if your transport hasn't already."""
108
+ import daily
109
+
110
+ try:
111
+ daily.Daily.init()
112
+ except Exception:
113
+ # Already started (DailyTransport does it too).
114
+ pass
115
+ return daily
116
+
117
+
118
+ class DailyRooms:
119
+ """A private Daily room in your Daily account for one call, with a meeting token for your bot and one for the avatar."""
120
+
121
+ def __init__(self, api_key: str, http: aiohttp.ClientSession, api_url: str = DAILY_API_URL) -> None:
122
+ self._key = api_key
123
+ self._http = http
124
+ self._url = api_url.rstrip("/")
125
+
126
+ async def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
127
+ async with self._http.request(
128
+ method,
129
+ self._url + path,
130
+ json=body,
131
+ headers={"Authorization": f"Bearer {self._key}"},
132
+ timeout=aiohttp.ClientTimeout(total=REQUEST_SECONDS),
133
+ ) as response:
134
+ try:
135
+ data = await response.json(content_type=None)
136
+ except Exception:
137
+ data = {}
138
+ data = data if isinstance(data, dict) else {}
139
+ if response.status >= 400:
140
+ reason = data.get("info") or data.get("error") or response.status
141
+ raise ChatFXException(f"Daily refused {method} {path}: {reason}", code="daily_refused", status=response.status)
142
+ return data
143
+
144
+ async def create(self) -> tuple[str, str, str, str]:
145
+ """(the room's address, its name, your bot's token, the avatar's token)."""
146
+ expires = int(time.time()) + ROOM_SECONDS
147
+ room = await self._request(
148
+ "POST",
149
+ "/rooms",
150
+ {"privacy": "private", "properties": {"exp": expires, "eject_at_room_exp": True, "max_participants": 2, "enable_chat": False}},
151
+ )
152
+ name, url = room["name"], room["url"]
153
+
154
+ async def token(user_name: str) -> str:
155
+ made = await self._request("POST", "/meeting-tokens", {"properties": {"room_name": name, "user_name": user_name, "exp": expires, "is_owner": False}})
156
+ return made["token"]
157
+
158
+ bot, avatar = await asyncio.gather(token(BOT_NAME), token(AVATAR_NAME))
159
+ return url, name, bot, avatar
160
+
161
+ async def delete(self, name: str) -> None:
162
+ try:
163
+ await self._request("DELETE", f"/rooms/{name}")
164
+ except Exception:
165
+ logger.warning(f"ChatFX: couldn't remove the Daily room {name}; it expires by itself")
166
+
167
+
168
+ class BotSeat:
169
+ """Your bot's place in the avatar's room (daily-python): it publishes nothing, sends app messages, and takes the
170
+ avatar's camera and microphone. Daily's callbacks arrive on its own threads and are handed to the pipeline's loop."""
171
+
172
+ def __init__(self, loop: asyncio.AbstractEventLoop, out_rate: int, on_video, on_audio, on_message, on_avatar) -> None:
173
+ self._loop = loop
174
+ self._out_rate = out_rate
175
+ self._on_video = on_video
176
+ self._on_audio = on_audio
177
+ self._on_message = on_message
178
+ self._on_avatar = on_avatar
179
+ self.avatar_id: str | None = None
180
+ self.local_id: str | None = None
181
+ self._client = None
182
+
183
+ async def join(self, url: str, token: str) -> None:
184
+ daily = daily_library()
185
+ seat = self
186
+
187
+ class Events(daily.EventHandler):
188
+ def on_participant_joined(self, participant):
189
+ seat._loop.call_soon_threadsafe(seat._joined, participant)
190
+
191
+ def on_participant_left(self, participant, reason):
192
+ seat._loop.call_soon_threadsafe(seat._left, participant)
193
+
194
+ def on_app_message(self, message, sender):
195
+ seat._loop.call_soon_threadsafe(seat._on_message, message, sender)
196
+
197
+ self._events = Events()
198
+ self._client = daily.CallClient(event_handler=self._events)
199
+ self._client.set_user_name(BOT_NAME)
200
+ joined = self._loop.create_future()
201
+
202
+ def done(data, error):
203
+ def settle():
204
+ if joined.done():
205
+ return
206
+ if error:
207
+ joined.set_exception(ChatFXException(f"Couldn't join the avatar's Daily room: {error}", code="daily_refused"))
208
+ else:
209
+ joined.set_result(data)
210
+
211
+ self._loop.call_soon_threadsafe(settle)
212
+
213
+ self._client.join(
214
+ url,
215
+ meeting_token=token,
216
+ client_settings={
217
+ "inputs": {"camera": {"isEnabled": False}, "microphone": {"isEnabled": False}},
218
+ "publishing": {"camera": {"isPublishing": False}, "microphone": {"isPublishing": False}},
219
+ "connection": {"initialReceiveBitrate": RECEIVE_BITRATE},
220
+ },
221
+ completion=done,
222
+ )
223
+ data = await asyncio.wait_for(joined, REQUEST_SECONDS)
224
+ self.local_id = ((data or {}).get("participants") or {}).get("local", {}).get("id")
225
+ for pid, participant in (self._client.participants() or {}).items():
226
+ if pid != "local":
227
+ self._joined(participant)
228
+
229
+ def _joined(self, participant: dict[str, Any]) -> None:
230
+ # The room is private and has two seats: anyone else in it is the avatar.
231
+ pid = participant.get("id")
232
+ if self.avatar_id or not pid or pid == self.local_id or participant.get("info", {}).get("isLocal"):
233
+ return
234
+ self.avatar_id = pid
235
+ loop = self._loop
236
+
237
+ def video(participant_id, frame, source):
238
+ loop.call_soon_threadsafe(self._on_video, frame)
239
+
240
+ def audio(participant_id, data, source):
241
+ loop.call_soon_threadsafe(self._on_audio, data)
242
+
243
+ self._client.update_subscriptions(participant_settings={pid: {"media": {"camera": "subscribed", "microphone": "subscribed"}}})
244
+ self._client.set_video_renderer(pid, video, video_source="camera", color_format="RGB")
245
+ self._client.set_audio_renderer(pid, audio, audio_source="microphone", sample_rate=self._out_rate, callback_interval_ms=20)
246
+ self._on_avatar(True)
247
+
248
+ def _left(self, participant: dict[str, Any]) -> None:
249
+ if participant.get("id") == self.avatar_id:
250
+ self.avatar_id = None
251
+ self._on_avatar(False)
252
+
253
+ def send(self, message: dict[str, Any]) -> None:
254
+ if self._client and self.avatar_id:
255
+ try:
256
+ self._client.send_app_message(message, self.avatar_id)
257
+ except Exception:
258
+ logger.debug("ChatFX: a message to the avatar didn't go", exc_info=True)
259
+
260
+ async def leave(self) -> None:
261
+ if not self._client:
262
+ return
263
+ left = self._loop.create_future()
264
+ self._client.leave(completion=lambda *_: self._loop.call_soon_threadsafe(lambda: left.done() or left.set_result(True)))
265
+ try:
266
+ await asyncio.wait_for(left, 10)
267
+ except asyncio.TimeoutError:
268
+ pass
269
+ self._client.release()
270
+ self._client = None
271
+ self.avatar_id = None
272
+
273
+
274
+ class ChatFXVideoService(AIService):
275
+ """Your bot's face. Put it right after your TTS service:
276
+
277
+ avatar = ChatFXVideoService(avatar_id="chatfx-lois") # CHATFX_AVATAR_API_KEY and DAILY_API_KEY from the environment
278
+ pipeline = Pipeline([transport.input(), stt, user_aggregator, llm, tts, avatar, transport.output(), assistant_aggregator])
279
+
280
+ and turn on your transport's video out at the avatar's size (912 x 512 landscape, 512 x 512 square, 384 x 512 portrait).
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ *,
286
+ avatar_id: str,
287
+ api_key: str | None = None,
288
+ daily_api_key: str | None = None,
289
+ api_url: str | None = None,
290
+ daily_api_url: str = DAILY_API_URL,
291
+ session: aiohttp.ClientSession | None = None,
292
+ face_first_seconds: float = 10.0,
293
+ start_timeout: float = 120.0,
294
+ waiting_picture: bool = True,
295
+ waiting_text: str | None = "Video starting...",
296
+ **kwargs: Any,
297
+ ) -> None:
298
+ super().__init__(settings=ServiceSettings(model=None), **kwargs)
299
+ self._avatar_id = avatar_id
300
+ self._api_key = api_key or os.environ.get("CHATFX_AVATAR_API_KEY") or ""
301
+ self._daily_key = daily_api_key or os.environ.get("DAILY_API_KEY") or ""
302
+ if not self._api_key:
303
+ raise ChatFXException("Set CHATFX_AVATAR_API_KEY, or pass api_key. Create a key on the API keys page.", code="missing_api_key")
304
+ if not self._daily_key:
305
+ raise ChatFXException("Set DAILY_API_KEY, or pass daily_api_key: the avatar meets your bot in a private room in your Daily account.", code="missing_daily_key")
306
+ self._api_url = (api_url or os.environ.get("CHATFX_AVATAR_API_URL") or DEFAULT_API_URL).rstrip("/")
307
+ self._daily_api_url = daily_api_url
308
+ self._session = session
309
+ self._own_session: aiohttp.ClientSession | None = None
310
+ self._face_first_seconds = face_first_seconds
311
+ self._start_timeout = start_timeout
312
+ self._waiting_picture = waiting_picture
313
+ self._waiting_text = waiting_text
314
+ self._started_at = 0.0
315
+ self._stills: tuple[OutputImageRawFrame, OutputImageRawFrame] | None = None
316
+ self._showing: asyncio.Task | None = None
317
+ self._out_rate = 24000
318
+ self._seat: BotSeat | None = None
319
+ self._rooms: DailyRooms | None = None
320
+ self._room_name: str | None = None
321
+ self._session_id: str | None = None
322
+ self._connecting: asyncio.Task | None = None
323
+ self._keepalive: asyncio.Task | None = None
324
+ self._pushing: asyncio.Task | None = None
325
+ # What comes back from the avatar, pushed in the order it came.
326
+ self._back: asyncio.Queue[Frame] = asyncio.Queue()
327
+ self._transport_ready = False
328
+ # The face is in when its first frame arrives; from the next reply on, replies go to it.
329
+ self._face_in = False
330
+ self._face_arrived = asyncio.Event()
331
+ # The reply being spoken: "face" (sent to the avatar), "own" (the bot's own voice), or None.
332
+ self._speaking: str | None = None
333
+ self._reply: str | None = None
334
+ self._held = bytearray()
335
+ self._held_rate = 24000
336
+ # Replies the avatar hasn't finished saying, so the call's end can wait for its last words.
337
+ self._unplayed = 0
338
+ self._all_played = asyncio.Event()
339
+ self._all_played.set()
340
+ self._cut_until = 0.0
341
+ self.failure: ChatFXException | None = None
342
+
343
+ def can_generate_metrics(self) -> bool:
344
+ return True
345
+
346
+ # ---- Lifecycle -------------------------------------------------------------------------------------------------------
347
+
348
+ async def setup(self, setup: FrameProcessorSetup) -> None:
349
+ await super().setup(setup)
350
+ # The avatar's voice comes back at your pipeline's output rate.
351
+ self._out_rate = setup.audio_out_sample_rate or self._out_rate
352
+
353
+ async def start(self, frame: StartFrame) -> None:
354
+ await super().start(frame)
355
+ self._started_at = time.monotonic()
356
+ self._pushing = self.create_task(self._push_back())
357
+ self._connecting = self.create_task(self._connect())
358
+ if self._waiting_picture:
359
+ self._showing = self.create_task(self._show_still())
360
+
361
+ async def stop(self, frame: EndFrame) -> None:
362
+ await super().stop(frame)
363
+ # Let the avatar finish its last words (a goodbye) before it leaves.
364
+ if self._face_in and self._unplayed:
365
+ try:
366
+ await asyncio.wait_for(self._all_played.wait(), LAST_WORDS_SECONDS)
367
+ # The end of its voice is still on its way back to the bot.
368
+ await asyncio.sleep(0.3)
369
+ except asyncio.TimeoutError:
370
+ pass
371
+ await self._close()
372
+
373
+ async def cancel(self, frame: CancelFrame) -> None:
374
+ await super().cancel(frame)
375
+ await self._close()
376
+
377
+ def _http(self) -> aiohttp.ClientSession:
378
+ if self._session is not None:
379
+ return self._session
380
+ if self._own_session is None or self._own_session.closed:
381
+ self._own_session = aiohttp.ClientSession()
382
+ return self._own_session
383
+
384
+ async def _connect(self) -> None:
385
+ try:
386
+ self._rooms = DailyRooms(self._daily_key, self._http(), self._daily_api_url)
387
+ url, self._room_name, bot_token, avatar_token = await self._rooms.create()
388
+ self._seat = BotSeat(asyncio.get_running_loop(), self._out_rate, self._video, self._audio, self._message, self._avatar)
389
+ await self._seat.join(url, bot_token)
390
+ await self._bring_face(url, avatar_token)
391
+ except ChatFXException as error:
392
+ self.failure = error
393
+ logger.warning(f"ChatFX: no face on this call ({error.code}): {error}. Your bot carries on with its own voice.")
394
+ await self._close()
395
+ except asyncio.CancelledError:
396
+ raise
397
+ except Exception as error:
398
+ self.failure = ChatFXException(str(error))
399
+ logger.exception("ChatFX: no face on this call. Your bot carries on with its own voice.")
400
+ await self._close()
401
+
402
+ async def _bring_face(self, room_url: str, avatar_token: str) -> None:
403
+ body = {"avatar_id": self._avatar_id, "connect": "daily", "daily": {"room_url": room_url, "token": avatar_token}}
404
+ deadline = time.monotonic() + self._start_timeout
405
+ while time.monotonic() < deadline:
406
+ wait = RETRY_SECONDS
407
+ try:
408
+ status, data, wait = await self._call("POST", "/v1/sessions", body, START_REQUEST_SECONDS)
409
+ except (aiohttp.ClientError, asyncio.TimeoutError):
410
+ logger.debug("ChatFX: the API isn't reachable yet", exc_info=True)
411
+ status, data = 0, {}
412
+ if status == 201 and data.get("id"):
413
+ self._session_id = str(data["id"])
414
+ self._keepalive = self.create_task(self._keep_alive())
415
+ try:
416
+ await asyncio.wait_for(self._face_arrived.wait(), FACE_SECONDS)
417
+ logger.info(f"ChatFX: avatar {self._avatar_id} is on (session {self._session_id})")
418
+ return
419
+ except asyncio.TimeoutError:
420
+ logger.warning("ChatFX: the avatar took the call but sent no face; trying again")
421
+ await self._end_session()
422
+ elif status not in (0, 503) and status < 500 and not (status == 429 and (data.get("error") or {}).get("code") == "rate_limited"):
423
+ error = data.get("error") or {}
424
+ raise ChatFXException(error.get("message") or f"ChatFX Avatar answered {status}.", code=error.get("code") or "error", status=status)
425
+ await asyncio.sleep(wait)
426
+ raise ChatFXException("The avatar couldn't start in time.", code="start_timeout")
427
+
428
+ async def _keep_alive(self) -> None:
429
+ while self._session_id:
430
+ await asyncio.sleep(KEEPALIVE_SECONDS)
431
+ session_id = self._session_id
432
+ if not session_id:
433
+ return
434
+ try:
435
+ status, data, _ = await self._call("POST", f"/v1/sessions/{session_id}/keepalive")
436
+ except (aiohttp.ClientError, asyncio.TimeoutError):
437
+ logger.warning("ChatFX: a check-in didn't go through; trying again")
438
+ continue
439
+ if status != 200:
440
+ # Ended, gone, or out of minutes: the face leaves, and the bot carries on with its own voice.
441
+ logger.warning(f"ChatFX: the avatar's session ended ({(data.get('error') or {}).get('code', status)})")
442
+ self._session_id = None
443
+ return
444
+
445
+ async def _end_session(self) -> None:
446
+ if self._keepalive and asyncio.current_task() is not self._keepalive:
447
+ await self.cancel_task(self._keepalive)
448
+ self._keepalive = None
449
+ session_id, self._session_id = self._session_id, None
450
+ if session_id:
451
+ try:
452
+ await self._call("POST", f"/v1/sessions/{session_id}/end")
453
+ except Exception:
454
+ logger.warning("ChatFX: couldn't end the session; it ends by itself a minute after its last check-in")
455
+
456
+ async def _close(self) -> None:
457
+ if self._connecting and not self._connecting.done() and asyncio.current_task() is not self._connecting:
458
+ await self.cancel_task(self._connecting)
459
+ self._connecting = None
460
+ if self._seat:
461
+ self._seat.send({"chatfx": "end"})
462
+ await self._end_session()
463
+ if self._seat:
464
+ await self._seat.leave()
465
+ self._seat = None
466
+ if self._rooms and self._room_name:
467
+ await self._rooms.delete(self._room_name)
468
+ self._room_name = None
469
+ self._face_in = False
470
+ if self._pushing:
471
+ await self.cancel_task(self._pushing)
472
+ self._pushing = None
473
+ if self._showing and asyncio.current_task() is not self._showing:
474
+ await self.cancel_task(self._showing)
475
+ self._showing = None
476
+ if self._own_session is not None:
477
+ await self._own_session.close()
478
+ self._own_session = None
479
+
480
+ async def _call(self, method: str, path: str, body: dict[str, Any] | None = None, seconds: float = REQUEST_SECONDS) -> tuple[int, dict[str, Any], float]:
481
+ async with self._http().request(
482
+ method,
483
+ self._api_url + path,
484
+ json=body,
485
+ headers={"Authorization": f"Bearer {self._api_key}"},
486
+ timeout=aiohttp.ClientTimeout(total=seconds),
487
+ ) as response:
488
+ try:
489
+ data = await response.json(content_type=None)
490
+ except Exception:
491
+ data = {}
492
+ try:
493
+ retry = float(response.headers.get("Retry-After", str(RETRY_SECONDS)))
494
+ except ValueError:
495
+ retry = RETRY_SECONDS
496
+ return response.status, data if isinstance(data, dict) else {}, max(0.5, min(retry, 30.0))
497
+
498
+ # ---- The avatar's face and voice, back into your pipeline ----------------------------------------------------------
499
+
500
+ async def _show_still(self) -> None:
501
+ """The avatar's picture as your bot's video until the live face is in (with the words while it's on its way)."""
502
+ try:
503
+ if self._stills is None:
504
+ async with self._http().get(
505
+ f"{self._api_url}/v1/avatars/{self._avatar_id}/picture",
506
+ headers={"Authorization": f"Bearer {self._api_key}"},
507
+ timeout=aiohttp.ClientTimeout(total=REQUEST_SECONDS),
508
+ ) as response:
509
+ if response.status != 200:
510
+ logger.debug(f"ChatFX: no waiting picture (the API answered {response.status})")
511
+ return
512
+ picture = await response.read()
513
+ words = still_frame(picture, self._waiting_text)
514
+ self._stills = (words, still_frame(picture, None) if self._waiting_text else words)
515
+ while not self._face_in:
516
+ if self._transport_ready:
517
+ # With no face coming (or gone), the picture stays without the words.
518
+ coming = self.failure is None and self._seat is not None
519
+ self._back.put_nowait(self._stills[0] if coming else self._stills[1])
520
+ await asyncio.sleep(1 / STILL_FPS)
521
+ except asyncio.CancelledError:
522
+ raise
523
+ except Exception:
524
+ logger.debug("ChatFX: couldn't show the waiting picture", exc_info=True)
525
+
526
+ async def _face_first(self) -> None:
527
+ """Holds the bot's first words (up to face_first_seconds from the start) until the face is in."""
528
+ left = self._started_at + self._face_first_seconds - time.monotonic()
529
+ connecting = self._connecting
530
+ if self._face_in or left <= 0 or connecting is None or connecting.done():
531
+ return
532
+ await asyncio.wait({connecting}, timeout=left)
533
+
534
+ async def _push_back(self) -> None:
535
+ while True:
536
+ frame = await self._back.get()
537
+ await self.push_frame(frame)
538
+
539
+ def _avatar(self, here: bool) -> None:
540
+ if not here:
541
+ self._face_in = False
542
+ self._face_arrived.clear()
543
+ self._unplayed = 0
544
+ self._all_played.set()
545
+ logger.warning("ChatFX: the avatar left the call; your bot carries on with its own voice")
546
+ if self._stills and self._waiting_picture and (self._showing is None or self._showing.done()):
547
+ self._showing = self.create_task(self._show_still())
548
+
549
+ def _video(self, frame: Any) -> None:
550
+ if not self._transport_ready or not self._seat:
551
+ return
552
+ if not self._face_in:
553
+ self._face_in = True
554
+ self._face_arrived.set()
555
+ self._back.put_nowait(OutputImageRawFrame(image=frame.buffer, size=(frame.width, frame.height), format=frame.color_format))
556
+
557
+ def _audio(self, data: Any) -> None:
558
+ # While the bot speaks with its own voice (before the face came in), the avatar's quiet stays out of the way.
559
+ if not self._face_in or self._speaking == "own" or time.monotonic() < self._cut_until:
560
+ return
561
+ self._back.put_nowait(SpeechOutputAudioRawFrame(audio=data.audio_frames, sample_rate=data.sample_rate, num_channels=data.num_channels))
562
+
563
+ def _message(self, message: Any, sender: str) -> None:
564
+ # {"chatfx": "played", "seconds": 2.4, "interrupted": false}: the avatar finished saying a reply.
565
+ if isinstance(message, dict) and message.get("chatfx") == "played" and not message.get("interrupted"):
566
+ self._unplayed = max(0, self._unplayed - 1)
567
+ if not self._unplayed:
568
+ self._all_played.set()
569
+
570
+ # ---- Your bot's voice, to the avatar --------------------------------------------------------------------------------
571
+
572
+ def _send_piece(self, pcm: bytes, rate: int) -> None:
573
+ if self._seat and self._reply:
574
+ self._seat.send({"chatfx": "voice", "reply": self._reply, "audio": base64.b64encode(pcm).decode(), "rate": rate})
575
+
576
+ def _send_sound(self, frame: TTSAudioRawFrame) -> None:
577
+ if self._held and self._held_rate != frame.sample_rate:
578
+ self._flush()
579
+ self._held_rate = frame.sample_rate
580
+ self._held.extend(frame.audio)
581
+ piece = int(frame.sample_rate * PIECE_SECONDS) * 2
582
+ while len(self._held) >= piece:
583
+ self._send_piece(bytes(self._held[:piece]), frame.sample_rate)
584
+ del self._held[:piece]
585
+
586
+ def _flush(self) -> None:
587
+ if self._held:
588
+ self._send_piece(bytes(self._held), self._held_rate)
589
+ self._held.clear()
590
+
591
+ def _begin_reply(self) -> None:
592
+ # A reply goes to the face only if the face is in when it starts, so a sentence is never split between voices.
593
+ if self._face_in and self._seat is not None:
594
+ self._speaking = "face"
595
+ self._reply = uuid.uuid4().hex[:12]
596
+ else:
597
+ self._speaking = "own"
598
+
599
+ def _end_reply(self) -> None:
600
+ if self._speaking == "face" and self._reply:
601
+ self._flush()
602
+ if self._seat:
603
+ self._seat.send({"chatfx": "voice", "reply": self._reply, "done": True})
604
+ self._unplayed += 1
605
+ self._all_played.clear()
606
+ self._speaking = None
607
+ self._reply = None
608
+
609
+ async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
610
+ await super().process_frame(frame, direction)
611
+ if isinstance(frame, TTSStartedFrame):
612
+ if self._speaking:
613
+ self._end_reply()
614
+ await self._face_first()
615
+ self._begin_reply()
616
+ if self._speaking == "face":
617
+ await self.start_ttfb_metrics()
618
+ else:
619
+ await self.push_frame(frame, direction)
620
+ elif isinstance(frame, TTSAudioRawFrame):
621
+ if not self._speaking:
622
+ # Some TTS services send sound without a TTSStartedFrame first.
623
+ await self._face_first()
624
+ self._begin_reply()
625
+ if self._speaking == "face" and not self._face_in:
626
+ # The avatar left mid-reply: the rest of it is the bot's own voice.
627
+ self._speaking, self._reply = "own", None
628
+ self._held.clear()
629
+ if self._speaking == "face":
630
+ self._send_sound(frame)
631
+ else:
632
+ await self.push_frame(frame, direction)
633
+ elif isinstance(frame, TTSStoppedFrame):
634
+ # For a reply sent to the face, the avatar's own voice coming back says when the bot stops speaking.
635
+ spoke = self._speaking
636
+ self._end_reply()
637
+ if spoke != "face":
638
+ await self.push_frame(frame, direction)
639
+ elif isinstance(frame, InterruptionFrame):
640
+ if self._speaking == "face" or self._unplayed:
641
+ self._cut_until = time.monotonic() + CUT_SECONDS
642
+ self._held.clear()
643
+ self._speaking = None
644
+ self._reply = None
645
+ self._unplayed = 0
646
+ self._all_played.set()
647
+ if self._seat:
648
+ self._seat.send({"chatfx": "interrupt"})
649
+ await self.push_frame(frame, direction)
650
+ elif isinstance(frame, OutputTransportReadyFrame):
651
+ self._transport_ready = True
652
+ await self.push_frame(frame, direction)
653
+ elif isinstance(frame, BotStartedSpeakingFrame):
654
+ # The avatar's first words reached the visitor: that's the time to first sound.
655
+ await self.stop_ttfb_metrics()
656
+ await self.push_frame(frame, direction)
657
+ else:
658
+ await self.push_frame(frame, direction)
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: pipecat-chatfx
3
+ Version: 0.1.0
4
+ Summary: ChatFX Avatar for Pipecat: a real-time talking face for your Pipecat bot
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: BSD-2-Clause
9
+ License-File: LICENSE
10
+ Keywords: avatar,chatfx,daily,pipecat,realtime,video,voice
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Multimedia :: Video
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: aiohttp>=3.9
17
+ Requires-Dist: pipecat-ai[daily]>=1.8
18
+ Description-Content-Type: text/markdown
19
+
20
+ # ChatFX Avatar for Pipecat
21
+
22
+ A real-time talking face for your Pipecat bot. Your bot's voice goes to the avatar, and its face and voice come back,
23
+ drawn together so the lips match, as your bot's own video and audio. You keep your own listening, AI and voice; ChatFX
24
+ adds the face, billed by the minute.
25
+
26
+ Tested with Pipecat v1.15.0.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install pipecat-chatfx
32
+ ```
33
+
34
+ Requires Python 3.10+ and `pipecat-ai` 1.8 or newer (it brings `pipecat-ai[daily]`).
35
+
36
+ ## Add your keys
37
+
38
+ - `CHATFX_AVATAR_API_KEY`: create one under **API keys** at [avatar.chatfx.com](https://avatar.chatfx.com).
39
+ - `DAILY_API_KEY`: from your [Daily dashboard](https://dashboard.daily.co/developers). Running on Pipecat Cloud, you already
40
+ have one. The avatar meets your bot in a private Daily room in your account, made for each call and removed when it
41
+ ends, the way Pipecat's own Tavus service works.
42
+
43
+ Put both in your bot's environment (your `.env`, or your Pipecat Cloud secret set).
44
+
45
+ ## Use it
46
+
47
+ Put `ChatFXVideoService` right after your TTS service, and turn on your transport's video out at the avatar's size:
48
+
49
+ ```python
50
+ from pipecat_chatfx import ChatFXVideoService
51
+
52
+ avatar = ChatFXVideoService(avatar_id="chatfx-lois") # copy the id from the Avatars page
53
+
54
+ pipeline = Pipeline([
55
+ transport.input(),
56
+ stt,
57
+ user_aggregator,
58
+ llm,
59
+ tts,
60
+ avatar,
61
+ transport.output(),
62
+ assistant_aggregator,
63
+ ])
64
+ ```
65
+
66
+ ```python
67
+ TransportParams( # or DailyParams
68
+ audio_in_enabled=True,
69
+ audio_out_enabled=True,
70
+ video_out_enabled=True,
71
+ video_out_is_live=True,
72
+ video_out_width=912, # 912 x 512 landscape, 512 x 512 square, 384 x 512 portrait
73
+ video_out_height=512,
74
+ video_out_framerate=25,
75
+ )
76
+ ```
77
+
78
+ That's all the code. When your pipeline ends (or is cancelled when the visitor leaves), the avatar's session ends too, so
79
+ billing stops. Your page shows the bot's video as before: the avatar is the bot.
80
+
81
+ Step by step: [Connecting your agent: Pipecat](https://chatfx.com/support/avatar-connect/#pipecat). A full sample bot
82
+ (Deepgram, OpenAI, Cartesia) comes in this package's source download, in `examples/`: `uv run bot.py`, then open
83
+ http://localhost:7860/client.
84
+
85
+ ## What it does for you
86
+
87
+ - **Shows a waiting picture.** Until the live face is in, your bot's video is the avatar's picture with "Video starting..."
88
+ on it.
89
+ - **Greets with the face.** Your bot's first words wait up to 10 seconds for the face, so they come with it.
90
+ - **Never depends on the face.** If the face can't come, or leaves mid-call, your bot carries on with its own voice.
91
+ - **Stops at once when talked over.** An interruption stops the avatar mid-word.
92
+ - **Lets the last words finish.** At the end of a call, the avatar finishes its goodbye before it leaves.
93
+
94
+ ## Options
95
+
96
+ ```python
97
+ ChatFXVideoService(
98
+ avatar_id="chatfx-lois",
99
+ api_key=None, # defaults to CHATFX_AVATAR_API_KEY
100
+ daily_api_key=None, # defaults to DAILY_API_KEY
101
+ api_url=None, # defaults to https://api.chatfx.com
102
+ face_first_seconds=10.0, # how long the first words wait for the face
103
+ start_timeout=120.0, # how long to keep trying to start the avatar
104
+ waiting_picture=True, # show the avatar's picture until the live face is in
105
+ waiting_text="Video starting...", # the words on it, in your language (None: no words)
106
+ )
107
+ ```
108
+
109
+ If the avatar can't start (a wrong key, no minutes left), `avatar.failure` says why, with the API's `code`.
110
+
111
+ ## Changelog
112
+
113
+ - 0.1.0: first release. Tested with Pipecat v1.15.0 and daily-python 0.37.0.
114
+
115
+ ## License
116
+
117
+ BSD 2-Clause, like Pipecat.
@@ -0,0 +1,7 @@
1
+ pipecat_chatfx/__init__.py,sha256=lMhUPRkAW5Z0agq7LR03-4kWQOWJxuJi_IsD3TH6pyo,261
2
+ pipecat_chatfx/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ pipecat_chatfx/video.py,sha256=UNqFGyrtdwqouZmtpmWYA_yiz3ancHMFwKqIVJF77js,30550
4
+ pipecat_chatfx-0.1.0.dist-info/METADATA,sha256=B97wHZioZ2Ea8ayGJgrLgZgVSxJwy61nhOglcH4vHwA,4401
5
+ pipecat_chatfx-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ pipecat_chatfx-0.1.0.dist-info/licenses/LICENSE,sha256=Ggq3MJ0uGVtgro7bnXm335A84Na6vcoTdcHkoaczLV0,1302
7
+ pipecat_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
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, ChatRep, Inc.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.