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.
- pipecat_bithuman/__init__.py +41 -0
- pipecat_bithuman/runtime.py +120 -0
- pipecat_bithuman/video.py +628 -0
- pipecat_bithuman-0.1.0.dist-info/METADATA +164 -0
- pipecat_bithuman-0.1.0.dist-info/RECORD +7 -0
- pipecat_bithuman-0.1.0.dist-info/WHEEL +4 -0
- pipecat_bithuman-0.1.0.dist-info/licenses/LICENSE +24 -0
|
@@ -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,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.
|