pipecat-bithuman 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,41 @@
1
+ #
2
+ # Copyright (c) 2026, bitHuman, Inc.
3
+ #
4
+ # SPDX-License-Identifier: BSD-2-Clause
5
+ #
6
+
7
+ """bitHuman real-time avatar video service for Pipecat.
8
+
9
+ TTS audio goes in; lip-synced avatar video and the matching audio come out.
10
+ See ``BitHumanVideoService``.
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ from .runtime import (
16
+ API_SECRET_ENV,
17
+ MODEL_PATH_ENV,
18
+ BitHumanRuntime,
19
+ RuntimeFactory,
20
+ resolve_model_path,
21
+ sdk_runtime_factory,
22
+ )
23
+ from .video import BitHumanServiceError, BitHumanVideoService, BitHumanVideoSettings
24
+
25
+ try:
26
+ __version__ = version("pipecat-bithuman")
27
+ except PackageNotFoundError: # running from a source tree without install
28
+ __version__ = "0.0.0"
29
+
30
+ __all__ = [
31
+ "API_SECRET_ENV",
32
+ "MODEL_PATH_ENV",
33
+ "BitHumanRuntime",
34
+ "BitHumanServiceError",
35
+ "BitHumanVideoService",
36
+ "BitHumanVideoSettings",
37
+ "RuntimeFactory",
38
+ "resolve_model_path",
39
+ "sdk_runtime_factory",
40
+ "__version__",
41
+ ]
@@ -0,0 +1,120 @@
1
+ #
2
+ # Copyright (c) 2026, bitHuman, Inc.
3
+ #
4
+ # SPDX-License-Identifier: BSD-2-Clause
5
+ #
6
+
7
+ """The seam between the Pipecat service and the bitHuman Python SDK.
8
+
9
+ ``BitHumanVideoService`` talks to the avatar only through the small
10
+ :class:`BitHumanRuntime` protocol below. The default factory builds it from
11
+ ``bithuman.AsyncBithuman``, the SDK's live-streaming class. Tests (and anyone
12
+ who wants to wrap the SDK) pass their own ``runtime_factory`` instead.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from collections.abc import AsyncIterator, Awaitable, Callable
19
+ from typing import Any, Protocol, runtime_checkable
20
+
21
+ API_SECRET_ENV = "BITHUMAN_API_SECRET"
22
+ MODEL_PATH_ENV = "BITHUMAN_MODEL_PATH"
23
+
24
+
25
+ @runtime_checkable
26
+ class BitHumanRuntime(Protocol):
27
+ """What the service needs from a live bitHuman avatar.
28
+
29
+ ``bithuman.AsyncBithuman`` satisfies this protocol as is.
30
+ """
31
+
32
+ async def push_audio(self, data: bytes, sample_rate: int, last_chunk: bool = True) -> None:
33
+ """Queue int16 little-endian mono PCM for the avatar to speak.
34
+
35
+ Args:
36
+ data: Raw int16 PCM bytes, mono.
37
+ sample_rate: Sample rate of ``data`` in hertz. The SDK resamples.
38
+ last_chunk: Whether this chunk ends the current reply.
39
+ """
40
+ ...
41
+
42
+ async def flush(self) -> None:
43
+ """Mark the end of the current reply."""
44
+ ...
45
+
46
+ def interrupt(self) -> None:
47
+ """Drop the reply in flight (barge-in) and stay ready for the next one."""
48
+ ...
49
+
50
+ def run(self) -> AsyncIterator[Any]:
51
+ """Yield frames at the model's play rate.
52
+
53
+ Each frame has ``bgr_image`` (an ``(H, W, 3)`` uint8 BGR array or
54
+ None), ``audio_chunk`` (the audio that plays with this frame, or None
55
+ when idle) and ``end_of_speech`` (True on the last frame of a reply).
56
+
57
+ Returns:
58
+ An async iterator of frames.
59
+ """
60
+ ...
61
+
62
+ async def shutdown(self) -> None:
63
+ """Stop rendering and release the model and the credential."""
64
+ ...
65
+
66
+
67
+ RuntimeFactory = Callable[[], Awaitable[BitHumanRuntime]]
68
+
69
+
70
+ def resolve_model_path(model_path: str | os.PathLike[str] | None) -> str:
71
+ """Return the avatar model path, from the argument or ``BITHUMAN_MODEL_PATH``.
72
+
73
+ Args:
74
+ model_path: Path to an ``.imx`` avatar model, or None to read the
75
+ environment.
76
+
77
+ Returns:
78
+ The model path as a string.
79
+
80
+ Raises:
81
+ ValueError: If neither the argument nor the environment names a model.
82
+ """
83
+ value = os.fspath(model_path) if model_path is not None else os.getenv(MODEL_PATH_ENV, "")
84
+ value = value.strip()
85
+ if not value:
86
+ raise ValueError(
87
+ "BitHumanVideoService needs an avatar model: pass model_path='path/to/avatar.imx' "
88
+ f"or set {MODEL_PATH_ENV}. Download one with the Agents API "
89
+ "(https://docs.bithuman.ai/platforms/python)."
90
+ )
91
+ return value
92
+
93
+
94
+ def sdk_runtime_factory(model_path: str, api_secret: str | None = None) -> RuntimeFactory:
95
+ """Build a factory that opens ``model_path`` with ``bithuman.AsyncBithuman``.
96
+
97
+ The avatar renders in this process, on this machine. When ``api_secret``
98
+ is None the SDK reads ``BITHUMAN_API_SECRET`` from the environment.
99
+
100
+ Args:
101
+ model_path: Path to an ``.imx`` avatar model.
102
+ api_secret: bitHuman API secret, or None to use the environment.
103
+
104
+ Returns:
105
+ An async callable that returns a ready :class:`BitHumanRuntime`.
106
+ """
107
+
108
+ async def _create() -> BitHumanRuntime:
109
+ try:
110
+ from bithuman import AsyncBithuman
111
+ except ImportError as e:
112
+ raise ImportError(
113
+ "The bitHuman Python SDK is not installed. Install it with "
114
+ "`pip install pipecat-bithuman` (Expression 2 avatars: `pip install "
115
+ '"pipecat-bithuman[expression-2]"`).'
116
+ ) from e
117
+
118
+ return await AsyncBithuman.create(model_path=model_path, api_secret=api_secret)
119
+
120
+ return _create
@@ -0,0 +1,628 @@
1
+ #
2
+ # Copyright (c) 2026, bitHuman, Inc.
3
+ #
4
+ # SPDX-License-Identifier: BSD-2-Clause
5
+ #
6
+
7
+ """bitHuman video service for Pipecat.
8
+
9
+ ``BitHumanVideoService`` sits between a TTS (or speech-to-speech) service and
10
+ the output transport. It sends the bot's speech to a bitHuman avatar that
11
+ renders in this process, and pushes the avatar's frames downstream: the
12
+ picture as ``OutputImageRawFrame`` and the speech that goes with each picture
13
+ as ``TTSAudioRawFrame``, so the mouth and the voice leave together.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ import time
21
+ from collections import deque
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ from loguru import logger
27
+ from pipecat.frames.frames import (
28
+ CancelFrame,
29
+ EndFrame,
30
+ Frame,
31
+ InterruptionFrame,
32
+ OutputImageRawFrame,
33
+ StartFrame,
34
+ TTSAudioRawFrame,
35
+ TTSStoppedFrame,
36
+ )
37
+ from pipecat.processors.frame_processor import FrameDirection
38
+ from pipecat.services.ai_service import AIService
39
+ from pipecat.services.settings import ServiceSettings
40
+ from pipecat.utils.errors import ErrorCategory
41
+
42
+ from .runtime import (
43
+ API_SECRET_ENV,
44
+ BitHumanRuntime,
45
+ RuntimeFactory,
46
+ resolve_model_path,
47
+ sdk_runtime_factory,
48
+ )
49
+
50
+ # Exception class names raised by the bitHuman SDK, matched by name so this
51
+ # module does not import the SDK (and so wrapped runtimes classify the same way).
52
+ _AUTHENTICATION_ERRORS = frozenset(
53
+ {
54
+ "NotAuthorised",
55
+ "TokenError",
56
+ "TokenExpiredError",
57
+ "TokenValidationError",
58
+ "TokenRequestError",
59
+ }
60
+ )
61
+ _AUTHORIZATION_ERRORS = frozenset({"AccountStatusError"})
62
+ _INVALID_REQUEST_ERRORS = frozenset(
63
+ {
64
+ "InvalidAvatar",
65
+ "NotSupported",
66
+ "ModelError",
67
+ "ModelNotFoundError",
68
+ "ModelLoadError",
69
+ "ModelSecurityError",
70
+ "FileNotFoundError",
71
+ }
72
+ )
73
+ _MISSING_SECRET_HINTS = ("api_secret is required", "no api secret")
74
+
75
+ _REDACTED = "***"
76
+
77
+
78
+ class BitHumanServiceError(Exception):
79
+ """An error from the bitHuman avatar, with any API secret removed from its text."""
80
+
81
+
82
+ @dataclass
83
+ class BitHumanVideoSettings(ServiceSettings):
84
+ """Settings for the bitHuman video service.
85
+
86
+ The service has no runtime-updatable settings yet. The avatar model is
87
+ chosen at construction time with ``model_path``.
88
+ """
89
+
90
+ pass
91
+
92
+
93
+ @dataclass
94
+ class _Reply:
95
+ """One bot reply on its way through the avatar.
96
+
97
+ Parameters:
98
+ context_id: The TTS context the reply's audio came from.
99
+ flushed: Whether the end of the reply was sent to the avatar.
100
+ flushed_at: Monotonic time of the flush.
101
+ voiced: Whether the avatar has emitted non-silent audio for it.
102
+ stop_frames: ``TTSStoppedFrame`` s held until the reply finishes playing.
103
+ """
104
+
105
+ context_id: str | None
106
+ flushed: bool = False
107
+ flushed_at: float = 0.0
108
+ voiced: bool = False
109
+ stop_frames: list[Frame] = field(default_factory=list)
110
+
111
+
112
+ class BitHumanVideoService(AIService):
113
+ """Lip-synced bitHuman avatar video for a Pipecat bot.
114
+
115
+ Place it after the TTS service (or a speech-to-speech LLM) and before
116
+ ``transport.output()``. It consumes ``TTSAudioRawFrame`` and pushes:
117
+
118
+ - ``OutputImageRawFrame`` (RGB) for every avatar frame, while talking and
119
+ while idle.
120
+ - ``TTSAudioRawFrame`` (16 kHz mono) with the speech that plays with each
121
+ frame. The input TTS audio is not forwarded, so the voice is the
122
+ avatar's copy and stays paired with the picture.
123
+ - ``TTSStoppedFrame`` only after the avatar has finished speaking the reply.
124
+
125
+ On ``InterruptionFrame`` (barge-in) the avatar drops the reply in flight
126
+ and goes back to idle. The avatar opens on ``StartFrame`` and closes on
127
+ ``EndFrame``, ``CancelFrame`` or cleanup, whichever comes first.
128
+
129
+ If the avatar cannot start or fails mid-session, the service pushes an
130
+ ``ErrorFrame`` upstream and, by default, passes TTS audio through unchanged
131
+ so the bot keeps talking without video.
132
+
133
+ Rendering runs in this process on this machine through the ``bithuman``
134
+ Python SDK, and is metered by active session time (talking or idle).
135
+ """
136
+
137
+ Settings = BitHumanVideoSettings
138
+ _settings: Settings
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ model_path: str | os.PathLike[str] | None = None,
144
+ api_secret: str | None = None,
145
+ runtime_factory: RuntimeFactory | None = None,
146
+ sync_video_to_audio: bool = True,
147
+ audio_passthrough_on_error: bool = True,
148
+ stop_frame_timeout_s: float = 2.0,
149
+ end_drain_timeout_s: float = 30.0,
150
+ settings: Settings | None = None,
151
+ **kwargs,
152
+ ):
153
+ """Initialize the bitHuman video service.
154
+
155
+ Args:
156
+ model_path: Path to the avatar's ``.imx`` model file. Defaults to
157
+ the ``BITHUMAN_MODEL_PATH`` environment variable.
158
+ api_secret: bitHuman API secret. Defaults to the
159
+ ``BITHUMAN_API_SECRET`` environment variable (read by the SDK).
160
+ It is never logged.
161
+ runtime_factory: Advanced. An async callable returning an object
162
+ that implements ``BitHumanRuntime``. Replaces the default,
163
+ which opens ``model_path`` with ``bithuman.AsyncBithuman``.
164
+ Useful for tests and for wrapping the SDK.
165
+ sync_video_to_audio: Mark each image with ``sync_with_audio`` so
166
+ the output transport shows it only after the audio before it
167
+ has been sent. Keep the transport's ``video_out_is_live`` off
168
+ (its default) when this is on.
169
+ audio_passthrough_on_error: If the avatar fails, forward TTS audio
170
+ unchanged so the bot still speaks. When False, the audio is
171
+ dropped.
172
+ stop_frame_timeout_s: Release a held ``TTSStoppedFrame`` if the
173
+ avatar has been idle this long after the end of a reply
174
+ without reporting the end of speech.
175
+ end_drain_timeout_s: On ``EndFrame``, the longest wait for queued
176
+ speech to finish playing before the avatar closes.
177
+ settings: Runtime-updatable settings. The service has none yet.
178
+ **kwargs: Additional arguments passed to ``AIService``.
179
+
180
+ Raises:
181
+ ValueError: If no avatar model is given and ``runtime_factory`` is
182
+ not set.
183
+ """
184
+ default_settings = self.Settings(model=None)
185
+ if settings is not None:
186
+ default_settings.apply_update(settings)
187
+
188
+ super().__init__(settings=default_settings, **kwargs)
189
+
190
+ if runtime_factory is None:
191
+ self._model_path: str | None = resolve_model_path(model_path)
192
+ runtime_factory = sdk_runtime_factory(self._model_path, api_secret)
193
+ else:
194
+ self._model_path = os.fspath(model_path) if model_path is not None else None
195
+
196
+ self._runtime_factory = runtime_factory
197
+ # Kept only to scrub secrets out of error text; never logged.
198
+ self._secrets = tuple(
199
+ s
200
+ for s in {api_secret, os.getenv(API_SECRET_ENV), os.getenv("BITHUMAN_API_KEY")}
201
+ if s and s.strip()
202
+ )
203
+
204
+ self._sync_video_to_audio = sync_video_to_audio
205
+ self._audio_passthrough_on_error = audio_passthrough_on_error
206
+ self._stop_frame_timeout_s = stop_frame_timeout_s
207
+ self._end_drain_timeout_s = end_drain_timeout_s
208
+
209
+ self._runtime: BitHumanRuntime | None = None
210
+ self._render_task: asyncio.Task | None = None
211
+ self._failed = False
212
+ self._closing = False
213
+
214
+ self._replies: deque[_Reply] = deque()
215
+ self._drained = asyncio.Event()
216
+ self._drained.set()
217
+ self._discard_audio = False
218
+ self._pending_audio_s = 0.0
219
+ self._last_voiced_at = 0.0
220
+ self._awaiting_first_voice = False
221
+ self._frame_size: tuple[int, int] | None = None
222
+
223
+ # ------------------------------------------------------------------
224
+ # Public properties
225
+ # ------------------------------------------------------------------
226
+
227
+ @property
228
+ def is_avatar_ready(self) -> bool:
229
+ """Whether the avatar is open and rendering.
230
+
231
+ Returns:
232
+ True between a successful start and teardown or failure.
233
+ """
234
+ return self._runtime is not None and not self._failed
235
+
236
+ @property
237
+ def frame_size(self) -> tuple[int, int] | None:
238
+ """The avatar's frame size as ``(width, height)``.
239
+
240
+ Returns:
241
+ The size of the first frame the avatar produced, or None before it.
242
+ """
243
+ return self._frame_size
244
+
245
+ def can_generate_metrics(self) -> bool:
246
+ """Check if this service can generate processing metrics.
247
+
248
+ Returns:
249
+ True. TTFB is the time from a reply's first audio in to its first
250
+ voiced avatar frame out.
251
+ """
252
+ return True
253
+
254
+ # ------------------------------------------------------------------
255
+ # Lifecycle
256
+ # ------------------------------------------------------------------
257
+
258
+ async def start(self, frame: StartFrame):
259
+ """Open the avatar and start pushing its frames.
260
+
261
+ Args:
262
+ frame: The start frame.
263
+ """
264
+ await super().start(frame)
265
+ self._closing = False
266
+ await self._open_runtime()
267
+
268
+ async def stop(self, frame: EndFrame):
269
+ """Let queued speech finish playing, then close the avatar.
270
+
271
+ Args:
272
+ frame: The end frame.
273
+ """
274
+ await super().stop(frame)
275
+ await self._drain_speech()
276
+ await self._teardown()
277
+
278
+ async def cancel(self, frame: CancelFrame):
279
+ """Close the avatar now, dropping queued speech.
280
+
281
+ Args:
282
+ frame: The cancel frame.
283
+ """
284
+ await super().cancel(frame)
285
+ await self._teardown()
286
+
287
+ async def cleanup(self):
288
+ """Release the avatar. Safe to call more than once."""
289
+ await super().cleanup()
290
+ await self._teardown()
291
+
292
+ async def _open_runtime(self):
293
+ if self._runtime is not None or self._failed:
294
+ return
295
+ started = time.monotonic()
296
+ try:
297
+ runtime = await self._runtime_factory()
298
+ except Exception as e:
299
+ await self._fail("the bitHuman avatar could not start", e)
300
+ return
301
+ self._runtime = runtime
302
+ logger.debug(f"{self}: avatar ready in {time.monotonic() - started:.2f} s")
303
+
304
+ def _start_render_task(self):
305
+ # Started only after StartFrame has gone downstream, so nothing below
306
+ # receives an avatar frame before it has started.
307
+ runtime = self._runtime
308
+ if runtime is None or self._render_task is not None:
309
+ return
310
+ self._render_task = self.create_task(self._render_loop(runtime), name="bithuman_render")
311
+
312
+ async def _teardown(self):
313
+ """Idempotent teardown shared by stop(), cancel() and cleanup()."""
314
+ self._closing = True
315
+ await self._cancel_render_task()
316
+ await self._close_runtime()
317
+ self._forget_replies()
318
+
319
+ async def _cancel_render_task(self):
320
+ task, self._render_task = self._render_task, None
321
+ if task is not None and task is not asyncio.current_task():
322
+ await self.cancel_task(task)
323
+
324
+ async def _close_runtime(self):
325
+ runtime, self._runtime = self._runtime, None
326
+ if runtime is None:
327
+ return
328
+ try:
329
+ await runtime.shutdown()
330
+ except Exception as e:
331
+ logger.warning(f"{self}: closing the avatar failed: {self._redact(str(e))}")
332
+
333
+ # ------------------------------------------------------------------
334
+ # Frame processing
335
+ # ------------------------------------------------------------------
336
+
337
+ async def process_frame(self, frame: Frame, direction: FrameDirection):
338
+ """Route TTS audio to the avatar and pass everything else on.
339
+
340
+ Args:
341
+ frame: The frame to process.
342
+ direction: The direction of frame processing.
343
+ """
344
+ await super().process_frame(frame, direction)
345
+
346
+ if direction != FrameDirection.DOWNSTREAM:
347
+ await self.push_frame(frame, direction)
348
+ elif isinstance(frame, StartFrame):
349
+ await self.push_frame(frame, direction)
350
+ self._start_render_task()
351
+ elif isinstance(frame, TTSAudioRawFrame):
352
+ await self._handle_tts_audio(frame)
353
+ elif isinstance(frame, TTSStoppedFrame):
354
+ await self._handle_tts_stopped(frame)
355
+ elif isinstance(frame, InterruptionFrame):
356
+ await self._handle_interruption()
357
+ await self.push_frame(frame, direction)
358
+ else:
359
+ await self.push_frame(frame, direction)
360
+
361
+ async def _handle_tts_audio(self, frame: TTSAudioRawFrame):
362
+ runtime = self._runtime
363
+ if runtime is None or self._failed:
364
+ if self._failed and self._audio_passthrough_on_error:
365
+ await self.push_frame(frame)
366
+ return
367
+
368
+ audio = _to_mono_int16(frame.audio, frame.num_channels)
369
+ if not audio:
370
+ return
371
+
372
+ is_new_reply = not self._replies or self._replies[-1].flushed
373
+ self._open_reply(frame.context_id)
374
+ if is_new_reply and len(self._replies) == 1:
375
+ # TTFB: this reply's first audio in -> its first voiced frame out.
376
+ self._awaiting_first_voice = True
377
+ await self.start_ttfb_metrics()
378
+ self._discard_audio = False
379
+ self._pending_audio_s += len(audio) / 2.0 / float(frame.sample_rate or 1)
380
+
381
+ try:
382
+ await runtime.push_audio(audio, frame.sample_rate, last_chunk=False)
383
+ except Exception as e:
384
+ await self._fail("sending audio to the bitHuman avatar failed", e)
385
+ if self._audio_passthrough_on_error:
386
+ await self.push_frame(frame)
387
+
388
+ async def _handle_tts_stopped(self, frame: TTSStoppedFrame):
389
+ runtime = self._runtime
390
+ if runtime is None or self._failed or not self._replies:
391
+ await self.push_frame(frame)
392
+ return
393
+
394
+ last = self._replies[-1]
395
+ last.stop_frames.append(frame)
396
+ if last.flushed:
397
+ # A stop with no new audio: keep it behind the speech still playing.
398
+ return
399
+ last.flushed = True
400
+ last.flushed_at = time.monotonic()
401
+ try:
402
+ await runtime.flush()
403
+ except Exception as e:
404
+ await self._fail("ending the reply on the bitHuman avatar failed", e)
405
+
406
+ async def _handle_interruption(self):
407
+ # The transport stops the bot on interruption, so held stop frames go.
408
+ self._forget_replies()
409
+ self._discard_audio = True
410
+ self._awaiting_first_voice = False
411
+ runtime = self._runtime
412
+ if runtime is None or self._failed:
413
+ return
414
+ try:
415
+ runtime.interrupt()
416
+ except Exception as e:
417
+ await self._fail("interrupting the bitHuman avatar failed", e)
418
+
419
+ def _open_reply(self, context_id: str | None) -> _Reply:
420
+ if not self._replies or self._replies[-1].flushed:
421
+ self._replies.append(_Reply(context_id=context_id))
422
+ self._drained.clear()
423
+ return self._replies[-1]
424
+
425
+ # ------------------------------------------------------------------
426
+ # Avatar output
427
+ # ------------------------------------------------------------------
428
+
429
+ async def _render_loop(self, runtime: BitHumanRuntime):
430
+ try:
431
+ async for avatar_frame in runtime.run():
432
+ await self._handle_avatar_frame(avatar_frame)
433
+ except asyncio.CancelledError:
434
+ raise
435
+ except Exception as e:
436
+ if self._runtime is runtime:
437
+ await self._fail("the bitHuman avatar stopped rendering", e)
438
+ return
439
+ if self._runtime is runtime and not self._closing:
440
+ await self._fail("the bitHuman avatar stream ended unexpectedly", None)
441
+
442
+ async def _handle_avatar_frame(self, avatar_frame: Any):
443
+ image = getattr(avatar_frame, "bgr_image", None)
444
+ chunk = getattr(avatar_frame, "audio_chunk", None)
445
+ end_of_speech = bool(getattr(avatar_frame, "end_of_speech", False))
446
+ now = time.monotonic()
447
+
448
+ if image is not None:
449
+ await self.push_frame(self._to_output_image(image))
450
+
451
+ head = self._replies[0] if self._replies else None
452
+ voiced = False
453
+ if chunk is not None and head is not None and not self._discard_audio:
454
+ data, sample_rate = _chunk_bytes(chunk)
455
+ if data:
456
+ voiced = bool(np.any(np.frombuffer(data, dtype=np.int16)))
457
+ # Leading silence (the avatar filling before it speaks) is
458
+ # dropped; once the reply is voiced every chunk is kept, so
459
+ # pauses inside the reply keep their length.
460
+ if voiced or head.voiced:
461
+ if voiced and not head.voiced:
462
+ head.voiced = True
463
+ if self._awaiting_first_voice:
464
+ self._awaiting_first_voice = False
465
+ await self.stop_ttfb_metrics()
466
+ await self.push_frame(
467
+ TTSAudioRawFrame(
468
+ audio=data,
469
+ sample_rate=sample_rate,
470
+ num_channels=1,
471
+ context_id=head.context_id,
472
+ )
473
+ )
474
+ seconds = len(data) / 2.0 / float(sample_rate or 1)
475
+ self._pending_audio_s = max(0.0, self._pending_audio_s - seconds)
476
+ if voiced:
477
+ self._last_voiced_at = now
478
+
479
+ if head is None or not head.flushed:
480
+ return
481
+ if end_of_speech and not self._discard_audio:
482
+ await self._release_head()
483
+ elif not voiced:
484
+ quiet_since = max(head.flushed_at, self._last_voiced_at)
485
+ if now - quiet_since >= self._stop_frame_timeout_s:
486
+ logger.debug(f"{self}: no end of speech from the avatar; releasing TTSStoppedFrame")
487
+ await self._release_head()
488
+
489
+ def _to_output_image(self, bgr: np.ndarray) -> OutputImageRawFrame:
490
+ rgb = np.ascontiguousarray(bgr[:, :, ::-1])
491
+ height, width = rgb.shape[0], rgb.shape[1]
492
+ if self._frame_size is None:
493
+ self._frame_size = (width, height)
494
+ logger.info(
495
+ f"{self}: avatar frames are {width}x{height}; set the transport's "
496
+ f"video_out_width={width} and video_out_height={height} to avoid resizing"
497
+ )
498
+ frame = OutputImageRawFrame(image=rgb.tobytes(), size=(width, height), format="RGB")
499
+ frame.sync_with_audio = self._sync_video_to_audio
500
+ return frame
501
+
502
+ async def _release_head(self):
503
+ if not self._replies:
504
+ return
505
+ reply = self._replies.popleft()
506
+ for stop in reply.stop_frames:
507
+ await self.push_frame(stop)
508
+ if not self._replies:
509
+ self._pending_audio_s = 0.0
510
+ self._drained.set()
511
+
512
+ async def _release_all(self):
513
+ while self._replies:
514
+ await self._release_head()
515
+ self._drained.set()
516
+
517
+ def _forget_replies(self):
518
+ self._replies.clear()
519
+ self._pending_audio_s = 0.0
520
+ self._drained.set()
521
+
522
+ async def _drain_speech(self):
523
+ runtime = self._runtime
524
+ if runtime is None or self._failed or not self._replies:
525
+ return
526
+ last = self._replies[-1]
527
+ if not last.flushed:
528
+ last.flushed = True
529
+ last.flushed_at = time.monotonic()
530
+ try:
531
+ await runtime.flush()
532
+ except Exception as e:
533
+ await self._fail("ending the reply on the bitHuman avatar failed", e)
534
+ return
535
+ timeout = min(
536
+ self._end_drain_timeout_s,
537
+ self._pending_audio_s + self._stop_frame_timeout_s + 1.0,
538
+ )
539
+ try:
540
+ await asyncio.wait_for(self._drained.wait(), timeout=timeout)
541
+ except TimeoutError:
542
+ logger.warning(f"{self}: queued speech did not finish in {timeout:.1f} s; closing")
543
+ await self._release_all()
544
+
545
+ # ------------------------------------------------------------------
546
+ # Errors
547
+ # ------------------------------------------------------------------
548
+
549
+ async def _fail(self, message: str, exception: Exception | None):
550
+ """Report an avatar failure once, close the avatar and fall back."""
551
+ already_failed = self._failed
552
+ self._failed = True
553
+ self._awaiting_first_voice = False
554
+ await self._release_all()
555
+
556
+ runtime = self._runtime
557
+ if runtime is not None:
558
+ self._runtime = None
559
+ try:
560
+ await runtime.shutdown()
561
+ except Exception as e:
562
+ logger.warning(f"{self}: closing the avatar failed: {self._redact(str(e))}")
563
+
564
+ if already_failed:
565
+ return
566
+
567
+ category = self._classify_error(exception) if exception is not None else None
568
+ detail = self._redact(f"{type(exception).__name__}: {exception}") if exception else ""
569
+ error_msg = f"{message}: {detail}" if detail else message
570
+ if self._audio_passthrough_on_error:
571
+ error_msg += " (TTS audio now passes through without video)"
572
+
573
+ reported: Exception | None = exception
574
+ if exception is not None and self._contains_secret(exception):
575
+ reported = BitHumanServiceError(detail)
576
+ await self.push_error(error_msg, exception=reported, category=category)
577
+
578
+ def _classify_error(self, exception: Exception) -> ErrorCategory | None:
579
+ """Map bitHuman SDK exceptions to Pipecat error categories.
580
+
581
+ Args:
582
+ exception: The exception to classify.
583
+
584
+ Returns:
585
+ The category, or None to let Pipecat classify it.
586
+ """
587
+ names = {cls.__name__ for cls in type(exception).__mro__}
588
+ if names & _AUTHENTICATION_ERRORS:
589
+ return ErrorCategory.AUTHENTICATION
590
+ if names & _AUTHORIZATION_ERRORS:
591
+ return ErrorCategory.AUTHORIZATION
592
+ if names & _INVALID_REQUEST_ERRORS:
593
+ return ErrorCategory.INVALID_REQUEST
594
+ text = str(exception).lower()
595
+ if any(hint in text for hint in _MISSING_SECRET_HINTS):
596
+ return ErrorCategory.AUTHENTICATION
597
+ return super()._classify_error(exception)
598
+
599
+ def _redact(self, text: str) -> str:
600
+ for secret in self._secrets:
601
+ text = text.replace(secret, _REDACTED)
602
+ return text
603
+
604
+ def _contains_secret(self, exception: Exception) -> bool:
605
+ text = f"{exception!s} {exception!r}"
606
+ return any(secret in text for secret in self._secrets)
607
+
608
+
609
+ def _to_mono_int16(audio: bytes, num_channels: int) -> bytes:
610
+ """Downmix interleaved int16 PCM to mono."""
611
+ if num_channels <= 1 or not audio:
612
+ return audio
613
+ samples = np.frombuffer(audio, dtype=np.int16)
614
+ usable = len(samples) - (len(samples) % num_channels)
615
+ frames = samples[:usable].reshape(-1, num_channels).astype(np.int32)
616
+ return frames.mean(axis=1).astype(np.int16).tobytes()
617
+
618
+
619
+ def _chunk_bytes(chunk: Any) -> tuple[bytes, int]:
620
+ """Return ``(int16 bytes, sample_rate)`` from an SDK audio chunk."""
621
+ sample_rate = int(getattr(chunk, "sample_rate", 16000) or 16000)
622
+ data = getattr(chunk, "bytes", None)
623
+ if data is None:
624
+ array = getattr(chunk, "data", None)
625
+ data = b"" if array is None else np.asarray(array, dtype=np.int16).tobytes()
626
+ elif callable(data):
627
+ data = data()
628
+ return bytes(data), sample_rate
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.5
2
+ Name: pipecat-bithuman
3
+ Version: 0.1.0
4
+ Summary: bitHuman real-time avatar video service for Pipecat: TTS audio in, lip-synced avatar video and audio out.
5
+ Project-URL: Homepage, https://www.bithuman.ai
6
+ Project-URL: Documentation, https://docs.bithuman.ai/platforms/python
7
+ Project-URL: Source, https://github.com/bithuman-product/pipecat-bithuman
8
+ Project-URL: Issues, https://github.com/bithuman-product/pipecat-bithuman/issues
9
+ Project-URL: Changelog, https://github.com/bithuman-product/pipecat-bithuman/blob/main/CHANGELOG.md
10
+ Author: bitHuman, Inc.
11
+ License-Expression: BSD-2-Clause
12
+ License-File: LICENSE
13
+ Keywords: avatar,bithuman,lip-sync,pipecat,real-time,video,voice-agent
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Multimedia :: Video
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: bithuman<3,>=2.11.18
28
+ Requires-Dist: numpy>=1.26
29
+ Requires-Dist: pipecat-ai>=1.12.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
32
+ Requires-Dist: pytest>=8; extra == 'dev'
33
+ Requires-Dist: ruff>=0.6; extra == 'dev'
34
+ Provides-Extra: expression-2
35
+ Requires-Dist: bithuman[expression-2]<3,>=2.11.18; extra == 'expression-2'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # pipecat-bithuman
39
+
40
+ A [bitHuman](https://www.bithuman.ai) avatar for your [Pipecat](https://github.com/pipecat-ai/pipecat) bot.
41
+ Your TTS audio goes in. A lip-synced avatar video, plus the audio that goes with it, comes out.
42
+
43
+ The avatar renders in your own process, on your own machine, through the
44
+ `bithuman` Python SDK. Both models run live on a standard Linux PC with no GPU.
45
+ Expression 2 animates a character from one portrait. Essence 2 is for photoreal people.
46
+
47
+ Maintained by bitHuman, Inc. (community integration, not maintained by the Pipecat team).
48
+
49
+ **Tested with Pipecat v1.12.0**, `bithuman` 2.11.18, Python 3.11 to 3.14.
50
+
51
+ **Demo (30 s):** [docs/demo.mp4](docs/demo.mp4), rendered through a Pipecat pipeline by
52
+ [`examples/render_demo.py`](examples/render_demo.py) with the Expression 2 sample avatar Wise Pup.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install pipecat-bithuman
58
+ # Expression 2 avatars (any character from one portrait):
59
+ pip install "pipecat-bithuman[expression-2]"
60
+ ```
61
+
62
+ ## Environment variables
63
+
64
+ | Variable | Needed | What it is |
65
+ | --- | --- | --- |
66
+ | `BITHUMAN_API_SECRET` | yes | Your bitHuman API secret. Get one at [www.bithuman.ai](https://www.bithuman.ai). The SDK reads it. This package never logs it. |
67
+ | `BITHUMAN_MODEL_PATH` | unless you pass `model_path` | Path to the avatar's `.imx` model file. |
68
+
69
+ Session time is metered while the avatar is open (talking or idle).
70
+ It closes on `EndFrame`, `CancelFrame` or cleanup.
71
+
72
+ Rendering bills active session time: 2 credits a minute on your own machine. From 2026-10-12, SDK use requires the Creator plan or higher. See docs.bithuman.ai/pricing.
73
+
74
+ ## Usage
75
+
76
+ Put `BitHumanVideoService` after the TTS service and before `transport.output()`:
77
+
78
+ ```python
79
+ from pipecat_bithuman import BitHumanVideoService
80
+
81
+ avatar = BitHumanVideoService(model_path="avatar.imx") # secret from BITHUMAN_API_SECRET
82
+
83
+ pipeline = Pipeline([
84
+ transport.input(), stt, user_aggregator, llm, tts,
85
+ avatar, # TTS audio -> avatar video + audio
86
+ transport.output(),
87
+ assistant_aggregator,
88
+ ])
89
+ ```
90
+
91
+ Turn on video out in the transport (`video_out_enabled=True`). The service logs the
92
+ frame size on the first frame; set `video_out_width` / `video_out_height` to match.
93
+
94
+ ### What the service does with frames
95
+
96
+ | In | Out |
97
+ | --- | --- |
98
+ | `TTSAudioRawFrame` | Sent to the avatar. Not forwarded as is. |
99
+ | (avatar frame) | `OutputImageRawFrame` (RGB), while talking and while idle. |
100
+ | (avatar audio) | `TTSAudioRawFrame` (16 kHz mono), paired with each picture. |
101
+ | `TTSStoppedFrame` | Held until the avatar has finished speaking the reply. |
102
+ | `InterruptionFrame` | The avatar drops the reply in flight and goes back to idle. |
103
+ | anything else | Passed on unchanged. |
104
+
105
+ If the avatar cannot start, or fails mid-session, the service pushes one
106
+ `ErrorFrame` upstream. By default TTS audio then passes through unchanged, so the bot
107
+ keeps talking without video (`audio_passthrough_on_error=False` turns this off).
108
+ Error text is scrubbed of the API secret.
109
+
110
+ ### Options
111
+
112
+ | Argument | Default | Meaning |
113
+ | --- | --- | --- |
114
+ | `model_path` | `BITHUMAN_MODEL_PATH` | The `.imx` avatar model. |
115
+ | `api_secret` | `BITHUMAN_API_SECRET` | The API secret. |
116
+ | `sync_video_to_audio` | `True` | Sets `sync_with_audio` on each image. |
117
+ | `audio_passthrough_on_error` | `True` | Keep the voice if the avatar fails. |
118
+ | `stop_frame_timeout_s` | `2.0` | Release a held `TTSStoppedFrame` after this much quiet. |
119
+ | `end_drain_timeout_s` | `30.0` | Longest wait on `EndFrame` for queued speech. |
120
+ | `runtime_factory` | SDK | Advanced: your own `BitHumanRuntime` (tests, wrappers). |
121
+
122
+ ## How it maps to the bitHuman Python SDK
123
+
124
+ | Pipecat | `bithuman.AsyncBithuman` |
125
+ | --- | --- |
126
+ | `StartFrame` | `AsyncBithuman.create(model_path=..., api_secret=...)`, then `run()` |
127
+ | `TTSAudioRawFrame` | `push_audio(pcm, sample_rate, last_chunk=False)` |
128
+ | `TTSStoppedFrame` | `flush()` |
129
+ | `InterruptionFrame` | `interrupt()` |
130
+ | `EndFrame` / `CancelFrame` / cleanup | `shutdown()` |
131
+
132
+ SDK docs: [docs.bithuman.ai/platforms/python](https://docs.bithuman.ai/platforms/python).
133
+
134
+ ## Run the example
135
+
136
+ ```bash
137
+ pip install "pipecat-bithuman[expression-2]" "pipecat-ai[daily,deepgram,openai,cartesia,silero]"
138
+ export BITHUMAN_API_SECRET=... BITHUMAN_MODEL_PATH=avatar.imx
139
+ export DAILY_ROOM_URL=... DEEPGRAM_API_KEY=... OPENAI_API_KEY=... CARTESIA_API_KEY=... CARTESIA_VOICE_ID=...
140
+ python examples/bot.py
141
+ ```
142
+
143
+ ## Tests
144
+
145
+ ```bash
146
+ pip install -e ".[dev]"
147
+ pytest # fakes only: no network, no API secret, no model file
148
+ ```
149
+
150
+ One live test talks to the real SDK. It is skipped unless you set
151
+ `PIPECAT_BITHUMAN_LIVE=1`, `BITHUMAN_API_SECRET` and `BITHUMAN_MODEL_PATH`.
152
+ It opens the avatar for a few seconds, and that time is billed.
153
+
154
+ ## Links
155
+
156
+ - bitHuman: [www.bithuman.ai](https://www.bithuman.ai)
157
+ - Docs: [docs.bithuman.ai](https://docs.bithuman.ai)
158
+ - Examples: [github.com/bithuman-product/bithuman-examples](https://github.com/bithuman-product/bithuman-examples)
159
+ - Contact: sgu@bithuman.ai
160
+
161
+ ## Licence
162
+
163
+ BSD 2-Clause, the same as Pipecat. See [LICENSE](LICENSE).
164
+ Copyright (c) 2026, bitHuman, Inc.
@@ -0,0 +1,7 @@
1
+ pipecat_bithuman/__init__.py,sha256=InKi5DiuNdqfqVNHDrvwH5Xvfr85nTbQSuwO1ryEUbA,968
2
+ pipecat_bithuman/runtime.py,sha256=iBG7sggbuj5ufexPoKc17jcOZSvmIcFJHyyKdd7xxFk,4001
3
+ pipecat_bithuman/video.py,sha256=W2rRfA0AWc4LAHKmrlQLUBsfbmi-8AUely4tD1pMIO4,23777
4
+ pipecat_bithuman-0.1.0.dist-info/METADATA,sha256=z_oWp8_El-oWJ3YneibpYFpbL3wQyqy1mY2A1KfAD8I,6828
5
+ pipecat_bithuman-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ pipecat_bithuman-0.1.0.dist-info/licenses/LICENSE,sha256=EtYBf42QvoD_R35d3a-F3gDwxWTp0PMs8_iz4ZvAcrI,1303
7
+ pipecat_bithuman-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, bitHuman, 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.