pipecat-anva 0.1.0__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_anva-0.1.0/.gitignore +6 -0
- pipecat_anva-0.1.0/PKG-INFO +103 -0
- pipecat_anva-0.1.0/README.md +79 -0
- pipecat_anva-0.1.0/pipecat_anva/__init__.py +15 -0
- pipecat_anva-0.1.0/pipecat_anva/session.py +409 -0
- pipecat_anva-0.1.0/pipecat_anva/version.py +1 -0
- pipecat_anva-0.1.0/pipecat_anva/video.py +165 -0
- pipecat_anva-0.1.0/pyproject.toml +39 -0
- pipecat_anva-0.1.0/tests/__init__.py +0 -0
- pipecat_anva-0.1.0/tests/fake_anva.py +161 -0
- pipecat_anva-0.1.0/tests/test_service.py +75 -0
- pipecat_anva-0.1.0/tests/test_session.py +135 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pipecat-anva
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anva video avatar service for Pipecat: a lip-synced face for your voice agent, on any transport.
|
|
5
|
+
Project-URL: Homepage, https://anva.ai
|
|
6
|
+
Project-URL: Documentation, https://anva.ai/docs#pipecat
|
|
7
|
+
Project-URL: Source, https://github.com/Anva-avatars/anva-sdk
|
|
8
|
+
Author-email: Anva <anva.ai.2026@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: anva,avatar,daily,pipecat,voice,webrtc
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Video
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: aiohttp>=3.9
|
|
18
|
+
Requires-Dist: aiortc>=1.9
|
|
19
|
+
Requires-Dist: av>=12
|
|
20
|
+
Requires-Dist: numpy>=1.24
|
|
21
|
+
Requires-Dist: pipecat-ai>=1.0
|
|
22
|
+
Requires-Dist: websockets>=14
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Anva avatars for Pipecat
|
|
26
|
+
|
|
27
|
+
Give your Pipecat voice agent a face. The pipeline keeps its own STT, LLM and
|
|
28
|
+
TTS; `AnvaVideoService` sits after the TTS, sends its audio to Anva and pushes
|
|
29
|
+
back the avatar's video and voice, lips in time, as ordinary output frames for
|
|
30
|
+
whatever transport you use: Daily, LiveKit, WebRTC, anything with video out.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install pipecat-anva
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pipecat_anva import AnvaVideoService
|
|
38
|
+
|
|
39
|
+
anva = AnvaVideoService(api_key=os.environ["ANVA_API_KEY"], avatar_id="av_...")
|
|
40
|
+
|
|
41
|
+
pipeline = Pipeline([
|
|
42
|
+
transport.input(),
|
|
43
|
+
stt,
|
|
44
|
+
context_aggregator.user(),
|
|
45
|
+
llm,
|
|
46
|
+
tts,
|
|
47
|
+
anva, # after the TTS, before the transport
|
|
48
|
+
transport.output(),
|
|
49
|
+
context_aggregator.assistant(),
|
|
50
|
+
])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Give the transport video out at the avatar's size, for example with Daily:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
DailyParams(video_out_enabled=True, video_out_width=480, video_out_height=480, ...)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Anva renders 480×480 at 25 fps by default. The service does not scale the
|
|
60
|
+
picture; set `video_out_width`/`video_out_height` to what the avatar sends
|
|
61
|
+
or let your transport scale it.
|
|
62
|
+
|
|
63
|
+
## What happens
|
|
64
|
+
|
|
65
|
+
1. On pipeline start the service creates an Anva `avatar_only` session with
|
|
66
|
+
your API key, opens its events socket and connects a WebRTC viewer to it,
|
|
67
|
+
the way Anva's embed player does. Nothing is billed until the viewer is
|
|
68
|
+
connected.
|
|
69
|
+
2. `TTSAudioRawFrame`s are converted to what Anva's lip-sync takes (24 kHz
|
|
70
|
+
mono PCM) and sent as one utterance per `TTSStartedFrame` /
|
|
71
|
+
`TTSStoppedFrame`. The pipeline's own copy of the audio stops at the
|
|
72
|
+
service.
|
|
73
|
+
3. The avatar's video comes back as `OutputImageRawFrame`s (RGB) and its
|
|
74
|
+
voice as `TTSAudioRawFrame`s at the pipeline's TTS sample rate, in sync.
|
|
75
|
+
4. An `InterruptionFrame` stops the avatar mid-sentence and drops what was
|
|
76
|
+
queued.
|
|
77
|
+
5. `EndFrame` / `CancelFrame` end the Anva session, which is what stops the
|
|
78
|
+
billing.
|
|
79
|
+
|
|
80
|
+
Sending is paced by Anva's playback reports: the service keeps at most four
|
|
81
|
+
seconds of unplayed audio in flight, so a long answer is never front-loaded.
|
|
82
|
+
|
|
83
|
+
## Options
|
|
84
|
+
|
|
85
|
+
| Argument | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `api_key` | Anva API key (anva.ai → API keys). The events socket needs the Developer plan or above. |
|
|
88
|
+
| `avatar_id` / `preset_id` | Exactly one. A preset's voice and prompt do not apply: the pipeline speaks. |
|
|
89
|
+
| `api_url` | Anva base URL (`https://anva.ai`). |
|
|
90
|
+
| `max_duration_seconds` | End the Anva session this long after it goes live. |
|
|
91
|
+
| `metadata` | Echoed on Anva's session and webhooks. |
|
|
92
|
+
|
|
93
|
+
A refused session (wrong avatar, plan limits, no capacity) is reported with
|
|
94
|
+
`push_error`; the pipeline keeps running without a face.
|
|
95
|
+
|
|
96
|
+
## Tests
|
|
97
|
+
|
|
98
|
+
The tests run against a stand-in for Anva, no account needed:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
pip install -e . pytest pytest-asyncio
|
|
102
|
+
python -m pytest tests
|
|
103
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Anva avatars for Pipecat
|
|
2
|
+
|
|
3
|
+
Give your Pipecat voice agent a face. The pipeline keeps its own STT, LLM and
|
|
4
|
+
TTS; `AnvaVideoService` sits after the TTS, sends its audio to Anva and pushes
|
|
5
|
+
back the avatar's video and voice, lips in time, as ordinary output frames for
|
|
6
|
+
whatever transport you use: Daily, LiveKit, WebRTC, anything with video out.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install pipecat-anva
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from pipecat_anva import AnvaVideoService
|
|
14
|
+
|
|
15
|
+
anva = AnvaVideoService(api_key=os.environ["ANVA_API_KEY"], avatar_id="av_...")
|
|
16
|
+
|
|
17
|
+
pipeline = Pipeline([
|
|
18
|
+
transport.input(),
|
|
19
|
+
stt,
|
|
20
|
+
context_aggregator.user(),
|
|
21
|
+
llm,
|
|
22
|
+
tts,
|
|
23
|
+
anva, # after the TTS, before the transport
|
|
24
|
+
transport.output(),
|
|
25
|
+
context_aggregator.assistant(),
|
|
26
|
+
])
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Give the transport video out at the avatar's size, for example with Daily:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
DailyParams(video_out_enabled=True, video_out_width=480, video_out_height=480, ...)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Anva renders 480×480 at 25 fps by default. The service does not scale the
|
|
36
|
+
picture; set `video_out_width`/`video_out_height` to what the avatar sends
|
|
37
|
+
or let your transport scale it.
|
|
38
|
+
|
|
39
|
+
## What happens
|
|
40
|
+
|
|
41
|
+
1. On pipeline start the service creates an Anva `avatar_only` session with
|
|
42
|
+
your API key, opens its events socket and connects a WebRTC viewer to it,
|
|
43
|
+
the way Anva's embed player does. Nothing is billed until the viewer is
|
|
44
|
+
connected.
|
|
45
|
+
2. `TTSAudioRawFrame`s are converted to what Anva's lip-sync takes (24 kHz
|
|
46
|
+
mono PCM) and sent as one utterance per `TTSStartedFrame` /
|
|
47
|
+
`TTSStoppedFrame`. The pipeline's own copy of the audio stops at the
|
|
48
|
+
service.
|
|
49
|
+
3. The avatar's video comes back as `OutputImageRawFrame`s (RGB) and its
|
|
50
|
+
voice as `TTSAudioRawFrame`s at the pipeline's TTS sample rate, in sync.
|
|
51
|
+
4. An `InterruptionFrame` stops the avatar mid-sentence and drops what was
|
|
52
|
+
queued.
|
|
53
|
+
5. `EndFrame` / `CancelFrame` end the Anva session, which is what stops the
|
|
54
|
+
billing.
|
|
55
|
+
|
|
56
|
+
Sending is paced by Anva's playback reports: the service keeps at most four
|
|
57
|
+
seconds of unplayed audio in flight, so a long answer is never front-loaded.
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
| Argument | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `api_key` | Anva API key (anva.ai → API keys). The events socket needs the Developer plan or above. |
|
|
64
|
+
| `avatar_id` / `preset_id` | Exactly one. A preset's voice and prompt do not apply: the pipeline speaks. |
|
|
65
|
+
| `api_url` | Anva base URL (`https://anva.ai`). |
|
|
66
|
+
| `max_duration_seconds` | End the Anva session this long after it goes live. |
|
|
67
|
+
| `metadata` | Echoed on Anva's session and webhooks. |
|
|
68
|
+
|
|
69
|
+
A refused session (wrong avatar, plan limits, no capacity) is reported with
|
|
70
|
+
`push_error`; the pipeline keeps running without a face.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
The tests run against a stand-in for Anva, no account needed:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pip install -e . pytest pytest-asyncio
|
|
78
|
+
python -m pytest tests
|
|
79
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Anva avatars for Pipecat.
|
|
2
|
+
|
|
3
|
+
from pipecat_anva import AnvaVideoService
|
|
4
|
+
|
|
5
|
+
avatar = AnvaVideoService(api_key=..., avatar_id="av_...")
|
|
6
|
+
pipeline = Pipeline([transport.input(), stt, llm, tts, avatar, transport.output()])
|
|
7
|
+
|
|
8
|
+
The service takes the TTS audio, sends it to Anva, and puts back the avatar's
|
|
9
|
+
video and its audio, in sync, for whatever transport the pipeline uses.
|
|
10
|
+
"""
|
|
11
|
+
from .session import AnvaError, AnvaSession
|
|
12
|
+
from .version import __version__
|
|
13
|
+
from .video import AnvaVideoService
|
|
14
|
+
|
|
15
|
+
__all__ = ["AnvaVideoService", "AnvaSession", "AnvaError", "__version__"]
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""An Anva ``avatar_only`` session driven from Python.
|
|
2
|
+
|
|
3
|
+
Three connections make one avatar: the REST call that creates the session, the
|
|
4
|
+
events socket that carries speech in and progress out, and a WebRTC viewer that
|
|
5
|
+
receives the avatar's video and audio -- the same media a browser would get
|
|
6
|
+
from Anva's embed player, decoded here instead. Speech goes in as raw PCM in
|
|
7
|
+
utterances; Anva's core plays each one through the avatar and reports what it
|
|
8
|
+
accepted and played, which is what paces the sending.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Awaitable, Callable
|
|
20
|
+
|
|
21
|
+
import aiohttp
|
|
22
|
+
import numpy as np
|
|
23
|
+
import websockets
|
|
24
|
+
from aiortc import RTCPeerConnection, RTCSessionDescription
|
|
25
|
+
from av.audio.frame import AudioFrame
|
|
26
|
+
from av.audio.resampler import AudioResampler
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("pipecat_anva")
|
|
29
|
+
|
|
30
|
+
DEFAULT_API_URL = "https://anva.ai"
|
|
31
|
+
#: What Anva's speech ingress takes.
|
|
32
|
+
CORE_RATE = 24000
|
|
33
|
+
#: The most one append may carry.
|
|
34
|
+
CHUNK_SAMPLES = 12000
|
|
35
|
+
#: Keep a second under the core's five-second window of unplayed audio.
|
|
36
|
+
OUTSTANDING_LIMIT = 4 * CORE_RATE
|
|
37
|
+
#: Anva's longest utterance.
|
|
38
|
+
UTTERANCE_LIMIT = 120 * CORE_RATE
|
|
39
|
+
|
|
40
|
+
FrameCallback = Callable[[Any], Awaitable[None]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AnvaError(Exception):
|
|
44
|
+
"""Anva could not start or run the session."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _cancel_task(task: asyncio.Task) -> None:
|
|
48
|
+
task.cancel()
|
|
49
|
+
try:
|
|
50
|
+
await task
|
|
51
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Utterance:
|
|
57
|
+
"""One stretch of speech from the agent, converted to core samples."""
|
|
58
|
+
|
|
59
|
+
turn_id: str
|
|
60
|
+
pending: bytearray = field(default_factory=bytearray)
|
|
61
|
+
pushed: int = 0
|
|
62
|
+
closed: bool = False
|
|
63
|
+
cancelled: bool = False
|
|
64
|
+
wake: asyncio.Event = field(default_factory=asyncio.Event)
|
|
65
|
+
|
|
66
|
+
def push(self, pcm: bytes) -> None:
|
|
67
|
+
if self.cancelled or not pcm:
|
|
68
|
+
return
|
|
69
|
+
room = UTTERANCE_LIMIT - self.pushed
|
|
70
|
+
if room <= 0:
|
|
71
|
+
return
|
|
72
|
+
pcm = pcm[: room * 2]
|
|
73
|
+
self.pending.extend(pcm)
|
|
74
|
+
self.pushed += len(pcm) // 2
|
|
75
|
+
self.wake.set()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self.closed = True
|
|
79
|
+
self.wake.set()
|
|
80
|
+
|
|
81
|
+
def cancel(self) -> None:
|
|
82
|
+
self.cancelled = True
|
|
83
|
+
self.pending.clear()
|
|
84
|
+
self.wake.set()
|
|
85
|
+
|
|
86
|
+
def take(self) -> bytes:
|
|
87
|
+
"""A full chunk, or whatever is left once the utterance is closed."""
|
|
88
|
+
if len(self.pending) >= CHUNK_SAMPLES * 2 or (self.closed and self.pending):
|
|
89
|
+
n = min(len(self.pending), CHUNK_SAMPLES * 2)
|
|
90
|
+
chunk = bytes(self.pending[:n])
|
|
91
|
+
del self.pending[:n]
|
|
92
|
+
return chunk
|
|
93
|
+
return b""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AnvaSession:
|
|
97
|
+
"""Create, watch and speak through one Anva avatar session.
|
|
98
|
+
|
|
99
|
+
``on_video`` and ``on_audio`` receive PyAV frames as they arrive from the
|
|
100
|
+
avatar. ``task_factory`` is how background tasks are started, so a host
|
|
101
|
+
such as Pipecat can own them.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
api_key: str,
|
|
108
|
+
avatar_id: str | None = None,
|
|
109
|
+
preset_id: str | None = None,
|
|
110
|
+
api_url: str = DEFAULT_API_URL,
|
|
111
|
+
max_duration_seconds: int | None = None,
|
|
112
|
+
metadata: dict[str, Any] | None = None,
|
|
113
|
+
on_video: FrameCallback | None = None,
|
|
114
|
+
on_audio: FrameCallback | None = None,
|
|
115
|
+
task_factory: Callable[[Awaitable[Any]], asyncio.Task] | None = None,
|
|
116
|
+
task_cancel: Callable[[asyncio.Task], Awaitable[None]] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
if bool(avatar_id) == bool(preset_id):
|
|
119
|
+
raise AnvaError("give exactly one of avatar_id and preset_id")
|
|
120
|
+
if not api_key:
|
|
121
|
+
raise AnvaError("api_key is required")
|
|
122
|
+
self._api_key = api_key
|
|
123
|
+
self._avatar_id, self._preset_id = avatar_id, preset_id
|
|
124
|
+
self._api_url = api_url.rstrip("/")
|
|
125
|
+
self._max_duration_seconds = max_duration_seconds
|
|
126
|
+
self._metadata = metadata
|
|
127
|
+
self._on_video, self._on_audio = on_video, on_audio
|
|
128
|
+
self._task_factory = task_factory or asyncio.create_task
|
|
129
|
+
self._task_cancel = task_cancel or _cancel_task
|
|
130
|
+
self._http: aiohttp.ClientSession | None = None
|
|
131
|
+
self._ws: Any = None
|
|
132
|
+
self._pc: RTCPeerConnection | None = None
|
|
133
|
+
self._tasks: list[asyncio.Task] = []
|
|
134
|
+
self.session_id: str | None = None
|
|
135
|
+
self.session_token: str | None = None
|
|
136
|
+
self.live = asyncio.Event()
|
|
137
|
+
self.ended = asyncio.Event()
|
|
138
|
+
# Every sentence the pipeline speaks waits its turn; an interruption drains it.
|
|
139
|
+
self._queue: asyncio.Queue[Utterance] = asyncio.Queue()
|
|
140
|
+
self._current: Utterance | None = None
|
|
141
|
+
self._states: asyncio.Queue[dict] = asyncio.Queue(maxsize=64)
|
|
142
|
+
self._turns = 0
|
|
143
|
+
self._resampler: AudioResampler | None = None
|
|
144
|
+
self._resampler_key: tuple[int, int] | None = None
|
|
145
|
+
|
|
146
|
+
# -- lifecycle ---------------------------------------------------------
|
|
147
|
+
async def start(self) -> None:
|
|
148
|
+
self._http = aiohttp.ClientSession()
|
|
149
|
+
await self._create()
|
|
150
|
+
await self._connect_events()
|
|
151
|
+
await self._attach_viewer()
|
|
152
|
+
try:
|
|
153
|
+
await asyncio.wait_for(self.live.wait(), timeout=60)
|
|
154
|
+
except asyncio.TimeoutError as exc:
|
|
155
|
+
raise AnvaError("Anva did not report the session live within 60 s") from exc
|
|
156
|
+
self._tasks.append(self._task_factory(self._feed()))
|
|
157
|
+
|
|
158
|
+
async def close(self) -> None:
|
|
159
|
+
for task in list(self._tasks):
|
|
160
|
+
await self._task_cancel(task)
|
|
161
|
+
self._tasks.clear()
|
|
162
|
+
if self._pc is not None:
|
|
163
|
+
await self._pc.close()
|
|
164
|
+
self._pc = None
|
|
165
|
+
if self._ws is not None:
|
|
166
|
+
try:
|
|
167
|
+
await self._ws.close()
|
|
168
|
+
except Exception: # noqa: BLE001
|
|
169
|
+
pass
|
|
170
|
+
self._ws = None
|
|
171
|
+
if self._http is not None:
|
|
172
|
+
if self.session_id:
|
|
173
|
+
try:
|
|
174
|
+
async with self._http.delete(
|
|
175
|
+
f"{self._api_url}/api/v2/sessions/{self.session_id}", headers=self._auth(),
|
|
176
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
177
|
+
):
|
|
178
|
+
pass
|
|
179
|
+
except Exception: # noqa: BLE001
|
|
180
|
+
log.debug("could not end the Anva session", exc_info=True)
|
|
181
|
+
await self._http.close()
|
|
182
|
+
self._http = None
|
|
183
|
+
self.ended.set()
|
|
184
|
+
|
|
185
|
+
def _auth(self) -> dict[str, str]:
|
|
186
|
+
return {"Authorization": f"Bearer {self._api_key}"}
|
|
187
|
+
|
|
188
|
+
async def _create(self) -> None:
|
|
189
|
+
assert self._http is not None
|
|
190
|
+
body: dict[str, Any] = {"service_mode": "avatar_only"}
|
|
191
|
+
if self._avatar_id:
|
|
192
|
+
body["avatar_id"] = self._avatar_id
|
|
193
|
+
else:
|
|
194
|
+
body["preset_id"] = self._preset_id
|
|
195
|
+
if self._max_duration_seconds:
|
|
196
|
+
body["max_duration_seconds"] = self._max_duration_seconds
|
|
197
|
+
if self._metadata:
|
|
198
|
+
body["metadata"] = self._metadata
|
|
199
|
+
async with self._http.post(f"{self._api_url}/api/v2/sessions", json=body, headers=self._auth(),
|
|
200
|
+
timeout=aiohttp.ClientTimeout(total=30)) as response:
|
|
201
|
+
text = await response.text()
|
|
202
|
+
if response.status != 201:
|
|
203
|
+
raise AnvaError(f"Anva refused the session ({response.status}): {text}")
|
|
204
|
+
data = json.loads(text)
|
|
205
|
+
self.session_id = data["session_id"]
|
|
206
|
+
self.session_token = data["session_token"]
|
|
207
|
+
self._events_url = data["events_ws_url"]
|
|
208
|
+
log.info("Anva session %s created", self.session_id)
|
|
209
|
+
|
|
210
|
+
async def _connect_events(self) -> None:
|
|
211
|
+
self._ws = await websockets.connect(self._events_url, additional_headers=self._auth(), max_size=1 << 20)
|
|
212
|
+
self._tasks.append(self._task_factory(self._read_events()))
|
|
213
|
+
|
|
214
|
+
async def _read_events(self) -> None:
|
|
215
|
+
assert self._ws is not None
|
|
216
|
+
try:
|
|
217
|
+
async for raw in self._ws:
|
|
218
|
+
try:
|
|
219
|
+
event = json.loads(raw)
|
|
220
|
+
except ValueError:
|
|
221
|
+
continue
|
|
222
|
+
typ, payload = event.get("type"), event.get("payload") or {}
|
|
223
|
+
if typ == "session.live":
|
|
224
|
+
self.live.set()
|
|
225
|
+
elif typ == "session.ended":
|
|
226
|
+
log.info("Anva session %s ended: %s", self.session_id, payload.get("reason"))
|
|
227
|
+
self.ended.set()
|
|
228
|
+
elif typ == "speech.state":
|
|
229
|
+
if self._states.full():
|
|
230
|
+
self._states.get_nowait()
|
|
231
|
+
self._states.put_nowait(payload)
|
|
232
|
+
elif typ == "error":
|
|
233
|
+
log.warning("Anva session %s error: %s", self.session_id, payload)
|
|
234
|
+
except websockets.ConnectionClosed:
|
|
235
|
+
log.info("Anva events socket closed for %s", self.session_id)
|
|
236
|
+
self.ended.set()
|
|
237
|
+
|
|
238
|
+
async def _attach_viewer(self) -> None:
|
|
239
|
+
"""Receive the avatar over WebRTC, as the embed player does."""
|
|
240
|
+
assert self._http is not None
|
|
241
|
+
pc = RTCPeerConnection()
|
|
242
|
+
self._pc = pc
|
|
243
|
+
pc.addTransceiver("video", direction="recvonly")
|
|
244
|
+
pc.addTransceiver("audio", direction="recvonly")
|
|
245
|
+
|
|
246
|
+
@pc.on("track")
|
|
247
|
+
def on_track(track):
|
|
248
|
+
self._tasks.append(self._task_factory(self._consume(track)))
|
|
249
|
+
|
|
250
|
+
offer = await pc.createOffer()
|
|
251
|
+
await pc.setLocalDescription(offer)
|
|
252
|
+
body = {"session_id": self.session_id, "session_url_token": self.session_token,
|
|
253
|
+
"sdp": pc.localDescription.sdp, "type": pc.localDescription.type,
|
|
254
|
+
"client_instance_id": "pipecat-anva-" + uuid.uuid4().hex[:12], "client_generation": 1}
|
|
255
|
+
for attempt in range(8):
|
|
256
|
+
async with self._http.post(f"{self._api_url}/offer", json=body,
|
|
257
|
+
timeout=aiohttp.ClientTimeout(total=60)) as response:
|
|
258
|
+
text = await response.text()
|
|
259
|
+
if response.status == 200:
|
|
260
|
+
answer = json.loads(text)
|
|
261
|
+
break
|
|
262
|
+
# Capacity and warming answers say when to try again.
|
|
263
|
+
retry_after = 5
|
|
264
|
+
try:
|
|
265
|
+
retry_after = int(json.loads(text).get("retry_after", retry_after))
|
|
266
|
+
except (ValueError, AttributeError):
|
|
267
|
+
pass
|
|
268
|
+
if response.status == 503 and attempt < 7:
|
|
269
|
+
log.info("Anva viewer not admitted yet (%s); retrying in %ss", text[:120], retry_after)
|
|
270
|
+
await asyncio.sleep(retry_after)
|
|
271
|
+
continue
|
|
272
|
+
raise AnvaError(f"Anva refused the viewer ({response.status}): {text}")
|
|
273
|
+
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer["sdp"], type=answer["type"]))
|
|
274
|
+
|
|
275
|
+
async def _consume(self, track) -> None:
|
|
276
|
+
callback = self._on_video if track.kind == "video" else self._on_audio
|
|
277
|
+
try:
|
|
278
|
+
while True:
|
|
279
|
+
frame = await track.recv()
|
|
280
|
+
if callback is not None:
|
|
281
|
+
await callback(frame)
|
|
282
|
+
except Exception: # noqa: BLE001 the track ended with the connection
|
|
283
|
+
log.debug("Anva %s track ended", track.kind, exc_info=True)
|
|
284
|
+
|
|
285
|
+
# -- speech in ---------------------------------------------------------
|
|
286
|
+
def begin_utterance(self) -> Utterance:
|
|
287
|
+
"""The agent starts speaking; audio follows in push_audio."""
|
|
288
|
+
self._turns += 1
|
|
289
|
+
u = Utterance(turn_id=f"pipecat-{self._turns}")
|
|
290
|
+
self._queue.put_nowait(u)
|
|
291
|
+
return u
|
|
292
|
+
|
|
293
|
+
def push_audio(self, u: Utterance, pcm: bytes, sample_rate: int, channels: int) -> None:
|
|
294
|
+
"""Add TTS audio to an utterance, converting it to what Anva takes."""
|
|
295
|
+
if u.cancelled or not pcm:
|
|
296
|
+
return
|
|
297
|
+
if channels == 1 and sample_rate == CORE_RATE:
|
|
298
|
+
u.push(pcm)
|
|
299
|
+
return
|
|
300
|
+
if self._resampler_key != (sample_rate, channels):
|
|
301
|
+
self._resampler = AudioResampler("s16", "mono", CORE_RATE)
|
|
302
|
+
self._resampler_key = (sample_rate, channels)
|
|
303
|
+
frame = AudioFrame.from_ndarray(np.frombuffer(pcm, dtype=np.int16)[None, :],
|
|
304
|
+
layout="mono" if channels == 1 else "stereo")
|
|
305
|
+
frame.sample_rate = sample_rate
|
|
306
|
+
assert self._resampler is not None
|
|
307
|
+
for out in self._resampler.resample(frame):
|
|
308
|
+
u.push(out.to_ndarray().astype(np.int16).tobytes())
|
|
309
|
+
|
|
310
|
+
def end_utterance(self, u: Utterance) -> None:
|
|
311
|
+
if self._resampler is not None:
|
|
312
|
+
for out in self._resampler.resample(None):
|
|
313
|
+
u.push(out.to_ndarray().astype(np.int16).tobytes())
|
|
314
|
+
self._resampler, self._resampler_key = None, None
|
|
315
|
+
u.close()
|
|
316
|
+
|
|
317
|
+
async def interrupt(self) -> None:
|
|
318
|
+
"""Stop what is playing and drop what is queued."""
|
|
319
|
+
while not self._queue.empty():
|
|
320
|
+
self._queue.get_nowait().cancel()
|
|
321
|
+
if self._current is not None:
|
|
322
|
+
self._current.cancel()
|
|
323
|
+
# What the resampler still holds belonged to the cut-off sentence.
|
|
324
|
+
self._resampler, self._resampler_key = None, None
|
|
325
|
+
|
|
326
|
+
async def _send(self, typ: str, payload: dict[str, Any]) -> None:
|
|
327
|
+
if self._ws is None:
|
|
328
|
+
raise AnvaError("events socket is not connected")
|
|
329
|
+
await self._ws.send(json.dumps({"type": typ, "payload": payload}))
|
|
330
|
+
|
|
331
|
+
async def _feed(self) -> None:
|
|
332
|
+
"""Serve utterances to the core one at a time, within its window."""
|
|
333
|
+
while True:
|
|
334
|
+
u = await self._queue.get()
|
|
335
|
+
self._current = u
|
|
336
|
+
try:
|
|
337
|
+
await self._serve(u)
|
|
338
|
+
except asyncio.CancelledError:
|
|
339
|
+
raise
|
|
340
|
+
except Exception: # noqa: BLE001
|
|
341
|
+
log.warning("Anva utterance %s failed", u.turn_id, exc_info=True)
|
|
342
|
+
finally:
|
|
343
|
+
self._current = None
|
|
344
|
+
|
|
345
|
+
async def _serve(self, u: Utterance) -> None:
|
|
346
|
+
if u.cancelled:
|
|
347
|
+
return
|
|
348
|
+
await self._send("speech.start", {"turn_id": u.turn_id, "codec": "pcm_s16le",
|
|
349
|
+
"sample_rate": CORE_RATE, "channels": 1})
|
|
350
|
+
sent = played = seq = 0
|
|
351
|
+
done = False
|
|
352
|
+
|
|
353
|
+
def note(state: dict) -> None:
|
|
354
|
+
nonlocal played, done
|
|
355
|
+
if state.get("turn_id") != u.turn_id:
|
|
356
|
+
return
|
|
357
|
+
played = max(played, int(state.get("played_samples") or 0))
|
|
358
|
+
if state.get("state") in ("finished", "cancelled", "error"):
|
|
359
|
+
done = True
|
|
360
|
+
|
|
361
|
+
while not done:
|
|
362
|
+
if u.cancelled:
|
|
363
|
+
await self._send("speech.cancel", {"turn_id": u.turn_id})
|
|
364
|
+
return
|
|
365
|
+
chunk = u.take()
|
|
366
|
+
if chunk:
|
|
367
|
+
while sent - played > OUTSTANDING_LIMIT and not u.cancelled:
|
|
368
|
+
try:
|
|
369
|
+
note(await asyncio.wait_for(self._states.get(), timeout=0.25))
|
|
370
|
+
except asyncio.TimeoutError:
|
|
371
|
+
pass
|
|
372
|
+
if u.cancelled:
|
|
373
|
+
continue
|
|
374
|
+
await self._send("speech.append", {"turn_id": u.turn_id, "seq": seq, "start_sample": sent,
|
|
375
|
+
"data": base64.b64encode(chunk).decode()})
|
|
376
|
+
seq += 1
|
|
377
|
+
sent += len(chunk) // 2
|
|
378
|
+
continue
|
|
379
|
+
if u.closed:
|
|
380
|
+
if sent == 0:
|
|
381
|
+
await self._send("speech.cancel", {"turn_id": u.turn_id})
|
|
382
|
+
return
|
|
383
|
+
await self._send("speech.done", {"turn_id": u.turn_id, "total_samples": sent})
|
|
384
|
+
remaining = (sent - played) / CORE_RATE + 10
|
|
385
|
+
deadline = asyncio.get_running_loop().time() + remaining
|
|
386
|
+
while not done and not u.cancelled:
|
|
387
|
+
wait = deadline - asyncio.get_running_loop().time()
|
|
388
|
+
if wait <= 0:
|
|
389
|
+
log.info("Anva did not confirm playback of %s; moving on", u.turn_id)
|
|
390
|
+
return
|
|
391
|
+
try:
|
|
392
|
+
note(await asyncio.wait_for(self._states.get(), timeout=min(wait, 0.5)))
|
|
393
|
+
except asyncio.TimeoutError:
|
|
394
|
+
pass
|
|
395
|
+
if u.cancelled and not done:
|
|
396
|
+
await self._send("speech.cancel", {"turn_id": u.turn_id})
|
|
397
|
+
return
|
|
398
|
+
# Nothing ready: wait for audio, a report, or the end.
|
|
399
|
+
u.wake.clear()
|
|
400
|
+
state_wait = asyncio.ensure_future(self._states.get())
|
|
401
|
+
wake_wait = asyncio.ensure_future(u.wake.wait())
|
|
402
|
+
finished, _ = await asyncio.wait({state_wait, wake_wait}, return_when=asyncio.FIRST_COMPLETED)
|
|
403
|
+
for task in (state_wait, wake_wait):
|
|
404
|
+
if task in finished:
|
|
405
|
+
result = task.result()
|
|
406
|
+
if task is state_wait:
|
|
407
|
+
note(result)
|
|
408
|
+
else:
|
|
409
|
+
task.cancel()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Anva as a Pipecat video service.
|
|
2
|
+
|
|
3
|
+
Sits after the TTS in the pipeline. The TTS audio goes to Anva instead of the
|
|
4
|
+
transport; what comes back is the avatar's video and its audio, in sync, which
|
|
5
|
+
the service pushes downstream as ordinary output frames for whatever transport
|
|
6
|
+
the pipeline uses -- Daily, LiveKit, a phone line's video, a browser.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from av.audio.resampler import AudioResampler
|
|
16
|
+
from pipecat.frames.frames import (
|
|
17
|
+
CancelFrame,
|
|
18
|
+
EndFrame,
|
|
19
|
+
Frame,
|
|
20
|
+
InterruptionFrame,
|
|
21
|
+
OutputImageRawFrame,
|
|
22
|
+
TTSAudioRawFrame,
|
|
23
|
+
TTSStartedFrame,
|
|
24
|
+
TTSStoppedFrame,
|
|
25
|
+
)
|
|
26
|
+
from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup
|
|
27
|
+
from pipecat.services.ai_service import AIService
|
|
28
|
+
from pipecat.services.settings import ServiceSettings
|
|
29
|
+
|
|
30
|
+
from .session import AnvaSession, Utterance
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AnvaVideoSettings(ServiceSettings):
|
|
35
|
+
"""Settings for the Anva video service."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AnvaVideoService(AIService):
|
|
39
|
+
"""Anva video service: a lip-synced avatar for the pipeline's TTS.
|
|
40
|
+
|
|
41
|
+
Consumes ``TTSAudioRawFrame``s and produces ``OutputImageRawFrame``s and
|
|
42
|
+
``TTSAudioRawFrame``s carrying the avatar's synchronized video and audio.
|
|
43
|
+
An interruption stops the avatar mid-sentence.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
Settings = AnvaVideoSettings
|
|
47
|
+
_settings: Settings
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
api_key: str,
|
|
53
|
+
avatar_id: str | None = None,
|
|
54
|
+
preset_id: str | None = None,
|
|
55
|
+
api_url: str = "https://anva.ai",
|
|
56
|
+
max_duration_seconds: int | None = None,
|
|
57
|
+
metadata: dict[str, Any] | None = None,
|
|
58
|
+
settings: Settings | None = None,
|
|
59
|
+
**kwargs,
|
|
60
|
+
):
|
|
61
|
+
"""Create the service.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
api_key: Anva API key.
|
|
65
|
+
avatar_id: An avatar from your Anva account. Exactly one of this and preset_id.
|
|
66
|
+
preset_id: A saved preset. Its voice and prompt do not apply: the pipeline speaks.
|
|
67
|
+
api_url: Anva base URL.
|
|
68
|
+
max_duration_seconds: End the Anva session this long after it goes live.
|
|
69
|
+
metadata: Echoed on the Anva session and its webhooks.
|
|
70
|
+
settings: Service settings.
|
|
71
|
+
**kwargs: Passed to AIService.
|
|
72
|
+
"""
|
|
73
|
+
default_settings = ServiceSettings(model=None)
|
|
74
|
+
if settings is not None:
|
|
75
|
+
default_settings.apply_update(settings)
|
|
76
|
+
super().__init__(settings=default_settings, **kwargs)
|
|
77
|
+
self._session = AnvaSession(
|
|
78
|
+
api_key=api_key, avatar_id=avatar_id, preset_id=preset_id, api_url=api_url,
|
|
79
|
+
max_duration_seconds=max_duration_seconds, metadata=metadata,
|
|
80
|
+
on_video=self._on_video, on_audio=self._on_audio,
|
|
81
|
+
task_factory=self.create_task, task_cancel=self.cancel_task,
|
|
82
|
+
)
|
|
83
|
+
self._utterance: Utterance | None = None
|
|
84
|
+
self._out_rate = 24000
|
|
85
|
+
self._out_resampler: AudioResampler | None = None
|
|
86
|
+
self._out_key: tuple[int, int] | None = None
|
|
87
|
+
self._started = False
|
|
88
|
+
|
|
89
|
+
async def setup(self, setup: FrameProcessorSetup):
|
|
90
|
+
await super().setup(setup)
|
|
91
|
+
await self._start_connection()
|
|
92
|
+
|
|
93
|
+
async def stop(self, frame: EndFrame):
|
|
94
|
+
await super().stop(frame)
|
|
95
|
+
await self._stop_connection()
|
|
96
|
+
|
|
97
|
+
async def cancel(self, frame: CancelFrame):
|
|
98
|
+
await super().cancel(frame)
|
|
99
|
+
await self._stop_connection()
|
|
100
|
+
|
|
101
|
+
async def cleanup(self):
|
|
102
|
+
await super().cleanup()
|
|
103
|
+
await self._stop_connection()
|
|
104
|
+
|
|
105
|
+
async def _start_connection(self):
|
|
106
|
+
if self._started:
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
await self._session.start()
|
|
110
|
+
self._started = True
|
|
111
|
+
except Exception as e: # noqa: BLE001
|
|
112
|
+
await self.push_error(error_msg=f"Unable to start the Anva avatar: {e}", exception=e)
|
|
113
|
+
|
|
114
|
+
async def _stop_connection(self):
|
|
115
|
+
if not self._started:
|
|
116
|
+
return
|
|
117
|
+
self._started = False
|
|
118
|
+
await self._session.close()
|
|
119
|
+
|
|
120
|
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
|
121
|
+
await super().process_frame(frame, direction)
|
|
122
|
+
if not self._started:
|
|
123
|
+
# No avatar: the pipeline speaks on its own.
|
|
124
|
+
await self.push_frame(frame, direction)
|
|
125
|
+
return
|
|
126
|
+
if isinstance(frame, TTSStartedFrame):
|
|
127
|
+
self._utterance = self._session.begin_utterance()
|
|
128
|
+
elif isinstance(frame, TTSAudioRawFrame):
|
|
129
|
+
# The avatar's own audio comes back in sync with its face; the
|
|
130
|
+
# pipeline's copy stops here.
|
|
131
|
+
self._out_rate = frame.sample_rate
|
|
132
|
+
if self._utterance is None:
|
|
133
|
+
self._utterance = self._session.begin_utterance()
|
|
134
|
+
self._session.push_audio(self._utterance, frame.audio, frame.sample_rate, frame.num_channels)
|
|
135
|
+
return
|
|
136
|
+
elif isinstance(frame, TTSStoppedFrame):
|
|
137
|
+
if self._utterance is not None:
|
|
138
|
+
self._session.end_utterance(self._utterance)
|
|
139
|
+
self._utterance = None
|
|
140
|
+
return
|
|
141
|
+
elif isinstance(frame, InterruptionFrame):
|
|
142
|
+
await self._session.interrupt()
|
|
143
|
+
self._utterance = None
|
|
144
|
+
await self.push_frame(frame, direction)
|
|
145
|
+
|
|
146
|
+
async def _on_video(self, frame) -> None:
|
|
147
|
+
rgb = frame.to_ndarray(format="rgb24")
|
|
148
|
+
await self.push_frame(OutputImageRawFrame(image=rgb.tobytes(), size=(frame.width, frame.height), format="RGB"))
|
|
149
|
+
|
|
150
|
+
async def _on_audio(self, frame) -> None:
|
|
151
|
+
key = (frame.sample_rate, self._out_rate)
|
|
152
|
+
if self._out_key != key:
|
|
153
|
+
self._out_resampler = AudioResampler("s16", "mono", self._out_rate)
|
|
154
|
+
self._out_key = key
|
|
155
|
+
assert self._out_resampler is not None
|
|
156
|
+
for out in self._out_resampler.resample(frame):
|
|
157
|
+
samples = out.to_ndarray().astype(np.int16)
|
|
158
|
+
if samples.any():
|
|
159
|
+
await self.push_frame(TTSAudioRawFrame(audio=samples.tobytes(), sample_rate=self._out_rate, num_channels=1))
|
|
160
|
+
|
|
161
|
+
def __repr__(self) -> str:
|
|
162
|
+
return f"AnvaVideoService(session={self._session.session_id})"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["AnvaVideoService", "AnvaVideoSettings"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pipecat-anva"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Anva video avatar service for Pipecat: a lip-synced face for your voice agent, on any transport."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Anva", email = "anva.ai.2026@gmail.com" }]
|
|
13
|
+
keywords = ["pipecat", "daily", "avatar", "anva", "webrtc", "voice"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Video",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"pipecat-ai>=1.0",
|
|
23
|
+
"aiohttp>=3.9",
|
|
24
|
+
"aiortc>=1.9",
|
|
25
|
+
"av>=12",
|
|
26
|
+
"numpy>=1.24",
|
|
27
|
+
"websockets>=14",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://anva.ai"
|
|
32
|
+
Documentation = "https://anva.ai/docs#pipecat"
|
|
33
|
+
Source = "https://github.com/Anva-avatars/anva-sdk"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.version]
|
|
36
|
+
path = "pipecat_anva/version.py"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["pipecat_anva"]
|
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Anva's API and node as the Pipecat service sees them, without a network.
|
|
2
|
+
|
|
3
|
+
Creates sessions, runs the events socket with instant playback reports, and
|
|
4
|
+
answers the viewer's WebRTC offer with a moving picture and a tone, the way the
|
|
5
|
+
real node does.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import fractions
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from aiohttp import WSMsgType, web
|
|
17
|
+
from aiortc import RTCPeerConnection, RTCSessionDescription
|
|
18
|
+
from aiortc.mediastreams import MediaStreamTrack, VideoStreamTrack
|
|
19
|
+
from av import AudioFrame, VideoFrame
|
|
20
|
+
|
|
21
|
+
WIDTH, HEIGHT = 160, 120
|
|
22
|
+
TONE_RATE = 48000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Picture(VideoStreamTrack):
|
|
26
|
+
"""A frame whose colour moves, so decoded frames are not all alike."""
|
|
27
|
+
|
|
28
|
+
async def recv(self):
|
|
29
|
+
pts, time_base = await self.next_timestamp()
|
|
30
|
+
arr = np.zeros((HEIGHT, WIDTH, 3), np.uint8)
|
|
31
|
+
arr[..., 0] = (pts // 3000) % 256
|
|
32
|
+
arr[..., 2] = 120
|
|
33
|
+
frame = VideoFrame.from_ndarray(arr, format="rgb24")
|
|
34
|
+
frame.pts, frame.time_base = pts, time_base
|
|
35
|
+
return frame
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Tone(MediaStreamTrack):
|
|
39
|
+
"""A 440 Hz tone at 48 kHz stereo, 20 ms a frame, as the node's Opus track decodes."""
|
|
40
|
+
|
|
41
|
+
kind = "audio"
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._pos = 0
|
|
46
|
+
self._start: float | None = None
|
|
47
|
+
|
|
48
|
+
async def recv(self):
|
|
49
|
+
samples = 960
|
|
50
|
+
if self._start is None:
|
|
51
|
+
self._start = time.monotonic()
|
|
52
|
+
wait = self._start + self._pos / TONE_RATE - time.monotonic()
|
|
53
|
+
if wait > 0:
|
|
54
|
+
await asyncio.sleep(wait)
|
|
55
|
+
t = (np.arange(samples) + self._pos) / TONE_RATE
|
|
56
|
+
mono = (np.sin(2 * np.pi * 440 * t) * 8000).astype(np.int16)
|
|
57
|
+
frame = AudioFrame.from_ndarray(np.repeat(mono, 2)[None, :], format="s16", layout="stereo")
|
|
58
|
+
frame.sample_rate = TONE_RATE
|
|
59
|
+
frame.pts, frame.time_base = self._pos, fractions.Fraction(1, TONE_RATE)
|
|
60
|
+
self._pos += samples
|
|
61
|
+
return frame
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FakeAnva:
|
|
65
|
+
"""The parts of Anva that pipecat-anva talks to."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self.app = web.Application()
|
|
69
|
+
self.app.router.add_post("/api/v2/sessions", self.create)
|
|
70
|
+
self.app.router.add_delete("/api/v2/sessions/{id}", self.delete)
|
|
71
|
+
self.app.router.add_get("/api/v2/sessions/{id}/events", self.events)
|
|
72
|
+
self.app.router.add_post("/offer", self.offer)
|
|
73
|
+
self.created: list[dict] = []
|
|
74
|
+
self.deleted: list[str] = []
|
|
75
|
+
self.offers: list[dict] = []
|
|
76
|
+
self.commands: list[dict] = []
|
|
77
|
+
self.ws_auth: str | None = None
|
|
78
|
+
self.pcs: list[RTCPeerConnection] = []
|
|
79
|
+
self.port = 0
|
|
80
|
+
#: When set, session creation answers with this status instead.
|
|
81
|
+
self.refuse_with: int | None = None
|
|
82
|
+
self._runner: web.AppRunner | None = None
|
|
83
|
+
|
|
84
|
+
async def start(self) -> str:
|
|
85
|
+
self._runner = web.AppRunner(self.app)
|
|
86
|
+
await self._runner.setup()
|
|
87
|
+
site = web.TCPSite(self._runner, "127.0.0.1", 0)
|
|
88
|
+
await site.start()
|
|
89
|
+
self.port = site._server.sockets[0].getsockname()[1] # noqa: SLF001
|
|
90
|
+
return f"http://127.0.0.1:{self.port}"
|
|
91
|
+
|
|
92
|
+
async def stop(self) -> None:
|
|
93
|
+
for pc in self.pcs:
|
|
94
|
+
await pc.close()
|
|
95
|
+
if self._runner is not None:
|
|
96
|
+
await self._runner.cleanup()
|
|
97
|
+
|
|
98
|
+
def commands_of(self, typ: str) -> list[dict]:
|
|
99
|
+
return [c["payload"] for c in self.commands if c["type"] == typ]
|
|
100
|
+
|
|
101
|
+
async def create(self, request: web.Request) -> web.Response:
|
|
102
|
+
if self.refuse_with:
|
|
103
|
+
return web.json_response({"error": {"code": "no_capacity", "message": "busy"}}, status=self.refuse_with)
|
|
104
|
+
body = await request.json()
|
|
105
|
+
self.created.append({"auth": request.headers.get("Authorization"), "body": body})
|
|
106
|
+
sid = f"apisess-{len(self.created)}"
|
|
107
|
+
return web.json_response({
|
|
108
|
+
"session_id": sid, "session_token": "anva_gr_test", "status": "created",
|
|
109
|
+
"events_ws_url": f"ws://127.0.0.1:{self.port}/api/v2/sessions/{sid}/events",
|
|
110
|
+
}, status=201)
|
|
111
|
+
|
|
112
|
+
async def delete(self, request: web.Request) -> web.Response:
|
|
113
|
+
self.deleted.append(request.match_info["id"])
|
|
114
|
+
return web.json_response({"session_id": request.match_info["id"], "status": "ended"})
|
|
115
|
+
|
|
116
|
+
async def events(self, request: web.Request) -> web.WebSocketResponse:
|
|
117
|
+
self.ws_auth = request.headers.get("Authorization")
|
|
118
|
+
sid = request.match_info["id"]
|
|
119
|
+
ws = web.WebSocketResponse()
|
|
120
|
+
await ws.prepare(request)
|
|
121
|
+
await ws.send_json({"type": "session.info", "payload": {"session_id": sid, "status": "created"}})
|
|
122
|
+
await ws.send_json({"type": "session.live", "payload": {"session_id": sid}})
|
|
123
|
+
accepted = 0
|
|
124
|
+
|
|
125
|
+
async def report(turn: str, state: str, played: int, **extra) -> None:
|
|
126
|
+
await ws.send_json({"type": "speech.state", "payload": {
|
|
127
|
+
"turn_id": turn, "state": state, "accepted_samples": accepted, "played_samples": played, **extra}})
|
|
128
|
+
|
|
129
|
+
async for msg in ws:
|
|
130
|
+
if msg.type != WSMsgType.TEXT:
|
|
131
|
+
continue
|
|
132
|
+
env = json.loads(msg.data)
|
|
133
|
+
typ, payload = env["type"], env.get("payload") or {}
|
|
134
|
+
record = dict(payload)
|
|
135
|
+
if "data" in record:
|
|
136
|
+
record["samples"] = len(base64.b64decode(record.pop("data"))) // 2
|
|
137
|
+
self.commands.append({"type": typ, "payload": record})
|
|
138
|
+
turn = payload.get("turn_id", "")
|
|
139
|
+
# Playback is instant here: what is accepted is played.
|
|
140
|
+
if typ == "speech.start":
|
|
141
|
+
accepted = 0
|
|
142
|
+
await report(turn, "started", 0)
|
|
143
|
+
elif typ == "speech.append":
|
|
144
|
+
accepted += record["samples"]
|
|
145
|
+
await report(turn, "accepted", accepted, next_seq=payload["seq"] + 1)
|
|
146
|
+
elif typ == "speech.done":
|
|
147
|
+
await report(turn, "finished", payload["total_samples"])
|
|
148
|
+
elif typ == "speech.cancel":
|
|
149
|
+
await report(turn, "cancelled", accepted)
|
|
150
|
+
return ws
|
|
151
|
+
|
|
152
|
+
async def offer(self, request: web.Request) -> web.Response:
|
|
153
|
+
body = await request.json()
|
|
154
|
+
self.offers.append(body)
|
|
155
|
+
pc = RTCPeerConnection()
|
|
156
|
+
self.pcs.append(pc)
|
|
157
|
+
pc.addTrack(Picture())
|
|
158
|
+
pc.addTrack(Tone())
|
|
159
|
+
await pc.setRemoteDescription(RTCSessionDescription(sdp=body["sdp"], type=body["type"]))
|
|
160
|
+
await pc.setLocalDescription(await pc.createAnswer())
|
|
161
|
+
return web.json_response({"type": pc.localDescription.type, "sdp": pc.localDescription.sdp})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The service inside a Pipecat pipeline, against the fake Anva."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
from pipecat.frames.frames import (
|
|
7
|
+
InterruptionFrame,
|
|
8
|
+
OutputImageRawFrame,
|
|
9
|
+
TTSAudioRawFrame,
|
|
10
|
+
TTSStartedFrame,
|
|
11
|
+
TTSStoppedFrame,
|
|
12
|
+
)
|
|
13
|
+
from pipecat.tests.utils import SleepFrame, run_test
|
|
14
|
+
|
|
15
|
+
from pipecat_anva import AnvaVideoService
|
|
16
|
+
from pipecat_anva.session import CORE_RATE
|
|
17
|
+
|
|
18
|
+
from .fake_anva import FakeAnva
|
|
19
|
+
from .test_session import tone
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_tts_audio_becomes_the_avatars_picture_and_voice():
|
|
24
|
+
anva = FakeAnva()
|
|
25
|
+
base = await anva.start()
|
|
26
|
+
service = AnvaVideoService(api_key="k", avatar_id="av_1", api_url=base)
|
|
27
|
+
sent = [TTSAudioRawFrame(audio=tone(0.4, 16000), sample_rate=16000, num_channels=1) for _ in range(3)]
|
|
28
|
+
try:
|
|
29
|
+
down, _ = await run_test(
|
|
30
|
+
service,
|
|
31
|
+
frames_to_send=[TTSStartedFrame(), *sent, TTSStoppedFrame(), SleepFrame(2.0)],
|
|
32
|
+
)
|
|
33
|
+
finally:
|
|
34
|
+
await anva.stop()
|
|
35
|
+
|
|
36
|
+
assert anva.created[0]["body"]["service_mode"] == "avatar_only"
|
|
37
|
+
assert anva.commands_of("speech.start")[0]["turn_id"] == "pipecat-1"
|
|
38
|
+
total = sum(a["samples"] for a in anva.commands_of("speech.append"))
|
|
39
|
+
assert abs(total - int(1.2 * CORE_RATE)) <= 64
|
|
40
|
+
assert anva.commands_of("speech.done") == [{"turn_id": "pipecat-1", "total_samples": total}]
|
|
41
|
+
|
|
42
|
+
assert any(isinstance(f, TTSStartedFrame) for f in down), "the start of speech is passed on"
|
|
43
|
+
assert not any(isinstance(f, TTSStoppedFrame) for f in down), "the pipeline's own audio and its end stop here"
|
|
44
|
+
images = [f for f in down if isinstance(f, OutputImageRawFrame)]
|
|
45
|
+
assert len(images) >= 5 and images[0].size == (160, 120) and images[0].format == "RGB"
|
|
46
|
+
assert len(images[0].image) == 160 * 120 * 3
|
|
47
|
+
audio = [f for f in down if isinstance(f, TTSAudioRawFrame)]
|
|
48
|
+
assert audio, "the avatar's voice comes back as audio"
|
|
49
|
+
assert all(f.sample_rate == 16000 and f.num_channels == 1 for f in audio), "at the pipeline's TTS rate"
|
|
50
|
+
assert not any(f.audio in {s.audio for s in sent} for f in audio), "and it is the avatar's, not the copy"
|
|
51
|
+
samples = np.frombuffer(b"".join(f.audio for f in audio), dtype=np.int16)
|
|
52
|
+
assert samples.size >= 16000 and np.abs(samples).max() > 3000, "a second or more of a real tone"
|
|
53
|
+
assert anva.deleted == ["apisess-1"], "the end of the pipeline ends the Anva session"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_an_interruption_stops_the_avatar():
|
|
58
|
+
anva = FakeAnva()
|
|
59
|
+
base = await anva.start()
|
|
60
|
+
service = AnvaVideoService(api_key="k", avatar_id="av_1", api_url=base)
|
|
61
|
+
try:
|
|
62
|
+
await run_test(
|
|
63
|
+
service,
|
|
64
|
+
frames_to_send=[
|
|
65
|
+
TTSStartedFrame(),
|
|
66
|
+
TTSAudioRawFrame(audio=tone(0.5, CORE_RATE), sample_rate=CORE_RATE, num_channels=1),
|
|
67
|
+
SleepFrame(0.5),
|
|
68
|
+
InterruptionFrame(),
|
|
69
|
+
SleepFrame(0.5),
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
finally:
|
|
73
|
+
await anva.stop()
|
|
74
|
+
assert [c["turn_id"] for c in anva.commands_of("speech.cancel")] == ["pipecat-1"]
|
|
75
|
+
assert not anva.commands_of("speech.done")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The session on its own: what it sends Anva and what it makes of the answers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from pipecat_anva import AnvaError, AnvaSession
|
|
10
|
+
from pipecat_anva.session import CHUNK_SAMPLES, CORE_RATE, Utterance
|
|
11
|
+
|
|
12
|
+
from .fake_anva import FakeAnva
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tone(seconds: float, rate: int, channels: int = 1) -> bytes:
|
|
16
|
+
t = np.arange(int(seconds * rate)) / rate
|
|
17
|
+
mono = (np.sin(2 * np.pi * 300 * t) * 6000).astype(np.int16)
|
|
18
|
+
return (np.repeat(mono, channels) if channels > 1 else mono).tobytes()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_one_avatar_reference_and_a_key_are_required():
|
|
22
|
+
with pytest.raises(AnvaError, match="exactly one"):
|
|
23
|
+
AnvaSession(api_key="k")
|
|
24
|
+
with pytest.raises(AnvaError, match="exactly one"):
|
|
25
|
+
AnvaSession(api_key="k", avatar_id="a", preset_id="p")
|
|
26
|
+
with pytest.raises(AnvaError, match="api_key"):
|
|
27
|
+
AnvaSession(api_key="", avatar_id="a")
|
|
28
|
+
AnvaSession(api_key="k", preset_id="p", api_url="https://anva.ai/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_utterances_are_taken_in_chunks_and_the_tail_only_when_closed():
|
|
32
|
+
u = Utterance(turn_id="t")
|
|
33
|
+
u.push(b"\0\0" * (CHUNK_SAMPLES + 10))
|
|
34
|
+
assert len(u.take()) == CHUNK_SAMPLES * 2
|
|
35
|
+
assert u.take() == b"", "a partial chunk waits for more audio"
|
|
36
|
+
u.close()
|
|
37
|
+
assert len(u.take()) == 20
|
|
38
|
+
assert u.take() == b""
|
|
39
|
+
u.cancel()
|
|
40
|
+
u.push(b"\0\0" * 5)
|
|
41
|
+
assert u.take() == b"" and u.cancelled
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_pipeline_audio_is_converted_to_what_anva_takes():
|
|
45
|
+
s = AnvaSession(api_key="k", avatar_id="a")
|
|
46
|
+
u = s.begin_utterance()
|
|
47
|
+
s.push_audio(u, tone(0.5, 48000, 2), 48000, 2)
|
|
48
|
+
s.push_audio(u, tone(0.25, 48000, 2), 48000, 2)
|
|
49
|
+
s.end_utterance(u)
|
|
50
|
+
assert abs(u.pushed - int(0.75 * CORE_RATE)) <= 64
|
|
51
|
+
# Native audio is taken as it is, sample for sample.
|
|
52
|
+
v = s.begin_utterance()
|
|
53
|
+
s.push_audio(v, tone(0.1, CORE_RATE), CORE_RATE, 1)
|
|
54
|
+
assert v.pushed == int(0.1 * CORE_RATE)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_speech_reaches_anva_as_turns_with_receipts():
|
|
59
|
+
anva = FakeAnva()
|
|
60
|
+
base = await anva.start()
|
|
61
|
+
frames: list = []
|
|
62
|
+
|
|
63
|
+
async def on_video(frame):
|
|
64
|
+
frames.append(frame)
|
|
65
|
+
|
|
66
|
+
session = AnvaSession(api_key="key-1", avatar_id="av_1", api_url=base, metadata={"k": "v"},
|
|
67
|
+
on_video=on_video)
|
|
68
|
+
try:
|
|
69
|
+
await session.start()
|
|
70
|
+
assert anva.created[0]["auth"] == "Bearer key-1"
|
|
71
|
+
assert anva.created[0]["body"] == {"service_mode": "avatar_only", "avatar_id": "av_1", "metadata": {"k": "v"}}
|
|
72
|
+
assert anva.ws_auth == "Bearer key-1"
|
|
73
|
+
assert anva.offers[0]["session_id"] == "apisess-1" and anva.offers[0]["session_url_token"] == "anva_gr_test"
|
|
74
|
+
assert anva.offers[0]["type"] == "offer" and anva.offers[0]["client_generation"] == 1
|
|
75
|
+
|
|
76
|
+
u = session.begin_utterance()
|
|
77
|
+
session.push_audio(u, tone(1.2, 16000), 16000, 1)
|
|
78
|
+
session.end_utterance(u)
|
|
79
|
+
for _ in range(100):
|
|
80
|
+
if anva.commands_of("speech.done"):
|
|
81
|
+
break
|
|
82
|
+
await asyncio.sleep(0.05)
|
|
83
|
+
starts = anva.commands_of("speech.start")
|
|
84
|
+
assert starts == [{"turn_id": "pipecat-1", "codec": "pcm_s16le", "sample_rate": CORE_RATE, "channels": 1}]
|
|
85
|
+
appends = anva.commands_of("speech.append")
|
|
86
|
+
assert [a["seq"] for a in appends] == list(range(len(appends)))
|
|
87
|
+
assert all(a["samples"] <= CHUNK_SAMPLES for a in appends)
|
|
88
|
+
total = sum(a["samples"] for a in appends)
|
|
89
|
+
assert abs(total - int(1.2 * CORE_RATE)) <= 64
|
|
90
|
+
assert anva.commands_of("speech.done") == [{"turn_id": "pipecat-1", "total_samples": total}]
|
|
91
|
+
|
|
92
|
+
# An interruption cancels what is playing and drops what waits.
|
|
93
|
+
second = session.begin_utterance()
|
|
94
|
+
session.push_audio(second, tone(3, CORE_RATE), CORE_RATE, 1)
|
|
95
|
+
third = session.begin_utterance()
|
|
96
|
+
session.push_audio(third, tone(1, CORE_RATE), CORE_RATE, 1)
|
|
97
|
+
for _ in range(100):
|
|
98
|
+
if any(a["turn_id"] == "pipecat-2" for a in anva.commands_of("speech.append")):
|
|
99
|
+
break
|
|
100
|
+
await asyncio.sleep(0.05)
|
|
101
|
+
await session.interrupt()
|
|
102
|
+
for _ in range(100):
|
|
103
|
+
if anva.commands_of("speech.cancel"):
|
|
104
|
+
break
|
|
105
|
+
await asyncio.sleep(0.05)
|
|
106
|
+
assert [c["turn_id"] for c in anva.commands_of("speech.cancel")] == ["pipecat-2"]
|
|
107
|
+
await asyncio.sleep(0.3)
|
|
108
|
+
assert not any(c["payload"].get("turn_id") == "pipecat-3" for c in anva.commands), \
|
|
109
|
+
"a queued utterance dropped by the interruption must never start"
|
|
110
|
+
|
|
111
|
+
# The viewer receives the avatar's picture.
|
|
112
|
+
for _ in range(100):
|
|
113
|
+
if len(frames) >= 3:
|
|
114
|
+
break
|
|
115
|
+
await asyncio.sleep(0.05)
|
|
116
|
+
assert len(frames) >= 3 and frames[0].width == 160 and frames[0].height == 120
|
|
117
|
+
finally:
|
|
118
|
+
await session.close()
|
|
119
|
+
await anva.stop()
|
|
120
|
+
assert anva.deleted == ["apisess-1"]
|
|
121
|
+
assert session.ended.is_set()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_a_refusal_is_an_error_not_a_hang():
|
|
126
|
+
anva = FakeAnva()
|
|
127
|
+
anva.refuse_with = 503
|
|
128
|
+
base = await anva.start()
|
|
129
|
+
session = AnvaSession(api_key="k", avatar_id="a", api_url=base)
|
|
130
|
+
try:
|
|
131
|
+
with pytest.raises(AnvaError, match="503"):
|
|
132
|
+
await session.start()
|
|
133
|
+
finally:
|
|
134
|
+
await session.close()
|
|
135
|
+
await anva.stop()
|