pipecat-ojin 0.1.1__tar.gz
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_ojin-0.1.1/LICENSE +201 -0
- pipecat_ojin-0.1.1/PKG-INFO +107 -0
- pipecat_ojin-0.1.1/README.md +80 -0
- pipecat_ojin-0.1.1/pyproject.toml +58 -0
- pipecat_ojin-0.1.1/setup.cfg +4 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin/__init__.py +20 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin/video.py +253 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin.egg-info/PKG-INFO +107 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin.egg-info/SOURCES.txt +12 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin.egg-info/dependency_links.txt +1 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin.egg-info/requires.txt +9 -0
- pipecat_ojin-0.1.1/src/pipecat_ojin.egg-info/top_level.txt +1 -0
- pipecat_ojin-0.1.1/tests/test_smoke.py +7 -0
- pipecat_ojin-0.1.1/tests/test_video.py +361 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipecat-ojin
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Ojin avatar (Speech-To-Video) service for Pipecat
|
|
5
|
+
Author: Ojin
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://ojin.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/ojinai/pipecat-ojin
|
|
9
|
+
Project-URL: Documentation, https://docs.ojin.ai
|
|
10
|
+
Project-URL: Issues, https://github.com/ojinai/pipecat-ojin/issues
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pipecat-ai>=1.3.0
|
|
19
|
+
Requires-Dist: ojin-client[stv]>=0.7.1
|
|
20
|
+
Requires-Dist: pydantic<3,>=2
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.3.5; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.26.0; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.11.4; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# pipecat-ojin
|
|
29
|
+
|
|
30
|
+
Ojin's [Pipecat](https://github.com/pipecat-ai/pipecat) integration: drop a
|
|
31
|
+
**lip-synced talking-avatar face** (`OjinVideoService`) into your existing pipeline in minutes.
|
|
32
|
+
|
|
33
|
+
`OjinVideoService` is the one stage that turns a voice agent into a video-call
|
|
34
|
+
avatar — it lip-syncs to whatever your TTS produces and streams the avatar video
|
|
35
|
+
back. It sits in the same slot as other video services in pipecat:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
transport.input() -> STT -> LLM -> TTS -> [OjinVideoService] -> transport.output()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This package is a thin adapter over the framework-agnostic
|
|
42
|
+
[`ojin-client`](https://github.com/ojinai/python-sdk) SDK — all avatar behaviour
|
|
43
|
+
(A/V sync, audio-as-clock playback, barge-in re-sync) lives in the SDK. It is
|
|
44
|
+
**not** a fork of Pipecat — it depends on `pipecat-ai` as a library.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install pipecat-ojin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This pulls in `pipecat-ai` and `ojin-client[stv]`. You provide the STT / LLM /
|
|
53
|
+
TTS services for your pipeline (e.g. `pip install "pipecat-ai[deepgram,groq,elevenlabs]"`).
|
|
54
|
+
|
|
55
|
+
## Quickstart — the avatar face
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pipecat.pipeline.pipeline import Pipeline
|
|
59
|
+
from pipecat_ojin import OjinVideoService, OjinVideoSettings
|
|
60
|
+
|
|
61
|
+
avatar = OjinVideoService(
|
|
62
|
+
OjinVideoSettings(
|
|
63
|
+
api_key="OJIN_API_KEY",
|
|
64
|
+
config_id="OJIN_CONFIG_ID", # the Face model to drive
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
pipeline = Pipeline(
|
|
69
|
+
[transport.input(), stt, llm, tts, avatar, transport.output()]
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The avatar's frame size comes from your Face model (`config_id`) — set your
|
|
74
|
+
transport's `video_out_width` / `video_out_height` to match it (the example uses
|
|
75
|
+
512×512).
|
|
76
|
+
|
|
77
|
+
Get your `OJIN_API_KEY` and a Face model `OJIN_CONFIG_ID` from
|
|
78
|
+
[ojin.ai](https://ojin.ai) (docs: [docs.ojin.ai](https://docs.ojin.ai)).
|
|
79
|
+
|
|
80
|
+
### Session tracing (optional)
|
|
81
|
+
|
|
82
|
+
Pass an `ojin.stv.OjinSessionTrace` to record a per-call Perfetto trace; the
|
|
83
|
+
service dumps it on close:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from ojin.stv import OjinSessionTrace
|
|
87
|
+
|
|
88
|
+
trace = OjinSessionTrace(session_id="my-call", config_id="OJIN_CONFIG_ID")
|
|
89
|
+
avatar = OjinVideoService(OjinVideoSettings(...), session_trace=trace)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Example
|
|
93
|
+
|
|
94
|
+
A complete, runnable voice + avatar agent (browser WebRTC or Daily) lives in
|
|
95
|
+
[`examples/ojin-bot/`](examples/ojin-bot/).
|
|
96
|
+
|
|
97
|
+
## Compatibility
|
|
98
|
+
|
|
99
|
+
| Requirement | Version |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Python | ≥ 3.11 |
|
|
102
|
+
| `pipecat-ai` | ≥ 1.3.0 |
|
|
103
|
+
| `ojin-client[stv]` | ≥ 0.7.1 |
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# pipecat-ojin
|
|
2
|
+
|
|
3
|
+
Ojin's [Pipecat](https://github.com/pipecat-ai/pipecat) integration: drop a
|
|
4
|
+
**lip-synced talking-avatar face** (`OjinVideoService`) into your existing pipeline in minutes.
|
|
5
|
+
|
|
6
|
+
`OjinVideoService` is the one stage that turns a voice agent into a video-call
|
|
7
|
+
avatar — it lip-syncs to whatever your TTS produces and streams the avatar video
|
|
8
|
+
back. It sits in the same slot as other video services in pipecat:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
transport.input() -> STT -> LLM -> TTS -> [OjinVideoService] -> transport.output()
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This package is a thin adapter over the framework-agnostic
|
|
15
|
+
[`ojin-client`](https://github.com/ojinai/python-sdk) SDK — all avatar behaviour
|
|
16
|
+
(A/V sync, audio-as-clock playback, barge-in re-sync) lives in the SDK. It is
|
|
17
|
+
**not** a fork of Pipecat — it depends on `pipecat-ai` as a library.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install pipecat-ojin
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This pulls in `pipecat-ai` and `ojin-client[stv]`. You provide the STT / LLM /
|
|
26
|
+
TTS services for your pipeline (e.g. `pip install "pipecat-ai[deepgram,groq,elevenlabs]"`).
|
|
27
|
+
|
|
28
|
+
## Quickstart — the avatar face
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from pipecat.pipeline.pipeline import Pipeline
|
|
32
|
+
from pipecat_ojin import OjinVideoService, OjinVideoSettings
|
|
33
|
+
|
|
34
|
+
avatar = OjinVideoService(
|
|
35
|
+
OjinVideoSettings(
|
|
36
|
+
api_key="OJIN_API_KEY",
|
|
37
|
+
config_id="OJIN_CONFIG_ID", # the Face model to drive
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
pipeline = Pipeline(
|
|
42
|
+
[transport.input(), stt, llm, tts, avatar, transport.output()]
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The avatar's frame size comes from your Face model (`config_id`) — set your
|
|
47
|
+
transport's `video_out_width` / `video_out_height` to match it (the example uses
|
|
48
|
+
512×512).
|
|
49
|
+
|
|
50
|
+
Get your `OJIN_API_KEY` and a Face model `OJIN_CONFIG_ID` from
|
|
51
|
+
[ojin.ai](https://ojin.ai) (docs: [docs.ojin.ai](https://docs.ojin.ai)).
|
|
52
|
+
|
|
53
|
+
### Session tracing (optional)
|
|
54
|
+
|
|
55
|
+
Pass an `ojin.stv.OjinSessionTrace` to record a per-call Perfetto trace; the
|
|
56
|
+
service dumps it on close:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from ojin.stv import OjinSessionTrace
|
|
60
|
+
|
|
61
|
+
trace = OjinSessionTrace(session_id="my-call", config_id="OJIN_CONFIG_ID")
|
|
62
|
+
avatar = OjinVideoService(OjinVideoSettings(...), session_trace=trace)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Example
|
|
66
|
+
|
|
67
|
+
A complete, runnable voice + avatar agent (browser WebRTC or Daily) lives in
|
|
68
|
+
[`examples/ojin-bot/`](examples/ojin-bot/).
|
|
69
|
+
|
|
70
|
+
## Compatibility
|
|
71
|
+
|
|
72
|
+
| Requirement | Version |
|
|
73
|
+
|---|---|
|
|
74
|
+
| Python | ≥ 3.11 |
|
|
75
|
+
| `pipecat-ai` | ≥ 1.3.0 |
|
|
76
|
+
| `ojin-client[stv]` | ≥ 0.7.1 |
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pipecat-ojin"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Ojin avatar (Speech-To-Video) service for Pipecat"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
# pipecat-ai (>=1.3.0) requires Python >=3.11, so the package floor matches it.
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Ojin" }]
|
|
9
|
+
license = { text = "Apache-2.0" }
|
|
10
|
+
classifiers = [
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
14
|
+
"Topic :: Multimedia :: Video",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pipecat-ai>=1.3.0",
|
|
18
|
+
"ojin-client[stv]>=0.7.1",
|
|
19
|
+
"pydantic>=2,<3",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["setuptools>=42", "wheel"]
|
|
24
|
+
build-backend = "setuptools.build_meta"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://ojin.ai"
|
|
28
|
+
Repository = "https://github.com/ojinai/pipecat-ojin"
|
|
29
|
+
Documentation = "https://docs.ojin.ai"
|
|
30
|
+
Issues = "https://github.com/ojinai/pipecat-ojin/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.3.5",
|
|
35
|
+
"pytest-asyncio>=0.26.0",
|
|
36
|
+
"ruff>=0.11.4",
|
|
37
|
+
"build>=0.10.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
package-dir = { "" = "src" }
|
|
42
|
+
packages = ["pipecat_ojin"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
lint.select = ["E", "F", "I", "B", "SIM", "RUF"]
|
|
47
|
+
lint.ignore = ["E501"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.isort]
|
|
50
|
+
known-first-party = ["pipecat_ojin"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"__init__.py" = ["F401"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
minversion = "8.0"
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""pipecat-ojin: Ojin avatar (Speech-To-Video) service for Pipecat."""
|
|
2
|
+
|
|
3
|
+
from pipecat_ojin.video import (
|
|
4
|
+
OjinBotStartedSpeakingFrame,
|
|
5
|
+
OjinBotStoppedSpeakingFrame,
|
|
6
|
+
OjinVideoInitializedFrame,
|
|
7
|
+
OjinVideoService,
|
|
8
|
+
OjinVideoSettings,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"OjinBotStartedSpeakingFrame",
|
|
15
|
+
"OjinBotStoppedSpeakingFrame",
|
|
16
|
+
"OjinVideoInitializedFrame",
|
|
17
|
+
"OjinVideoService",
|
|
18
|
+
"OjinVideoSettings",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""OjinVideoService — a Pipecat adapter for an Ojin lip-synced talking avatar.
|
|
2
|
+
|
|
3
|
+
`OjinVideoService` is a Pipecat ``FrameProcessor`` that turns the TTS audio
|
|
4
|
+
stream into a lip-synced avatar. Place it after your TTS service and before
|
|
5
|
+
``transport.output()`` — the slot Pipecat's built-in avatar services use::
|
|
6
|
+
|
|
7
|
+
transport.input() -> STT -> LLM -> TTS -> [OjinVideoService] -> transport.output()
|
|
8
|
+
|
|
9
|
+
All avatar behaviour (connect/retry, audio-as-clock playback, A/V sync, re-sync
|
|
10
|
+
after barge-in, off-loop JPEG decode, session tracing) lives in
|
|
11
|
+
``ojin.stv.OjinSTVClient``; this class is only the mapping between Pipecat frames
|
|
12
|
+
and the client's API. It needs ``ojin-client[stv]`` and ``pipecat-ai``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from ojin.stv import (
|
|
24
|
+
OjinSessionTrace,
|
|
25
|
+
OjinSTVClient,
|
|
26
|
+
STVAudioFrame,
|
|
27
|
+
STVEvent,
|
|
28
|
+
STVVideoFrame,
|
|
29
|
+
)
|
|
30
|
+
from pipecat.frames.frames import (
|
|
31
|
+
CancelFrame,
|
|
32
|
+
EndFrame,
|
|
33
|
+
Frame,
|
|
34
|
+
InterruptionFrame,
|
|
35
|
+
OutputAudioRawFrame,
|
|
36
|
+
OutputImageRawFrame,
|
|
37
|
+
StartFrame,
|
|
38
|
+
TTSAudioRawFrame,
|
|
39
|
+
TTSStartedFrame,
|
|
40
|
+
UserStartedSpeakingFrame,
|
|
41
|
+
)
|
|
42
|
+
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Per-session Perfetto trace output dir (same layout as the python-sdk example).
|
|
47
|
+
_TRACE_ROOT = os.getenv("OJIN_STV_TRACE_DIR", "/root/debug/sessions/stv-pipecat-ojin")
|
|
48
|
+
|
|
49
|
+
# The ~0.5 s all-zero trailing-silence sentinel some TTS engines emit. These mirror
|
|
50
|
+
# OjinSTVClient.send_tts_audio's own discard rule (ojin.stv) so the adapter can drop
|
|
51
|
+
# it *before* arming TTFB — TTFB must time the first real audio, not the silence.
|
|
52
|
+
_HALF_SECOND = 0.5
|
|
53
|
+
_HALF_SECOND_TOL = 0.01
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class OjinVideoInitializedFrame(Frame):
|
|
58
|
+
"""Frame indicating the avatar session is ready (server handshake complete)."""
|
|
59
|
+
|
|
60
|
+
session_data: Optional[dict] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OjinBotStartedSpeakingFrame(Frame):
|
|
64
|
+
"""Emitted when the avatar starts speaking (a buffer is promoted to current)."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OjinBotStoppedSpeakingFrame(Frame):
|
|
70
|
+
"""Emitted when the avatar stops speaking (current buffer drains, none queued)."""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class OjinVideoSettings:
|
|
77
|
+
"""Connection identity and frame size for :class:`OjinVideoService`."""
|
|
78
|
+
|
|
79
|
+
api_key: str = ""
|
|
80
|
+
config_id: str = ""
|
|
81
|
+
ws_url: str = "wss://models.ojin.ai/realtime"
|
|
82
|
+
# When True (default), TTS audio sent during the avatar's cold-start handshake
|
|
83
|
+
# is buffered and replayed once the session is ready, instead of dropped — so
|
|
84
|
+
# an opening line isn't lost. Set False to drop pre-init audio (old behavior).
|
|
85
|
+
buffer_preinit_tts_audio: bool = True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _PushFrameOutput:
|
|
89
|
+
"""STVOutput sink: forwards the client's synced A/V downstream, behind the gate."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, service: "OjinVideoService") -> None:
|
|
92
|
+
self._svc = service
|
|
93
|
+
|
|
94
|
+
async def write_audio(self, frame: STVAudioFrame) -> None:
|
|
95
|
+
if self._svc._can_start_playback:
|
|
96
|
+
await self._svc.push_frame(
|
|
97
|
+
OutputAudioRawFrame(frame.pcm, frame.sample_rate, frame.num_channels)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def write_video(self, frame: STVVideoFrame) -> None:
|
|
101
|
+
if self._svc._can_start_playback and frame.rgb is not None:
|
|
102
|
+
await self._svc.push_frame(
|
|
103
|
+
OutputImageRawFrame(
|
|
104
|
+
image=frame.rgb,
|
|
105
|
+
size=(frame.width, frame.height),
|
|
106
|
+
format=frame.format,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def on_event(self, event: STVEvent, **kwargs: object) -> None:
|
|
111
|
+
pass # lifecycle events are wired via the client's emitter (see _wire_events)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class OjinVideoService(FrameProcessor):
|
|
115
|
+
"""Pipecat FrameProcessor that turns the TTS audio stream into a lip-synced avatar."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
settings: OjinVideoSettings,
|
|
120
|
+
*,
|
|
121
|
+
session_trace: Optional[OjinSessionTrace] = None,
|
|
122
|
+
stv_client: Optional[OjinSTVClient] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Build the adapter and wire the client's lifecycle events.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
settings: connection identity + avatar frame size.
|
|
128
|
+
session_trace: an ``ojin.stv.OjinSessionTrace`` to record this call;
|
|
129
|
+
injected as the client's tracer and dumped to disk on close.
|
|
130
|
+
``None`` -> the client uses a no-op tracer.
|
|
131
|
+
stv_client: a pre-built client (dependency injection for tests or
|
|
132
|
+
alternative transports). When given, ``settings`` connection
|
|
133
|
+
fields are not used to build a client.
|
|
134
|
+
"""
|
|
135
|
+
super().__init__(name="ojin-video")
|
|
136
|
+
self._settings = settings
|
|
137
|
+
self._can_start_playback = True # gate open by default; close to defer A/V
|
|
138
|
+
self._waiting_for_first_tts = False
|
|
139
|
+
self._trace = session_trace
|
|
140
|
+
self._output = _PushFrameOutput(self)
|
|
141
|
+
self._stv = stv_client or OjinSTVClient(
|
|
142
|
+
api_key=settings.api_key,
|
|
143
|
+
config_id=settings.config_id,
|
|
144
|
+
ws_url=settings.ws_url,
|
|
145
|
+
output=self._output,
|
|
146
|
+
tracer=session_trace,
|
|
147
|
+
buffer_preinit_tts_audio=settings.buffer_preinit_tts_audio,
|
|
148
|
+
)
|
|
149
|
+
self._wire_events()
|
|
150
|
+
|
|
151
|
+
def set_can_start_playback(self, value: bool) -> None:
|
|
152
|
+
"""Open (``True``) or close (``False``) the playback gate.
|
|
153
|
+
|
|
154
|
+
Defaults open. Close it before participant join to keep the connect->join
|
|
155
|
+
idle backlog out of the transport, then re-open at join.
|
|
156
|
+
"""
|
|
157
|
+
self._can_start_playback = value
|
|
158
|
+
|
|
159
|
+
def can_generate_metrics(self) -> bool:
|
|
160
|
+
"""Enable Pipecat TTFB / processing metrics for this service."""
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
async def connect_with_retry(self) -> bool:
|
|
164
|
+
"""Connect the underlying client with retry; ``True`` on success."""
|
|
165
|
+
return await self._stv.connect_with_retry()
|
|
166
|
+
|
|
167
|
+
def _wire_events(self) -> None:
|
|
168
|
+
"""Map ``OjinSTVClient`` events onto Pipecat frames + TTFB metrics."""
|
|
169
|
+
|
|
170
|
+
@self._stv.on(STVEvent.SESSION_READY)
|
|
171
|
+
async def _on_ready(session_data=None, **_):
|
|
172
|
+
frame = OjinVideoInitializedFrame(session_data=session_data)
|
|
173
|
+
await self.push_frame(frame, FrameDirection.DOWNSTREAM)
|
|
174
|
+
await self.push_frame(frame, FrameDirection.UPSTREAM)
|
|
175
|
+
|
|
176
|
+
@self._stv.on(STVEvent.BOT_STARTED_SPEAKING)
|
|
177
|
+
async def _on_started(**_):
|
|
178
|
+
await self.push_frame(OjinBotStartedSpeakingFrame())
|
|
179
|
+
await self.stop_ttfb_metrics()
|
|
180
|
+
|
|
181
|
+
@self._stv.on(STVEvent.BOT_STOPPED_SPEAKING)
|
|
182
|
+
async def _on_stopped(**_):
|
|
183
|
+
await self.push_frame(OjinBotStoppedSpeakingFrame())
|
|
184
|
+
|
|
185
|
+
@self._stv.on(STVEvent.ERROR)
|
|
186
|
+
async def _on_error(message="", fatal=False, **_):
|
|
187
|
+
await self.push_error(message, fatal=fatal)
|
|
188
|
+
|
|
189
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
|
|
190
|
+
"""Map each inbound Pipecat frame to the matching OjinSTVClient call."""
|
|
191
|
+
await super().process_frame(frame, direction)
|
|
192
|
+
|
|
193
|
+
if isinstance(frame, StartFrame):
|
|
194
|
+
await self.push_frame(frame, direction)
|
|
195
|
+
await self._stv.start()
|
|
196
|
+
elif isinstance(frame, TTSStartedFrame):
|
|
197
|
+
self._waiting_for_first_tts = True
|
|
198
|
+
await self._stv.start_turn()
|
|
199
|
+
await self.push_frame(frame, direction)
|
|
200
|
+
elif isinstance(frame, TTSAudioRawFrame):
|
|
201
|
+
if self._is_trailing_silence(frame):
|
|
202
|
+
# Drop the trailing-silence sentinel here, before arming TTFB: TTFB
|
|
203
|
+
# must time the first *real* audio. It never reaches the client (which
|
|
204
|
+
# would discard it too) and leaves TTFB un-armed for the next frame.
|
|
205
|
+
return
|
|
206
|
+
if self._waiting_for_first_tts:
|
|
207
|
+
self._waiting_for_first_tts = False
|
|
208
|
+
await self.start_ttfb_metrics()
|
|
209
|
+
await self._stv.send_tts_audio(
|
|
210
|
+
frame.audio, frame.sample_rate, frame.num_channels
|
|
211
|
+
)
|
|
212
|
+
elif isinstance(frame, (InterruptionFrame, UserStartedSpeakingFrame)):
|
|
213
|
+
# Barge-in: an explicit interruption — or the user starting to speak —
|
|
214
|
+
# cuts the avatar's current turn. Forward the frame so the rest of the
|
|
215
|
+
# pipeline still sees it.
|
|
216
|
+
await self._stv.interrupt()
|
|
217
|
+
await self.push_frame(frame, direction)
|
|
218
|
+
elif isinstance(frame, (EndFrame, CancelFrame)):
|
|
219
|
+
await self._stv.close()
|
|
220
|
+
self._write_trace()
|
|
221
|
+
await self.push_frame(frame, direction)
|
|
222
|
+
else:
|
|
223
|
+
await self.push_frame(frame, direction)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _is_trailing_silence(frame: TTSAudioRawFrame) -> bool:
|
|
227
|
+
"""Whether ``frame`` is the ~0.5 s all-zero trailing-silence sentinel.
|
|
228
|
+
|
|
229
|
+
Mirrors ``OjinSTVClient.send_tts_audio``'s discard rule so the adapter can
|
|
230
|
+
drop it before arming TTFB metrics (which must time the first real audio).
|
|
231
|
+
"""
|
|
232
|
+
pcm = frame.audio
|
|
233
|
+
if not pcm or any(pcm): # empty, or any non-zero byte -> real audio
|
|
234
|
+
return False
|
|
235
|
+
duration = len(pcm) / (frame.sample_rate * frame.num_channels * 2)
|
|
236
|
+
return abs(duration - _HALF_SECOND) < _HALF_SECOND_TOL
|
|
237
|
+
|
|
238
|
+
def _write_trace(self) -> None:
|
|
239
|
+
"""Dump the session's Perfetto trace to disk on close (best-effort, once)."""
|
|
240
|
+
trace, self._trace = self._trace, None # write at most once
|
|
241
|
+
if trace is None:
|
|
242
|
+
return
|
|
243
|
+
now = datetime.now(timezone.utc)
|
|
244
|
+
path = os.path.join(
|
|
245
|
+
_TRACE_ROOT,
|
|
246
|
+
now.strftime("%Y-%m-%d"),
|
|
247
|
+
f"{now.strftime('%H-%M-%S')}_{trace.session_id}",
|
|
248
|
+
"session.json",
|
|
249
|
+
)
|
|
250
|
+
try:
|
|
251
|
+
logger.info("Ojin STV session trace written to %s", trace.dump(path))
|
|
252
|
+
except Exception as exc: # never let trace I/O break teardown
|
|
253
|
+
logger.warning("Failed to write Ojin STV session trace: %s", exc)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipecat-ojin
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Ojin avatar (Speech-To-Video) service for Pipecat
|
|
5
|
+
Author: Ojin
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://ojin.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/ojinai/pipecat-ojin
|
|
9
|
+
Project-URL: Documentation, https://docs.ojin.ai
|
|
10
|
+
Project-URL: Issues, https://github.com/ojinai/pipecat-ojin/issues
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pipecat-ai>=1.3.0
|
|
19
|
+
Requires-Dist: ojin-client[stv]>=0.7.1
|
|
20
|
+
Requires-Dist: pydantic<3,>=2
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.3.5; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.26.0; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.11.4; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# pipecat-ojin
|
|
29
|
+
|
|
30
|
+
Ojin's [Pipecat](https://github.com/pipecat-ai/pipecat) integration: drop a
|
|
31
|
+
**lip-synced talking-avatar face** (`OjinVideoService`) into your existing pipeline in minutes.
|
|
32
|
+
|
|
33
|
+
`OjinVideoService` is the one stage that turns a voice agent into a video-call
|
|
34
|
+
avatar — it lip-syncs to whatever your TTS produces and streams the avatar video
|
|
35
|
+
back. It sits in the same slot as other video services in pipecat:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
transport.input() -> STT -> LLM -> TTS -> [OjinVideoService] -> transport.output()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This package is a thin adapter over the framework-agnostic
|
|
42
|
+
[`ojin-client`](https://github.com/ojinai/python-sdk) SDK — all avatar behaviour
|
|
43
|
+
(A/V sync, audio-as-clock playback, barge-in re-sync) lives in the SDK. It is
|
|
44
|
+
**not** a fork of Pipecat — it depends on `pipecat-ai` as a library.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install pipecat-ojin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This pulls in `pipecat-ai` and `ojin-client[stv]`. You provide the STT / LLM /
|
|
53
|
+
TTS services for your pipeline (e.g. `pip install "pipecat-ai[deepgram,groq,elevenlabs]"`).
|
|
54
|
+
|
|
55
|
+
## Quickstart — the avatar face
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pipecat.pipeline.pipeline import Pipeline
|
|
59
|
+
from pipecat_ojin import OjinVideoService, OjinVideoSettings
|
|
60
|
+
|
|
61
|
+
avatar = OjinVideoService(
|
|
62
|
+
OjinVideoSettings(
|
|
63
|
+
api_key="OJIN_API_KEY",
|
|
64
|
+
config_id="OJIN_CONFIG_ID", # the Face model to drive
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
pipeline = Pipeline(
|
|
69
|
+
[transport.input(), stt, llm, tts, avatar, transport.output()]
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The avatar's frame size comes from your Face model (`config_id`) — set your
|
|
74
|
+
transport's `video_out_width` / `video_out_height` to match it (the example uses
|
|
75
|
+
512×512).
|
|
76
|
+
|
|
77
|
+
Get your `OJIN_API_KEY` and a Face model `OJIN_CONFIG_ID` from
|
|
78
|
+
[ojin.ai](https://ojin.ai) (docs: [docs.ojin.ai](https://docs.ojin.ai)).
|
|
79
|
+
|
|
80
|
+
### Session tracing (optional)
|
|
81
|
+
|
|
82
|
+
Pass an `ojin.stv.OjinSessionTrace` to record a per-call Perfetto trace; the
|
|
83
|
+
service dumps it on close:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from ojin.stv import OjinSessionTrace
|
|
87
|
+
|
|
88
|
+
trace = OjinSessionTrace(session_id="my-call", config_id="OJIN_CONFIG_ID")
|
|
89
|
+
avatar = OjinVideoService(OjinVideoSettings(...), session_trace=trace)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Example
|
|
93
|
+
|
|
94
|
+
A complete, runnable voice + avatar agent (browser WebRTC or Daily) lives in
|
|
95
|
+
[`examples/ojin-bot/`](examples/ojin-bot/).
|
|
96
|
+
|
|
97
|
+
## Compatibility
|
|
98
|
+
|
|
99
|
+
| Requirement | Version |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Python | ≥ 3.11 |
|
|
102
|
+
| `pipecat-ai` | ≥ 1.3.0 |
|
|
103
|
+
| `ojin-client[stv]` | ≥ 0.7.1 |
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pipecat_ojin/__init__.py
|
|
5
|
+
src/pipecat_ojin/video.py
|
|
6
|
+
src/pipecat_ojin.egg-info/PKG-INFO
|
|
7
|
+
src/pipecat_ojin.egg-info/SOURCES.txt
|
|
8
|
+
src/pipecat_ojin.egg-info/dependency_links.txt
|
|
9
|
+
src/pipecat_ojin.egg-info/requires.txt
|
|
10
|
+
src/pipecat_ojin.egg-info/top_level.txt
|
|
11
|
+
tests/test_smoke.py
|
|
12
|
+
tests/test_video.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pipecat_ojin
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Tests for the OjinVideoService adapter over ojin.stv.OjinSTVClient.
|
|
2
|
+
|
|
3
|
+
The adapter only (a) translates Pipecat frames into OjinSTVClient calls,
|
|
4
|
+
(b) implements the STVOutput sink (pushing Output*RawFrame downstream) behind
|
|
5
|
+
the playback-start gate, and (c) maps client events to Pipecat frames + TTFB
|
|
6
|
+
metrics. All avatar behaviour lives in ojin.stv and is tested there.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import AsyncMock
|
|
11
|
+
|
|
12
|
+
from ojin.stv import STVAudioFrame, STVEvent, STVVideoFrame
|
|
13
|
+
from ojin.stv.events import EventEmitter
|
|
14
|
+
from pipecat.frames.frames import (
|
|
15
|
+
CancelFrame,
|
|
16
|
+
EndFrame,
|
|
17
|
+
OutputAudioRawFrame,
|
|
18
|
+
OutputImageRawFrame,
|
|
19
|
+
TTSAudioRawFrame,
|
|
20
|
+
TTSStartedFrame,
|
|
21
|
+
UserStartedSpeakingFrame,
|
|
22
|
+
)
|
|
23
|
+
from pipecat.processors.frame_processor import FrameDirection
|
|
24
|
+
|
|
25
|
+
from pipecat_ojin.video import (
|
|
26
|
+
OjinBotStartedSpeakingFrame,
|
|
27
|
+
OjinBotStoppedSpeakingFrame,
|
|
28
|
+
OjinVideoInitializedFrame,
|
|
29
|
+
OjinVideoService,
|
|
30
|
+
OjinVideoSettings,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from pipecat.tests.utils import run_test
|
|
35
|
+
|
|
36
|
+
_HAS_RUN_TEST = True
|
|
37
|
+
except Exception: # pragma: no cover - depends on pipecat packaging test utils
|
|
38
|
+
_HAS_RUN_TEST = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeSTVClient:
|
|
42
|
+
"""Stand-in for OjinSTVClient.
|
|
43
|
+
|
|
44
|
+
Records adapter->client calls and uses the real EventEmitter so the adapter's
|
|
45
|
+
event wiring runs against production dispatch logic.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._events = EventEmitter()
|
|
50
|
+
self.calls: list = []
|
|
51
|
+
self.connect_return = True
|
|
52
|
+
|
|
53
|
+
def on(self, event):
|
|
54
|
+
return self._events.on(event)
|
|
55
|
+
|
|
56
|
+
def add_listener(self, event, cb) -> None:
|
|
57
|
+
self._events.add_listener(event, cb)
|
|
58
|
+
|
|
59
|
+
async def emit(self, event, **kwargs) -> None:
|
|
60
|
+
await self._events.emit(event, **kwargs)
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
self.calls.append("start")
|
|
64
|
+
|
|
65
|
+
async def start_turn(self) -> None:
|
|
66
|
+
self.calls.append("start_turn")
|
|
67
|
+
|
|
68
|
+
async def send_tts_audio(self, pcm, sample_rate, num_channels) -> None:
|
|
69
|
+
self.calls.append(("send_tts_audio", pcm, sample_rate, num_channels))
|
|
70
|
+
|
|
71
|
+
async def interrupt(self) -> None:
|
|
72
|
+
self.calls.append("interrupt")
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
self.calls.append("close")
|
|
76
|
+
|
|
77
|
+
async def connect_with_retry(self) -> bool:
|
|
78
|
+
self.calls.append("connect_with_retry")
|
|
79
|
+
return self.connect_return
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _audio() -> TTSAudioRawFrame:
|
|
83
|
+
return TTSAudioRawFrame(audio=b"\x01\x00" * 160, sample_rate=24000, num_channels=1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _silence() -> TTSAudioRawFrame:
|
|
87
|
+
# 0.5 s of all-zero PCM @ 24 kHz mono int16 == the client's trailing-silence
|
|
88
|
+
# sentinel (24000 samples * 2 bytes = 24000 bytes => 0.5 s).
|
|
89
|
+
return TTSAudioRawFrame(audio=b"\x00" * 24000, sample_rate=24000, num_channels=1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _adapter(fake: FakeSTVClient, **settings_kw) -> OjinVideoService:
|
|
93
|
+
return OjinVideoService(OjinVideoSettings(**settings_kw), stv_client=fake)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestPlaybackGate(unittest.IsolatedAsyncioTestCase):
|
|
97
|
+
"""The gate lives in the adapter; default open, close to drop A/V."""
|
|
98
|
+
|
|
99
|
+
async def test_open_by_default_forwards_audio_and_video(self) -> None:
|
|
100
|
+
svc = _adapter(FakeSTVClient())
|
|
101
|
+
svc.push_frame = AsyncMock()
|
|
102
|
+
await svc._output.write_audio(
|
|
103
|
+
STVAudioFrame(pcm=b"\x02\x00", sample_rate=24000, num_channels=1, pts=0)
|
|
104
|
+
)
|
|
105
|
+
await svc._output.write_video(
|
|
106
|
+
STVVideoFrame(
|
|
107
|
+
rgb=b"rgbrgb",
|
|
108
|
+
source_bytes=b"jpg",
|
|
109
|
+
width=1,
|
|
110
|
+
height=2,
|
|
111
|
+
frame_type=1,
|
|
112
|
+
pts=0,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
pushed = [c.args[0] for c in svc.push_frame.call_args_list]
|
|
116
|
+
audio = [f for f in pushed if isinstance(f, OutputAudioRawFrame)]
|
|
117
|
+
image = [f for f in pushed if isinstance(f, OutputImageRawFrame)]
|
|
118
|
+
self.assertEqual(len(audio), 1)
|
|
119
|
+
self.assertEqual(audio[0].audio, b"\x02\x00")
|
|
120
|
+
self.assertEqual(audio[0].sample_rate, 24000)
|
|
121
|
+
self.assertEqual(audio[0].num_channels, 1)
|
|
122
|
+
self.assertEqual(len(image), 1)
|
|
123
|
+
self.assertEqual(image[0].image, b"rgbrgb")
|
|
124
|
+
self.assertEqual(image[0].size, (1, 2))
|
|
125
|
+
self.assertEqual(image[0].format, "RGB")
|
|
126
|
+
|
|
127
|
+
async def test_closed_gate_drops_audio_and_video(self) -> None:
|
|
128
|
+
svc = _adapter(FakeSTVClient())
|
|
129
|
+
svc.push_frame = AsyncMock()
|
|
130
|
+
svc.set_can_start_playback(False)
|
|
131
|
+
await svc._output.write_audio(
|
|
132
|
+
STVAudioFrame(pcm=b"\x01\x00", sample_rate=24000, num_channels=1, pts=0)
|
|
133
|
+
)
|
|
134
|
+
await svc._output.write_video(
|
|
135
|
+
STVVideoFrame(
|
|
136
|
+
rgb=b"rgb", source_bytes=b"jpg", width=4, height=4, frame_type=1, pts=0
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
svc.push_frame.assert_not_called()
|
|
140
|
+
|
|
141
|
+
async def test_open_but_no_rgb_drops_video(self) -> None:
|
|
142
|
+
svc = _adapter(FakeSTVClient())
|
|
143
|
+
svc.push_frame = AsyncMock()
|
|
144
|
+
await svc._output.write_video(
|
|
145
|
+
STVVideoFrame(
|
|
146
|
+
rgb=None, source_bytes=b"jpg", width=1, height=1, frame_type=0, pts=0
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
svc.push_frame.assert_not_called()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestEventToFrameMapping(unittest.IsolatedAsyncioTestCase):
|
|
153
|
+
"""Client lifecycle events map to the Pipecat frames + TTFB the bot expects."""
|
|
154
|
+
|
|
155
|
+
def _wired(self):
|
|
156
|
+
fake = FakeSTVClient()
|
|
157
|
+
svc = _adapter(fake)
|
|
158
|
+
svc.push_frame = AsyncMock()
|
|
159
|
+
svc.push_error = AsyncMock()
|
|
160
|
+
svc.start_ttfb_metrics = AsyncMock()
|
|
161
|
+
svc.stop_ttfb_metrics = AsyncMock()
|
|
162
|
+
return fake, svc
|
|
163
|
+
|
|
164
|
+
async def test_session_ready_pushes_initialized_frame_both_directions(self) -> None:
|
|
165
|
+
fake, svc = self._wired()
|
|
166
|
+
await fake.emit(STVEvent.SESSION_READY, session_data={"foo": 1})
|
|
167
|
+
init = [
|
|
168
|
+
c
|
|
169
|
+
for c in svc.push_frame.call_args_list
|
|
170
|
+
if isinstance(c.args[0], OjinVideoInitializedFrame)
|
|
171
|
+
]
|
|
172
|
+
self.assertEqual(len(init), 2)
|
|
173
|
+
self.assertEqual(init[0].args[0].session_data, {"foo": 1})
|
|
174
|
+
dirs = {c.args[1] for c in init}
|
|
175
|
+
self.assertEqual(dirs, {FrameDirection.DOWNSTREAM, FrameDirection.UPSTREAM})
|
|
176
|
+
|
|
177
|
+
async def test_started_speaking_emits_frame_and_stops_ttfb(self) -> None:
|
|
178
|
+
fake, svc = self._wired()
|
|
179
|
+
await fake.emit(STVEvent.BOT_STARTED_SPEAKING)
|
|
180
|
+
pushed = [type(c.args[0]) for c in svc.push_frame.call_args_list]
|
|
181
|
+
self.assertIn(OjinBotStartedSpeakingFrame, pushed)
|
|
182
|
+
svc.stop_ttfb_metrics.assert_awaited_once()
|
|
183
|
+
|
|
184
|
+
async def test_stopped_speaking_emits_frame(self) -> None:
|
|
185
|
+
fake, svc = self._wired()
|
|
186
|
+
await fake.emit(STVEvent.BOT_STOPPED_SPEAKING)
|
|
187
|
+
pushed = [type(c.args[0]) for c in svc.push_frame.call_args_list]
|
|
188
|
+
self.assertIn(OjinBotStoppedSpeakingFrame, pushed)
|
|
189
|
+
|
|
190
|
+
async def test_error_event_pushes_error_with_message_and_fatal(self) -> None:
|
|
191
|
+
fake, svc = self._wired()
|
|
192
|
+
await fake.emit(STVEvent.ERROR, message="boom", code="X", fatal=True)
|
|
193
|
+
svc.push_error.assert_awaited_once()
|
|
194
|
+
call = svc.push_error.call_args
|
|
195
|
+
self.assertEqual(call.args[0], "boom")
|
|
196
|
+
self.assertTrue(call.kwargs.get("fatal"))
|
|
197
|
+
|
|
198
|
+
async def test_error_event_without_code_kwarg(self) -> None:
|
|
199
|
+
fake, svc = self._wired()
|
|
200
|
+
await fake.emit(STVEvent.ERROR, message="connect failed", fatal=True)
|
|
201
|
+
svc.push_error.assert_awaited_once()
|
|
202
|
+
self.assertEqual(svc.push_error.call_args.args[0], "connect failed")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TestFrameRouting(unittest.IsolatedAsyncioTestCase):
|
|
206
|
+
"""Inbound Pipecat frames route to the right client calls (mid-stream frames)."""
|
|
207
|
+
|
|
208
|
+
def _svc(self, **kw):
|
|
209
|
+
fake = FakeSTVClient()
|
|
210
|
+
svc = _adapter(fake, **kw)
|
|
211
|
+
svc.push_frame = AsyncMock()
|
|
212
|
+
svc.start_ttfb_metrics = AsyncMock()
|
|
213
|
+
return fake, svc
|
|
214
|
+
|
|
215
|
+
async def test_tts_started_opens_turn(self) -> None:
|
|
216
|
+
fake, svc = self._svc()
|
|
217
|
+
await svc.process_frame(TTSStartedFrame(), FrameDirection.DOWNSTREAM)
|
|
218
|
+
self.assertIn("start_turn", fake.calls)
|
|
219
|
+
|
|
220
|
+
async def test_tts_audio_sends_to_client_and_arms_ttfb_once(self) -> None:
|
|
221
|
+
fake, svc = self._svc()
|
|
222
|
+
await svc.process_frame(TTSStartedFrame(), FrameDirection.DOWNSTREAM)
|
|
223
|
+
first = _audio()
|
|
224
|
+
await svc.process_frame(first, FrameDirection.DOWNSTREAM)
|
|
225
|
+
await svc.process_frame(_audio(), FrameDirection.DOWNSTREAM)
|
|
226
|
+
sends = [
|
|
227
|
+
c for c in fake.calls if isinstance(c, tuple) and c[0] == "send_tts_audio"
|
|
228
|
+
]
|
|
229
|
+
self.assertEqual(len(sends), 2)
|
|
230
|
+
self.assertEqual(
|
|
231
|
+
sends[0],
|
|
232
|
+
("send_tts_audio", first.audio, first.sample_rate, first.num_channels),
|
|
233
|
+
)
|
|
234
|
+
svc.start_ttfb_metrics.assert_awaited_once()
|
|
235
|
+
|
|
236
|
+
async def test_tts_audio_not_pushed_downstream(self) -> None:
|
|
237
|
+
# The adapter never passes the TTS audio frame through; the client's
|
|
238
|
+
# output sink emits the played audio instead.
|
|
239
|
+
_fake, svc = self._svc()
|
|
240
|
+
frame = _audio()
|
|
241
|
+
await svc.process_frame(frame, FrameDirection.DOWNSTREAM)
|
|
242
|
+
pushed = [c.args[0] for c in svc.push_frame.call_args_list]
|
|
243
|
+
self.assertNotIn(frame, pushed)
|
|
244
|
+
|
|
245
|
+
async def test_user_started_speaking_interrupts(self) -> None:
|
|
246
|
+
fake, svc = self._svc()
|
|
247
|
+
await svc.process_frame(UserStartedSpeakingFrame(), FrameDirection.DOWNSTREAM)
|
|
248
|
+
self.assertIn("interrupt", fake.calls)
|
|
249
|
+
|
|
250
|
+
async def test_end_frame_closes_client(self) -> None:
|
|
251
|
+
fake, svc = self._svc()
|
|
252
|
+
await svc.process_frame(EndFrame(), FrameDirection.DOWNSTREAM)
|
|
253
|
+
self.assertIn("close", fake.calls)
|
|
254
|
+
|
|
255
|
+
async def test_cancel_frame_closes_client(self) -> None:
|
|
256
|
+
fake, svc = self._svc()
|
|
257
|
+
await svc.process_frame(CancelFrame(), FrameDirection.DOWNSTREAM)
|
|
258
|
+
self.assertIn("close", fake.calls)
|
|
259
|
+
|
|
260
|
+
async def test_can_generate_metrics_is_true(self) -> None:
|
|
261
|
+
_, svc = self._svc()
|
|
262
|
+
self.assertTrue(svc.can_generate_metrics())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestTtfbAndSilence(unittest.IsolatedAsyncioTestCase):
|
|
266
|
+
"""TTFB arming honours the trailing-silence sentinel and re-arms per turn."""
|
|
267
|
+
|
|
268
|
+
def _svc(self, **kw):
|
|
269
|
+
fake = FakeSTVClient()
|
|
270
|
+
svc = _adapter(fake, **kw)
|
|
271
|
+
svc.push_frame = AsyncMock()
|
|
272
|
+
svc.start_ttfb_metrics = AsyncMock()
|
|
273
|
+
return fake, svc
|
|
274
|
+
|
|
275
|
+
async def test_trailing_silence_first_frame_is_dropped_not_armed(self) -> None:
|
|
276
|
+
fake, svc = self._svc()
|
|
277
|
+
await svc.process_frame(TTSStartedFrame(), FrameDirection.DOWNSTREAM)
|
|
278
|
+
await svc.process_frame(_silence(), FrameDirection.DOWNSTREAM)
|
|
279
|
+
svc.start_ttfb_metrics.assert_not_awaited()
|
|
280
|
+
self.assertNotIn(
|
|
281
|
+
"send_tts_audio", [c[0] for c in fake.calls if isinstance(c, tuple)]
|
|
282
|
+
)
|
|
283
|
+
# the real first frame that follows still arms TTFB once
|
|
284
|
+
await svc.process_frame(_audio(), FrameDirection.DOWNSTREAM)
|
|
285
|
+
svc.start_ttfb_metrics.assert_awaited_once()
|
|
286
|
+
|
|
287
|
+
async def test_ttfb_not_armed_without_a_tts_started_frame(self) -> None:
|
|
288
|
+
_, svc = self._svc()
|
|
289
|
+
await svc.process_frame(_audio(), FrameDirection.DOWNSTREAM)
|
|
290
|
+
svc.start_ttfb_metrics.assert_not_awaited()
|
|
291
|
+
|
|
292
|
+
async def test_ttfb_rearmed_on_each_turn(self) -> None:
|
|
293
|
+
_, svc = self._svc()
|
|
294
|
+
for _turn in range(2):
|
|
295
|
+
await svc.process_frame(TTSStartedFrame(), FrameDirection.DOWNSTREAM)
|
|
296
|
+
await svc.process_frame(_audio(), FrameDirection.DOWNSTREAM)
|
|
297
|
+
self.assertEqual(svc.start_ttfb_metrics.await_count, 2)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class TestClientDelegation(unittest.IsolatedAsyncioTestCase):
|
|
301
|
+
"""connect_with_retry delegates to the client."""
|
|
302
|
+
|
|
303
|
+
async def test_connect_with_retry_delegates_and_returns_true(self) -> None:
|
|
304
|
+
fake = FakeSTVClient()
|
|
305
|
+
fake.connect_return = True
|
|
306
|
+
svc = _adapter(fake)
|
|
307
|
+
self.assertTrue(await svc.connect_with_retry())
|
|
308
|
+
self.assertIn("connect_with_retry", fake.calls)
|
|
309
|
+
|
|
310
|
+
async def test_connect_with_retry_propagates_false(self) -> None:
|
|
311
|
+
fake = FakeSTVClient()
|
|
312
|
+
fake.connect_return = False
|
|
313
|
+
svc = _adapter(fake)
|
|
314
|
+
self.assertFalse(await svc.connect_with_retry())
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class TestFrameTransparency(unittest.IsolatedAsyncioTestCase):
|
|
318
|
+
"""Handled frames are still forwarded; unknown frames pass through."""
|
|
319
|
+
|
|
320
|
+
def _svc(self, **kw):
|
|
321
|
+
fake = FakeSTVClient()
|
|
322
|
+
svc = _adapter(fake, **kw)
|
|
323
|
+
svc.push_frame = AsyncMock()
|
|
324
|
+
svc.start_ttfb_metrics = AsyncMock()
|
|
325
|
+
return fake, svc
|
|
326
|
+
|
|
327
|
+
def _pushed_args(self, svc):
|
|
328
|
+
return [c.args for c in svc.push_frame.call_args_list]
|
|
329
|
+
|
|
330
|
+
async def test_tts_started_forwarded_downstream(self) -> None:
|
|
331
|
+
_fake, svc = self._svc()
|
|
332
|
+
frame = TTSStartedFrame()
|
|
333
|
+
await svc.process_frame(frame, FrameDirection.DOWNSTREAM)
|
|
334
|
+
self.assertIn((frame, FrameDirection.DOWNSTREAM), self._pushed_args(svc))
|
|
335
|
+
|
|
336
|
+
async def test_unknown_frame_passes_through(self) -> None:
|
|
337
|
+
_fake, svc = self._svc()
|
|
338
|
+
frame = OutputAudioRawFrame(b"\x00\x00", 24000, 1)
|
|
339
|
+
await svc.process_frame(frame, FrameDirection.DOWNSTREAM)
|
|
340
|
+
self.assertIn((frame, FrameDirection.DOWNSTREAM), self._pushed_args(svc))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@unittest.skipUnless(_HAS_RUN_TEST, "pipecat.tests.utils.run_test not available")
|
|
344
|
+
class TestLifecycleThroughPipeline(unittest.IsolatedAsyncioTestCase):
|
|
345
|
+
"""End-to-end through a real pipeline: StartFrame -> start, EndFrame -> close."""
|
|
346
|
+
|
|
347
|
+
async def test_start_starts_client_and_end_closes_it(self) -> None:
|
|
348
|
+
fake = FakeSTVClient()
|
|
349
|
+
svc = _adapter(fake)
|
|
350
|
+
await run_test(
|
|
351
|
+
svc,
|
|
352
|
+
frames_to_send=[],
|
|
353
|
+
expected_down_frames=None,
|
|
354
|
+
send_end_frame=True,
|
|
355
|
+
)
|
|
356
|
+
self.assertIn("start", fake.calls)
|
|
357
|
+
self.assertIn("close", fake.calls)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
unittest.main()
|