From b2175600dced17f0c7ca397312a62ac2b5b5a12c Mon Sep 17 00:00:00 2001 From: kamrankamrankhan Date: Wed, 24 Jun 2026 17:10:07 +0500 Subject: [PATCH 1/6] fix: align saa-py and saa-js README signatures with current client API --- packages/saa-js/README.md | 8 ++++---- packages/saa-py/README.md | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/saa-js/README.md b/packages/saa-js/README.md index 34420b2..ceb9c11 100644 --- a/packages/saa-js/README.md +++ b/packages/saa-js/README.md @@ -51,7 +51,7 @@ await client.start({ videoElement: videoEl }); | Method | Description | | --------------------------- | ----------- | -| `start({ videoElement, mediaStream? })` | Start streaming + connect. Calls `getUserMedia` unless `mediaStream` is supplied. `videoElement` is required when video capture is enabled. | +| `start(options?)` | Start streaming + connect. Pass `{ videoElement }` when video capture is enabled; `{ mediaStream }` to reuse an existing stream. Calls `getUserMedia` unless `mediaStream` is supplied. With both `enableAudio` and `enableVideo` false, call with no args. | | `stop()` | Stop streaming and disconnect. | | `feedAudio(audio, sampleRate?)` | Push externally-captured audio (requires `enableAudio: false`). Accepts Float32 `[-1,1]`, Int16 PCM, or a raw int16 buffer; re-chunked + resampled to the wire's 16 kHz / 100 ms blocks. See [External capture](#external-capture). | | `feedVideo(jpeg)` | Push an externally-captured JPEG frame (requires `enableVideo: false`). Accepts a `Blob`, `ArrayBuffer`, or view. | @@ -70,13 +70,13 @@ await client.start({ videoElement: videoEl }); | `prediction` | `{ cls, rawCls, confidence, source, numFaces, responding }` | | `vad` | `{ probability, isSpeech }` | | `state` | `{ state }` (one of `listening`, `sending`, `cancelled`, `idle`) | -| `turnReady` | `{ audioBase64, audioPcm16, durationSec, frames, context }` | +| `turnReady` | `{ audioBase64, audioPcm16, durationSec, serverTurnReadyTsMs, frames, context }` | | `config` | `{ modelClass2Threshold }` | | `stats` | `{ rttMs, bufferedAmount, sentVideo, skippedVideo, sentAudio, uptimeMs }` | | `interrupt` | `{ fadeMs, confidence }` | | `interjection` | `{ reason, audioBase64, audioPcm16, durationSec }` | -| `error` | `{ title, message, detail }` | -| `disconnected` | `{ code, reason }` | +| `error` | `{ title, message, detail, code? }` | +| `disconnected` | `{ code, reason, wasClean }` | `warmupComplete` fires once the server model has warmed up and is producing real predictions; use it to drop any loading UI. `prediction.responding` is `true` while your app is mid-response (see `markResponding`), and `interjection` fires when the agent should volunteer after humans go quiet. diff --git a/packages/saa-py/README.md b/packages/saa-py/README.md index cbe4ed9..24802eb 100644 --- a/packages/saa-py/README.md +++ b/packages/saa-py/README.md @@ -71,6 +71,7 @@ client = AttentionClient( enable_audio=True, # Set False to skip mic capture enable_video=True, # Set False to skip webcam capture server_profile=None, # Override server profile; auto "audio_only" when enable_video=False + auto_reconnect=True, # Reconnect on retriable WebSocket drops ) ``` @@ -157,6 +158,8 @@ def handle(event): | `@on_interjection` | `InterjectionEvent` | Proactive AI volunteer after humans go quiet | | `@on_error` | `AttentionErrorEvent` | Connection, auth, or server error | | `@on_disconnected` | `DisconnectedEvent` | WebSocket closes | +| `@on_reconnecting` | `ReconnectingEvent` | Backing off before a reconnect attempt | +| `@on_reconnected` | `ReconnectedEvent` | WebSocket reopened after a drop | ### Event types @@ -244,6 +247,22 @@ title: str # error category ("Auth Failed", "Connection Stalled message: str # human-readable message detail: str | None = None # technical detail code: int | None = None # WebSocket close code, if applicable +kind: str | None = None # transport | auth | rate_limit | audio | server | environment +retriable: bool = False # whether auto_reconnect will retry this class of error +``` + +#### `ReconnectingEvent` + +```python +attempt: int # 1-based attempt counter +delay_s: float # backoff before this attempt +last_code: int # close code that triggered the reconnect +``` + +#### `ReconnectedEvent` + +```python +attempts: int # attempts it took to reconnect ``` #### `DisconnectedEvent` From c7f8d693765c3244c0e2d97259549db7c7f5d230 Mon Sep 17 00:00:00 2001 From: kamrankamrankhan Date: Wed, 24 Jun 2026 17:18:06 +0500 Subject: [PATCH 2/6] add: test coverage for error delivery without a prediction listener --- packages/saa-js/package-lock.json | 30 ++++++++++ packages/saa-js/test/client.test.mjs | 87 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 packages/saa-js/package-lock.json create mode 100644 packages/saa-js/test/client.test.mjs diff --git a/packages/saa-js/package-lock.json b/packages/saa-js/package-lock.json new file mode 100644 index 0000000..9b174f2 --- /dev/null +++ b/packages/saa-js/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "@attenlabs/saa-js", + "version": "0.6.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@attenlabs/saa-js", + "version": "0.6.1", + "license": "Apache-2.0", + "devDependencies": { + "typescript": "^5.4.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/saa-js/test/client.test.mjs b/packages/saa-js/test/client.test.mjs new file mode 100644 index 0000000..c4f7d28 --- /dev/null +++ b/packages/saa-js/test/client.test.mjs @@ -0,0 +1,87 @@ +// node:test suite for @attenlabs/saa-js AttentionClient edge cases. +// Uses a fake WebSocket so tests run without a live broker or browser APIs. + +import test, { afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { AttentionClient } from "../dist/index.js"; + +/** @type {typeof globalThis.WebSocket | undefined} */ +let savedWebSocket; + +/** @type {MockWebSocket | null} */ +let activeSocket = null; + +class MockWebSocket { + static OPEN = 1; + + constructor(url, protocols) { + this.url = url; + this.protocols = protocols; + this.readyState = MockWebSocket.OPEN; + this.binaryType = "arraybuffer"; + activeSocket = this; + // Defer open until the client assigns onopen/onclose handlers. + setTimeout(() => this.onopen?.({}), 0); + } + + send() {} + + close(code = 1000, reason = "") { + setTimeout( + () => + this.onclose?.({ + code, + reason, + wasClean: code === 1000, + }), + 0, + ); + } +} + +afterEach(async () => { + if (savedWebSocket !== undefined) { + globalThis.WebSocket = savedWebSocket; + } else { + delete globalThis.WebSocket; + } + savedWebSocket = undefined; + activeSocket = null; +}); + +function installMockWebSocket() { + savedWebSocket = globalThis.WebSocket; + // @ts-expect-error test double + globalThis.WebSocket = MockWebSocket; +} + +test("mid-session disconnect delivers error when prediction listener was never registered", async () => { + installMockWebSocket(); + + const errors = []; + const client = new AttentionClient({ + token: "bad-token", + url: "wss://test.example/ws", + enableAudio: false, + enableVideo: false, + }); + + client.on("error", (event) => { + errors.push(event); + }); + + await client.start(); + assert.ok(activeSocket); + assert.deepEqual(activeSocket.protocols, ["bad-token"]); + + activeSocket.close(1006, "connection dropped"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(errors.length, 1); + assert.equal(errors[0].title, "Connection Failed"); + assert.match(errors[0].message, /Could not reach the server/); + assert.equal(errors[0].code, 1006); + + await client.stop(); +}); From ed00971d8fa1ce3fdb9293fb0fd208c63670af0a Mon Sep 17 00:00:00 2001 From: kamrankamrankhan Date: Wed, 24 Jun 2026 17:20:48 +0500 Subject: [PATCH 3/6] fix: surface root cause in _close_to_error error message --- packages/saa-py/src/saa/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/saa-py/src/saa/client.py b/packages/saa-py/src/saa/client.py index 3b506f7..915369c 100644 --- a/packages/saa-py/src/saa/client.py +++ b/packages/saa-py/src/saa/client.py @@ -907,7 +907,10 @@ def _close_to_error(code: int, reason: str, was_clean: bool) -> Optional[Attenti return AttentionErrorEvent( title="Connection Failed", message="Could not reach the server.", - detail=f"The server may be down or unreachable. (close code {code})", + detail=( + f"The server may be down or unreachable." + f" (close code {code}, reason={reason or 'none'})" + ), code=code, kind="transport", retriable=True, From a97e216f76eb0010712e1a989cbe378ecdc67214 Mon Sep 17 00:00:00 2001 From: kamrankamrankhan Date: Wed, 24 Jun 2026 17:30:06 +0500 Subject: [PATCH 4/6] fix: server not loading .env in twilio example --- examples/twilio/media_streams/server.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/twilio/media_streams/server.py b/examples/twilio/media_streams/server.py index 5abdf22..7408ed3 100644 --- a/examples/twilio/media_streams/server.py +++ b/examples/twilio/media_streams/server.py @@ -73,6 +73,12 @@ from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Optional, Union +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv(Path(__file__).resolve().parents[1] / ".env") + try: from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse, PlainTextResponse, Response From 5cae050fe5050853d18f546b63e79eb304e52e93 Mon Sep 17 00:00:00 2001 From: kamrankamrankhan Date: Wed, 24 Jun 2026 17:40:30 +0500 Subject: [PATCH 5/6] add: vapi integration example --- examples/README.md | 10 + examples/vapi/.env.example | 14 + examples/vapi/README.md | 66 +++++ examples/vapi/voice_agent/README.md | 48 +++ examples/vapi/voice_agent/agent.py | 323 +++++++++++++++++++++ examples/vapi/voice_agent/requirements.txt | 6 + 6 files changed, 467 insertions(+) create mode 100644 examples/vapi/.env.example create mode 100644 examples/vapi/README.md create mode 100644 examples/vapi/voice_agent/README.md create mode 100644 examples/vapi/voice_agent/agent.py create mode 100644 examples/vapi/voice_agent/requirements.txt diff --git a/examples/README.md b/examples/README.md index 3a84a26..cb40fde 100644 --- a/examples/README.md +++ b/examples/README.md @@ -57,6 +57,16 @@ ElevenLabs runs its agent inside its own sealed WebRTC room, so this sample uses Needs **attenlabs-saa >= 0.6.0** and **elevenlabs >= 2.45**. +## Vapi WebSocket transport + +Vapi exposes a raw PCM16 WebSocket seam, so this sample uses the **streaming SDK's `feed_audio` ingestion**: it captures the local mic with `sounddevice`, feeds every frame to SAA, and streams gated audio to Vapi's WebSocket transport. + +| Sample | What it shows | Run | +|---|---|---| +| [`vapi/voice_agent/`](./vapi/voice_agent) | SAA gating a Vapi assistant over the WebSocket transport so only device-directed speech reaches the model, via `attenlabs-saa`'s `feed_audio`. | `python agent.py` | + +Needs **attenlabs-saa >= 0.6.0**, **sounddevice**, and **websockets**. See [`vapi/README.md`](./vapi/README.md) for setup. + ## Twilio Media Streams Inbound or outbound PSTN phone calls, gated by SAA before any audio reaches STT or LLM. The adapter in [`twilio/media_streams/server.py`](./twilio/media_streams/server.py) transcodes μ-law 8 kHz Twilio frames to PCM16 16 kHz and feeds them to SAA via `feed_audio`. Only device-directed caller speech reaches the bridge; side talk and the agent's own TTS echo are filtered out. Three reference bridges are included: `LoggingBridge` (no keys, good for smoke-testing), `OpenAIRealtimeBridge`, and `DeepgramOpenAIElevenLabsBridge`. diff --git a/examples/vapi/.env.example b/examples/vapi/.env.example new file mode 100644 index 0000000..be82462 --- /dev/null +++ b/examples/vapi/.env.example @@ -0,0 +1,14 @@ +# Shared environment for the examples/vapi sample. +# Copy to .env (cp .env.example .env) and fill in. The sample auto-loads it. + +SAA_API_KEY= + +# --- Vapi WebSocket transport ----------------------------------------------- +# Private API key from https://dashboard.vapi.ai +VAPI_API_KEY= +# Assistant to run (create one in the Vapi dashboard) +VAPI_ASSISTANT_ID= + +# --- optional --------------------------------------------------------------- +# Class-2 (device-directed) confidence threshold, 0..1. Higher = stricter gate. +# SAA_CLASS2_THRESHOLD=0.7 diff --git a/examples/vapi/README.md b/examples/vapi/README.md new file mode 100644 index 0000000..d517ace --- /dev/null +++ b/examples/vapi/README.md @@ -0,0 +1,66 @@ +# SAA + Vapi WebSocket transport + +A sample that enables your [Vapi](https://docs.vapi.ai) assistant to only respond when people are talking to it, and stay silent to side conversations or background voices. + +## How SAA integrates + +Vapi's WebSocket transport exposes a raw PCM16 seam you own end to end, so this sample uses the streaming SDK's `feed_audio` ingestion: + +1. `sounddevice` captures 16 kHz mono PCM from the local mic. +2. Every frame is teed into SAA via [`attenlabs-saa`](../../packages/saa-py)'s `feed_audio()` (feed mode: `enable_audio=False`, the SDK captures nothing itself). +3. SAA classifies each frame and emits `prediction` / `turn_ready` / `interrupt` events. +4. The sample gates what reaches Vapi: real audio while device-directed, silence otherwise. Assistant playback drives `mark_responding()` so SAA knows when the agent is speaking. + +## Samples + +| Sample | Stack | Run | +|---|---|---| +| [`voice_agent/`](./voice_agent) | Vapi WebSocket transport + local mic/speaker, SAA-gated via `feed_audio` | `python agent.py` | + +## Quick start + +Needs **Python 3.10+**, a Vapi private API key, and an assistant ID from the [Vapi dashboard](https://dashboard.vapi.ai). + +```bash +git clone https://github.com/attenlabs/saa-sdk.git +cd saa-sdk/examples/vapi +cp .env.example .env # fill SAA_API_KEY, VAPI_API_KEY, VAPI_ASSISTANT_ID + +cd voice_agent +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ../../../packages/saa-py # local dev against this repo +pip install -r requirements.txt +python agent.py +``` + +The sample **auto-loads** `examples/vapi/.env`. + +## The lines that integrate SAA + +```python +saa = AttentionClient(token=SAA_API_KEY, enable_audio=False, enable_video=False) + +@saa.on_prediction +def _(ev): gate.update_gate(ev.cls) # only device-directed audio reaches Vapi + +saa.start() +# mic tee -> saa.feed_audio() + gated PCM over the Vapi WebSocket +``` + +See [`voice_agent/README.md`](./voice_agent/README.md) for the full walk-through. + +## Shared environment + +One env file, [`.env`](./.env.example) in this directory: + +| Key | Purpose | +|---|---| +| `SAA_API_KEY` | Your attention labs API key. Get one at [attentionlabs.ai/dashboard](https://attentionlabs.ai/dashboard). | +| `VAPI_API_KEY` | Vapi private API key | +| `VAPI_ASSISTANT_ID` | The assistant to run | + +## Requirements & limitations + +- **Audio-only** — no video track on the WebSocket transport. +- Turn boundaries are Vapi's STT/VAD on the gated stream (same tradeoff as the ElevenLabs sample). +- Requires PortAudio for local mic + speaker (`sounddevice`). diff --git a/examples/vapi/voice_agent/README.md b/examples/vapi/voice_agent/README.md new file mode 100644 index 0000000..201ec9f --- /dev/null +++ b/examples/vapi/voice_agent/README.md @@ -0,0 +1,48 @@ +# voice_agent: SAA-gated Vapi WebSocket assistant + +A [Vapi](https://docs.vapi.ai) assistant reached over the **WebSocket transport**, with **attention labs SAA** device-directed gating wired on top via the streaming SDK's `feed_audio` ingestion. + +## The integration + +Single file ([`agent.py`](./agent.py)). The moving parts: + +- `AttentionClient(token=..., enable_audio=False, enable_video=False)` → streaming SDK in **feed mode**: it opens the cloud WebSocket but captures nothing itself. +- `sounddevice` captures 16 kHz mono PCM from the local mic; every frame is teed into `saa.feed_audio()`. +- `@saa.on_prediction` → `gate.update(ev.cls)` → **the gate**: opens on class-2 (device-directed), closes immediately on class-1 (human-directed). Vapi receives the user's real audio while the gate is open and silence otherwise, so side talk never reaches the assistant's STT. +- Vapi assistant audio on the return WebSocket drives `saa.mark_responding(True/False)` so SAA knows when the agent is speaking (echo is not fed back as user speech). + +### Warmup-gated session start + +SAA's model isn't classifying for real until its inference buffer fills (~10–15 s of audio). The mic starts feeding SAA before the Vapi call is created; `start_session()` (REST + WebSocket dial) is held until `@saa.on_warmup_complete` fires, with a 20 s timeout fallback. + +## Quickstart + +```bash +git clone https://github.com/attenlabs/saa-sdk.git +cd saa-sdk/examples/vapi/voice_agent +cp ../.env.example ../.env # fill SAA_API_KEY, VAPI_API_KEY, VAPI_ASSISTANT_ID + +python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ../../../packages/saa-py # local dev against this repo +pip install -r requirements.txt +python agent.py +``` + +Talk to it. The assistant answers only when you're addressing it; speech directed at someone else in the room is replaced with silence before it reaches Vapi. + +## Logs + +One line per SAA prediction so the gating is observable: + +``` +PRED cls=2 conf=0.91 src=model | resp=False gate=open send=real +``` + +`cls` is the prediction (0 silent / 1 human-directed / 2 device-directed), `gate` the debounced gate, and `send` whether real audio or silence (`muted`) is reaching Vapi that tick. + +## Requirements & limitations + +- **Audio-only** — Vapi WebSocket transport carries no video, so class-1 ("talking to a human") is weaker than on the multimodal LiveKit / Pipecat paths. +- Needs a Vapi **assistant** configured in the dashboard; this sample does not create one inline. +- Turn boundaries are Vapi's own STT/VAD on the gated audio stream (same tradeoff as the ElevenLabs sample). +- Requires PortAudio (`sounddevice`) for local mic + speaker access. diff --git a/examples/vapi/voice_agent/agent.py b/examples/vapi/voice_agent/agent.py new file mode 100644 index 0000000..5bfe4cb --- /dev/null +++ b/examples/vapi/voice_agent/agent.py @@ -0,0 +1,323 @@ +# SAA-gated Vapi WebSocket assistant +# +# SAA owns addressee detection. Vapi receives the user's real mic audio only while +# SAA says it's device-directed; otherwise silence is streamed so Vapi's own VAD +# never sees side talk. Assistant playback drives mark_responding so SAA knows +# when the agent is speaking. +import asyncio +import json +import logging +import os +import threading +import time +import urllib.error +import urllib.request +from collections import deque +from contextlib import suppress +from pathlib import Path + +import sounddevice as sd +import websockets +from dotenv import load_dotenv + +from saa import AttentionClient + +load_dotenv(Path(__file__).resolve().parents[1] / ".env") + +log = logging.getLogger("vapi-saa") + +SAMPLE_RATE = 16000 +CHANNELS = 1 +BLOCK_SAMPLES = 320 # 20 ms @ 16 kHz +BLOCK_BYTES = BLOCK_SAMPLES * 2 +OUTPUT_BYTES_PER_SEC = SAMPLE_RATE * 2 +SILENCE_BLOCK = bytes(BLOCK_BYTES) + + +class SAAGate: + """Device-directed gate + responding tracker for a downstream audio sink.""" + + def __init__(self, saa: AttentionClient, *, close_debounce_ticks: int = 4, + responding_tail_ms: int = 300): + self._saa = saa + self._close_debounce = close_debounce_ticks + self._tail_s = responding_tail_ms / 1000.0 + self._gate_open = False + self._misses = 0 + self._responding = False + self._play_until = 0.0 + self._sending_real = False + self._lock = threading.Lock() + + @property + def responding(self) -> bool: + return self._responding + + @property + def gate_open(self) -> bool: + return self._gate_open + + def update_gate(self, cls: int) -> None: + if cls == 2: + self._misses = 0 + self._gate_open = True + elif cls == 1: + self._gate_open = False + elif self._gate_open: + self._misses += 1 + if self._misses >= self._close_debounce: + self._gate_open = False + + def chunk_for_vapi(self, pcm16: bytes) -> tuple[bytes, bool]: + """Return (bytes to forward, whether they are real mic audio).""" + send_real = self._gate_open and not self._responding + if send_real != self._sending_real: + self._sending_real = send_real + log.info( + "SEND %s (gate=%s resp=%s)", + "real" if send_real else "muted", + self._gate_open, + self._responding, + ) + return (pcm16 if send_real else SILENCE_BLOCK[: len(pcm16)]), send_real + + def on_agent_audio(self, nbytes: int) -> None: + now = time.monotonic() + first = False + with self._lock: + self._play_until = max(self._play_until, now) + nbytes / OUTPUT_BYTES_PER_SEC + if not self._responding: + self._responding = True + first = True + if first: + self._saa.mark_responding(True) + log.info("RESP -> SPEAKING") + + def poll_responding(self) -> None: + now = time.monotonic() + with self._lock: + done = self._responding and now >= self._play_until + self._tail_s + if done: + with self._lock: + self._responding = False + self._saa.mark_responding(False) + log.info("RESP -> idle (playback-done)") + + +def create_vapi_websocket_call(api_key: str, assistant_id: str) -> str: + """POST /call with vapi.websocket transport; return websocketCallUrl.""" + body = json.dumps({ + "assistantId": assistant_id, + "transport": { + "provider": "vapi.websocket", + "audioFormat": { + "format": "pcm_s16le", + "container": "raw", + "sampleRate": SAMPLE_RATE, + }, + }, + }).encode("utf-8") + req = urllib.request.Request( + "https://api.vapi.ai/call", + method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + data=body, + ) + try: + with urllib.request.urlopen(req, timeout=30.0) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Vapi /call failed: HTTP {e.code} {detail or e.reason}") from e + url = (payload.get("transport") or {}).get("websocketCallUrl") + if not url: + raise RuntimeError(f"Vapi /call returned no websocketCallUrl: {payload!r}") + return url + + +async def _responding_poll(gate: SAAGate, stop: asyncio.Event) -> None: + while not stop.is_set(): + gate.poll_responding() + await asyncio.sleep(0.1) + + +async def run_vapi_session(ws_url: str, gate: SAAGate, uplink: asyncio.Queue, + playback: deque[bytes], stop: asyncio.Event) -> None: + async with websockets.connect(ws_url) as ws: + log.info("Vapi WebSocket connected") + + async def sender() -> None: + while not stop.is_set(): + try: + chunk = await asyncio.wait_for(uplink.get(), timeout=0.1) + except asyncio.TimeoutError: + continue + if chunk is None: + break + await ws.send(chunk) + + async def receiver() -> None: + async for message in ws: + if isinstance(message, bytes): + gate.on_agent_audio(len(message)) + playback.append(message) + else: + log.info("VAPI control: %s", message) + + await asyncio.gather(sender(), receiver()) + + +def main() -> int: + saa_api_key = os.environ.get("SAA_API_KEY", "").strip() + vapi_api_key = os.environ.get("VAPI_API_KEY", "").strip() + assistant_id = os.environ.get("VAPI_ASSISTANT_ID", "").strip() + if not (saa_api_key and vapi_api_key and assistant_id): + print("set SAA_API_KEY, VAPI_API_KEY, VAPI_ASSISTANT_ID") + return 2 + + threshold = float(os.environ.get("SAA_CLASS2_THRESHOLD", "0.7")) + saa = AttentionClient( + token=saa_api_key, + enable_audio=False, + enable_video=False, + initial_threshold=threshold, + ) + gate = SAAGate(saa) + + warmed = threading.Event() + + @saa.on_warmup_complete + def _on_warmup(): + log.info("WARMUP complete — SAA predicting") + warmed.set() + + @saa.on_prediction + def _on_prediction(ev): + gate.update_gate(ev.cls) + log.info( + "PRED cls=%d conf=%.2f src=%s | resp=%s gate=%s send=%s", + ev.cls, + ev.confidence or 0.0, + ev.source, + gate.responding, + "open" if gate.gate_open else "shut", + "real" if (gate.gate_open and not gate.responding) else "muted", + ) + + @saa.on_turn_ready + def _on_turn_ready(ev): + log.info("SAA turn_ready dur=%.1fs", ev.duration_sec) + + @saa.on_interrupt + def _on_interrupt(ev): + log.info("SAA interrupt conf=%.2f fade_ms=%d", ev.confidence, ev.fade_ms) + saa.unmute() + saa.mark_responding(False) + + @saa.on_error + def _on_error(ev): + log.warning("SAA error: %s — %s", ev.title, ev.message) + + @saa.on_disconnected + def _on_disconnected(ev): + log.error( + "SAA DISCONNECTED code=%s reason=%s clean=%s", + ev.code, ev.reason or "none", ev.was_clean, + ) + + saa.start() + log.info("warming up SAA model... (~10-15s)") + + loop = asyncio.new_event_loop() + uplink: asyncio.Queue[bytes | None] = asyncio.Queue() + playback: deque[bytes] = deque() + stop = asyncio.Event() + stream_to_vapi = threading.Event() + + def mic_callback(indata, frames, time_info, status): + if status: + log.debug("mic status: %s", status) + pcm = bytes(indata) + try: + saa.feed_audio(pcm) + except Exception: + log.exception("feed_audio failed") + if stream_to_vapi.is_set(): + chunk, _ = gate.chunk_for_vapi(pcm) + loop.call_soon_threadsafe(uplink.put_nowait, chunk) + + def out_callback(outdata, frames, time_info, status): + del time_info, status + need = frames * CHANNELS * 2 + buf = bytearray() + while len(buf) < need and playback: + buf.extend(playback.popleft()) + if len(buf) < need: + buf.extend(b"\x00" * (need - len(buf))) + outdata[:] = buf[:need] + + in_stream = sd.RawInputStream( + samplerate=SAMPLE_RATE, + channels=CHANNELS, + dtype="int16", + blocksize=BLOCK_SAMPLES, + callback=mic_callback, + ) + out_stream = sd.RawOutputStream( + samplerate=SAMPLE_RATE, + channels=CHANNELS, + dtype="int16", + blocksize=BLOCK_SAMPLES, + callback=out_callback, + ) + + async def run() -> None: + ready = await asyncio.to_thread(warmed.wait, 20.0) + if not ready: + log.warning("SAA warmup did not complete within 20s — starting Vapi anyway") + ws_url = await asyncio.to_thread(create_vapi_websocket_call, vapi_api_key, assistant_id) + log.info("Vapi call created: %s", ws_url) + stream_to_vapi.set() + responding_task = asyncio.create_task(_responding_poll(gate, stop)) + try: + await run_vapi_session(ws_url, gate, uplink, playback, stop) + finally: + stop.set() + await uplink.put(None) + responding_task.cancel() + with suppress(asyncio.CancelledError): + await responding_task + + exit_code = 0 + try: + in_stream.start() + out_stream.start() + loop.run_until_complete(run()) + except KeyboardInterrupt: + pass + except Exception: + log.exception("session ended with exception") + exit_code = 1 + finally: + stop.set() + stream_to_vapi.clear() + in_stream.stop() + out_stream.stop() + in_stream.close() + out_stream.close() + saa.stop() + loop.close() + return exit_code + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s.%(msecs)03d %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("saa").setLevel(logging.WARNING) + raise SystemExit(main()) diff --git a/examples/vapi/voice_agent/requirements.txt b/examples/vapi/voice_agent/requirements.txt new file mode 100644 index 0000000..83a1775 --- /dev/null +++ b/examples/vapi/voice_agent/requirements.txt @@ -0,0 +1,6 @@ +# tested against attenlabs-saa 0.7.0 (feed_audio) +attenlabs-saa>=0.6.0 # installed via `pip install -e ../../../packages/saa-py` +python-dotenv>=1.0 +sounddevice>=0.4.6 +numpy>=1.24 +websockets>=12.0 From ba63a00ed67b85af590ce1cc9ae90d6d9643c148 Mon Sep 17 00:00:00 2001 From: kamrankamrankhan Date: Thu, 25 Jun 2026 06:30:50 +0500 Subject: [PATCH 6/6] Revert "add: vapi integration example" This reverts commit 5cae050fe5050853d18f546b63e79eb304e52e93. --- examples/README.md | 10 - examples/vapi/.env.example | 14 - examples/vapi/README.md | 66 ----- examples/vapi/voice_agent/README.md | 48 --- examples/vapi/voice_agent/agent.py | 323 --------------------- examples/vapi/voice_agent/requirements.txt | 6 - 6 files changed, 467 deletions(-) delete mode 100644 examples/vapi/.env.example delete mode 100644 examples/vapi/README.md delete mode 100644 examples/vapi/voice_agent/README.md delete mode 100644 examples/vapi/voice_agent/agent.py delete mode 100644 examples/vapi/voice_agent/requirements.txt diff --git a/examples/README.md b/examples/README.md index cb40fde..3a84a26 100644 --- a/examples/README.md +++ b/examples/README.md @@ -57,16 +57,6 @@ ElevenLabs runs its agent inside its own sealed WebRTC room, so this sample uses Needs **attenlabs-saa >= 0.6.0** and **elevenlabs >= 2.45**. -## Vapi WebSocket transport - -Vapi exposes a raw PCM16 WebSocket seam, so this sample uses the **streaming SDK's `feed_audio` ingestion**: it captures the local mic with `sounddevice`, feeds every frame to SAA, and streams gated audio to Vapi's WebSocket transport. - -| Sample | What it shows | Run | -|---|---|---| -| [`vapi/voice_agent/`](./vapi/voice_agent) | SAA gating a Vapi assistant over the WebSocket transport so only device-directed speech reaches the model, via `attenlabs-saa`'s `feed_audio`. | `python agent.py` | - -Needs **attenlabs-saa >= 0.6.0**, **sounddevice**, and **websockets**. See [`vapi/README.md`](./vapi/README.md) for setup. - ## Twilio Media Streams Inbound or outbound PSTN phone calls, gated by SAA before any audio reaches STT or LLM. The adapter in [`twilio/media_streams/server.py`](./twilio/media_streams/server.py) transcodes μ-law 8 kHz Twilio frames to PCM16 16 kHz and feeds them to SAA via `feed_audio`. Only device-directed caller speech reaches the bridge; side talk and the agent's own TTS echo are filtered out. Three reference bridges are included: `LoggingBridge` (no keys, good for smoke-testing), `OpenAIRealtimeBridge`, and `DeepgramOpenAIElevenLabsBridge`. diff --git a/examples/vapi/.env.example b/examples/vapi/.env.example deleted file mode 100644 index be82462..0000000 --- a/examples/vapi/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Shared environment for the examples/vapi sample. -# Copy to .env (cp .env.example .env) and fill in. The sample auto-loads it. - -SAA_API_KEY= - -# --- Vapi WebSocket transport ----------------------------------------------- -# Private API key from https://dashboard.vapi.ai -VAPI_API_KEY= -# Assistant to run (create one in the Vapi dashboard) -VAPI_ASSISTANT_ID= - -# --- optional --------------------------------------------------------------- -# Class-2 (device-directed) confidence threshold, 0..1. Higher = stricter gate. -# SAA_CLASS2_THRESHOLD=0.7 diff --git a/examples/vapi/README.md b/examples/vapi/README.md deleted file mode 100644 index d517ace..0000000 --- a/examples/vapi/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# SAA + Vapi WebSocket transport - -A sample that enables your [Vapi](https://docs.vapi.ai) assistant to only respond when people are talking to it, and stay silent to side conversations or background voices. - -## How SAA integrates - -Vapi's WebSocket transport exposes a raw PCM16 seam you own end to end, so this sample uses the streaming SDK's `feed_audio` ingestion: - -1. `sounddevice` captures 16 kHz mono PCM from the local mic. -2. Every frame is teed into SAA via [`attenlabs-saa`](../../packages/saa-py)'s `feed_audio()` (feed mode: `enable_audio=False`, the SDK captures nothing itself). -3. SAA classifies each frame and emits `prediction` / `turn_ready` / `interrupt` events. -4. The sample gates what reaches Vapi: real audio while device-directed, silence otherwise. Assistant playback drives `mark_responding()` so SAA knows when the agent is speaking. - -## Samples - -| Sample | Stack | Run | -|---|---|---| -| [`voice_agent/`](./voice_agent) | Vapi WebSocket transport + local mic/speaker, SAA-gated via `feed_audio` | `python agent.py` | - -## Quick start - -Needs **Python 3.10+**, a Vapi private API key, and an assistant ID from the [Vapi dashboard](https://dashboard.vapi.ai). - -```bash -git clone https://github.com/attenlabs/saa-sdk.git -cd saa-sdk/examples/vapi -cp .env.example .env # fill SAA_API_KEY, VAPI_API_KEY, VAPI_ASSISTANT_ID - -cd voice_agent -python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate -pip install -e ../../../packages/saa-py # local dev against this repo -pip install -r requirements.txt -python agent.py -``` - -The sample **auto-loads** `examples/vapi/.env`. - -## The lines that integrate SAA - -```python -saa = AttentionClient(token=SAA_API_KEY, enable_audio=False, enable_video=False) - -@saa.on_prediction -def _(ev): gate.update_gate(ev.cls) # only device-directed audio reaches Vapi - -saa.start() -# mic tee -> saa.feed_audio() + gated PCM over the Vapi WebSocket -``` - -See [`voice_agent/README.md`](./voice_agent/README.md) for the full walk-through. - -## Shared environment - -One env file, [`.env`](./.env.example) in this directory: - -| Key | Purpose | -|---|---| -| `SAA_API_KEY` | Your attention labs API key. Get one at [attentionlabs.ai/dashboard](https://attentionlabs.ai/dashboard). | -| `VAPI_API_KEY` | Vapi private API key | -| `VAPI_ASSISTANT_ID` | The assistant to run | - -## Requirements & limitations - -- **Audio-only** — no video track on the WebSocket transport. -- Turn boundaries are Vapi's STT/VAD on the gated stream (same tradeoff as the ElevenLabs sample). -- Requires PortAudio for local mic + speaker (`sounddevice`). diff --git a/examples/vapi/voice_agent/README.md b/examples/vapi/voice_agent/README.md deleted file mode 100644 index 201ec9f..0000000 --- a/examples/vapi/voice_agent/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# voice_agent: SAA-gated Vapi WebSocket assistant - -A [Vapi](https://docs.vapi.ai) assistant reached over the **WebSocket transport**, with **attention labs SAA** device-directed gating wired on top via the streaming SDK's `feed_audio` ingestion. - -## The integration - -Single file ([`agent.py`](./agent.py)). The moving parts: - -- `AttentionClient(token=..., enable_audio=False, enable_video=False)` → streaming SDK in **feed mode**: it opens the cloud WebSocket but captures nothing itself. -- `sounddevice` captures 16 kHz mono PCM from the local mic; every frame is teed into `saa.feed_audio()`. -- `@saa.on_prediction` → `gate.update(ev.cls)` → **the gate**: opens on class-2 (device-directed), closes immediately on class-1 (human-directed). Vapi receives the user's real audio while the gate is open and silence otherwise, so side talk never reaches the assistant's STT. -- Vapi assistant audio on the return WebSocket drives `saa.mark_responding(True/False)` so SAA knows when the agent is speaking (echo is not fed back as user speech). - -### Warmup-gated session start - -SAA's model isn't classifying for real until its inference buffer fills (~10–15 s of audio). The mic starts feeding SAA before the Vapi call is created; `start_session()` (REST + WebSocket dial) is held until `@saa.on_warmup_complete` fires, with a 20 s timeout fallback. - -## Quickstart - -```bash -git clone https://github.com/attenlabs/saa-sdk.git -cd saa-sdk/examples/vapi/voice_agent -cp ../.env.example ../.env # fill SAA_API_KEY, VAPI_API_KEY, VAPI_ASSISTANT_ID - -python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate -pip install -e ../../../packages/saa-py # local dev against this repo -pip install -r requirements.txt -python agent.py -``` - -Talk to it. The assistant answers only when you're addressing it; speech directed at someone else in the room is replaced with silence before it reaches Vapi. - -## Logs - -One line per SAA prediction so the gating is observable: - -``` -PRED cls=2 conf=0.91 src=model | resp=False gate=open send=real -``` - -`cls` is the prediction (0 silent / 1 human-directed / 2 device-directed), `gate` the debounced gate, and `send` whether real audio or silence (`muted`) is reaching Vapi that tick. - -## Requirements & limitations - -- **Audio-only** — Vapi WebSocket transport carries no video, so class-1 ("talking to a human") is weaker than on the multimodal LiveKit / Pipecat paths. -- Needs a Vapi **assistant** configured in the dashboard; this sample does not create one inline. -- Turn boundaries are Vapi's own STT/VAD on the gated audio stream (same tradeoff as the ElevenLabs sample). -- Requires PortAudio (`sounddevice`) for local mic + speaker access. diff --git a/examples/vapi/voice_agent/agent.py b/examples/vapi/voice_agent/agent.py deleted file mode 100644 index 5bfe4cb..0000000 --- a/examples/vapi/voice_agent/agent.py +++ /dev/null @@ -1,323 +0,0 @@ -# SAA-gated Vapi WebSocket assistant -# -# SAA owns addressee detection. Vapi receives the user's real mic audio only while -# SAA says it's device-directed; otherwise silence is streamed so Vapi's own VAD -# never sees side talk. Assistant playback drives mark_responding so SAA knows -# when the agent is speaking. -import asyncio -import json -import logging -import os -import threading -import time -import urllib.error -import urllib.request -from collections import deque -from contextlib import suppress -from pathlib import Path - -import sounddevice as sd -import websockets -from dotenv import load_dotenv - -from saa import AttentionClient - -load_dotenv(Path(__file__).resolve().parents[1] / ".env") - -log = logging.getLogger("vapi-saa") - -SAMPLE_RATE = 16000 -CHANNELS = 1 -BLOCK_SAMPLES = 320 # 20 ms @ 16 kHz -BLOCK_BYTES = BLOCK_SAMPLES * 2 -OUTPUT_BYTES_PER_SEC = SAMPLE_RATE * 2 -SILENCE_BLOCK = bytes(BLOCK_BYTES) - - -class SAAGate: - """Device-directed gate + responding tracker for a downstream audio sink.""" - - def __init__(self, saa: AttentionClient, *, close_debounce_ticks: int = 4, - responding_tail_ms: int = 300): - self._saa = saa - self._close_debounce = close_debounce_ticks - self._tail_s = responding_tail_ms / 1000.0 - self._gate_open = False - self._misses = 0 - self._responding = False - self._play_until = 0.0 - self._sending_real = False - self._lock = threading.Lock() - - @property - def responding(self) -> bool: - return self._responding - - @property - def gate_open(self) -> bool: - return self._gate_open - - def update_gate(self, cls: int) -> None: - if cls == 2: - self._misses = 0 - self._gate_open = True - elif cls == 1: - self._gate_open = False - elif self._gate_open: - self._misses += 1 - if self._misses >= self._close_debounce: - self._gate_open = False - - def chunk_for_vapi(self, pcm16: bytes) -> tuple[bytes, bool]: - """Return (bytes to forward, whether they are real mic audio).""" - send_real = self._gate_open and not self._responding - if send_real != self._sending_real: - self._sending_real = send_real - log.info( - "SEND %s (gate=%s resp=%s)", - "real" if send_real else "muted", - self._gate_open, - self._responding, - ) - return (pcm16 if send_real else SILENCE_BLOCK[: len(pcm16)]), send_real - - def on_agent_audio(self, nbytes: int) -> None: - now = time.monotonic() - first = False - with self._lock: - self._play_until = max(self._play_until, now) + nbytes / OUTPUT_BYTES_PER_SEC - if not self._responding: - self._responding = True - first = True - if first: - self._saa.mark_responding(True) - log.info("RESP -> SPEAKING") - - def poll_responding(self) -> None: - now = time.monotonic() - with self._lock: - done = self._responding and now >= self._play_until + self._tail_s - if done: - with self._lock: - self._responding = False - self._saa.mark_responding(False) - log.info("RESP -> idle (playback-done)") - - -def create_vapi_websocket_call(api_key: str, assistant_id: str) -> str: - """POST /call with vapi.websocket transport; return websocketCallUrl.""" - body = json.dumps({ - "assistantId": assistant_id, - "transport": { - "provider": "vapi.websocket", - "audioFormat": { - "format": "pcm_s16le", - "container": "raw", - "sampleRate": SAMPLE_RATE, - }, - }, - }).encode("utf-8") - req = urllib.request.Request( - "https://api.vapi.ai/call", - method="POST", - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - data=body, - ) - try: - with urllib.request.urlopen(req, timeout=30.0) as resp: - payload = json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as e: - detail = e.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Vapi /call failed: HTTP {e.code} {detail or e.reason}") from e - url = (payload.get("transport") or {}).get("websocketCallUrl") - if not url: - raise RuntimeError(f"Vapi /call returned no websocketCallUrl: {payload!r}") - return url - - -async def _responding_poll(gate: SAAGate, stop: asyncio.Event) -> None: - while not stop.is_set(): - gate.poll_responding() - await asyncio.sleep(0.1) - - -async def run_vapi_session(ws_url: str, gate: SAAGate, uplink: asyncio.Queue, - playback: deque[bytes], stop: asyncio.Event) -> None: - async with websockets.connect(ws_url) as ws: - log.info("Vapi WebSocket connected") - - async def sender() -> None: - while not stop.is_set(): - try: - chunk = await asyncio.wait_for(uplink.get(), timeout=0.1) - except asyncio.TimeoutError: - continue - if chunk is None: - break - await ws.send(chunk) - - async def receiver() -> None: - async for message in ws: - if isinstance(message, bytes): - gate.on_agent_audio(len(message)) - playback.append(message) - else: - log.info("VAPI control: %s", message) - - await asyncio.gather(sender(), receiver()) - - -def main() -> int: - saa_api_key = os.environ.get("SAA_API_KEY", "").strip() - vapi_api_key = os.environ.get("VAPI_API_KEY", "").strip() - assistant_id = os.environ.get("VAPI_ASSISTANT_ID", "").strip() - if not (saa_api_key and vapi_api_key and assistant_id): - print("set SAA_API_KEY, VAPI_API_KEY, VAPI_ASSISTANT_ID") - return 2 - - threshold = float(os.environ.get("SAA_CLASS2_THRESHOLD", "0.7")) - saa = AttentionClient( - token=saa_api_key, - enable_audio=False, - enable_video=False, - initial_threshold=threshold, - ) - gate = SAAGate(saa) - - warmed = threading.Event() - - @saa.on_warmup_complete - def _on_warmup(): - log.info("WARMUP complete — SAA predicting") - warmed.set() - - @saa.on_prediction - def _on_prediction(ev): - gate.update_gate(ev.cls) - log.info( - "PRED cls=%d conf=%.2f src=%s | resp=%s gate=%s send=%s", - ev.cls, - ev.confidence or 0.0, - ev.source, - gate.responding, - "open" if gate.gate_open else "shut", - "real" if (gate.gate_open and not gate.responding) else "muted", - ) - - @saa.on_turn_ready - def _on_turn_ready(ev): - log.info("SAA turn_ready dur=%.1fs", ev.duration_sec) - - @saa.on_interrupt - def _on_interrupt(ev): - log.info("SAA interrupt conf=%.2f fade_ms=%d", ev.confidence, ev.fade_ms) - saa.unmute() - saa.mark_responding(False) - - @saa.on_error - def _on_error(ev): - log.warning("SAA error: %s — %s", ev.title, ev.message) - - @saa.on_disconnected - def _on_disconnected(ev): - log.error( - "SAA DISCONNECTED code=%s reason=%s clean=%s", - ev.code, ev.reason or "none", ev.was_clean, - ) - - saa.start() - log.info("warming up SAA model... (~10-15s)") - - loop = asyncio.new_event_loop() - uplink: asyncio.Queue[bytes | None] = asyncio.Queue() - playback: deque[bytes] = deque() - stop = asyncio.Event() - stream_to_vapi = threading.Event() - - def mic_callback(indata, frames, time_info, status): - if status: - log.debug("mic status: %s", status) - pcm = bytes(indata) - try: - saa.feed_audio(pcm) - except Exception: - log.exception("feed_audio failed") - if stream_to_vapi.is_set(): - chunk, _ = gate.chunk_for_vapi(pcm) - loop.call_soon_threadsafe(uplink.put_nowait, chunk) - - def out_callback(outdata, frames, time_info, status): - del time_info, status - need = frames * CHANNELS * 2 - buf = bytearray() - while len(buf) < need and playback: - buf.extend(playback.popleft()) - if len(buf) < need: - buf.extend(b"\x00" * (need - len(buf))) - outdata[:] = buf[:need] - - in_stream = sd.RawInputStream( - samplerate=SAMPLE_RATE, - channels=CHANNELS, - dtype="int16", - blocksize=BLOCK_SAMPLES, - callback=mic_callback, - ) - out_stream = sd.RawOutputStream( - samplerate=SAMPLE_RATE, - channels=CHANNELS, - dtype="int16", - blocksize=BLOCK_SAMPLES, - callback=out_callback, - ) - - async def run() -> None: - ready = await asyncio.to_thread(warmed.wait, 20.0) - if not ready: - log.warning("SAA warmup did not complete within 20s — starting Vapi anyway") - ws_url = await asyncio.to_thread(create_vapi_websocket_call, vapi_api_key, assistant_id) - log.info("Vapi call created: %s", ws_url) - stream_to_vapi.set() - responding_task = asyncio.create_task(_responding_poll(gate, stop)) - try: - await run_vapi_session(ws_url, gate, uplink, playback, stop) - finally: - stop.set() - await uplink.put(None) - responding_task.cancel() - with suppress(asyncio.CancelledError): - await responding_task - - exit_code = 0 - try: - in_stream.start() - out_stream.start() - loop.run_until_complete(run()) - except KeyboardInterrupt: - pass - except Exception: - log.exception("session ended with exception") - exit_code = 1 - finally: - stop.set() - stream_to_vapi.clear() - in_stream.stop() - out_stream.stop() - in_stream.close() - out_stream.close() - saa.stop() - loop.close() - return exit_code - - -if __name__ == "__main__": - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s.%(msecs)03d %(message)s", - datefmt="%H:%M:%S", - ) - logging.getLogger("saa").setLevel(logging.WARNING) - raise SystemExit(main()) diff --git a/examples/vapi/voice_agent/requirements.txt b/examples/vapi/voice_agent/requirements.txt deleted file mode 100644 index 83a1775..0000000 --- a/examples/vapi/voice_agent/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -# tested against attenlabs-saa 0.7.0 (feed_audio) -attenlabs-saa>=0.6.0 # installed via `pip install -e ../../../packages/saa-py` -python-dotenv>=1.0 -sounddevice>=0.4.6 -numpy>=1.24 -websockets>=12.0