adding a frame processor with the ability to save a conversation to a buffer and another frame processor to upload audio to Canonical for evaluation and metrics collection. Examples included
This commit is contained in:
161
examples/canonical-metrics/.gitignore
vendored
Normal file
161
examples/canonical-metrics/.gitignore
vendored
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
recordings/
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
runpod.toml
|
||||||
16
examples/canonical-metrics/Dockerfile
Normal file
16
examples/canonical-metrics/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.10-bullseye
|
||||||
|
|
||||||
|
RUN mkdir /app
|
||||||
|
RUN mkdir /app/assets
|
||||||
|
RUN mkdir /app/utils
|
||||||
|
COPY *.py /app/
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
copy assets/* /app/assets/
|
||||||
|
copy utils/* /app/utils/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 7860
|
||||||
|
|
||||||
|
CMD ["python3", "server.py"]
|
||||||
37
examples/canonical-metrics/README.md
Normal file
37
examples/canonical-metrics/README.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Simple Chatbot
|
||||||
|
|
||||||
|
<img src="image.png" width="420px">
|
||||||
|
|
||||||
|
This app connects you to a chatbot powered by GPT-4, complete with animations generated by Stable Video Diffusion.
|
||||||
|
|
||||||
|
See a video of it in action: https://x.com/kwindla/status/1778628911817183509
|
||||||
|
|
||||||
|
And a quick video walkthrough of the code: https://www.loom.com/share/13df1967161f4d24ade054e7f8753416
|
||||||
|
|
||||||
|
ℹ️ The first time, things might take extra time to get started since VAD (Voice Activity Detection) model needs to be downloaded.
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
```python
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
cp env.example .env # and add your credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, visit `http://localhost:7860/start` in your browser to start a chatbot session.
|
||||||
|
|
||||||
|
## Build and test the Docker image
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t chatbot .
|
||||||
|
docker run --env-file .env -p 7860:7860 chatbot
|
||||||
|
```
|
||||||
146
examples/canonical-metrics/bot.py
Normal file
146
examples/canonical-metrics/bot.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from runner import configure
|
||||||
|
|
||||||
|
from pipecat.frames.frames import EndFrame, LLMMessagesFrame
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.llm_response import (
|
||||||
|
LLMAssistantResponseAggregator, LLMUserResponseAggregator)
|
||||||
|
from pipecat.processors.canonical_metrics_processor import CanonicalMetrics
|
||||||
|
from pipecat.processors.user_marker_processor import UserMarkerProcessor
|
||||||
|
from pipecat.services.elevenlabs import ElevenLabsTTSService
|
||||||
|
from pipecat.services.openai import OpenAILLMService
|
||||||
|
from pipecat.transports.services.daily import DailyParams, DailyTransport
|
||||||
|
from pipecat.vad.silero import SileroVADAnalyzer
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
logger.remove(0)
|
||||||
|
logger.add(sys.stderr, level="DEBUG")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
(room_url, token) = await configure(session)
|
||||||
|
|
||||||
|
transport = DailyTransport(
|
||||||
|
room_url,
|
||||||
|
token,
|
||||||
|
"Chatbot",
|
||||||
|
DailyParams(
|
||||||
|
audio_out_enabled=True,
|
||||||
|
audio_in_enabled=True,
|
||||||
|
camera_out_enabled=False,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_audio_passthrough=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(),
|
||||||
|
transcription_enabled=True,
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# transcription_settings=DailyTranscriptionSettings(
|
||||||
|
# language="es",
|
||||||
|
# tier="nova",
|
||||||
|
# model="2-general"
|
||||||
|
# )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tts = ElevenLabsTTSService(
|
||||||
|
api_key=os.getenv("ELEVENLABS_API_KEY"),
|
||||||
|
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
voice_id="cgSgspJ2msm6clMCkdW9",
|
||||||
|
aiohttp_session=session,
|
||||||
|
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# model="eleven_multilingual_v2",
|
||||||
|
# voice_id="gD1IexrzCvsXPHUuT0s3",
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = OpenAILLMService(
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY"),
|
||||||
|
model="gpt-4o")
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
"content": "You are 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, but keep your responses brief. Start by introducing yourself. Keep all your responses to 12 words or fewer.",
|
||||||
|
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# "content": "Eres Chatbot, un amigable y útil robot. Tu objetivo es demostrar tus capacidades de una manera breve. Tus respuestas se convertiran a audio así que nunca no debes incluir caracteres especiales. Contesta a lo que el usuario pregunte de una manera creativa, útil y breve. Empieza por presentarte a ti mismo.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
user_response = LLMUserResponseAggregator()
|
||||||
|
assistant_response = LLMAssistantResponseAggregator()
|
||||||
|
|
||||||
|
"""
|
||||||
|
CanonicalMetrics uses AudioBufferProcessor under the hood to buffer the audio. On
|
||||||
|
call completion, CanonicalMetrics will send the audio buffer to Canonical for
|
||||||
|
analysis. Visit https://voice.canonical.chat to learn more.
|
||||||
|
"""
|
||||||
|
canonical = CanonicalMetrics(
|
||||||
|
call_id=str(uuid.uuid4()),
|
||||||
|
assistant="pipecat-chatbot",
|
||||||
|
assistant_speaks_first=True,
|
||||||
|
)
|
||||||
|
usermarker = UserMarkerProcessor()
|
||||||
|
pipeline = Pipeline([
|
||||||
|
transport.input(), # microphone
|
||||||
|
usermarker, # used to mark the user's audio in the pipeline
|
||||||
|
user_response,
|
||||||
|
llm,
|
||||||
|
tts,
|
||||||
|
canonical, # captures audio and uploads to Canonical AI for metrics
|
||||||
|
transport.output(),
|
||||||
|
assistant_response,
|
||||||
|
])
|
||||||
|
|
||||||
|
task = PipelineTask(pipeline, PipelineParams(allow_interruptions=True))
|
||||||
|
|
||||||
|
@transport.event_handler("on_first_participant_joined")
|
||||||
|
async def on_first_participant_joined(transport, participant):
|
||||||
|
transport.capture_participant_transcription(participant["id"])
|
||||||
|
await task.queue_frames([LLMMessagesFrame(messages)])
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_left")
|
||||||
|
async def on_participant_left(transport, participant, reason):
|
||||||
|
print(f"Participant left: {participant}")
|
||||||
|
await task.queue_frame(EndFrame())
|
||||||
|
|
||||||
|
@transport.event_handler("on_call_state_updated")
|
||||||
|
async def on_call_state_updated(transport, state):
|
||||||
|
if state == "left":
|
||||||
|
await task.queue_frame(EndFrame())
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
|
||||||
|
await runner.run(task)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
5
examples/canonical-metrics/env.example
Normal file
5
examples/canonical-metrics/env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DAILY_SAMPLE_ROOM_URL=https://yourdomain.daily.co/yourroom # (for joining the bot to the same room repeatedly for local dev)
|
||||||
|
DAILY_API_KEY=7df...
|
||||||
|
OPENAI_API_KEY=sk-PL...
|
||||||
|
ELEVENLABS_API_KEY=aeb...
|
||||||
|
CANONICAL_API_KEY=can...
|
||||||
5
examples/canonical-metrics/requirements.txt
Normal file
5
examples/canonical-metrics/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
python-dotenv
|
||||||
|
fastapi[all]
|
||||||
|
uvicorn
|
||||||
|
pipecat-ai[daily,openai,silero,elevenlabs,canonical]
|
||||||
|
|
||||||
54
examples/canonical-metrics/runner.py
Normal file
54
examples/canonical-metrics/runner.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pipecat.transports.services.helpers.daily_rest import DailyRESTHelper
|
||||||
|
|
||||||
|
|
||||||
|
async def configure(aiohttp_session: aiohttp.ClientSession):
|
||||||
|
parser = argparse.ArgumentParser(description="Daily AI SDK Bot Sample")
|
||||||
|
parser.add_argument(
|
||||||
|
"-u",
|
||||||
|
"--url",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="URL of the Daily room to join")
|
||||||
|
parser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--apikey",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="Daily API Key (needed to create an owner token for the room)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args, unknown = parser.parse_known_args()
|
||||||
|
|
||||||
|
url = args.url or os.getenv("DAILY_SAMPLE_ROOM_URL")
|
||||||
|
key = args.apikey or os.getenv("DAILY_API_KEY")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
raise Exception(
|
||||||
|
"No Daily room specified. use the -u/--url option from the command line, or set DAILY_SAMPLE_ROOM_URL in your environment to specify a Daily room URL.")
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
raise Exception("No Daily API key specified. use the -k/--apikey option from the command line, or set DAILY_API_KEY in your environment to specify a Daily API key, available from https://dashboard.daily.co/developers.")
|
||||||
|
|
||||||
|
daily_rest_helper = DailyRESTHelper(
|
||||||
|
daily_api_key=key,
|
||||||
|
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
|
||||||
|
aiohttp_session=aiohttp_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a meeting token for the given room with an expiration 1 hour in
|
||||||
|
# the future.
|
||||||
|
expiry_time: float = 60 * 60
|
||||||
|
|
||||||
|
token = await daily_rest_helper.get_token(url, expiry_time)
|
||||||
|
|
||||||
|
return (url, token)
|
||||||
147
examples/canonical-metrics/server.py
Normal file
147
examples/canonical-metrics/server.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from pipecat.transports.services.helpers.daily_rest import (DailyRESTHelper,
|
||||||
|
DailyRoomParams)
|
||||||
|
|
||||||
|
MAX_BOTS_PER_ROOM = 1
|
||||||
|
|
||||||
|
# Bot sub-process dict for status reporting and concurrency control
|
||||||
|
bot_procs = {}
|
||||||
|
|
||||||
|
daily_helpers = {}
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
# Clean up function, just to be extra safe
|
||||||
|
for entry in bot_procs.values():
|
||||||
|
proc = entry[0]
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
aiohttp_session = aiohttp.ClientSession()
|
||||||
|
daily_helpers["rest"] = DailyRESTHelper(
|
||||||
|
daily_api_key=os.getenv("DAILY_API_KEY", ""),
|
||||||
|
daily_api_url=os.getenv("DAILY_API_URL", 'https://api.daily.co/v1'),
|
||||||
|
aiohttp_session=aiohttp_session
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
await aiohttp_session.close()
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/start")
|
||||||
|
async def start_agent(request: Request):
|
||||||
|
print(f"!!! Creating room")
|
||||||
|
room = await daily_helpers["rest"].create_room(DailyRoomParams())
|
||||||
|
print(f"!!! Room URL: {room.url}")
|
||||||
|
# Ensure the room property is present
|
||||||
|
if not room.url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Missing 'room' property in request data. Cannot start agent without a target room!")
|
||||||
|
|
||||||
|
# Check if there is already an existing process running in this room
|
||||||
|
num_bots_in_room = sum(
|
||||||
|
1 for proc in bot_procs.values() if proc[1] == room.url and proc[0].poll() is None)
|
||||||
|
if num_bots_in_room >= MAX_BOTS_PER_ROOM:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Max bot limited reach for room: {room.url}")
|
||||||
|
|
||||||
|
# Get the token for the room
|
||||||
|
token = await daily_helpers["rest"].get_token(room.url)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get token for room: {room.url}")
|
||||||
|
|
||||||
|
# Spawn a new agent, and join the user session
|
||||||
|
# Note: this is mostly for demonstration purposes (refer to 'deployment' in README)
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
f"python3 -m bot -u {room.url} -t {token}"
|
||||||
|
],
|
||||||
|
shell=True,
|
||||||
|
bufsize=1,
|
||||||
|
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
)
|
||||||
|
bot_procs[proc.pid] = (proc, room.url)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to start subprocess: {e}")
|
||||||
|
|
||||||
|
return RedirectResponse(room.url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status/{pid}")
|
||||||
|
def get_status(pid: int):
|
||||||
|
# Look up the subprocess
|
||||||
|
proc = bot_procs.get(pid)
|
||||||
|
|
||||||
|
# If the subprocess doesn't exist, return an error
|
||||||
|
if not proc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Bot with process id: {pid} not found")
|
||||||
|
|
||||||
|
# Check the status of the subprocess
|
||||||
|
if proc[0].poll() is None:
|
||||||
|
status = "running"
|
||||||
|
else:
|
||||||
|
status = "finished"
|
||||||
|
|
||||||
|
return JSONResponse({"bot_id": pid, "status": status})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
default_host = os.getenv("HOST", "0.0.0.0")
|
||||||
|
default_port = int(os.getenv("FAST_API_PORT", "7860"))
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Daily Storyteller FastAPI server")
|
||||||
|
parser.add_argument("--host", type=str,
|
||||||
|
default=default_host, help="Host address")
|
||||||
|
parser.add_argument("--port", type=int,
|
||||||
|
default=default_port, help="Port number")
|
||||||
|
parser.add_argument("--reload", action="store_true",
|
||||||
|
help="Reload code on change")
|
||||||
|
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"server:app",
|
||||||
|
host=config.host,
|
||||||
|
port=config.port,
|
||||||
|
reload=config.reload,
|
||||||
|
)
|
||||||
161
examples/chatbot-audio-recording/.gitignore
vendored
Normal file
161
examples/chatbot-audio-recording/.gitignore
vendored
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
runpod.toml
|
||||||
16
examples/chatbot-audio-recording/Dockerfile
Normal file
16
examples/chatbot-audio-recording/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.10-bullseye
|
||||||
|
|
||||||
|
RUN mkdir /app
|
||||||
|
RUN mkdir /app/assets
|
||||||
|
RUN mkdir /app/utils
|
||||||
|
COPY *.py /app/
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
copy assets/* /app/assets/
|
||||||
|
copy utils/* /app/utils/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 7860
|
||||||
|
|
||||||
|
CMD ["python3", "server.py"]
|
||||||
37
examples/chatbot-audio-recording/README.md
Normal file
37
examples/chatbot-audio-recording/README.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Simple Chatbot
|
||||||
|
|
||||||
|
<img src="image.png" width="420px">
|
||||||
|
|
||||||
|
This app connects you to a chatbot powered by GPT-4, complete with animations generated by Stable Video Diffusion.
|
||||||
|
|
||||||
|
See a video of it in action: https://x.com/kwindla/status/1778628911817183509
|
||||||
|
|
||||||
|
And a quick video walkthrough of the code: https://www.loom.com/share/13df1967161f4d24ade054e7f8753416
|
||||||
|
|
||||||
|
ℹ️ The first time, things might take extra time to get started since VAD (Voice Activity Detection) model needs to be downloaded.
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
```python
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
cp env.example .env # and add your credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, visit `http://localhost:7860/start` in your browser to start a chatbot session.
|
||||||
|
|
||||||
|
## Build and test the Docker image
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t chatbot .
|
||||||
|
docker run --env-file .env -p 7860:7860 chatbot
|
||||||
|
```
|
||||||
136
examples/chatbot-audio-recording/bot.py
Normal file
136
examples/chatbot-audio-recording/bot.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from runner import configure
|
||||||
|
|
||||||
|
from pipecat.frames.frames import EndFrame, LLMMessagesFrame
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.llm_response import (
|
||||||
|
LLMAssistantResponseAggregator, LLMUserResponseAggregator)
|
||||||
|
from pipecat.processors.audio_buffer_processor import AudioBufferProcessor
|
||||||
|
from pipecat.processors.user_marker_processor import UserMarkerProcessor
|
||||||
|
from pipecat.services.elevenlabs import ElevenLabsTTSService
|
||||||
|
from pipecat.services.openai import OpenAILLMService
|
||||||
|
from pipecat.transports.services.daily import DailyParams, DailyTransport
|
||||||
|
from pipecat.vad.silero import SileroVADAnalyzer
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
logger.remove(0)
|
||||||
|
logger.add(sys.stderr, level="DEBUG")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
(room_url, token) = await configure(session)
|
||||||
|
|
||||||
|
transport = DailyTransport(
|
||||||
|
room_url,
|
||||||
|
token,
|
||||||
|
"Chatbot",
|
||||||
|
DailyParams(
|
||||||
|
audio_out_enabled=True,
|
||||||
|
audio_in_enabled=True,
|
||||||
|
camera_out_enabled=False,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_audio_passthrough=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(),
|
||||||
|
transcription_enabled=True,
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# transcription_settings=DailyTranscriptionSettings(
|
||||||
|
# language="es",
|
||||||
|
# tier="nova",
|
||||||
|
# model="2-general"
|
||||||
|
# )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
tts = ElevenLabsTTSService(
|
||||||
|
api_key=os.getenv("ELEVENLABS_API_KEY"),
|
||||||
|
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
voice_id="cgSgspJ2msm6clMCkdW9",
|
||||||
|
aiohttp_session=session,
|
||||||
|
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# model="eleven_multilingual_v2",
|
||||||
|
# voice_id="gD1IexrzCvsXPHUuT0s3",
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = OpenAILLMService(
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY"),
|
||||||
|
model="gpt-4o")
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
"content": "You are 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, but keep your responses brief. Start by introducing yourself. Keep all your response to 12 words or fewer.",
|
||||||
|
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# "content": "Eres Chatbot, un amigable y útil robot. Tu objetivo es demostrar tus capacidades de una manera breve. Tus respuestas se convertiran a audio así que nunca no debes incluir caracteres especiales. Contesta a lo que el usuario pregunte de una manera creativa, útil y breve. Empieza por presentarte a ti mismo.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
user_response = LLMUserResponseAggregator()
|
||||||
|
assistant_response = LLMAssistantResponseAggregator()
|
||||||
|
|
||||||
|
audiobuffer = AudioBufferProcessor()
|
||||||
|
usermarker = UserMarkerProcessor()
|
||||||
|
pipeline = Pipeline([
|
||||||
|
transport.input(), # microphone
|
||||||
|
usermarker, # used to mark the user's audio in the pipeline
|
||||||
|
user_response,
|
||||||
|
llm,
|
||||||
|
tts,
|
||||||
|
audiobuffer, # used to buffer the audio in the pipeline
|
||||||
|
transport.output(),
|
||||||
|
assistant_response,
|
||||||
|
])
|
||||||
|
|
||||||
|
task = PipelineTask(pipeline, PipelineParams(allow_interruptions=True))
|
||||||
|
|
||||||
|
@transport.event_handler("on_first_participant_joined")
|
||||||
|
async def on_first_participant_joined(transport, participant):
|
||||||
|
transport.capture_participant_transcription(participant["id"])
|
||||||
|
await task.queue_frames([LLMMessagesFrame(messages)])
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_left")
|
||||||
|
async def on_participant_left(transport, participant, reason):
|
||||||
|
print(f"Participant left: {participant}")
|
||||||
|
await task.queue_frame(EndFrame())
|
||||||
|
|
||||||
|
@transport.event_handler("on_call_state_updated")
|
||||||
|
async def on_call_state_updated(transport, state):
|
||||||
|
if state == "left":
|
||||||
|
await task.queue_frame(EndFrame())
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
|
||||||
|
await runner.run(task)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
4
examples/chatbot-audio-recording/env.example
Normal file
4
examples/chatbot-audio-recording/env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DAILY_SAMPLE_ROOM_URL=https://yourdomain.daily.co/yourroom # (for joining the bot to the same room repeatedly for local dev)
|
||||||
|
DAILY_API_KEY=7df...
|
||||||
|
OPENAI_API_KEY=sk-PL...
|
||||||
|
ELEVENLABS_API_KEY=aeb...
|
||||||
4
examples/chatbot-audio-recording/requirements.txt
Normal file
4
examples/chatbot-audio-recording/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
python-dotenv
|
||||||
|
fastapi[all]
|
||||||
|
uvicorn
|
||||||
|
pipecat-ai[daily,openai,silero,elevenlabs]
|
||||||
54
examples/chatbot-audio-recording/runner.py
Normal file
54
examples/chatbot-audio-recording/runner.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pipecat.transports.services.helpers.daily_rest import DailyRESTHelper
|
||||||
|
|
||||||
|
|
||||||
|
async def configure(aiohttp_session: aiohttp.ClientSession):
|
||||||
|
parser = argparse.ArgumentParser(description="Daily AI SDK Bot Sample")
|
||||||
|
parser.add_argument(
|
||||||
|
"-u",
|
||||||
|
"--url",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="URL of the Daily room to join")
|
||||||
|
parser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--apikey",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="Daily API Key (needed to create an owner token for the room)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args, unknown = parser.parse_known_args()
|
||||||
|
|
||||||
|
url = args.url or os.getenv("DAILY_SAMPLE_ROOM_URL")
|
||||||
|
key = args.apikey or os.getenv("DAILY_API_KEY")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
raise Exception(
|
||||||
|
"No Daily room specified. use the -u/--url option from the command line, or set DAILY_SAMPLE_ROOM_URL in your environment to specify a Daily room URL.")
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
raise Exception("No Daily API key specified. use the -k/--apikey option from the command line, or set DAILY_API_KEY in your environment to specify a Daily API key, available from https://dashboard.daily.co/developers.")
|
||||||
|
|
||||||
|
daily_rest_helper = DailyRESTHelper(
|
||||||
|
daily_api_key=key,
|
||||||
|
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
|
||||||
|
aiohttp_session=aiohttp_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a meeting token for the given room with an expiration 1 hour in
|
||||||
|
# the future.
|
||||||
|
expiry_time: float = 60 * 60
|
||||||
|
|
||||||
|
token = await daily_rest_helper.get_token(url, expiry_time)
|
||||||
|
|
||||||
|
return (url, token)
|
||||||
147
examples/chatbot-audio-recording/server.py
Normal file
147
examples/chatbot-audio-recording/server.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from pipecat.transports.services.helpers.daily_rest import (DailyRESTHelper,
|
||||||
|
DailyRoomParams)
|
||||||
|
|
||||||
|
MAX_BOTS_PER_ROOM = 1
|
||||||
|
|
||||||
|
# Bot sub-process dict for status reporting and concurrency control
|
||||||
|
bot_procs = {}
|
||||||
|
|
||||||
|
daily_helpers = {}
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
# Clean up function, just to be extra safe
|
||||||
|
for entry in bot_procs.values():
|
||||||
|
proc = entry[0]
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
aiohttp_session = aiohttp.ClientSession()
|
||||||
|
daily_helpers["rest"] = DailyRESTHelper(
|
||||||
|
daily_api_key=os.getenv("DAILY_API_KEY", ""),
|
||||||
|
daily_api_url=os.getenv("DAILY_API_URL", 'https://api.daily.co/v1'),
|
||||||
|
aiohttp_session=aiohttp_session
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
await aiohttp_session.close()
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/start")
|
||||||
|
async def start_agent(request: Request):
|
||||||
|
print(f"!!! Creating room")
|
||||||
|
room = await daily_helpers["rest"].create_room(DailyRoomParams())
|
||||||
|
print(f"!!! Room URL: {room.url}")
|
||||||
|
# Ensure the room property is present
|
||||||
|
if not room.url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Missing 'room' property in request data. Cannot start agent without a target room!")
|
||||||
|
|
||||||
|
# Check if there is already an existing process running in this room
|
||||||
|
num_bots_in_room = sum(
|
||||||
|
1 for proc in bot_procs.values() if proc[1] == room.url and proc[0].poll() is None)
|
||||||
|
if num_bots_in_room >= MAX_BOTS_PER_ROOM:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Max bot limited reach for room: {room.url}")
|
||||||
|
|
||||||
|
# Get the token for the room
|
||||||
|
token = await daily_helpers["rest"].get_token(room.url)
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get token for room: {room.url}")
|
||||||
|
|
||||||
|
# Spawn a new agent, and join the user session
|
||||||
|
# Note: this is mostly for demonstration purposes (refer to 'deployment' in README)
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
f"python3 -m bot -u {room.url} -t {token}"
|
||||||
|
],
|
||||||
|
shell=True,
|
||||||
|
bufsize=1,
|
||||||
|
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
)
|
||||||
|
bot_procs[proc.pid] = (proc, room.url)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to start subprocess: {e}")
|
||||||
|
|
||||||
|
return RedirectResponse(room.url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status/{pid}")
|
||||||
|
def get_status(pid: int):
|
||||||
|
# Look up the subprocess
|
||||||
|
proc = bot_procs.get(pid)
|
||||||
|
|
||||||
|
# If the subprocess doesn't exist, return an error
|
||||||
|
if not proc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Bot with process id: {pid} not found")
|
||||||
|
|
||||||
|
# Check the status of the subprocess
|
||||||
|
if proc[0].poll() is None:
|
||||||
|
status = "running"
|
||||||
|
else:
|
||||||
|
status = "finished"
|
||||||
|
|
||||||
|
return JSONResponse({"bot_id": pid, "status": status})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
default_host = os.getenv("HOST", "0.0.0.0")
|
||||||
|
default_port = int(os.getenv("FAST_API_PORT", "7860"))
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Daily Storyteller FastAPI server")
|
||||||
|
parser.add_argument("--host", type=str,
|
||||||
|
default=default_host, help="Host address")
|
||||||
|
parser.add_argument("--port", type=int,
|
||||||
|
default=default_port, help="Port number")
|
||||||
|
parser.add_argument("--reload", action="store_true",
|
||||||
|
help="Reload code on change")
|
||||||
|
|
||||||
|
config = parser.parse_args()
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"server:app",
|
||||||
|
host=config.host,
|
||||||
|
port=config.port,
|
||||||
|
reload=config.reload,
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
fastapi[all]
|
fastapi[all]
|
||||||
uvicorn
|
uvicorn
|
||||||
pipecat-ai[daily,openai,silero]
|
pipecat-ai[daily,openai,silero,elevenlabs]
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Website = "https://pipecat.ai"
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
anthropic = [ "anthropic~=0.34.0" ]
|
anthropic = [ "anthropic~=0.34.0" ]
|
||||||
azure = [ "azure-cognitiveservices-speech~=1.40.0" ]
|
azure = [ "azure-cognitiveservices-speech~=1.40.0" ]
|
||||||
|
canonical = [ "aiofiles~=24.1.0" ]
|
||||||
cartesia = [ "websockets~=12.0" ]
|
cartesia = [ "websockets~=12.0" ]
|
||||||
daily = [ "daily-python~=0.10.1" ]
|
daily = [ "daily-python~=0.10.1" ]
|
||||||
deepgram = [ "deepgram-sdk~=3.5.0" ]
|
deepgram = [ "deepgram-sdk~=3.5.0" ]
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
# SPDX-License-Identifier: BSD 2-Clause License
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
#
|
#
|
||||||
|
|
||||||
from typing import Any, List, Mapping, Optional, Tuple
|
import time
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.utils import obj_count, obj_id
|
from pipecat.utils.utils import obj_count, obj_id
|
||||||
@@ -223,6 +223,16 @@ class TransportMessageFrame(DataFrame):
|
|||||||
class AppFrame(Frame):
|
class AppFrame(Frame):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserAudioFrame(AudioRawFrame):
|
||||||
|
"""
|
||||||
|
Indicates user audio in the pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, frame: AudioRawFrame):
|
||||||
|
super().__init__(frame.audio, frame.sample_rate, frame.num_channels)
|
||||||
|
|
||||||
#
|
#
|
||||||
# System frames
|
# System frames
|
||||||
#
|
#
|
||||||
|
|||||||
75
src/pipecat/processors/audio_buffer_processor.py
Normal file
75
src/pipecat/processors/audio_buffer_processor.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from pipecat.frames.frames import (AudioRawFrame, BotStartedSpeakingFrame,
|
||||||
|
BotStoppedSpeakingFrame, Frame,
|
||||||
|
UserAudioFrame, UserStoppedSpeakingFrame)
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
|
||||||
|
|
||||||
|
class AudioBufferProcessor(FrameProcessor):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Initialize the AudioBufferProcessor.
|
||||||
|
|
||||||
|
This constructor sets up the initial state for audio processing:
|
||||||
|
- audio_buffer: A bytearray to store incoming audio data.
|
||||||
|
- num_channels: The number of audio channels (initialized as None).
|
||||||
|
- sample_rate: The sample rate of the audio (initialized as None).
|
||||||
|
- assistant_audio: A boolean flag to indicate if assistant audio is being processed.
|
||||||
|
- user_audio: A boolean flag to indicate if user audio is being processed.
|
||||||
|
|
||||||
|
The num_channels and sample_rate are set to None initially and will be
|
||||||
|
populated when the first audio frame is processed.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.audio_buffer = bytearray()
|
||||||
|
self.num_channels = None
|
||||||
|
self.sample_rate = None
|
||||||
|
self.assistant_audio = False
|
||||||
|
self.user_audio = False
|
||||||
|
|
||||||
|
def has_audio(self):
|
||||||
|
return (
|
||||||
|
self.audio_buffer and
|
||||||
|
len(self.audio_buffer) > 0 and
|
||||||
|
self.num_channels and
|
||||||
|
self.sample_rate
|
||||||
|
)
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if isinstance(frame, AudioRawFrame) or isinstance(frame, UserAudioFrame):
|
||||||
|
if self.num_channels is None:
|
||||||
|
self.num_channels = frame.num_channels
|
||||||
|
if self.sample_rate is None:
|
||||||
|
self.sample_rate = frame.sample_rate
|
||||||
|
|
||||||
|
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||||
|
self.user_audio = False
|
||||||
|
|
||||||
|
if isinstance(frame, BotStartedSpeakingFrame):
|
||||||
|
self.assistant_audio = True
|
||||||
|
self.user_audio = False # do not capture user audio if assistant is speaking
|
||||||
|
if isinstance(frame, BotStoppedSpeakingFrame):
|
||||||
|
self.assistant_audio = False
|
||||||
|
# Capture user audio if assistant is not speaking, even if it's silence, the point
|
||||||
|
# here is to capture so that the conversation is as close to reality as possible.
|
||||||
|
# This is important for evaluation and metrics capture.
|
||||||
|
self.user_audio = True
|
||||||
|
|
||||||
|
# only include audio from the user if the user is speaking, this is because audio from the user's
|
||||||
|
# mic is always coming in. if we include all the user's audio there will be a long latency before
|
||||||
|
# the user starts speaking because all of the user's silence during the assistant's speech will have been
|
||||||
|
# added to the buffer.
|
||||||
|
if isinstance(frame, UserAudioFrame) and self.user_audio:
|
||||||
|
self.audio_buffer.extend(frame.audio)
|
||||||
|
|
||||||
|
# include all audio from the assistant
|
||||||
|
if (
|
||||||
|
isinstance(frame, AudioRawFrame)
|
||||||
|
and not isinstance(frame, UserAudioFrame)
|
||||||
|
):
|
||||||
|
self.audio_buffer.extend(frame.audio)
|
||||||
|
|
||||||
|
# do not push the user's audio frame, doing so will result in echo
|
||||||
|
if not isinstance(frame, UserAudioFrame):
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
202
src/pipecat/processors/canonical_metrics_processor.py
Normal file
202
src/pipecat/processors/canonical_metrics_processor.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import wave
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiofiles
|
||||||
|
import aiofiles.os
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
logger.error(f"Exception: {e}")
|
||||||
|
logger.error(
|
||||||
|
"In order to use Canonical Metrics, you need to `pip install pipecat-ai[canonical]`. " +
|
||||||
|
"Also, set the `CANONICAL_API_KEY` environment variable.")
|
||||||
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
from pipecat.frames.frames import CancelFrame, EndFrame, Frame
|
||||||
|
from pipecat.processors.audio_buffer_processor import AudioBufferProcessor
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
|
||||||
|
"""
|
||||||
|
This class extends AudioBufferProcessor to handle audio processing and uploading
|
||||||
|
for the Canonical Voice API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalMetrics(AudioBufferProcessor):
|
||||||
|
"""
|
||||||
|
Initialize a CanonicalAudioProcessor instance.
|
||||||
|
|
||||||
|
This class extends AudioBufferProcessor to handle audio processing and uploading
|
||||||
|
for the Canonical Voice API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
call_id (str): Your unique identifier for the call. This is used to match the call in the Canonical Voice system to the call in your system.
|
||||||
|
assistant (str): Identifier for the AI assistant. This can be whatever you want, it's intended for you convenience so you can distinguish
|
||||||
|
between different assistants and a grouping mechanism for calls.
|
||||||
|
assistant_speaks_first (bool, optional): Indicates if the assistant speaks first in the conversation. Defaults to True.
|
||||||
|
output_dir (str, optional): Directory to save temporary audio files. Defaults to "recordings".
|
||||||
|
default_part_size (int, optional): Default size for multipart upload parts in bytes. Defaults to 1MB (1024 * 1024 * 1).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
call_id (str): Stores the unique call identifier.
|
||||||
|
assistant (str): Stores the assistant identifier.
|
||||||
|
assistant_speaks_first (bool): Indicates whether the assistant speaks first.
|
||||||
|
output_dir (str): Directory path for saving temporary audio files.
|
||||||
|
partsize (int): Size of each part for multipart uploads.
|
||||||
|
|
||||||
|
The constructor also ensures that the output directory exists.
|
||||||
|
This class requires a Canonical API key to be set in the CANONICAL_API_KEY environment variable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
call_id: str,
|
||||||
|
assistant: str,
|
||||||
|
assistant_speaks_first: bool = True,
|
||||||
|
output_dir: str = "recordings",
|
||||||
|
default_part_size: int = 1024 * 1024 * 1):
|
||||||
|
super().__init__()
|
||||||
|
if not os.environ.get("CANONICAL_API_KEY"):
|
||||||
|
raise ValueError(
|
||||||
|
"CANONICAL_API_KEY is not set, a Canonical API key is required to use this class")
|
||||||
|
self.call_id = call_id
|
||||||
|
self.assistant = assistant
|
||||||
|
self.assistant_speaks_first = assistant_speaks_first
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.partsize = default_part_size
|
||||||
|
self.end_of_call = False
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if self.end_of_call:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (isinstance(frame, EndFrame) or isinstance(frame, CancelFrame)):
|
||||||
|
self.end_of_call = True
|
||||||
|
if self.has_audio():
|
||||||
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
filename = self.get_output_filename()
|
||||||
|
with BytesIO() as buffer:
|
||||||
|
with wave.open(buffer, 'wb') as wf:
|
||||||
|
wf.setnchannels(self.num_channels)
|
||||||
|
wf.setsampwidth(self.sample_rate // 8000)
|
||||||
|
wf.setframerate(self.sample_rate)
|
||||||
|
wf.writeframes(self.audio_buffer)
|
||||||
|
wave_data = buffer.getvalue()
|
||||||
|
|
||||||
|
async with aiofiles.open(filename, 'wb') as file:
|
||||||
|
await file.write(wave_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.multipart_upload(filename)
|
||||||
|
await aiofiles.os.remove(filename)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
self.audio_buffer = bytearray()
|
||||||
|
|
||||||
|
def get_output_filename(self):
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return f"{self.output_dir}/{timestamp}-{uuid.uuid4().hex}.wav"
|
||||||
|
|
||||||
|
def canonical_api_url(self):
|
||||||
|
return os.environ.get("CANONICAL_API_URL", "https://voiceapp.canonical.chat/api/v1")
|
||||||
|
|
||||||
|
def request_headers(self):
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Canonical-Api-Key": os.environ.get("CANONICAL_API_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
async def multipart_upload(self, file_path: str):
|
||||||
|
upload_request, upload_response = await self.request_upload(file_path)
|
||||||
|
parts = await self.upload_parts(file_path, upload_request, upload_response)
|
||||||
|
await self.upload_complete(parts, upload_request, upload_response)
|
||||||
|
|
||||||
|
async def request_upload(self, file_path: str) -> Tuple[Dict, Dict]:
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
filename = f"{str(uuid.uuid4())}-{filename}"
|
||||||
|
filesize = os.path.getsize(file_path)
|
||||||
|
numparts = int((filesize + self.partsize - 1) / self.partsize)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'filename': filename,
|
||||||
|
'parts': numparts,
|
||||||
|
'assistant': self.assistant,
|
||||||
|
'assistantSpeaksFirst': self.assistant_speaks_first
|
||||||
|
}
|
||||||
|
print(f"Requesting presigned URLs for {numparts} parts")
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
f"{self.canonical_api_url()}/recording/uploadRequest",
|
||||||
|
headers=self.request_headers(),
|
||||||
|
json=params
|
||||||
|
) as response:
|
||||||
|
if not response.ok:
|
||||||
|
raise Exception(f"Failed to get presigned URLs: {await response.text()}")
|
||||||
|
response_json = await response.json()
|
||||||
|
return params, response_json
|
||||||
|
|
||||||
|
async def upload_parts(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
upload_request: Dict,
|
||||||
|
upload_response: Dict) -> List[Dict]:
|
||||||
|
|
||||||
|
urls = upload_response['urls']
|
||||||
|
parts = []
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(file_path, 'rb') as file:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
for partnum, upload_url in enumerate(urls, start=1):
|
||||||
|
data = await file.read(self.partsize)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
async with session.put(upload_url, data=data) as response:
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to upload part {partnum}: {await response.text()}")
|
||||||
|
raise Exception(f"Failed to upload part {partnum}: {await response.text()}")
|
||||||
|
|
||||||
|
etag = response.headers['ETag']
|
||||||
|
parts.append({'partnum': str(partnum), 'etag': etag})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Multipart upload aborted, an error occurred: {str(e)}")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
async def upload_complete(
|
||||||
|
self,
|
||||||
|
parts: List[Dict],
|
||||||
|
upload_request: Dict,
|
||||||
|
upload_response: Dict):
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'filename': upload_request['filename'],
|
||||||
|
'parts': parts,
|
||||||
|
'slug': upload_response['slug'],
|
||||||
|
'callId': self.call_id,
|
||||||
|
'assistant': {
|
||||||
|
'id': self.assistant,
|
||||||
|
'speaksFirst': self.assistant_speaks_first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(f"Completing upload for {params['filename']}")
|
||||||
|
print(f"Slug: {params['slug']}")
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
f"{self.canonical_api_url()}/recording/uploadComplete",
|
||||||
|
headers=self.request_headers(),
|
||||||
|
json=params
|
||||||
|
) as response:
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Failed to complete upload: {await response.text()}")
|
||||||
|
raise Exception(f"Failed to complete upload: {await response.text()}")
|
||||||
19
src/pipecat/processors/user_marker_processor.py
Normal file
19
src/pipecat/processors/user_marker_processor.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from pipecat.frames.frames import AudioRawFrame, Frame, UserAudioFrame
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
|
||||||
|
|
||||||
|
class UserMarkerProcessor(FrameProcessor):
|
||||||
|
"""
|
||||||
|
This class extends FrameProcessor, used to mark the user's audio in the pipeline.
|
||||||
|
This FrameProcessor must be inserted after transport.input() so that the only
|
||||||
|
AudioRaw it receives are from the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if isinstance(frame, AudioRawFrame):
|
||||||
|
frame = UserAudioFrame(frame)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
Reference in New Issue
Block a user