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 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pipecat_chatfx/video.py
ADDED
|
@@ -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,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.
|