diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef26f0266..4b4aae8ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Added `SmallWebRTCTransport`, a new P2P WebRTC transport.
+ - Created two examples in `p2p-webrtc`:
+ - **video-transform**: Demonstrates sending and receiving audio/video with `SmallWebRTCTransport` using `TypeScript`.
+ Includes video frame processing with OpenCV.
+ - **voice-agent**: A minimal example of creating a voice agent with `SmallWebRTCTransport`.
+
- Added support to `ProtobufFrameSerializer` to send the messages from `TransportMessageFrame` and `TransportMessageUrgentFrame`.
- Added support for a new TTS service, `PiperTTSService`.
diff --git a/examples/p2p-webrtc/video-transform/README.md b/examples/p2p-webrtc/video-transform/README.md
new file mode 100644
index 000000000..7125e3e68
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/README.md
@@ -0,0 +1,59 @@
+# Video Transform
+
+A Pipecat example demonstrating how to send and receive audio and video using `SmallWebRTCTransport`. This project also applies image processing to video frames using OpenCV.
+
+## 🚀 Quick Start
+
+### 1️⃣ Start the Bot Server
+
+#### 📂 Navigate to the Server Directory
+```bash
+cd server
+```
+
+#### 🔧 Set Up the Environment
+1. Create and activate a virtual environment:
+ ```bash
+ python3 -m venv venv
+ source venv/bin/activate # On Windows: venv\Scripts\activate
+ ```
+
+2. Install dependencies:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+3. Configure environment variables:
+ - Copy `env.example` to `.env`
+ ```bash
+ cp env.example .env
+ ```
+ - Add your API keys
+
+#### ▶️ Run the Server
+```bash
+python server.py
+```
+
+### 2️⃣ Connect Using the Client App
+
+For client-side setup, refer to the [JavaScript Guide](client/typescript/README.md).
+
+## ⚠️ Important Note
+Ensure the bot server is running before using any client implementations.
+
+## 📌 Requirements
+
+- Python **3.10+**
+- Node.js **16+** (for JavaScript components)
+- Google API Key
+- Modern web browser with WebRTC support
+
+---
+
+### 💡 Notes
+- Ensure all dependencies are installed before running the server.
+- Check the `.env` file for missing configurations.
+- WebRTC requires a secure environment (HTTPS) for full functionality in production.
+
+Happy coding! 🎉
\ No newline at end of file
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/README.md b/examples/p2p-webrtc/video-transform/client/typescript/README.md
new file mode 100644
index 000000000..3c7043edc
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/README.md
@@ -0,0 +1,27 @@
+# JavaScript Implementation
+
+Basic implementation using the [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/js/introduction).
+
+## Setup
+
+1. Run the bot server. See the [server README](../../README).
+
+2. Navigate to the `client/typescript` directory:
+
+```bash
+cd client/typescript
+```
+
+3. Install dependencies:
+
+```bash
+npm install
+```
+
+4. Run the client app:
+
+```
+npm run dev
+```
+
+5. Visit http://localhost:5173 in your browser.
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/index.html b/examples/p2p-webrtc/video-transform/client/typescript/index.html
new file mode 100644
index 000000000..d8d82ff52
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/index.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+ WebRTC demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status: Disconnected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/package-lock.json b/examples/p2p-webrtc/video-transform/client/typescript/package-lock.json
new file mode 100644
index 000000000..4fb1ecd50
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/package-lock.json
@@ -0,0 +1,668 @@
+{
+ "name": "client",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "client",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "@pipecat-ai/client-js": "^0.3.2",
+ "@pipecat-ai/small-webrtc-transport": "^0.0.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.13.1",
+ "@vitejs/plugin-react-swc": "^3.7.2",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.2"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+ "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
+ "license": "MIT",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@daily-co/daily-js": {
+ "version": "0.73.0",
+ "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.73.0.tgz",
+ "integrity": "sha512-Wz8c60hgmkx8fcEeDAi4L4J0rbafiihWKyXFyhYoFYPsw2OdChHpA4RYwIB+1enRws5IK+/HdmzFDYLQsB4A6w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@sentry/browser": "^8.33.1",
+ "bowser": "^2.8.1",
+ "dequal": "^2.0.3",
+ "events": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.24.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz",
+ "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@pipecat-ai/client-js": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@pipecat-ai/client-js/-/client-js-0.3.5.tgz",
+ "integrity": "sha512-qmhnDjwY2XUtLjww35ShsYf5TF9BCuAk0tIj0oHjpTe6v6QOlgKQt8JVCAdc32p5ycouzSZOeDFtBd2aNWuq1g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/events": "^3.0.3",
+ "clone-deep": "^4.0.1",
+ "events": "^3.3.0",
+ "typed-emitter": "^2.1.0",
+ "uuid": "^10.0.0"
+ }
+ },
+ "node_modules/@pipecat-ai/small-webrtc-transport": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@pipecat-ai/small-webrtc-transport/-/small-webrtc-transport-0.0.1.tgz",
+ "integrity": "sha512-WAOI7lT0V7cYOn0+qwUAryGxcOGe+wPVPEPzkR3qsM5GWIZ73spykZnuOndQGycq4UkcXVawCzERfNhpi+Uv7A==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@daily-co/daily-js": "^0.73.0",
+ "dequal": "^2.0.3"
+ },
+ "peerDependencies": {
+ "@pipecat-ai/client-js": "~0.3.5"
+ }
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.28.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz",
+ "integrity": "sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@sentry-internal/browser-utils": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz",
+ "integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "8.55.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry-internal/feedback": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.0.tgz",
+ "integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "8.55.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry-internal/replay": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.0.tgz",
+ "integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "8.55.0",
+ "@sentry/core": "8.55.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry-internal/replay-canvas": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz",
+ "integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/replay": "8.55.0",
+ "@sentry/core": "8.55.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry/browser": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.0.tgz",
+ "integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "8.55.0",
+ "@sentry-internal/feedback": "8.55.0",
+ "@sentry-internal/replay": "8.55.0",
+ "@sentry-internal/replay-canvas": "8.55.0",
+ "@sentry/core": "8.55.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "8.55.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.0.tgz",
+ "integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@swc/core": {
+ "version": "1.10.14",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.14.tgz",
+ "integrity": "sha512-WSrnE6JRnH20ZYjOOgSS4aOaPv9gxlkI2KRkN24kagbZnPZMnN8bZZyzw1rrLvwgpuRGv17Uz+hflosbR+SP6w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.17"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.10.14",
+ "@swc/core-darwin-x64": "1.10.14",
+ "@swc/core-linux-arm-gnueabihf": "1.10.14",
+ "@swc/core-linux-arm64-gnu": "1.10.14",
+ "@swc/core-linux-arm64-musl": "1.10.14",
+ "@swc/core-linux-x64-gnu": "1.10.14",
+ "@swc/core-linux-x64-musl": "1.10.14",
+ "@swc/core-win32-arm64-msvc": "1.10.14",
+ "@swc/core-win32-ia32-msvc": "1.10.14",
+ "@swc/core-win32-x64-msvc": "1.10.14"
+ },
+ "peerDependencies": {
+ "@swc/helpers": "*"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.10.14",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.14.tgz",
+ "integrity": "sha512-Dh4VyrhDDb05tdRmqJ/MucOPMTnrB4pRJol18HVyLlqu1HOT5EzonUniNTCdQbUXjgdv5UVJSTE1lYTzrp+myA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.17",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz",
+ "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/events": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
+ "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.13.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
+ "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react-swc": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz",
+ "integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@swc/core": "^1.7.26"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5 || ^6"
+ }
+ },
+ "node_modules/bowser": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
+ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
+ "license": "MIT"
+ },
+ "node_modules/clone-deep": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
+ "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-object": "^2.0.4",
+ "kind-of": "^6.0.2",
+ "shallow-clone": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.24.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
+ "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.24.0",
+ "@esbuild/android-arm": "0.24.0",
+ "@esbuild/android-arm64": "0.24.0",
+ "@esbuild/android-x64": "0.24.0",
+ "@esbuild/darwin-arm64": "0.24.0",
+ "@esbuild/darwin-x64": "0.24.0",
+ "@esbuild/freebsd-arm64": "0.24.0",
+ "@esbuild/freebsd-x64": "0.24.0",
+ "@esbuild/linux-arm": "0.24.0",
+ "@esbuild/linux-arm64": "0.24.0",
+ "@esbuild/linux-ia32": "0.24.0",
+ "@esbuild/linux-loong64": "0.24.0",
+ "@esbuild/linux-mips64el": "0.24.0",
+ "@esbuild/linux-ppc64": "0.24.0",
+ "@esbuild/linux-riscv64": "0.24.0",
+ "@esbuild/linux-s390x": "0.24.0",
+ "@esbuild/linux-x64": "0.24.0",
+ "@esbuild/netbsd-x64": "0.24.0",
+ "@esbuild/openbsd-arm64": "0.24.0",
+ "@esbuild/openbsd-x64": "0.24.0",
+ "@esbuild/sunos-x64": "0.24.0",
+ "@esbuild/win32-arm64": "0.24.0",
+ "@esbuild/win32-ia32": "0.24.0",
+ "@esbuild/win32-x64": "0.24.0"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "license": "MIT",
+ "dependencies": {
+ "isobject": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.8",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+ "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.4.49",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
+ "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.28.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.0.tgz",
+ "integrity": "sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.6"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.28.0",
+ "@rollup/rollup-android-arm64": "4.28.0",
+ "@rollup/rollup-darwin-arm64": "4.28.0",
+ "@rollup/rollup-darwin-x64": "4.28.0",
+ "@rollup/rollup-freebsd-arm64": "4.28.0",
+ "@rollup/rollup-freebsd-x64": "4.28.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.28.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.28.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.28.0",
+ "@rollup/rollup-linux-arm64-musl": "4.28.0",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.28.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.28.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.28.0",
+ "@rollup/rollup-linux-x64-gnu": "4.28.0",
+ "@rollup/rollup-linux-x64-musl": "4.28.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.28.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.28.0",
+ "@rollup/rollup-win32-x64-msvc": "4.28.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
+ "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/shallow-clone": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
+ "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
+ "license": "MIT",
+ "dependencies": {
+ "kind-of": "^6.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typed-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
+ "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "rxjs": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
+ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.2.tgz",
+ "integrity": "sha512-XdQ+VsY2tJpBsKGs0wf3U/+azx8BBpYRHFAyKm5VeEZNOJZRB63q7Sc8Iup3k0TrN3KO6QgyzFf+opSbfY1y0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.24.0",
+ "postcss": "^8.4.49",
+ "rollup": "^4.23.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/package.json b/examples/p2p-webrtc/video-transform/client/typescript/package.json
new file mode 100644
index 000000000..e50084020
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "client",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "dev": "node_modules/.bin/vite",
+ "build": "node_modules/.bin/tsc && vite build",
+ "preview": "node_modules/.bin/vite preview"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "devDependencies": {
+ "@types/node": "^22.13.1",
+ "@vitejs/plugin-react-swc": "^3.7.2",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.2"
+ },
+ "dependencies": {
+ "@pipecat-ai/client-js": "^0.3.2",
+ "@pipecat-ai/small-webrtc-transport": "^0.0.1"
+ }
+}
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/src/app.ts b/examples/p2p-webrtc/video-transform/client/typescript/src/app.ts
new file mode 100644
index 000000000..6402d4c9a
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/src/app.ts
@@ -0,0 +1,212 @@
+import {
+ SmallWebRTCTransport
+} from "@pipecat-ai/small-webrtc-transport";
+import {Participant, RTVIClient, RTVIClientOptions} from "@pipecat-ai/client-js";
+
+class WebRTCApp {
+
+ private declare connectBtn: HTMLButtonElement;
+ private declare disconnectBtn: HTMLButtonElement;
+
+ private declare audioInput: HTMLSelectElement;
+ private declare videoInput: HTMLSelectElement;
+ private declare audioCodec: HTMLSelectElement;
+ private declare videoCodec: HTMLSelectElement;
+
+ private declare videoElement: HTMLVideoElement;
+ private declare audioElement: HTMLAudioElement;
+
+ private debugLog: HTMLElement | null = null;
+ private statusSpan: HTMLElement | null = null;
+
+ private declare smallWebRTCTransport: SmallWebRTCTransport;
+ private declare rtviClient: RTVIClient;
+
+ constructor() {
+ this.setupDOMElements();
+ this.setupDOMEventListeners();
+ this.initializeRTVIClient()
+ void this.populateDevices();
+ }
+
+ private initializeRTVIClient(): void {
+ const transport = new SmallWebRTCTransport();
+ const RTVIConfig: RTVIClientOptions = {
+ // need to understand why it is complaining
+ // @ts-ignore
+ transport,
+ params: {
+ baseUrl: "/api/offer"
+ },
+ enableMic: true,
+ enableCam: true,
+ callbacks: {
+ onTransportStateChanged: (state) => {
+ this.log(`Transport state: ${state}`)
+ },
+ onConnected: () => {
+ this.onConnectedHandler()
+ },
+ onBotReady: () => {
+ this.log("Bot is ready.")
+ },
+ onDisconnected: () => {
+ this.onDisconnectedHandler()
+ },
+ onUserStartedSpeaking: () => {
+ this.log("User started speaking.")
+ },
+ onUserStoppedSpeaking: () => {
+ this.log("User stopped speaking.")
+ },
+ onBotStartedSpeaking: () => {
+ this.log("Bot started speaking.")
+ },
+ onBotStoppedSpeaking: () => {
+ this.log("Bot stopped speaking.")
+ },
+ onUserTranscript: (transcript) => {
+ if (transcript.final) {
+ this.log(`User transcript: ${transcript.text}`)
+ }
+ },
+ onBotTranscript: (transcript) => {
+ this.log(`Bot transcript: ${transcript.text}`)
+ },
+ onTrackStarted: (track: MediaStreamTrack, participant?: Participant) => {
+ if (participant?.local) {
+ return
+ }
+ this.onBotTrackStarted(track)
+ },
+ onServerMessage: (msg) => {
+ this.log(`Server message: ${msg}`)
+ }
+ },
+ }
+ RTVIConfig.customConnectHandler = () => Promise.resolve();
+ this.rtviClient = new RTVIClient(RTVIConfig);
+ this.smallWebRTCTransport = transport
+ }
+
+ private setupDOMElements(): void {
+ this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement;
+ this.disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement;
+
+ this.audioInput = document.getElementById('audio-input') as HTMLSelectElement;
+ this.videoInput = document.getElementById('video-input') as HTMLSelectElement;
+ this.audioCodec = document.getElementById('audio-codec') as HTMLSelectElement;
+ this.videoCodec = document.getElementById('video-codec') as HTMLSelectElement;
+
+ this.videoElement = document.getElementById('bot-video') as HTMLVideoElement;
+ this.audioElement = document.getElementById('bot-audio') as HTMLAudioElement;
+
+ this.debugLog = document.getElementById('debug-log');
+ this.statusSpan = document.getElementById('connection-status');
+ }
+
+ private setupDOMEventListeners(): void {
+ this.connectBtn.addEventListener("click", () => this.start());
+ this.disconnectBtn.addEventListener("click", () => this.stop());
+ this.audioInput.addEventListener("change", (e) => {
+ // @ts-ignore
+ let audioDevice = e.target?.value
+ this.rtviClient.updateMic(audioDevice)
+ })
+ this.videoInput.addEventListener("change", (e) => {
+ // @ts-ignore
+ let videoDevice = e.target?.value
+ this.rtviClient.updateCam(videoDevice)
+ })
+ }
+
+ private log(message: string): void {
+ if (!this.debugLog) return;
+ const entry = document.createElement('div');
+ entry.textContent = `${new Date().toISOString()} - ${message}`;
+ if (message.startsWith('User: ')) {
+ entry.style.color = '#2196F3';
+ } else if (message.startsWith('Bot: ')) {
+ entry.style.color = '#4CAF50';
+ }
+ this.debugLog.appendChild(entry);
+ this.debugLog.scrollTop = this.debugLog.scrollHeight;
+ }
+
+ private clearAllLogs() {
+ this.debugLog!.innerText = ''
+ }
+
+ private updateStatus(status: string): void {
+ if (this.statusSpan) {
+ this.statusSpan.textContent = status;
+ }
+ this.log(`Status: ${status}`);
+ }
+
+ private onConnectedHandler() {
+ this.updateStatus('Connected');
+ if (this.connectBtn) this.connectBtn.disabled = true;
+ if (this.disconnectBtn) this.disconnectBtn.disabled = false;
+ }
+
+ private onDisconnectedHandler() {
+ this.updateStatus('Disconnected');
+ if (this.connectBtn) this.connectBtn.disabled = false;
+ if (this.disconnectBtn) this.disconnectBtn.disabled = true;
+ }
+
+ private onBotTrackStarted(track: MediaStreamTrack) {
+ if (track.kind === 'video') {
+ this.videoElement.srcObject = new MediaStream([track]);
+ } else {
+ this.audioElement.srcObject = new MediaStream([track]);
+ }
+ }
+
+ private async populateDevices(): Promise {
+ const populateSelect = (select: HTMLSelectElement, devices: MediaDeviceInfo[]): void => {
+ let counter = 1;
+ devices.forEach((device) => {
+ const option = document.createElement('option');
+ option.value = device.deviceId;
+ option.text = device.label || ('Device #' + counter);
+ select.appendChild(option);
+ counter += 1;
+ });
+ };
+
+ try {
+ const audioDevices = await this.rtviClient.getAllMics();
+ populateSelect(this.audioInput, audioDevices);
+ const videoDevices = await this.rtviClient.getAllCams();
+ populateSelect(this.videoInput, videoDevices);
+ } catch (e) {
+ alert(e);
+ }
+ }
+
+ private async start(): Promise {
+ this.clearAllLogs()
+
+ this.connectBtn.disabled = true;
+ this.updateStatus("Connecting")
+
+ this.smallWebRTCTransport.setAudioCodec(this.audioCodec.value)
+ this.smallWebRTCTransport.setVideoCodec(this.videoCodec.value)
+ try {
+ await this.rtviClient.connect()
+ } catch (e) {
+ console.log(`Failed to connect ${e}`)
+ this.stop()
+ }
+
+ }
+
+ private stop(): void {
+ void this.rtviClient.disconnect()
+ }
+}
+
+// Create the WebRTCConnection instance
+const webRTCConnection = new WebRTCApp();
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/src/style.css b/examples/p2p-webrtc/video-transform/client/typescript/src/style.css
new file mode 100644
index 000000000..6be42d2ea
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/src/style.css
@@ -0,0 +1,120 @@
+body {
+ margin: 0;
+ padding: 20px;
+ font-family: Arial, sans-serif;
+ background-color: #f0f0f0;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+}
+
+.container {
+ margin: 0 auto;
+ width: 90%;
+}
+
+.option {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+label {
+ margin: 5px;
+}
+
+select {
+ padding: 8px;
+ margin: 10px;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+}
+
+.status-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px;
+ background-color: #fff;
+ border-radius: 8px;
+ margin-bottom: 20px;
+}
+
+.controls button {
+ padding: 8px 16px;
+ margin-left: 10px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#connect-btn {
+ background-color: #4caf50;
+ color: white;
+}
+
+#disconnect-btn {
+ background-color: #f44336;
+ color: white;
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.main-content {
+ background-color: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ display: flex;
+}
+
+.bot-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 50%;
+}
+
+#bot-video-container {
+ width: 640px;
+ height: 360px;
+ background-color: #e0e0e0;
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+#bot-video-container video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.debug-panel {
+ background-color: #fff;
+ border-radius: 8px;
+ padding-left: 20px;
+ width: 50%;
+}
+
+.debug-panel h3 {
+ margin: 0 0 10px 0;
+ font-size: 16px;
+ font-weight: bold;
+}
+
+#debug-log {
+ height: 500px;
+ overflow-y: auto;
+ background-color: #f8f8f8;
+ padding: 10px;
+ border-radius: 4px;
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 1.4;
+}
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/tsconfig.json b/examples/p2p-webrtc/video-transform/client/typescript/tsconfig.json
new file mode 100644
index 000000000..c9c555d96
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/tsconfig.json
@@ -0,0 +1,111 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+
+ /* Language and Environment */
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+
+ /* Modules */
+ "module": "commonjs", /* Specify what module code is generated. */
+ // "rootDir": "./", /* Specify the root folder within your source files. */
+ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
+ // "resolveJsonModule": true, /* Enable importing .json files. */
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+
+ /* JavaScript Support */
+ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
+ // "noEmit": true, /* Disable emitting files from a compilation. */
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+ // "outDir": "./", /* Specify an output folder for all emitted files. */
+ // "removeComments": true, /* Disable emitting comments. */
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
+ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
+
+ /* Interop Constraints */
+ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+
+ /* Type Checking */
+ "strict": true, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
+ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
+
+ /* Completeness */
+ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ }
+}
diff --git a/examples/p2p-webrtc/video-transform/client/typescript/vite.config.js b/examples/p2p-webrtc/video-transform/client/typescript/vite.config.js
new file mode 100644
index 000000000..58f9cfaf9
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/client/typescript/vite.config.js
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react-swc';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ // Proxy /api requests to the backend server
+ '/api': {
+ target: 'http://0.0.0.0:7860', // Replace with your backend URL
+ changeOrigin: true,
+ },
+ },
+ },
+});
diff --git a/examples/p2p-webrtc/video-transform/server/bot.py b/examples/p2p-webrtc/video-transform/server/bot.py
new file mode 100644
index 000000000..917a13fb4
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/server/bot.py
@@ -0,0 +1,156 @@
+#
+# Copyright (c) 2025, Daily
+#
+# SPDX-License-Identifier: BSD 2-Clause License
+#
+import os
+import sys
+
+import cv2
+import numpy as np
+from dotenv import load_dotenv
+from loguru import logger
+
+from pipecat.audio.vad.silero import SileroVADAnalyzer
+from pipecat.frames.frames import Frame, InputImageRawFrame, OutputImageRawFrame
+from pipecat.pipeline.pipeline import Pipeline
+from pipecat.pipeline.runner import PipelineRunner
+from pipecat.pipeline.task import PipelineParams, PipelineTask
+from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
+from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
+from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor
+from pipecat.services.gemini_multimodal_live import GeminiMultimodalLiveLLMService
+from pipecat.transports.base_transport import TransportParams
+from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
+
+load_dotenv(override=True)
+
+logger.remove(0)
+logger.add(sys.stderr, level="DEBUG")
+
+
+class EdgeDetectionProcessor(FrameProcessor):
+ def __init__(self, camera_out_width, camera_out_height: int):
+ super().__init__()
+ self._camera_out_width = camera_out_width
+ self._camera_out_height = camera_out_height
+
+ async def process_frame(self, frame: Frame, direction: FrameDirection):
+ await super().process_frame(frame, direction)
+
+ if isinstance(frame, InputImageRawFrame):
+ # Convert bytes to NumPy array
+ img = np.frombuffer(frame.image, dtype=np.uint8).reshape(
+ (frame.size[1], frame.size[0], 3)
+ )
+
+ # perform edge detection
+ img = cv2.cvtColor(cv2.Canny(img, 100, 200), cv2.COLOR_GRAY2BGR)
+
+ # convert the size if needed
+ desired_size = (self._camera_out_width, self._camera_out_height)
+ if frame.size != desired_size:
+ resized_image = cv2.resize(img, desired_size)
+ frame = OutputImageRawFrame(resized_image.tobytes(), desired_size, frame.format)
+ await self.push_frame(frame)
+ else:
+ await self.push_frame(
+ OutputImageRawFrame(image=img.tobytes(), size=frame.size, format=frame.format)
+ )
+ else:
+ await self.push_frame(frame, direction)
+
+
+SYSTEM_INSTRUCTION = f"""
+"You are Gemini Chatbot, a friendly, helpful robot.
+
+Your goal is to demonstrate your capabilities in a succinct way.
+
+Your output will be converted to audio so don't include special characters in your answers.
+
+Respond to what the user said in a creative and helpful way. Keep your responses brief. One or two sentences at most.
+"""
+
+
+async def run_bot(webrtc_connection):
+ transport_params = TransportParams(
+ camera_in_enabled=True,
+ camera_out_enabled=True,
+ camera_out_is_live=True,
+ audio_in_enabled=True,
+ audio_out_enabled=True,
+ vad_enabled=True,
+ vad_analyzer=SileroVADAnalyzer(),
+ vad_audio_passthrough=True,
+ )
+
+ pipecat_transport = SmallWebRTCTransport(
+ webrtc_connection=webrtc_connection, params=transport_params
+ )
+
+ llm = GeminiMultimodalLiveLLMService(
+ api_key=os.getenv("GOOGLE_API_KEY"),
+ voice_id="Puck", # Aoede, Charon, Fenrir, Kore, Puck
+ transcribe_user_audio=True,
+ transcribe_model_audio=True,
+ system_instruction=SYSTEM_INSTRUCTION,
+ )
+
+ context = OpenAILLMContext(
+ [
+ {
+ "role": "user",
+ "content": "Start by greeting the user warmly and introducing yourself.",
+ }
+ ],
+ )
+ context_aggregator = llm.create_context_aggregator(context)
+
+ # RTVI events for Pipecat client UI
+ rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
+
+ pipeline = Pipeline(
+ [
+ pipecat_transport.input(),
+ context_aggregator.user(),
+ rtvi,
+ llm, # LLM
+ EdgeDetectionProcessor(
+ transport_params.camera_out_width, transport_params.camera_out_height
+ ), # Sending the video back to the user
+ pipecat_transport.output(),
+ context_aggregator.assistant(),
+ ]
+ )
+
+ task = PipelineTask(
+ pipeline,
+ params=PipelineParams(
+ allow_interruptions=True,
+ observers=[RTVIObserver(rtvi)],
+ ),
+ )
+
+ @rtvi.event_handler("on_client_ready")
+ async def on_client_ready(rtvi):
+ logger.info("Pipecat client ready.")
+ await rtvi.set_bot_ready()
+
+ @pipecat_transport.event_handler("on_client_connected")
+ async def on_client_connected(transport, client):
+ logger.info("Pipecat Client connected")
+ # Kick off the conversation.
+ await task.queue_frames([context_aggregator.user().get_context_frame()])
+
+ @pipecat_transport.event_handler("on_client_disconnected")
+ async def on_client_disconnected(transport, client):
+ logger.info("Pipecat Client disconnected")
+
+ @pipecat_transport.event_handler("on_client_closed")
+ async def on_client_closed(transport, client):
+ logger.info("Pipecat Client closed")
+ await task.cancel()
+
+ runner = PipelineRunner(handle_sigint=False)
+
+ await runner.run(task)
diff --git a/examples/p2p-webrtc/video-transform/server/env.example b/examples/p2p-webrtc/video-transform/server/env.example
new file mode 100644
index 000000000..b8d79805b
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/server/env.example
@@ -0,0 +1 @@
+GOOGLE_API_KEY=
\ No newline at end of file
diff --git a/examples/p2p-webrtc/video-transform/server/requirements.txt b/examples/p2p-webrtc/video-transform/server/requirements.txt
new file mode 100644
index 000000000..24579bd41
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/server/requirements.txt
@@ -0,0 +1,6 @@
+python-dotenv
+fastapi[all]
+uvicorn
+aiortc
+opencv-python
+pipecat-ai[google,silero]
\ No newline at end of file
diff --git a/examples/p2p-webrtc/video-transform/server/server.py b/examples/p2p-webrtc/video-transform/server/server.py
new file mode 100644
index 000000000..59f182f62
--- /dev/null
+++ b/examples/p2p-webrtc/video-transform/server/server.py
@@ -0,0 +1,79 @@
+import argparse
+import asyncio
+import logging
+from contextlib import asynccontextmanager
+from typing import Dict
+
+import uvicorn
+from bot import run_bot
+from dotenv import load_dotenv
+from fastapi import BackgroundTasks, FastAPI
+
+from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
+
+# Load environment variables
+load_dotenv(override=True)
+
+logger = logging.getLogger("pc")
+
+app = FastAPI()
+
+# Store connections by pc_id
+pcs_map: Dict[str, SmallWebRTCConnection] = {}
+
+ice_servers = ["stun:stun.l.google.com:19302"]
+
+
+@app.post("/api/offer")
+async def offer(request: dict, background_tasks: BackgroundTasks):
+ pc_id = request.get("pc_id")
+
+ if pc_id and pc_id in pcs_map:
+ pipecat_connection = pcs_map[pc_id]
+ logger.info(f"Reusing existing connection for pc_id: {pc_id}")
+ await pipecat_connection.renegotiate(
+ sdp=request["sdp"], type=request["type"], restart_pc=request.get("restart_pc", False)
+ )
+ else:
+ pipecat_connection = SmallWebRTCConnection(ice_servers)
+ await pipecat_connection.initialize(sdp=request["sdp"], type=request["type"])
+
+ @pipecat_connection.on("closed")
+ async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
+ logger.info(f"Discarding peer connection for pc_id: {webrtc_connection.pc_id}")
+ pcs_map.pop(webrtc_connection.pc_id, None)
+
+ background_tasks.add_task(run_bot, pipecat_connection)
+
+ answer = pipecat_connection.get_answer()
+ # Updating the peer connection inside the map
+ pcs_map[answer["pc_id"]] = pipecat_connection
+
+ return answer
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ yield # Run app
+ coros = [pc.close() for pc in pcs_map.values()]
+ await asyncio.gather(*coros)
+ pcs_map.clear()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="WebRTC demo")
+ parser.add_argument(
+ "--host", default="localhost", help="Host for HTTP server (default: localhost)"
+ )
+ parser.add_argument(
+ "--port", type=int, default=7860, help="Port for HTTP server (default: 7860)"
+ )
+ parser.add_argument("--verbose", "-v", action="count")
+ args = parser.parse_args()
+
+ if args.verbose:
+ logging.basicConfig(level=logging.DEBUG)
+ else:
+ logging.basicConfig(level=logging.INFO)
+
+ uvicorn.run(app, host=args.host, port=args.port)
diff --git a/examples/p2p-webrtc/voice-agent/README.md b/examples/p2p-webrtc/voice-agent/README.md
new file mode 100644
index 000000000..17bf165af
--- /dev/null
+++ b/examples/p2p-webrtc/voice-agent/README.md
@@ -0,0 +1,54 @@
+# Voice Agent
+
+A Pipecat example demonstrating the simplest way to create a voice agent using `SmallWebRTCTransport`.
+
+## 🚀 Quick Start
+
+### 1️⃣ Start the Bot Server
+
+#### 🔧 Set Up the Environment
+1. Create and activate a virtual environment:
+ ```bash
+ python3 -m venv venv
+ source venv/bin/activate # On Windows: venv\Scripts\activate
+ ```
+
+2. Install dependencies:
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+3. Configure environment variables:
+ - Copy `env.example` to `.env`
+ ```bash
+ cp env.example .env
+ ```
+ - Add your API keys
+
+#### ▶️ Run the Server
+```bash
+python server.py
+```
+
+### 2️⃣ Connect Using the Client App
+
+Open your browser and visit:
+```
+http://localhost:7860
+```
+
+## 📌 Requirements
+
+- Python **3.10+**
+- Node.js **16+** (for JavaScript components)
+- Google API Key
+- Modern web browser with WebRTC support
+
+---
+
+### 💡 Notes
+- Ensure all dependencies are installed before running the server.
+- Check the `.env` file for missing configurations.
+- WebRTC requires a secure environment (HTTPS) for full functionality in production.
+
+Happy coding! 🎉
\ No newline at end of file
diff --git a/examples/p2p-webrtc/voice-agent/bot.py b/examples/p2p-webrtc/voice-agent/bot.py
new file mode 100644
index 000000000..07106022c
--- /dev/null
+++ b/examples/p2p-webrtc/voice-agent/bot.py
@@ -0,0 +1,102 @@
+#
+# Copyright (c) 2025, Daily
+#
+# SPDX-License-Identifier: BSD 2-Clause License
+#
+import os
+import sys
+
+from dotenv import load_dotenv
+from loguru import logger
+
+from pipecat.audio.vad.silero import SileroVADAnalyzer
+from pipecat.pipeline.pipeline import Pipeline
+from pipecat.pipeline.runner import PipelineRunner
+from pipecat.pipeline.task import PipelineParams, PipelineTask
+from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
+from pipecat.services.gemini_multimodal_live import GeminiMultimodalLiveLLMService
+from pipecat.transports.base_transport import TransportParams
+from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
+
+load_dotenv(override=True)
+
+logger.remove(0)
+logger.add(sys.stderr, level="DEBUG")
+
+
+SYSTEM_INSTRUCTION = f"""
+"You are Gemini Chatbot, a friendly, helpful robot.
+
+Your goal is to demonstrate your capabilities in a succinct way.
+
+Your output will be converted to audio so don't include special characters in your answers.
+
+Respond to what the user said in a creative and helpful way. Keep your responses brief. One or two sentences at most.
+"""
+
+
+async def run_bot(webrtc_connection):
+ pipecat_transport = SmallWebRTCTransport(
+ webrtc_connection=webrtc_connection,
+ params=TransportParams(
+ audio_in_enabled=True,
+ audio_out_enabled=True,
+ vad_enabled=True,
+ vad_analyzer=SileroVADAnalyzer(),
+ vad_audio_passthrough=True,
+ ),
+ )
+
+ llm = GeminiMultimodalLiveLLMService(
+ api_key=os.getenv("GOOGLE_API_KEY"),
+ voice_id="Puck", # Aoede, Charon, Fenrir, Kore, Puck
+ transcribe_user_audio=True,
+ transcribe_model_audio=True,
+ system_instruction=SYSTEM_INSTRUCTION,
+ )
+
+ context = OpenAILLMContext(
+ [
+ {
+ "role": "user",
+ "content": "Start by greeting the user warmly and introducing yourself.",
+ }
+ ],
+ )
+ context_aggregator = llm.create_context_aggregator(context)
+
+ pipeline = Pipeline(
+ [
+ pipecat_transport.input(),
+ context_aggregator.user(),
+ llm, # LLM
+ pipecat_transport.output(),
+ context_aggregator.assistant(),
+ ]
+ )
+
+ task = PipelineTask(
+ pipeline,
+ params=PipelineParams(
+ allow_interruptions=True,
+ ),
+ )
+
+ @pipecat_transport.event_handler("on_client_connected")
+ async def on_client_connected(transport, client):
+ logger.info("Pipecat Client connected")
+ # Kick off the conversation.
+ await task.queue_frames([context_aggregator.user().get_context_frame()])
+
+ @pipecat_transport.event_handler("on_client_disconnected")
+ async def on_client_disconnected(transport, client):
+ logger.info("Pipecat Client disconnected")
+
+ @pipecat_transport.event_handler("on_client_closed")
+ async def on_client_closed(transport, client):
+ logger.info("Pipecat Client closed")
+ await task.cancel()
+
+ runner = PipelineRunner(handle_sigint=False)
+
+ await runner.run(task)
diff --git a/examples/p2p-webrtc/voice-agent/env.example b/examples/p2p-webrtc/voice-agent/env.example
new file mode 100644
index 000000000..b8d79805b
--- /dev/null
+++ b/examples/p2p-webrtc/voice-agent/env.example
@@ -0,0 +1 @@
+GOOGLE_API_KEY=
\ No newline at end of file
diff --git a/examples/p2p-webrtc/voice-agent/index.html b/examples/p2p-webrtc/voice-agent/index.html
new file mode 100644
index 000000000..dea1bbccf
--- /dev/null
+++ b/examples/p2p-webrtc/voice-agent/index.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+ WebRTC Voice Agent
+
+
+
+
WebRTC Voice Agent
+
Disconnected
+
+
+
+
+
+
diff --git a/examples/p2p-webrtc/voice-agent/requirements.txt b/examples/p2p-webrtc/voice-agent/requirements.txt
new file mode 100644
index 000000000..d8ffd53ef
--- /dev/null
+++ b/examples/p2p-webrtc/voice-agent/requirements.txt
@@ -0,0 +1,5 @@
+python-dotenv
+fastapi[all]
+uvicorn
+aiortc
+pipecat-ai[google,silero]
\ No newline at end of file
diff --git a/examples/p2p-webrtc/voice-agent/server.py b/examples/p2p-webrtc/voice-agent/server.py
new file mode 100644
index 000000000..3e5f8dcfb
--- /dev/null
+++ b/examples/p2p-webrtc/voice-agent/server.py
@@ -0,0 +1,81 @@
+import argparse
+import asyncio
+import logging
+from contextlib import asynccontextmanager
+from typing import Dict
+
+import uvicorn
+from bot import run_bot
+from dotenv import load_dotenv
+from fastapi import BackgroundTasks, FastAPI
+from fastapi.responses import FileResponse
+
+from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
+
+# Load environment variables
+load_dotenv(override=True)
+
+logger = logging.getLogger("pc")
+
+app = FastAPI()
+
+# Store connections by pc_id
+pcs_map: Dict[str, SmallWebRTCConnection] = {}
+
+
+@app.post("/api/offer")
+async def offer(request: dict, background_tasks: BackgroundTasks):
+ pc_id = request.get("pc_id")
+
+ if pc_id and pc_id in pcs_map:
+ pipecat_connection = pcs_map[pc_id]
+ logger.info(f"Reusing existing connection for pc_id: {pc_id}")
+ await pipecat_connection.renegotiate(sdp=request["sdp"], type=request["type"])
+ else:
+ pipecat_connection = SmallWebRTCConnection()
+ await pipecat_connection.initialize(sdp=request["sdp"], type=request["type"])
+
+ @pipecat_connection.on("closed")
+ async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
+ logger.info(f"Discarding peer connection for pc_id: {webrtc_connection.pc_id}")
+ pcs_map.pop(webrtc_connection.pc_id, None)
+
+ background_tasks.add_task(run_bot, pipecat_connection)
+
+ answer = pipecat_connection.get_answer()
+ # Updating the peer connection inside the map
+ pcs_map[answer["pc_id"]] = pipecat_connection
+
+ return answer
+
+
+@app.get("/")
+async def serve_index():
+ return FileResponse("index.html")
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ yield # Run app
+ coros = [pc.close() for pc in pcs_map.values()]
+ await asyncio.gather(*coros)
+ pcs_map.clear()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="WebRTC demo")
+ parser.add_argument(
+ "--host", default="localhost", help="Host for HTTP server (default: localhost)"
+ )
+ parser.add_argument(
+ "--port", type=int, default=7860, help="Port for HTTP server (default: 7860)"
+ )
+ parser.add_argument("--verbose", "-v", action="count")
+ args = parser.parse_args()
+
+ if args.verbose:
+ logging.basicConfig(level=logging.DEBUG)
+ else:
+ logging.basicConfig(level=logging.INFO)
+
+ uvicorn.run(app, host=args.host, port=args.port)
diff --git a/src/pipecat/services/gemini_multimodal_live/gemini.py b/src/pipecat/services/gemini_multimodal_live/gemini.py
index 801af46b4..965648ada 100644
--- a/src/pipecat/services/gemini_multimodal_live/gemini.py
+++ b/src/pipecat/services/gemini_multimodal_live/gemini.py
@@ -7,6 +7,7 @@
import asyncio
import base64
import json
+import time
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Mapping, Optional, Union
@@ -177,6 +178,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
**kwargs,
):
super().__init__(base_url=base_url, **kwargs)
+ self._last_sent_time = 0
self.api_key = api_key
self.base_url = base_url
self.set_model_name(model)
@@ -548,7 +550,13 @@ class GeminiMultimodalLiveLLMService(LLMService):
async def _send_user_video(self, frame):
if self._video_input_paused:
return
- # logger.debug(f"Sending video frame to Gemini: {frame}")
+
+ now = time.time()
+ if now - self._last_sent_time < 1:
+ return # Ignore if less than 1 second has passed
+
+ self._last_sent_time = now # Update last sent time
+ logger.debug(f"Sending video frame to Gemini: {frame}")
evt = events.VideoInputMessage.from_image_frame(frame)
await self.send_client_event(evt)
diff --git a/src/pipecat/transports/base_transport.py b/src/pipecat/transports/base_transport.py
index 06f6cb920..30e126ae3 100644
--- a/src/pipecat/transports/base_transport.py
+++ b/src/pipecat/transports/base_transport.py
@@ -19,6 +19,7 @@ from pipecat.utils.base_object import BaseObject
class TransportParams(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
+ camera_in_enabled: bool = False
camera_out_enabled: bool = False
camera_out_is_live: bool = False
camera_out_width: int = 1024
diff --git a/src/pipecat/transports/network/small_webrtc.py b/src/pipecat/transports/network/small_webrtc.py
new file mode 100644
index 000000000..bb3044a75
--- /dev/null
+++ b/src/pipecat/transports/network/small_webrtc.py
@@ -0,0 +1,512 @@
+#
+# Copyright (c) 2024–2025, Daily
+#
+# SPDX-License-Identifier: BSD 2-Clause License
+#
+
+import asyncio
+import fractions
+import logging
+import time
+from collections import deque
+from typing import Any, Awaitable, Callable, Optional
+
+import cv2
+import numpy as np
+from aiortc import VideoStreamTrack
+from aiortc.mediastreams import AudioStreamTrack, MediaStreamError, VideoFrame
+from av import AudioFrame, AudioResampler
+from loguru import logger
+from pydantic import BaseModel
+
+# Get the logger for aiortc
+# aiortc_logger = logging.getLogger("aiortc")
+# aiortc_logger.setLevel(logging.DEBUG)
+from pipecat.frames.frames import (
+ CancelFrame,
+ EndFrame,
+ InputAudioRawFrame,
+ InputImageRawFrame,
+ OutputImageRawFrame,
+ StartFrame,
+ TransportMessageFrame,
+ TransportMessageUrgentFrame,
+)
+from pipecat.transports.base_input import BaseInputTransport
+from pipecat.transports.base_output import BaseOutputTransport
+from pipecat.transports.base_transport import BaseTransport, TransportParams
+from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
+
+
+class SmallWebRTCCallbacks(BaseModel):
+ on_app_message: Callable[[Any], Awaitable[None]]
+ on_client_connected: Callable[[SmallWebRTCConnection], Awaitable[None]]
+ on_client_disconnected: Callable[[SmallWebRTCConnection], Awaitable[None]]
+ on_client_closed: Callable[[SmallWebRTCConnection], Awaitable[None]]
+
+
+class RawAudioTrack(AudioStreamTrack):
+ def __init__(self, sample_rate):
+ super().__init__()
+ self._sample_rate = sample_rate
+ self._samples_per_frame = self._sample_rate // 50 # 20ms per frame
+ self._timestamp = 0
+ self._audio_buffer = deque()
+ self._start = time.time()
+
+ def add_audio_bytes(self, audio_bytes: bytes):
+ """
+ Adds bytes to the audio buffer and returns a Future that completes when the data is processed.
+ """
+ if len(audio_bytes) % 2 != 0:
+ raise ValueError("Audio bytes length must be even (16-bit samples).")
+ future = asyncio.get_running_loop().create_future()
+ self._audio_buffer.append((audio_bytes, future))
+ return future
+
+ async def recv(self):
+ """
+ Returns the next audio frame, generating silence if needed.
+ """
+ # Compute required wait time for synchronization
+ if self._timestamp > 0:
+ wait = self._start + (self._timestamp / self._sample_rate) - time.time()
+ if wait > 0:
+ await asyncio.sleep(wait)
+
+ # Check if we have enough data
+ needed_bytes = self._samples_per_frame * 2 # 16-bit (2 bytes per sample)
+ available_bytes = sum(len(audio_bytes) for audio_bytes, _ in self._audio_buffer)
+ consumed_futures = [] # Track futures for processed data
+ if available_bytes >= needed_bytes:
+ # Extract data from deque
+ chunk = bytearray()
+ while len(chunk) < needed_bytes:
+ audio_bytes, future = self._audio_buffer.popleft()
+ chunk.extend(audio_bytes)
+ consumed_futures.append(future) # Track the future
+ chunk = bytes(chunk[:needed_bytes]) # Trim excess bytes
+ else:
+ chunk = bytes(needed_bytes) # Generate silent frame
+
+ # Convert the byte data to an ndarray of int16 samples
+ samples = np.frombuffer(chunk, dtype=np.int16)
+
+ # Create AudioFrame
+ frame = AudioFrame.from_ndarray(samples[None, :], layout="mono")
+ self._timestamp += self._samples_per_frame
+ frame.pts = self._timestamp
+ frame.sample_rate = self._sample_rate
+ frame.time_base = fractions.Fraction(1, self._sample_rate)
+
+ # Resolve all futures corresponding to consumed data
+ for future in consumed_futures:
+ if not future.done():
+ future.set_result(True)
+
+ return frame
+
+
+class RawVideoTrack(VideoStreamTrack):
+ def __init__(self, width, height):
+ super().__init__()
+ self._width = width
+ self._height = height
+ self._video_buffer = asyncio.Queue()
+
+ def add_video_frame(self, frame):
+ """Adds a raw video frame to the buffer."""
+ self._video_buffer.put_nowait(frame)
+
+ async def recv(self):
+ """Returns the next video frame, waiting if the buffer is empty."""
+ raw_frame = await self._video_buffer.get()
+
+ # Convert bytes to NumPy array
+ frame_data = np.frombuffer(raw_frame.image, dtype=np.uint8).reshape(
+ (self._height, self._width, 3)
+ )
+
+ frame = VideoFrame.from_ndarray(frame_data, format="rgb24")
+
+ # Assign timestamp
+ frame.pts, frame.time_base = await self.next_timestamp()
+
+ return frame
+
+
+class SmallWebRTCClient:
+ def __init__(self, webrtc_connection: SmallWebRTCConnection, callbacks: SmallWebRTCCallbacks):
+ self._webrtcConnection = webrtc_connection
+ self._closing = False
+ self._callbacks = callbacks
+
+ self._audio_output_track = None
+ self._video_output_track = None
+ self._audio_input_track: Optional[AudioStreamTrack] = None
+ self._video_input_track: Optional[VideoStreamTrack] = None
+
+ self._params = None
+ self._audio_in_channels = None
+ self._in_sample_rate = None
+ self._out_sample_rate = None
+
+ # We are always resampling it for 16000 if the sample_rate that we receive is bigger than that.
+ # otherwise we face issues with Silero VAD
+ self._pipecat_resampler = AudioResampler("s16", "mono", 16000)
+
+ @self._webrtcConnection.on("connected")
+ async def on_connected(connection: SmallWebRTCConnection):
+ logger.info("Peer connection established.")
+ await self._handle_client_connected()
+
+ @self._webrtcConnection.on("disconnected")
+ async def on_disconnected(connection: SmallWebRTCConnection):
+ logger.info("Peer connection lost.")
+ await self._handle_client_disconnected()
+
+ @self._webrtcConnection.on("closed")
+ async def on_closed(connection: SmallWebRTCConnection):
+ logger.info("Client connection closed.")
+ await self._handle_client_closed()
+
+ @self._webrtcConnection.on("appMessage")
+ async def on_app_message(message: Any):
+ await self._handle_app_message(message)
+
+ async def read_video_frame(self):
+ """
+ Reads a video frame from the given MediaStreamTrack, converts it to RGB,
+ and creates an InputImageRawFrame.
+ """
+ while True:
+ if self._video_input_track is None:
+ await asyncio.sleep(0.01)
+ continue
+
+ try:
+ frame = await asyncio.wait_for(self._video_input_track.recv(), timeout=2.0)
+ except asyncio.TimeoutError:
+ if self._webrtcConnection.is_connected():
+ logger.warning("Timeout: No video frame received within the specified time.")
+ # self._webrtcConnection.ask_to_renegotiate()
+ frame = None
+ except MediaStreamError:
+ logger.warning("Received an unexpected media stream error while reading the audio.")
+ frame = None
+
+ if frame is None or not isinstance(frame, VideoFrame):
+ # If no valid frame, sleep for a bit
+ await asyncio.sleep(0.01)
+ continue
+
+ format_name = frame.format.name
+
+ # Convert frame to NumPy array in its native format
+ frame_array = frame.to_ndarray(format=format_name)
+
+ # Handle different formats dynamically
+ if format_name == "yuv420p":
+ frame_rgb = cv2.cvtColor(frame_array, cv2.COLOR_YUV2RGB_I420)
+ elif format_name == "nv12":
+ frame_rgb = cv2.cvtColor(frame_array, cv2.COLOR_YUV2RGB_NV12)
+ elif format_name == "gray":
+ frame_rgb = cv2.cvtColor(frame_array, cv2.COLOR_GRAY2RGB)
+ elif format_name.startswith("rgb"): # Already RGB, no conversion needed
+ frame_rgb = frame_array
+ else:
+ raise ValueError(f"Unsupported format: {format_name}")
+
+ image_frame = InputImageRawFrame(
+ image=frame_rgb.tobytes(),
+ size=(frame.width, frame.height),
+ format="RGB",
+ )
+
+ yield image_frame
+
+ async def read_audio_frame(self):
+ """
+ Reads 20ms of audio from the given MediaStreamTrack and creates an InputAudioRawFrame.
+ """
+ while True:
+ if self._audio_input_track is None:
+ await asyncio.sleep(0.01)
+ continue
+
+ try:
+ frame = await asyncio.wait_for(self._audio_input_track.recv(), timeout=2.0)
+ except asyncio.TimeoutError:
+ if self._webrtcConnection.is_connected():
+ logger.warning("Timeout: No audio frame received within the specified time.")
+ frame = None
+ except MediaStreamError:
+ logger.warning("Received an unexpected media stream error while reading the audio.")
+ frame = None
+
+ if frame is None or not isinstance(frame, AudioFrame):
+ # If we don't read any audio let's sleep for a little bit (i.e. busy wait).
+ await asyncio.sleep(0.01)
+ continue
+
+ if frame.sample_rate > self._in_sample_rate:
+ resampled_frames = self._pipecat_resampler.resample(frame)
+ for resampled_frame in resampled_frames:
+ # 16-bit PCM bytes
+ pcm_bytes = resampled_frame.to_ndarray().astype(np.int16).tobytes()
+ audio_frame = InputAudioRawFrame(
+ audio=pcm_bytes,
+ sample_rate=resampled_frame.sample_rate,
+ num_channels=self._audio_in_channels,
+ )
+ yield audio_frame
+ else:
+ # 16-bit PCM bytes
+ pcm_bytes = frame.to_ndarray().astype(np.int16).tobytes()
+ audio_frame = InputAudioRawFrame(
+ audio=pcm_bytes,
+ sample_rate=frame.sample_rate,
+ num_channels=self._audio_in_channels,
+ )
+ yield audio_frame
+
+ async def write_raw_audio_frames(self, data: bytes):
+ if self._can_send() and self._audio_output_track:
+ await self._audio_output_track.add_audio_bytes(data)
+
+ async def write_frame_to_camera(self, frame: OutputImageRawFrame):
+ if self._can_send() and self._video_output_track:
+ self._video_output_track.add_video_frame(frame)
+
+ async def setup(self, _params: TransportParams, frame):
+ self._audio_in_channels = _params.audio_in_channels
+ self._in_sample_rate = _params.audio_in_sample_rate or frame.audio_in_sample_rate
+ self._out_sample_rate = _params.audio_out_sample_rate or frame.audio_out_sample_rate
+ self._params = _params
+
+ async def connect(self):
+ if self._webrtcConnection.is_connected():
+ # already initialized
+ return
+
+ logger.info(f"Connecting to Small WebRTC")
+ await self._webrtcConnection.connect()
+
+ async def disconnect(self):
+ if self.is_connected and not self.is_closing:
+ logger.info(f"Disconnecting to Small WebRTC")
+ self._closing = True
+ await self._webrtcConnection.close()
+ await self._handle_client_disconnected()
+
+ async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
+ if self._can_send():
+ self._webrtcConnection.send_app_message(frame.message)
+
+ async def _handle_client_connected(self):
+ # There is nothing to do here yet, the pipeline is still not ready
+ if not self._params:
+ return
+
+ self._audio_input_track = self._webrtcConnection.audio_input_track()
+ self._video_input_track = self._webrtcConnection.video_input_track()
+ if self._params.audio_out_enabled:
+ self._audio_output_track = RawAudioTrack(sample_rate=self._out_sample_rate)
+ self._webrtcConnection.replace_audio_track(self._audio_output_track)
+
+ if self._params.camera_out_enabled:
+ self._video_output_track = RawVideoTrack(
+ width=self._params.camera_out_width, height=self._params.camera_out_height
+ )
+ self._webrtcConnection.replace_video_track(self._video_output_track)
+
+ await self._callbacks.on_client_connected(self._webrtcConnection)
+
+ async def _handle_client_disconnected(self):
+ self._audio_input_track = None
+ self._video_input_track = None
+ self._audio_output_track = None
+ self._video_output_track = None
+ await self._callbacks.on_client_disconnected(self._webrtcConnection)
+
+ async def _handle_client_closed(self):
+ self._audio_input_track = None
+ self._video_input_track = None
+ self._audio_output_track = None
+ self._video_output_track = None
+ await self._callbacks.on_client_closed(self._webrtcConnection)
+
+ async def _handle_app_message(self, message: Any):
+ await self._callbacks.on_app_message(message)
+
+ def _can_send(self):
+ return self.is_connected and not self.is_closing
+
+ @property
+ def is_connected(self) -> bool:
+ return self._webrtcConnection.is_connected()
+
+ @property
+ def is_closing(self) -> bool:
+ return self._closing
+
+
+class SmallWebRTCInputTransport(BaseInputTransport):
+ def __init__(
+ self,
+ client: SmallWebRTCClient,
+ params: TransportParams,
+ **kwargs,
+ ):
+ super().__init__(params, **kwargs)
+ self._client = client
+ self._params = params
+ self._receive_audio_task = None
+ self._receive_video_task = None
+
+ async def start(self, frame: StartFrame):
+ await super().start(frame)
+ await self._client.setup(self._params, frame)
+ await self._client.connect()
+ if not self._receive_audio_task and (
+ self._params.audio_in_enabled or self._params.vad_enabled
+ ):
+ self._receive_audio_task = self.create_task(self._receive_audio())
+ if not self._receive_video_task and self._params.camera_in_enabled:
+ self._receive_video_task = self.create_task(self._receive_video())
+
+ async def _stop_tasks(self):
+ if self._receive_audio_task:
+ await self.cancel_task(self._receive_audio_task)
+ self._receive_audio_task = None
+ if self._receive_video_task:
+ await self.cancel_task(self._receive_video_task)
+ self._receive_video_task = None
+
+ async def stop(self, frame: EndFrame):
+ await super().stop(frame)
+ await self._stop_tasks()
+ await self._client.disconnect()
+
+ async def cancel(self, frame: CancelFrame):
+ await super().cancel(frame)
+ await self._stop_tasks()
+ await self._client.disconnect()
+
+ async def _receive_audio(self):
+ try:
+ async for audio_frame in self._client.read_audio_frame():
+ if audio_frame:
+ await self.push_audio_frame(audio_frame)
+
+ except Exception as e:
+ logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
+
+ async def _receive_video(self):
+ try:
+ async for video_frame in self._client.read_video_frame():
+ if video_frame:
+ await self.push_frame(video_frame)
+
+ except Exception as e:
+ logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
+
+ async def push_app_message(self, message: Any):
+ logger.info(f"Received app message inside SmallWebRTCInputTransport {message}")
+ frame = TransportMessageUrgentFrame(message=message)
+ await self.push_frame(frame)
+
+
+class SmallWebRTCOutputTransport(BaseOutputTransport):
+ def __init__(
+ self,
+ client: SmallWebRTCClient,
+ params: TransportParams,
+ **kwargs,
+ ):
+ super().__init__(params, **kwargs)
+ self._client = client
+ self._params = params
+
+ async def start(self, frame: StartFrame):
+ await super().start(frame)
+ await self._client.setup(self._params, frame)
+ await self._client.connect()
+
+ async def stop(self, frame: EndFrame):
+ await super().stop(frame)
+ await self._client.disconnect()
+
+ async def cancel(self, frame: CancelFrame):
+ await super().cancel(frame)
+ await self._client.disconnect()
+
+ async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
+ await self._client.send_message(frame)
+
+ async def write_raw_audio_frames(self, frames: bytes):
+ await self._client.write_raw_audio_frames(frames)
+
+ async def write_frame_to_camera(self, frame: OutputImageRawFrame):
+ await self._client.write_frame_to_camera(frame)
+
+
+class SmallWebRTCTransport(BaseTransport):
+ def __init__(
+ self,
+ webrtc_connection: SmallWebRTCConnection,
+ params: TransportParams,
+ input_name: Optional[str] = None,
+ output_name: Optional[str] = None,
+ ):
+ super().__init__(input_name=input_name, output_name=output_name)
+ self._params = params
+
+ self._callbacks = SmallWebRTCCallbacks(
+ on_app_message=self._on_app_message,
+ on_client_connected=self._on_client_connected,
+ on_client_disconnected=self._on_client_disconnected,
+ on_client_closed=self._on_client_closed,
+ )
+
+ self._client = SmallWebRTCClient(webrtc_connection, self._callbacks)
+
+ self._input = SmallWebRTCInputTransport(self._client, self._params, name=self._input_name)
+ self._output = SmallWebRTCOutputTransport(
+ self._client, self._params, name=self._output_name
+ )
+
+ # Register supported handlers. The user will only be able to register
+ # these handlers.
+ self._register_event_handler("on_app_message")
+ self._register_event_handler("on_client_connected")
+ self._register_event_handler("on_client_disconnected")
+ self._register_event_handler("on_client_closed")
+
+ def input(self) -> SmallWebRTCInputTransport:
+ if not self._input:
+ self._input = SmallWebRTCInputTransport(
+ self._client, self._params, name=self._input_name
+ )
+ return self._input
+
+ def output(self) -> SmallWebRTCOutputTransport:
+ if not self._output:
+ self._output = SmallWebRTCOutputTransport(
+ self._client, self._params, name=self._input_name
+ )
+ return self._output
+
+ async def _on_app_message(self, message: Any):
+ if self._input:
+ await self._input.push_app_message(message)
+ await self._call_event_handler("on_app_message", message)
+
+ async def _on_client_connected(self, webrtc_connection):
+ await self._call_event_handler("on_client_connected", webrtc_connection)
+
+ async def _on_client_disconnected(self, webrtc_connection):
+ await self._call_event_handler("on_client_disconnected", webrtc_connection)
+
+ async def _on_client_closed(self, webrtc_connection):
+ await self._call_event_handler("on_client_closed", webrtc_connection)
diff --git a/src/pipecat/transports/network/webrtc_connection.py b/src/pipecat/transports/network/webrtc_connection.py
new file mode 100644
index 000000000..e18879add
--- /dev/null
+++ b/src/pipecat/transports/network/webrtc_connection.py
@@ -0,0 +1,246 @@
+import asyncio
+import json
+import time
+import uuid
+from enum import Enum
+from typing import Any, Optional
+
+from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription
+from loguru import logger
+
+from pipecat.utils.event_emitter import EventEmitter
+
+SIGNALLING_TYPE = "signalling"
+
+
+class SignallingMessage(Enum):
+ RENEGOTIATE = "renegotiate"
+
+
+class SmallWebRTCConnection(EventEmitter):
+ def __init__(self, ice_servers=None):
+ super().__init__()
+ if ice_servers:
+ self.ice_servers = [RTCIceServer(urls=server) for server in ice_servers]
+ else:
+ self.ice_servers = []
+ self._connect_invoked = False
+ self._initialize()
+
+ def _initialize(self):
+ logger.info("Initializing new peer connection")
+ rtc_config = RTCConfiguration(iceServers=self.ice_servers)
+
+ self.answer: Optional[RTCSessionDescription] = None
+ self.pc = RTCPeerConnection(rtc_config)
+ self.pc_id = "PeerConnection(%s)" % uuid.uuid4()
+ self._setup_listeners()
+ self._tracks = set()
+ self._data_channel = None
+ self._renegotiation_in_progress = False
+ self._last_received_time = None
+
+ def _setup_listeners(self):
+ @self.pc.on("datachannel")
+ def on_datachannel(channel):
+ self._data_channel = channel
+
+ @channel.on("message")
+ async def on_message(message):
+ try:
+ # aiortc does not provide any way so we can be aware when we are disconnected,
+ # so we are using this keep alive message as a way to implement that
+ if isinstance(message, str) and message.startswith("ping"):
+ self._last_received_time = time.time()
+ else:
+ json_message = json.loads(message)
+ await self.emit("appMessage", json_message)
+ except Exception as e:
+ logger.exception(f"Error parsing JSON message {message}, {e}")
+
+ # Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
+ # So, in case we loose connection, this event will not be triggered
+ @self.pc.on("connectionstatechange")
+ async def on_connectionstatechange():
+ await self._handle_new_connection_state()
+
+ # Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
+ # So, in case we loose connection, this event will not be triggered
+ @self.pc.on("iceconnectionstatechange")
+ async def on_iceconnectionstatechange():
+ logger.info(
+ f"Ice connection state is {self.pc.iceConnectionState}, connection is {self.pc.connectionState}"
+ )
+
+ @self.pc.on("icegatheringstatechange")
+ async def on_icegatheringstatechange():
+ logger.info(f"Ice gathering state is {self.pc.iceGatheringState}")
+
+ @self.pc.on("track")
+ async def on_track(track):
+ logger.info(f"Track {track.kind} received")
+ self._tracks.add(track)
+ await self.emit("track-started", track)
+
+ @track.on("ended")
+ async def on_ended():
+ logger.info(f"Track {track.kind} ended")
+ self._tracks.discard(track)
+ await self.emit("track-ended", track)
+
+ async def _create_answer(self, sdp: str, type: str):
+ offer = RTCSessionDescription(sdp=sdp, type=type)
+ await self.pc.setRemoteDescription(offer)
+
+ # For some reason, aiortc is not respecting the SDP for the transceivers to be sendrcv
+ # so we are basically forcing it to act this way
+ self.force_transceivers_to_send_recv()
+
+ # this answer does not contain the ice candidates, which will be gathered later, after the setLocalDescription
+ logger.info(f"Creating answer")
+ local_answer = await self.pc.createAnswer()
+ await self.pc.setLocalDescription(local_answer)
+ logger.info(f"Setting the answer after the local description is created")
+ self.answer = self.pc.localDescription
+
+ async def initialize(self, sdp: str, type: str):
+ await self._create_answer(sdp, type)
+
+ async def connect(self):
+ self._connect_invoked = True
+ # If we already connected, trigger again the connected event
+ if self.is_connected():
+ await self.emit("connected", self)
+ # We are renegotiating here, because likely we have loose the first video frames
+ # and aiortc does not handle that pretty well.
+ self.ask_to_renegotiate()
+
+ async def renegotiate(self, sdp: str, type: str, restart_pc: bool = False):
+ logger.info(f"Renegotiating {self.pc_id}")
+
+ if restart_pc:
+ await self.emit("disconnected", self)
+ logger.info("Closing old peer connection")
+ # removing the listeners to prevent the bot from closing
+ self.pc.remove_all_listeners()
+ await self.close()
+ # we are initializing a new peer connection in this case.
+ self._initialize()
+
+ await self._create_answer(sdp, type)
+
+ # Maybe we should refactor to receive a message from the client side when the renegotiation is completed.
+ # or look at the peer connection listeners
+ # but this is good enough for now for testing.
+ async def delayed_task():
+ await asyncio.sleep(2)
+ self._renegotiation_in_progress = False
+
+ asyncio.create_task(delayed_task())
+
+ def force_transceivers_to_send_recv(self):
+ for transceiver in self.pc.getTransceivers():
+ transceiver.direction = "sendrecv"
+ # logger.info(
+ # f"Transceiver: {transceiver}, Mid: {transceiver.mid}, Direction: {transceiver.direction}"
+ # )
+ # logger.info(f"Sender track: {transceiver.sender.track}")
+
+ def replace_audio_track(self, track):
+ logger.info(f"Replacing audio track {track.kind}")
+ # Transceivers always appear in creation-order for both peers
+ # For now we are only considering that we are going to have 02 transceivers,
+ # one for audio and one for video
+ transceivers = self.pc.getTransceivers()
+ if len(transceivers) > 0 and transceivers[0].sender:
+ transceivers[0].sender.replaceTrack(track)
+ else:
+ logger.warning("Audio transceiver not found. Cannot replace audio track.")
+
+ def replace_video_track(self, track):
+ logger.info(f"Replacing video track {track.kind}")
+ # Transceivers always appear in creation-order for both peers
+ # For now we are only considering that we are going to have 02 transceivers,
+ # one for audio and one for video
+ transceivers = self.pc.getTransceivers()
+ if len(transceivers) > 1 and transceivers[1].sender:
+ transceivers[1].sender.replaceTrack(track)
+ else:
+ logger.warning("Video transceiver not found. Cannot replace video track.")
+
+ async def close(self):
+ if self.pc:
+ await self.pc.close()
+
+ def get_answer(self):
+ if not self.answer:
+ return None
+
+ return {
+ "sdp": self.answer.sdp,
+ "type": self.answer.type,
+ "pc_id": self.pc_id,
+ }
+
+ async def _handle_new_connection_state(self):
+ state = self.pc.connectionState
+ logger.info(f"Connection state changed to: {state}")
+ await self.emit(state, self)
+ if state == "failed":
+ logger.warning("Connection failed, closing peer connection.")
+ await self.close()
+
+ # Despite the fact that aiortc provides this listener, they don't have a status for "disconnected"
+ # So, there is no advantage in looking at self.pc.connectionState
+ # That is why we are trying to keep our own state
+ def is_connected(self):
+ # If the small webrtc transport has never invoked to connect
+ # we are acting like if we are not connected
+ if not self._connect_invoked:
+ return False
+
+ if self._last_received_time is None:
+ # if we have never received a message, it is probably because the client has not created a data channel
+ # so we are going to trust aiortc in this case
+ return self.pc.connectionState == "connected"
+ # Checks if the last received ping was within the last 3 seconds.
+ return (time.time() - self._last_received_time) < 3
+
+ def audio_input_track(self):
+ # Transceivers always appear in creation-order for both peers
+ # For now we are only considering that we are going to have 02 transceivers,
+ # one for audio and one for video
+ transceivers = self.pc.getTransceivers()
+ if len(transceivers) == 0 or not transceivers[0].receiver:
+ logger.warning("No audio transceiver is available")
+ return None
+
+ return transceivers[0].receiver.track
+
+ def video_input_track(self):
+ # Transceivers always appear in creation-order for both peers
+ # For now we are only considering that we are going to have 02 transceivers,
+ # one for audio and one for video
+ transceivers = self.pc.getTransceivers()
+ if len(transceivers) <= 1 or not transceivers[1].receiver:
+ logger.warning("No video transceiver is available")
+ return None
+
+ return transceivers[1].receiver.track
+
+ def tracks(self):
+ return self._tracks
+
+ def send_app_message(self, message: Any):
+ if self._data_channel:
+ json_message = json.dumps(message)
+ self._data_channel.send(json_message)
+
+ def ask_to_renegotiate(self):
+ if self._renegotiation_in_progress:
+ return
+
+ self._renegotiation_in_progress = True
+ self.send_app_message(
+ {"type": SIGNALLING_TYPE, "message": SignallingMessage.RENEGOTIATE.value}
+ )
diff --git a/src/pipecat/utils/event_emitter.py b/src/pipecat/utils/event_emitter.py
new file mode 100644
index 000000000..dcffd2fd1
--- /dev/null
+++ b/src/pipecat/utils/event_emitter.py
@@ -0,0 +1,20 @@
+class EventEmitter:
+ def __init__(self):
+ self._events = {}
+
+ def on(self, event_name):
+ """Decorator to register an event handler."""
+
+ def decorator(func):
+ if event_name not in self._events:
+ self._events[event_name] = []
+ self._events[event_name].append(func)
+ return func
+
+ return decorator
+
+ async def emit(self, event_name, *args, **kwargs):
+ """Trigger all handlers for a given event."""
+ if event_name in self._events:
+ for handler in self._events[event_name]:
+ await handler(*args, **kwargs)