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.
@@ -0,0 +1,6 @@
1
+ .npmrc
2
+ dist/
3
+ node_modules/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .venv/
@@ -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()