introduce PipelineParams audio input/output sample rates

This commit is contained in:
Aleix Conchillo Flaqué
2025-02-04 12:22:41 -08:00
parent cc54255c41
commit ab45e481be
61 changed files with 570 additions and 402 deletions

View File

@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added new fields to `PipelineParams` to control audio input and output sample
rates for the whole pipeline. This allows controlling sample rates from a
single place instead of having to specify sample rates in each
service. Setting a sample rate to a service is still possible and will
override the value from `PipelineParams`.
- Introduce audio resamplers (`BaseAudioResampler`). This is just a base class - Introduce audio resamplers (`BaseAudioResampler`). This is just a base class
to implement audio resamplers. Currently, two implementations are provided to implement audio resamplers. Currently, two implementations are provided
`SOXRAudioResampler` and `ResampyResampler`. A new `SOXRAudioResampler` and `ResampyResampler`. A new

View File

@@ -17,7 +17,7 @@ from runner import configure
from pipecat.frames.frames import AudioRawFrame, EndFrame, OutputAudioRawFrame, TTSSpeakFrame from pipecat.frames.frames import AudioRawFrame, EndFrame, OutputAudioRawFrame, TTSSpeakFrame
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.services.cartesia import CartesiaTTSService from pipecat.services.cartesia import CartesiaTTSService
from pipecat.transports.services.daily import DailyParams, DailyTransport from pipecat.transports.services.daily import DailyParams, DailyTransport
@@ -31,16 +31,15 @@ logger.add(sys.stderr, level="DEBUG")
class SilenceFrame(OutputAudioRawFrame): class SilenceFrame(OutputAudioRawFrame):
def __init__( def __init__(
self, self,
audio: bytes = None, *,
sample_rate: int = 16000, sample_rate: int,
num_channels: int = 1, duration: float,
duration: float = 0.1,
): ):
# Initialize the parent class with the silent frame's data # Initialize the parent class with the silent frame's data
super().__init__( super().__init__(
audio=self.create_silent_audio_frame(sample_rate, num_channels, duration).audio, audio=self.create_silent_audio_frame(sample_rate, 1, duration).audio,
sample_rate=sample_rate, sample_rate=sample_rate,
num_channels=num_channels, num_channels=1,
) )
@staticmethod @staticmethod
@@ -80,7 +79,10 @@ async def main():
return return
await task.queue_frames( await task.queue_frames(
[ [
SilenceFrame(duration=0.5), SilenceFrame(
sample_rate=task.params.audio_out_sample_rate,
duration=0.5,
),
TTSSpeakFrame(f"Hello there, how are you doing today ?"), TTSSpeakFrame(f"Hello there, how are you doing today ?"),
EndFrame(), EndFrame(),
] ]

View File

@@ -37,7 +37,6 @@ async def main():
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=24000,
transcription_enabled=True, transcription_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),

View File

@@ -38,7 +38,6 @@ async def main():
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=24000,
transcription_enabled=True, transcription_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),

View File

@@ -40,7 +40,6 @@ async def main():
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=24000,
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),
vad_audio_passthrough=True, vad_audio_passthrough=True,

View File

@@ -21,7 +21,7 @@ from pipecat.frames.frames import (
) )
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.transports.services.daily import DailyParams, DailyTransport from pipecat.transports.services.daily import DailyParams, DailyTransport
@@ -61,7 +61,6 @@ async def main():
"Test", "Test",
DailyParams( DailyParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_in_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
camera_out_enabled=True, camera_out_enabled=True,
camera_out_is_live=True, camera_out_is_live=True,
@@ -78,7 +77,9 @@ async def main():
runner = PipelineRunner() runner = PipelineRunner()
task = PipelineTask(pipeline) task = PipelineTask(
pipeline, PipelineParams(audio_in_sample_rate=24000, audio_out_sample_rate=24000)
)
await runner.run(task) await runner.run(task)

View File

@@ -22,7 +22,7 @@ from pipecat.frames.frames import (
) )
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.transports.base_transport import TransportParams from pipecat.transports.base_transport import TransportParams
from pipecat.transports.local.tk import TkLocalTransport from pipecat.transports.local.tk import TkLocalTransport
@@ -62,7 +62,7 @@ async def main():
tk_root.title("Local Mirror") tk_root.title("Local Mirror")
daily_transport = DailyTransport( daily_transport = DailyTransport(
room_url, token, "Test", DailyParams(audio_in_enabled=True, audio_in_sample_rate=24000) room_url, token, "Test", DailyParams(audio_in_enabled=True)
) )
tk_transport = TkLocalTransport( tk_transport = TkLocalTransport(
@@ -82,7 +82,9 @@ async def main():
pipeline = Pipeline([daily_transport.input(), MirrorProcessor(), tk_transport.output()]) pipeline = Pipeline([daily_transport.input(), MirrorProcessor(), tk_transport.output()])
task = PipelineTask(pipeline) task = PipelineTask(
pipeline, PipelineParams(audio_in_sample_rate=24000, audio_out_sample_rate=24000)
)
async def run_tk(): async def run_tk():
while not task.has_finished(): while not task.has_finished():

View File

@@ -51,8 +51,6 @@ async def main():
out_params=GStreamerPipelineSource.OutputParams( out_params=GStreamerPipelineSource.OutputParams(
video_width=1280, video_width=1280,
video_height=720, video_height=720,
audio_sample_rate=24000,
audio_channels=1,
), ),
) )

View File

@@ -80,9 +80,7 @@ async def main():
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_in_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=24000,
transcription_enabled=False, transcription_enabled=False,
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.8)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.8)),

View File

@@ -177,9 +177,7 @@ async def main():
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_in_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=24000,
transcription_enabled=False, transcription_enabled=False,
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.8)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.8)),

View File

@@ -88,6 +88,10 @@ async def main():
task = PipelineTask( task = PipelineTask(
pipeline, pipeline,
PipelineParams( PipelineParams(
# We just use 16000 because that's what Tavus is expecting and
# we avoid resampling.
audio_in_sample_rate=16000,
audio_out_sample_rate=16000,
allow_interruptions=True, allow_interruptions=True,
enable_metrics=True, enable_metrics=True,
enable_usage_metrics=True, enable_usage_metrics=True,

View File

@@ -639,7 +639,6 @@ async def main():
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),
vad_audio_passthrough=True, vad_audio_passthrough=True,
audio_in_sample_rate=16000,
), ),
) )

View File

@@ -37,8 +37,6 @@ async def main():
token, token,
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_audio_passthrough=True, vad_audio_passthrough=True,

View File

@@ -37,8 +37,6 @@ async def main():
token, token,
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_audio_passthrough=True, vad_audio_passthrough=True,

View File

@@ -84,8 +84,6 @@ async def main():
token, token,
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_audio_passthrough=True, vad_audio_passthrough=True,

View File

@@ -37,8 +37,6 @@ async def main():
token, token,
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_audio_passthrough=True, vad_audio_passthrough=True,
@@ -47,8 +45,6 @@ async def main():
# matter because we can only use the Multimodal Live API's phrase # matter because we can only use the Multimodal Live API's phrase
# endpointing, for now. # endpointing, for now.
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)),
start_audio_paused=True,
start_video_paused=True,
), ),
) )

View File

@@ -52,8 +52,6 @@ async def main():
token, token,
"Respond bot", "Respond bot",
DailyParams( DailyParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
vad_enabled=True, vad_enabled=True,
vad_audio_passthrough=True, vad_audio_passthrough=True,

View File

@@ -38,8 +38,6 @@ load_dotenv(override=True)
logger.remove(0) logger.remove(0)
logger.add(sys.stderr, level="DEBUG") logger.add(sys.stderr, level="DEBUG")
DESIRED_SAMPLE_RATE = 16000
def generate_token(room_name: str, participant_name: str, api_key: str, api_secret: str) -> str: def generate_token(room_name: str, participant_name: str, api_key: str, api_secret: str) -> str:
token = api.AccessToken(api_key, api_secret) token = api.AccessToken(api_key, api_secret)
@@ -114,11 +112,8 @@ async def main():
token=token, token=token,
room_name=room_name, room_name=room_name,
params=LiveKitParams( params=LiveKitParams(
audio_in_channels=1,
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
audio_in_sample_rate=DESIRED_SAMPLE_RATE,
audio_out_sample_rate=DESIRED_SAMPLE_RATE,
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),
vad_enabled=True, vad_enabled=True,
vad_audio_passthrough=True, vad_audio_passthrough=True,
@@ -128,7 +123,6 @@ async def main():
stt = DeepgramSTTService( stt = DeepgramSTTService(
api_key=os.getenv("DEEPGRAM_API_KEY"), api_key=os.getenv("DEEPGRAM_API_KEY"),
live_options=LiveOptions( live_options=LiveOptions(
sample_rate=DESIRED_SAMPLE_RATE,
vad_events=True, vad_events=True,
), ),
) )
@@ -138,7 +132,6 @@ async def main():
tts = CartesiaTTSService( tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady
sample_rate=DESIRED_SAMPLE_RATE,
) )
messages = [ messages = [

View File

@@ -121,8 +121,6 @@ async def main():
token, token,
"Chatbot", "Chatbot",
DailyParams( DailyParams(
audio_in_sample_rate=16000,
audio_out_sample_rate=24000,
audio_out_enabled=True, audio_out_enabled=True,
camera_out_enabled=True, camera_out_enabled=True,
camera_out_width=1024, camera_out_width=1024,

View File

@@ -112,7 +112,6 @@ async def main():
token, token,
"studypal", "studypal",
DailyParams( DailyParams(
audio_out_sample_rate=44100,
audio_out_enabled=True, audio_out_enabled=True,
transcription_enabled=True, transcription_enabled=True,
vad_enabled=True, vad_enabled=True,
@@ -124,7 +123,6 @@ async def main():
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id=os.getenv("CARTESIA_VOICE_ID", "4d2fd738-3b3d-4368-957a-bb4805275bd9"), voice_id=os.getenv("CARTESIA_VOICE_ID", "4d2fd738-3b3d-4368-957a-bb4805275bd9"),
# British Narration Lady: 4d2fd738-3b3d-4368-957a-bb4805275bd9 # British Narration Lady: 4d2fd738-3b3d-4368-957a-bb4805275bd9
sample_rate=44100,
) )
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini") llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini")
@@ -155,7 +153,12 @@ Your task is to help the user understand and learn from this article in 2 senten
] ]
) )
task = PipelineTask(pipeline, PipelineParams(allow_interruptions=True, enable_metrics=True)) task = PipelineTask(
pipeline,
PipelineParams(
audio_out_sample_rate=44100, allow_interruptions=True, enable_metrics=True
),
)
@transport.event_handler("on_first_participant_joined") @transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant): async def on_first_participant_joined(transport, participant):

View File

@@ -11,7 +11,6 @@ import sys
import wave import wave
import aiofiles import aiofiles
from deepgram import LiveOptions
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import WebSocket from fastapi import WebSocket
from loguru import logger from loguru import logger
@@ -36,8 +35,6 @@ load_dotenv(override=True)
logger.remove(0) logger.remove(0)
logger.add(sys.stderr, level="DEBUG") logger.add(sys.stderr, level="DEBUG")
SAMPLE_RATE = 8000
async def save_audio(server_name: str, audio: bytes, sample_rate: int, num_channels: int): async def save_audio(server_name: str, audio: bytes, sample_rate: int, num_channels: int):
if len(audio) > 0: if len(audio) > 0:
@@ -63,29 +60,21 @@ async def run_bot(websocket_client: WebSocket, stream_sid: str, testing: bool):
params=FastAPIWebsocketParams( params=FastAPIWebsocketParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=SAMPLE_RATE,
add_wav_header=False, add_wav_header=False,
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(sample_rate=SAMPLE_RATE), vad_analyzer=SileroVADAnalyzer(),
vad_audio_passthrough=True, vad_audio_passthrough=True,
serializer=TwilioFrameSerializer( serializer=TwilioFrameSerializer(stream_sid),
stream_sid, TwilioFrameSerializer.InputParams(sample_rate=SAMPLE_RATE)
),
), ),
) )
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o")
stt = DeepgramSTTService( stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"), audio_passthrough=True)
api_key=os.getenv("DEEPGRAM_API_KEY"),
live_options=LiveOptions(sample_rate=SAMPLE_RATE),
audio_passthrough=True,
)
tts = CartesiaTTSService( tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady
sample_rate=SAMPLE_RATE,
push_silence_after_stop=testing, push_silence_after_stop=testing,
) )
@@ -101,7 +90,7 @@ async def run_bot(websocket_client: WebSocket, stream_sid: str, testing: bool):
# NOTE: Watch out! This will save all the conversation in memory. You can # NOTE: Watch out! This will save all the conversation in memory. You can
# pass `buffer_size` to get periodic callbacks. # pass `buffer_size` to get periodic callbacks.
audiobuffer = AudioBufferProcessor(sample_rate=SAMPLE_RATE) audiobuffer = AudioBufferProcessor()
pipeline = Pipeline( pipeline = Pipeline(
[ [
@@ -116,7 +105,12 @@ async def run_bot(websocket_client: WebSocket, stream_sid: str, testing: bool):
] ]
) )
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) task = PipelineTask(
pipeline,
params=PipelineParams(
audio_in_sample_rate=8000, audio_out_sample_rate=8000, allow_interruptions=True
),
)
@transport.event_handler("on_client_connected") @transport.event_handler("on_client_connected")
async def on_client_connected(transport, client): async def on_client_connected(transport, client):

View File

@@ -16,7 +16,6 @@ from uuid import uuid4
import aiofiles import aiofiles
import aiohttp import aiohttp
from deepgram import LiveOptions
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
@@ -44,7 +43,6 @@ logger.add(sys.stderr, level="DEBUG")
DEFAULT_CLIENT_DURATION = 30 DEFAULT_CLIENT_DURATION = 30
SAMPLE_RATE = 8000
async def download_twiml(server_url: str) -> str: async def download_twiml(server_url: str) -> str:
@@ -92,15 +90,10 @@ async def run_client(client_name: str, server_url: str, duration_secs: int):
params=WebsocketClientParams( params=WebsocketClientParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
audio_out_sample_rate=SAMPLE_RATE,
add_wav_header=False, add_wav_header=False,
serializer=TwilioFrameSerializer( serializer=TwilioFrameSerializer(stream_sid),
stream_sid, params=TwilioFrameSerializer.InputParams(sample_rate=SAMPLE_RATE)
),
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer( vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=1.5)),
params=VADParams(stop_secs=1.5), sample_rate=SAMPLE_RATE
),
vad_audio_passthrough=True, vad_audio_passthrough=True,
), ),
) )
@@ -110,14 +103,12 @@ async def run_client(client_name: str, server_url: str, duration_secs: int):
# We let the audio passthrough so we can record the conversation. # We let the audio passthrough so we can record the conversation.
stt = DeepgramSTTService( stt = DeepgramSTTService(
api_key=os.getenv("DEEPGRAM_API_KEY"), api_key=os.getenv("DEEPGRAM_API_KEY"),
live_options=LiveOptions(sample_rate=SAMPLE_RATE),
audio_passthrough=True, audio_passthrough=True,
) )
tts = CartesiaTTSService( tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="e13cae5c-ec59-4f71-b0a6-266df3c9bb8e", # Madame Mischief voice_id="e13cae5c-ec59-4f71-b0a6-266df3c9bb8e", # Madame Mischief
sample_rate=SAMPLE_RATE,
push_silence_after_stop=True, push_silence_after_stop=True,
) )
@@ -133,7 +124,7 @@ async def run_client(client_name: str, server_url: str, duration_secs: int):
# NOTE: Watch out! This will save all the conversation in memory. You can # NOTE: Watch out! This will save all the conversation in memory. You can
# pass `buffer_size` to get periodic callbacks. # pass `buffer_size` to get periodic callbacks.
audiobuffer = AudioBufferProcessor(sample_rate=SAMPLE_RATE) audiobuffer = AudioBufferProcessor()
pipeline = Pipeline( pipeline = Pipeline(
[ [
@@ -148,7 +139,12 @@ async def run_client(client_name: str, server_url: str, duration_secs: int):
] ]
) )
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) task = PipelineTask(
pipeline,
params=PipelineParams(
audio_in_sample_rate=8000, audio_out_sample_rate=8000, allow_interruptions=True
),
)
@transport.event_handler("on_connected") @transport.event_handler("on_connected")
async def on_connected(transport: WebsocketClientTransport, client): async def on_connected(transport: WebsocketClientTransport, client):

View File

@@ -17,6 +17,7 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.serializers.protobuf import ProtobufFrameSerializer
from pipecat.services.cartesia import CartesiaTTSService from pipecat.services.cartesia import CartesiaTTSService
from pipecat.services.deepgram import DeepgramSTTService from pipecat.services.deepgram import DeepgramSTTService
from pipecat.services.openai import OpenAILLMService from pipecat.services.openai import OpenAILLMService
@@ -80,7 +81,7 @@ class SessionTimeoutHandler:
async def main(): async def main():
transport = WebsocketServerTransport( transport = WebsocketServerTransport(
params=WebsocketServerParams( params=WebsocketServerParams(
audio_out_sample_rate=16000, serializer=ProtobufFrameSerializer(),
audio_out_enabled=True, audio_out_enabled=True,
add_wav_header=True, add_wav_header=True,
vad_enabled=True, vad_enabled=True,
@@ -97,7 +98,6 @@ async def main():
tts = CartesiaTTSService( tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady
sample_rate=16000,
) )
messages = [ messages = [
@@ -122,7 +122,12 @@ async def main():
] ]
) )
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True)) task = PipelineTask(
pipeline,
params=PipelineParams(
audio_in_sample_rate=16000, audio_out_sample_rate=16000, allow_interruptions=True
),
)
@transport.event_handler("on_client_connected") @transport.event_handler("on_client_connected")
async def on_client_connected(transport, client): async def on_client_connected(transport, client):

View File

@@ -5,6 +5,7 @@
# #
import time import time
from typing import Optional
import numpy as np import numpy as np
from loguru import logger from loguru import logger
@@ -104,11 +105,8 @@ class SileroOnnxModel:
class SileroVADAnalyzer(VADAnalyzer): class SileroVADAnalyzer(VADAnalyzer):
def __init__(self, *, sample_rate: int = 16000, params: VADParams = VADParams()): def __init__(self, *, sample_rate: Optional[int] = None, params: VADParams = VADParams()):
super().__init__(sample_rate=sample_rate, num_channels=1, params=params) super().__init__(sample_rate=sample_rate, params=params)
if sample_rate != 16000 and sample_rate != 8000:
raise ValueError("Silero VAD sample rate needs to be 16000 or 8000")
logger.debug("Loading Silero VAD model...") logger.debug("Loading Silero VAD model...")
@@ -138,6 +136,12 @@ class SileroVADAnalyzer(VADAnalyzer):
# VADAnalyzer # VADAnalyzer
# #
def set_sample_rate(self, sample_rate: int):
if sample_rate != 16000 and sample_rate != 8000:
raise ValueError("Silero VAD sample rate needs to be 16000 or 8000")
super().set_sample_rate(sample_rate)
def num_frames_required(self) -> int: def num_frames_required(self) -> int:
return 512 if self.sample_rate == 16000 else 256 return 512 if self.sample_rate == 16000 else 256

View File

@@ -6,6 +6,7 @@
from abc import abstractmethod from abc import abstractmethod
from enum import Enum from enum import Enum
from typing import Optional
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
@@ -33,11 +34,11 @@ class VADParams(BaseModel):
class VADAnalyzer: class VADAnalyzer:
def __init__(self, *, sample_rate: int, num_channels: int, params: VADParams): def __init__(self, *, sample_rate: Optional[int] = None, params: VADParams):
self._sample_rate = sample_rate self._init_sample_rate = sample_rate
self._num_channels = num_channels self._sample_rate = 0
self._params = params
self.set_params(params) self._num_channels = 1
self._vad_buffer = b"" self._vad_buffer = b""
@@ -65,13 +66,17 @@ class VADAnalyzer:
def voice_confidence(self, buffer) -> float: def voice_confidence(self, buffer) -> float:
pass pass
def set_sample_rate(self, sample_rate: int):
self._sample_rate = self._init_sample_rate or sample_rate
self.set_params(self._params)
def set_params(self, params: VADParams): def set_params(self, params: VADParams):
logger.info(f"Setting VAD params to: {params}") logger.info(f"Setting VAD params to: {params}")
self._params = params self._params = params
self._vad_frames = self.num_frames_required() self._vad_frames = self.num_frames_required()
self._vad_frames_num_bytes = self._vad_frames * self._num_channels * 2 self._vad_frames_num_bytes = self._vad_frames * self._num_channels * 2
vad_frames_per_sec = self._vad_frames / self._sample_rate vad_frames_per_sec = self._vad_frames / self.sample_rate
self._vad_start_frames = round(self._params.start_secs / vad_frames_per_sec) self._vad_start_frames = round(self._params.start_secs / vad_frames_per_sec)
self._vad_stop_frames = round(self._params.stop_secs / vad_frames_per_sec) self._vad_stop_frames = round(self._params.stop_secs / vad_frames_per_sec)
@@ -80,7 +85,7 @@ class VADAnalyzer:
self._vad_state: VADState = VADState.QUIET self._vad_state: VADState = VADState.QUIET
def _get_smoothed_volume(self, audio: bytes) -> float: def _get_smoothed_volume(self, audio: bytes) -> float:
volume = calculate_audio_volume(audio, self._sample_rate) volume = calculate_audio_volume(audio, self.sample_rate)
return exp_smoothing(volume, self._prev_volume, self._smoothing_factor) return exp_smoothing(volume, self._prev_volume, self._smoothing_factor)
def analyze_audio(self, buffer) -> VADState: def analyze_audio(self, buffer) -> VADState:

View File

@@ -428,6 +428,8 @@ class StartFrame(SystemFrame):
clock: BaseClock clock: BaseClock
task_manager: TaskManager task_manager: TaskManager
audio_in_sample_rate: int = 16000
audio_out_sample_rate: int = 24000
allow_interruptions: bool = False allow_interruptions: bool = False
enable_metrics: bool = False enable_metrics: bool = False
enable_usage_metrics: bool = False enable_usage_metrics: bool = False

View File

@@ -40,6 +40,8 @@ HEARTBEAT_MONITOR_SECONDS = HEARTBEAT_SECONDS * 5
class PipelineParams(BaseModel): class PipelineParams(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
audio_in_sample_rate: int = 16000
audio_out_sample_rate: int = 24000
allow_interruptions: bool = False allow_interruptions: bool = False
enable_heartbeats: bool = False enable_heartbeats: bool = False
enable_metrics: bool = False enable_metrics: bool = False
@@ -136,6 +138,11 @@ class PipelineTask(BaseTask):
"""Returns the name of this task.""" """Returns the name of this task."""
return self._name return self._name
@property
def params(self) -> PipelineParams:
"""Returns the pipeline parameters of this task."""
return self._params
def set_event_loop(self, loop: asyncio.AbstractEventLoop): def set_event_loop(self, loop: asyncio.AbstractEventLoop):
self._task_manager.set_event_loop(loop) self._task_manager.set_event_loop(loop)
@@ -275,6 +282,8 @@ class PipelineTask(BaseTask):
enable_usage_metrics=self._params.enable_usage_metrics, enable_usage_metrics=self._params.enable_usage_metrics,
report_only_initial_ttfb=self._params.report_only_initial_ttfb, report_only_initial_ttfb=self._params.report_only_initial_ttfb,
observer=self._observer, observer=self._observer,
audio_in_sample_rate=self._params.audio_in_sample_rate,
audio_out_sample_rate=self._params.audio_out_sample_rate,
) )
await self._source.queue_frame(start_frame, FrameDirection.DOWNSTREAM) await self._source.queue_frame(start_frame, FrameDirection.DOWNSTREAM)

View File

@@ -5,6 +5,7 @@
# #
import time import time
from typing import Optional
from pipecat.audio.utils import create_default_resampler, interleave_stereo_audio, mix_audio from pipecat.audio.utils import create_default_resampler, interleave_stereo_audio, mix_audio
from pipecat.frames.frames import ( from pipecat.frames.frames import (
@@ -14,6 +15,7 @@ from pipecat.frames.frames import (
Frame, Frame,
InputAudioRawFrame, InputAudioRawFrame,
OutputAudioRawFrame, OutputAudioRawFrame,
StartFrame,
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@@ -33,10 +35,16 @@ class AudioBufferProcessor(FrameProcessor):
""" """
def __init__( def __init__(
self, *, sample_rate: int = 24000, num_channels: int = 1, buffer_size: int = 0, **kwargs self,
*,
sample_rate: Optional[int] = None,
num_channels: int = 1,
buffer_size: int = 0,
**kwargs,
): ):
super().__init__(**kwargs) super().__init__(**kwargs)
self._sample_rate = sample_rate self._init_sample_rate = sample_rate
self._sample_rate = 0
self._num_channels = num_channels self._num_channels = num_channels
self._buffer_size = buffer_size self._buffer_size = buffer_size
@@ -86,6 +94,10 @@ class AudioBufferProcessor(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
# Update output sample rate if necessary.
if isinstance(frame, StartFrame):
self._update_sample_rate(frame)
if self._recording and isinstance(frame, InputAudioRawFrame): if self._recording and isinstance(frame, InputAudioRawFrame):
# Add silence if we need to. # Add silence if we need to.
silence = self._compute_silence(self._last_user_frame_at) silence = self._compute_silence(self._last_user_frame_at)
@@ -113,6 +125,9 @@ class AudioBufferProcessor(FrameProcessor):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
def _update_sample_rate(self, frame: StartFrame):
self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate
async def _call_on_audio_data_handler(self): async def _call_on_audio_data_handler(self):
if not self.has_audio() or not self._recording: if not self.has_audio() or not self._recording:
return return

View File

@@ -4,6 +4,8 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
from typing import Optional
from loguru import logger from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer
@@ -11,6 +13,7 @@ from pipecat.audio.vad.vad_analyzer import VADParams, VADState
from pipecat.frames.frames import ( from pipecat.frames.frames import (
AudioRawFrame, AudioRawFrame,
Frame, Frame,
StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
StopInterruptionFrame, StopInterruptionFrame,
UserStartedSpeakingFrame, UserStartedSpeakingFrame,
@@ -23,7 +26,7 @@ class SileroVAD(FrameProcessor):
def __init__( def __init__(
self, self,
*, *,
sample_rate: int = 16000, sample_rate: Optional[int] = None,
vad_params: VADParams = VADParams(), vad_params: VADParams = VADParams(),
audio_passthrough: bool = False, audio_passthrough: bool = False,
): ):
@@ -41,6 +44,9 @@ class SileroVAD(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
self._vad_analyzer.set_sample_rate(frame.audio_in_sample_rate)
if isinstance(frame, AudioRawFrame): if isinstance(frame, AudioRawFrame):
await self._analyze_audio(frame) await self._analyze_audio(frame)
if self._audio_passthrough: if self._audio_passthrough:

View File

@@ -5,6 +5,7 @@
# #
import asyncio import asyncio
from typing import Optional
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
@@ -38,7 +39,7 @@ class GStreamerPipelineSource(FrameProcessor):
class OutputParams(BaseModel): class OutputParams(BaseModel):
video_width: int = 1280 video_width: int = 1280
video_height: int = 720 video_height: int = 720
audio_sample_rate: int = 24000 audio_sample_rate: Optional[int] = None
audio_channels: int = 1 audio_channels: int = 1
clock_sync: bool = True clock_sync: bool = True
@@ -46,6 +47,7 @@ class GStreamerPipelineSource(FrameProcessor):
super().__init__(**kwargs) super().__init__(**kwargs)
self._out_params = out_params self._out_params = out_params
self._sample_rate = 0
Gst.init() Gst.init()
@@ -90,6 +92,7 @@ class GStreamerPipelineSource(FrameProcessor):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
async def _start(self, frame: StartFrame): async def _start(self, frame: StartFrame):
self._sample_rate = self._out_params.audio_sample_rate or frame.audio_out_sample_rate
self._player.set_state(Gst.State.PLAYING) self._player.set_state(Gst.State.PLAYING)
async def _stop(self, frame: EndFrame): async def _stop(self, frame: EndFrame):
@@ -122,7 +125,7 @@ class GStreamerPipelineSource(FrameProcessor):
audioresample = Gst.ElementFactory.make("audioresample", None) audioresample = Gst.ElementFactory.make("audioresample", None)
audiocapsfilter = Gst.ElementFactory.make("capsfilter", None) audiocapsfilter = Gst.ElementFactory.make("capsfilter", None)
audiocaps = Gst.Caps.from_string( audiocaps = Gst.Caps.from_string(
f"audio/x-raw,format=S16LE,rate={self._out_params.audio_sample_rate},channels={self._out_params.audio_channels},layout=interleaved" f"audio/x-raw,format=S16LE,rate={self._sample_rate},channels={self._out_params.audio_channels},layout=interleaved"
) )
audiocapsfilter.set_property("caps", audiocaps) audiocapsfilter.set_property("caps", audiocaps)
appsink_audio = Gst.ElementFactory.make("appsink", None) appsink_audio = Gst.ElementFactory.make("appsink", None)
@@ -188,7 +191,7 @@ class GStreamerPipelineSource(FrameProcessor):
(_, info) = buffer.map(Gst.MapFlags.READ) (_, info) = buffer.map(Gst.MapFlags.READ)
frame = OutputAudioRawFrame( frame = OutputAudioRawFrame(
audio=info.data, audio=info.data,
sample_rate=self._out_params.audio_sample_rate, sample_rate=self._sample_rate,
num_channels=self._out_params.audio_channels, num_channels=self._out_params.audio_channels,
) )
asyncio.run_coroutine_threadsafe(self.push_frame(frame), self.get_event_loop()) asyncio.run_coroutine_threadsafe(self.push_frame(frame), self.get_event_loop())

View File

@@ -7,7 +7,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from pipecat.frames.frames import Frame from pipecat.frames.frames import Frame, StartFrame
class FrameSerializerType(Enum): class FrameSerializerType(Enum):
@@ -21,6 +21,9 @@ class FrameSerializer(ABC):
def type(self) -> FrameSerializerType: def type(self) -> FrameSerializerType:
pass pass
async def setup(self, frame: StartFrame):
pass
@abstractmethod @abstractmethod
async def serialize(self, frame: Frame) -> str | bytes | None: async def serialize(self, frame: Frame) -> str | bytes | None:
pass pass

View File

@@ -6,6 +6,7 @@
import base64 import base64
import json import json
from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
@@ -22,6 +23,7 @@ from pipecat.frames.frames import (
InputAudioRawFrame, InputAudioRawFrame,
InputDTMFFrame, InputDTMFFrame,
KeypadEntry, KeypadEntry,
StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
) )
from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType
@@ -29,8 +31,8 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer
class TelnyxFrameSerializer(FrameSerializer): class TelnyxFrameSerializer(FrameSerializer):
class InputParams(BaseModel): class InputParams(BaseModel):
telnyx_sample_rate: int = 8000 telnyx_sample_rate: Optional[int] = None
sample_rate: int = 16000 sample_rate: Optional[int] = None
inbound_encoding: str = "PCMU" inbound_encoding: str = "PCMU"
outbound_encoding: str = "PCMU" outbound_encoding: str = "PCMU"
@@ -52,17 +54,21 @@ class TelnyxFrameSerializer(FrameSerializer):
def type(self) -> FrameSerializerType: def type(self) -> FrameSerializerType:
return FrameSerializerType.TEXT return FrameSerializerType.TEXT
async def setup(self, frame: StartFrame):
self._telnyx_sample_rate = self._params.telnyx_sample_rate or frame.audio_in_sample_rate
self._sample_rate = self._params.sample_rate or frame.audio_out_sample_rate
async def serialize(self, frame: Frame) -> str | bytes | None: async def serialize(self, frame: Frame) -> str | bytes | None:
if isinstance(frame, AudioRawFrame): if isinstance(frame, AudioRawFrame):
data = frame.audio data = frame.audio
if self._params.inbound_encoding == "PCMU": if self._params.inbound_encoding == "PCMU":
serialized_data = await pcm_to_ulaw( serialized_data = await pcm_to_ulaw(
data, frame.sample_rate, self._params.telnyx_sample_rate, self._resampler data, frame.sample_rate, self._telnyx_sample_rate, self._resampler
) )
elif self._params.inbound_encoding == "PCMA": elif self._params.inbound_encoding == "PCMA":
serialized_data = await pcm_to_alaw( serialized_data = await pcm_to_alaw(
data, frame.sample_rate, self._params.telnyx_sample_rate, self._resampler data, frame.sample_rate, self._telnyx_sample_rate, self._resampler
) )
else: else:
raise ValueError(f"Unsupported encoding: {self._params.inbound_encoding}") raise ValueError(f"Unsupported encoding: {self._params.inbound_encoding}")
@@ -89,22 +95,22 @@ class TelnyxFrameSerializer(FrameSerializer):
if self._params.outbound_encoding == "PCMU": if self._params.outbound_encoding == "PCMU":
deserialized_data = await ulaw_to_pcm( deserialized_data = await ulaw_to_pcm(
payload, payload,
self._params.telnyx_sample_rate, self._telnyx_sample_rate,
self._params.sample_rate, self._sample_rate,
self._resampler, self._resampler,
) )
elif self._params.outbound_encoding == "PCMA": elif self._params.outbound_encoding == "PCMA":
deserialized_data = await alaw_to_pcm( deserialized_data = await alaw_to_pcm(
payload, payload,
self._params.telnyx_sample_rate, self._telnyx_sample_rate,
self._params.sample_rate, self._sample_rate,
self._resampler, self._resampler,
) )
else: else:
raise ValueError(f"Unsupported encoding: {self._params.outbound_encoding}") raise ValueError(f"Unsupported encoding: {self._params.outbound_encoding}")
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(
audio=deserialized_data, num_channels=1, sample_rate=self._params.sample_rate audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate
) )
return audio_frame return audio_frame
elif message["event"] == "dtmf": elif message["event"] == "dtmf":

View File

@@ -6,6 +6,7 @@
import base64 import base64
import json import json
from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
@@ -16,6 +17,7 @@ from pipecat.frames.frames import (
InputAudioRawFrame, InputAudioRawFrame,
InputDTMFFrame, InputDTMFFrame,
KeypadEntry, KeypadEntry,
StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TransportMessageFrame, TransportMessageFrame,
TransportMessageUrgentFrame, TransportMessageUrgentFrame,
@@ -25,19 +27,26 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer
class TwilioFrameSerializer(FrameSerializer): class TwilioFrameSerializer(FrameSerializer):
class InputParams(BaseModel): class InputParams(BaseModel):
twilio_sample_rate: int = 8000 twilio_sample_rate: Optional[int] = None
sample_rate: int = 16000 sample_rate: Optional[int] = None
def __init__(self, stream_sid: str, params: InputParams = InputParams()): def __init__(self, stream_sid: str, params: InputParams = InputParams()):
self._stream_sid = stream_sid self._stream_sid = stream_sid
self._params = params self._params = params
self._twilio_sample_rate = 0
self._sample_rate = 0
self._resampler = create_default_resampler() self._resampler = create_default_resampler()
@property @property
def type(self) -> FrameSerializerType: def type(self) -> FrameSerializerType:
return FrameSerializerType.TEXT return FrameSerializerType.TEXT
async def setup(self, frame: StartFrame):
self._twilio_sample_rate = self._params.twilio_sample_rate or frame.audio_in_sample_rate
self._sample_rate = self._params.sample_rate or frame.audio_out_sample_rate
async def serialize(self, frame: Frame) -> str | bytes | None: async def serialize(self, frame: Frame) -> str | bytes | None:
if isinstance(frame, StartInterruptionFrame): if isinstance(frame, StartInterruptionFrame):
answer = {"event": "clear", "streamSid": self._stream_sid} answer = {"event": "clear", "streamSid": self._stream_sid}
@@ -46,7 +55,7 @@ class TwilioFrameSerializer(FrameSerializer):
data = frame.audio data = frame.audio
serialized_data = await pcm_to_ulaw( serialized_data = await pcm_to_ulaw(
data, frame.sample_rate, self._params.twilio_sample_rate, self._resampler data, frame.sample_rate, self._twilio_sample_rate, self._resampler
) )
payload = base64.b64encode(serialized_data).decode("utf-8") payload = base64.b64encode(serialized_data).decode("utf-8")
answer = { answer = {
@@ -67,10 +76,10 @@ class TwilioFrameSerializer(FrameSerializer):
payload = base64.b64decode(payload_base64) payload = base64.b64decode(payload_base64)
deserialized_data = await ulaw_to_pcm( deserialized_data = await ulaw_to_pcm(
payload, self._params.twilio_sample_rate, self._params.sample_rate, self._resampler payload, self._twilio_sample_rate, self._sample_rate, self._resampler
) )
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(
audio=deserialized_data, num_channels=1, sample_rate=self._params.sample_rate audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate
) )
return audio_frame return audio_frame
elif message["event"] == "dtmf": elif message["event"] == "dtmf":

View File

@@ -213,7 +213,7 @@ class TTSService(AIService):
# if push_silence_after_stop is True, send this amount of audio silence # if push_silence_after_stop is True, send this amount of audio silence
silence_time_s: float = 2.0, silence_time_s: float = 2.0,
# TTS output sample rate # TTS output sample rate
sample_rate: int = 24000, sample_rate: Optional[int] = None,
text_filter: Optional[BaseTextFilter] = None, text_filter: Optional[BaseTextFilter] = None,
**kwargs, **kwargs,
): ):
@@ -224,7 +224,8 @@ class TTSService(AIService):
self._stop_frame_timeout_s: float = stop_frame_timeout_s self._stop_frame_timeout_s: float = stop_frame_timeout_s
self._push_silence_after_stop: bool = push_silence_after_stop self._push_silence_after_stop: bool = push_silence_after_stop
self._silence_time_s: float = silence_time_s self._silence_time_s: float = silence_time_s
self._sample_rate: int = sample_rate self._init_sample_rate = sample_rate
self._sample_rate = 0
self._voice_id: str = "" self._voice_id: str = ""
self._settings: Dict[str, Any] = {} self._settings: Dict[str, Any] = {}
self._text_filter: Optional[BaseTextFilter] = text_filter self._text_filter: Optional[BaseTextFilter] = text_filter
@@ -248,16 +249,20 @@ class TTSService(AIService):
async def flush_audio(self): async def flush_audio(self):
pass pass
def language_to_service_language(self, language: Language) -> str | None:
return Language(language)
# Converts the text to audio. # Converts the text to audio.
@abstractmethod @abstractmethod
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
pass pass
def language_to_service_language(self, language: Language) -> str | None:
return Language(language)
async def update_setting(self, key: str, value: Any):
pass
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate
if self._push_stop_frames: if self._push_stop_frames:
self._stop_frame_task = self.create_task(self._stop_frame_handler()) self._stop_frame_task = self.create_task(self._stop_frame_handler())
@@ -467,9 +472,17 @@ class WordTTSService(TTSService):
class STTService(AIService): class STTService(AIService):
"""STTService is a base class for speech-to-text services.""" """STTService is a base class for speech-to-text services."""
def __init__(self, audio_passthrough=False, **kwargs): def __init__(
self,
audio_passthrough=False,
# STT input sample rate
sample_rate: Optional[int] = None,
**kwargs,
):
super().__init__(**kwargs) super().__init__(**kwargs)
self._audio_passthrough = audio_passthrough self._audio_passthrough = audio_passthrough
self._init_sample_rate = sample_rate
self._sample_rate = 0
self._settings: Dict[str, Any] = {} self._settings: Dict[str, Any] = {}
self._muted: bool = False self._muted: bool = False
@@ -478,6 +491,10 @@ class STTService(AIService):
"""Returns whether the STT service is currently muted.""" """Returns whether the STT service is currently muted."""
return self._muted return self._muted
@property
def sample_rate(self) -> int:
return self._sample_rate
@abstractmethod @abstractmethod
async def set_model(self, model: str): async def set_model(self, model: str):
self.set_model_name(model) self.set_model_name(model)
@@ -491,6 +508,10 @@ class STTService(AIService):
"""Returns transcript as a string""" """Returns transcript as a string"""
pass pass
async def start(self, frame: StartFrame):
await super().start(frame)
self._sample_rate = self._init_sample_rate or frame.audio_in_sample_rate
async def _update_settings(self, settings: Mapping[str, Any]): async def _update_settings(self, settings: Mapping[str, Any]):
logger.info(f"Updating STT settings: {self._settings}") logger.info(f"Updating STT settings: {self._settings}")
for key, value in settings.items(): for key, value in settings.items():
@@ -540,17 +561,15 @@ class SegmentedSTTService(STTService):
min_volume: float = 0.6, min_volume: float = 0.6,
max_silence_secs: float = 0.3, max_silence_secs: float = 0.3,
max_buffer_secs: float = 1.5, max_buffer_secs: float = 1.5,
sample_rate: int = 24000, sample_rate: Optional[int] = None,
num_channels: int = 1,
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._min_volume = min_volume self._min_volume = min_volume
self._max_silence_secs = max_silence_secs self._max_silence_secs = max_silence_secs
self._max_buffer_secs = max_buffer_secs self._max_buffer_secs = max_buffer_secs
self._sample_rate = sample_rate self._content = None
self._num_channels = num_channels self._wave = None
(self._content, self._wave) = self._new_wave()
self._silence_num_frames = 0 self._silence_num_frames = 0
# Volume exponential smoothing # Volume exponential smoothing
self._smoothing_factor = 0.2 self._smoothing_factor = 0.2
@@ -569,8 +588,8 @@ class SegmentedSTTService(STTService):
# If buffer is not empty and we have enough data or there's been a long # If buffer is not empty and we have enough data or there's been a long
# silence, transcribe the audio gathered so far. # silence, transcribe the audio gathered so far.
silence_secs = self._silence_num_frames / self._sample_rate silence_secs = self._silence_num_frames / self.sample_rate
buffer_secs = self._wave.getnframes() / self._sample_rate buffer_secs = self._wave.getnframes() / self.sample_rate
if self._content.tell() > 0 and ( if self._content.tell() > 0 and (
buffer_secs > self._max_buffer_secs or silence_secs > self._max_silence_secs buffer_secs > self._max_buffer_secs or silence_secs > self._max_silence_secs
): ):
@@ -580,18 +599,24 @@ class SegmentedSTTService(STTService):
await self.process_generator(self.run_stt(self._content.read())) await self.process_generator(self.run_stt(self._content.read()))
(self._content, self._wave) = self._new_wave() (self._content, self._wave) = self._new_wave()
async def start(self, frame: StartFrame):
await super().start(frame)
(self._content, self._wave) = self._new_wave()
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):
await super().stop(frame)
self._wave.close() self._wave.close()
async def cancel(self, frame: CancelFrame): async def cancel(self, frame: CancelFrame):
await super().cancel(frame)
self._wave.close() self._wave.close()
def _new_wave(self): def _new_wave(self):
content = io.BytesIO() content = io.BytesIO()
ww = wave.open(content, "wb") ww = wave.open(content, "wb")
ww.setsampwidth(2) ww.setsampwidth(2)
ww.setnchannels(self._num_channels) ww.setnchannels(1)
ww.setframerate(self._sample_rate) ww.setframerate(self.sample_rate)
return (content, ww) return (content, ww)
def _get_smoothed_volume(self, frame: AudioRawFrame) -> float: def _get_smoothed_volume(self, frame: AudioRawFrame) -> float:

View File

@@ -5,7 +5,7 @@
# #
import asyncio import asyncio
from typing import AsyncGenerator from typing import AsyncGenerator, Optional
from loguru import logger from loguru import logger
@@ -38,20 +38,17 @@ class AssemblyAISTTService(STTService):
self, self,
*, *,
api_key: str, api_key: str,
sample_rate: int = 16000, sample_rate: Optional[int] = None,
encoding: AudioEncoding = AudioEncoding("pcm_s16le"), encoding: AudioEncoding = AudioEncoding("pcm_s16le"),
language=Language.EN, # Only English is supported for Realtime language=Language.EN, # Only English is supported for Realtime
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
aai.settings.api_key = api_key aai.settings.api_key = api_key
self._transcriber: aai.RealtimeTranscriber | None = None self._transcriber: aai.RealtimeTranscriber | None = None
# Store reference to the main event loop for use in callback functions
self._loop = asyncio.get_event_loop()
self._settings = { self._settings = {
"sample_rate": sample_rate,
"encoding": encoding, "encoding": encoding,
"language": language, "language": language,
} }
@@ -121,7 +118,7 @@ class AssemblyAISTTService(STTService):
# Schedule the coroutine to run in the main event loop # Schedule the coroutine to run in the main event loop
# This is necessary because this callback runs in a different thread # This is necessary because this callback runs in a different thread
asyncio.run_coroutine_threadsafe(self.push_frame(frame), self._loop) asyncio.run_coroutine_threadsafe(self.push_frame(frame), self.get_event_loop())
def on_error(error: aai.RealtimeError): def on_error(error: aai.RealtimeError):
"""Callback for handling errors from AssemblyAI. """Callback for handling errors from AssemblyAI.
@@ -131,14 +128,16 @@ class AssemblyAISTTService(STTService):
""" """
logger.error(f"{self}: An error occurred: {error}") logger.error(f"{self}: An error occurred: {error}")
# Schedule the coroutine to run in the main event loop # Schedule the coroutine to run in the main event loop
asyncio.run_coroutine_threadsafe(self.push_frame(ErrorFrame(str(error))), self._loop) asyncio.run_coroutine_threadsafe(
self.push_frame(ErrorFrame(str(error))), self.get_event_loop()
)
def on_close(): def on_close():
"""Callback for when the connection to AssemblyAI is closed.""" """Callback for when the connection to AssemblyAI is closed."""
logger.info(f"{self}: Disconnected from AssemblyAI") logger.info(f"{self}: Disconnected from AssemblyAI")
self._transcriber = aai.RealtimeTranscriber( self._transcriber = aai.RealtimeTranscriber(
sample_rate=self._settings["sample_rate"], sample_rate=self.sample_rate,
encoding=self._settings["encoding"], encoding=self._settings["encoding"],
on_data=on_data, on_data=on_data,
on_error=on_error, on_error=on_error,

View File

@@ -124,7 +124,7 @@ class PollyTTSService(TTSService):
aws_session_token: Optional[str] = None, aws_session_token: Optional[str] = None,
region: Optional[str] = None, region: Optional[str] = None,
voice_id: str = "Joanna", voice_id: str = "Joanna",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
@@ -138,7 +138,6 @@ class PollyTTSService(TTSService):
region_name=region, region_name=region,
) )
self._settings = { self._settings = {
"sample_rate": sample_rate,
"engine": params.engine, "engine": params.engine,
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
@@ -226,9 +225,7 @@ class PollyTTSService(TTSService):
yield None yield None
return return
audio_data = await self._resampler.resample( audio_data = await self._resampler.resample(audio_data, 16000, self.sample_rate)
audio_data, 16000, self._settings["sample_rate"]
)
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
@@ -239,7 +236,7 @@ class PollyTTSService(TTSService):
chunk = audio_data[i : i + chunk_size] chunk = audio_data[i : i + chunk_size]
if len(chunk) > 0: if len(chunk) > 0:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(chunk, self._settings["sample_rate"], 1) frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield frame yield frame
yield TTSStoppedFrame() yield TTSStoppedFrame()

View File

@@ -450,14 +450,13 @@ class AzureBaseTTSService(TTSService):
api_key: str, api_key: str,
region: str, region: str,
voice="en-US-SaraNeural", voice="en-US-SaraNeural",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._settings = { self._settings = {
"sample_rate": sample_rate,
"emphasis": params.emphasis, "emphasis": params.emphasis,
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
@@ -537,7 +536,7 @@ class AzureTTSService(AzureBaseTTSService):
speech_recognition_language=self._settings["language"], speech_recognition_language=self._settings["language"],
) )
speech_config.set_speech_synthesis_output_format( speech_config.set_speech_synthesis_output_format(
sample_rate_to_output_format(self._settings["sample_rate"]) sample_rate_to_output_format(self.sample_rate)
) )
speech_config.set_service_property( speech_config.set_service_property(
"synthesizer.synthesis.connection.synthesisConnectionImpl", "synthesizer.synthesis.connection.synthesisConnectionImpl",
@@ -591,7 +590,7 @@ class AzureTTSService(AzureBaseTTSService):
yield TTSAudioRawFrame( yield TTSAudioRawFrame(
audio=chunk, audio=chunk,
sample_rate=self._settings["sample_rate"], sample_rate=self.sample_rate,
num_channels=1, num_channels=1,
) )
@@ -612,7 +611,7 @@ class AzureHttpTTSService(AzureBaseTTSService):
speech_recognition_language=self._settings["language"], speech_recognition_language=self._settings["language"],
) )
speech_config.set_speech_synthesis_output_format( speech_config.set_speech_synthesis_output_format(
sample_rate_to_output_format(self._settings["sample_rate"]) sample_rate_to_output_format(self.sample_rate)
) )
self._speech_synthesizer = SpeechSynthesizer(speech_config=speech_config, audio_config=None) self._speech_synthesizer = SpeechSynthesizer(speech_config=speech_config, audio_config=None)
@@ -633,7 +632,7 @@ class AzureHttpTTSService(AzureBaseTTSService):
# Azure always sends a 44-byte header. Strip it off. # Azure always sends a 44-byte header. Strip it off.
yield TTSAudioRawFrame( yield TTSAudioRawFrame(
audio=result.audio_data[44:], audio=result.audio_data[44:],
sample_rate=self._settings["sample_rate"], sample_rate=self.sample_rate,
num_channels=1, num_channels=1,
) )
yield TTSStoppedFrame() yield TTSStoppedFrame()
@@ -650,24 +649,14 @@ class AzureSTTService(STTService):
*, *,
api_key: str, api_key: str,
region: str, region: str,
language=Language.EN_US, language: Language = Language.EN_US,
sample_rate=24000, sample_rate: Optional[int] = None,
channels=1,
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
speech_config = SpeechConfig(subscription=api_key, region=region) self._speech_config = SpeechConfig(subscription=api_key, region=region)
speech_config.speech_recognition_language = language self._speech_config.speech_recognition_language = language
stream_format = AudioStreamFormat(samples_per_second=sample_rate, channels=channels)
self._audio_stream = PushAudioInputStream(stream_format)
audio_config = AudioConfig(stream=self._audio_stream)
self._speech_recognizer = SpeechRecognizer(
speech_config=speech_config, audio_config=audio_config
)
self._speech_recognizer.recognized.connect(self._on_handle_recognized)
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
await self.start_processing_metrics() await self.start_processing_metrics()
@@ -677,6 +666,16 @@ class AzureSTTService(STTService):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
stream_format = AudioStreamFormat(samples_per_second=self.sample_rate, channels=1)
self._audio_stream = PushAudioInputStream(stream_format)
audio_config = AudioConfig(stream=self._audio_stream)
self._speech_recognizer = SpeechRecognizer(
speech_config=self._speech_config, audio_config=audio_config
)
self._speech_recognizer.recognized.connect(self._on_handle_recognized)
self._speech_recognizer.start_continuous_recognition_async() self._speech_recognizer.start_continuous_recognition_async()
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):

View File

@@ -89,7 +89,7 @@ class CartesiaTTSService(WordTTSService, WebsocketService):
cartesia_version: str = "2024-06-10", cartesia_version: str = "2024-06-10",
url: str = "wss://api.cartesia.ai/tts/websocket", url: str = "wss://api.cartesia.ai/tts/websocket",
model: str = "sonic", model: str = "sonic",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
encoding: str = "pcm_s16le", encoding: str = "pcm_s16le",
container: str = "raw", container: str = "raw",
params: InputParams = InputParams(), params: InputParams = InputParams(),
@@ -121,7 +121,7 @@ class CartesiaTTSService(WordTTSService, WebsocketService):
"output_format": { "output_format": {
"container": container, "container": container,
"encoding": encoding, "encoding": encoding,
"sample_rate": sample_rate, "sample_rate": 0,
}, },
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
@@ -174,6 +174,7 @@ class CartesiaTTSService(WordTTSService, WebsocketService):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._settings["output_format"]["sample_rate"] = self.sample_rate
await self._connect() await self._connect()
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):
@@ -262,7 +263,7 @@ class CartesiaTTSService(WordTTSService, WebsocketService):
self.start_word_timestamps() self.start_word_timestamps()
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(
audio=base64.b64decode(msg["data"]), audio=base64.b64decode(msg["data"]),
sample_rate=self._settings["output_format"]["sample_rate"], sample_rate=self.sample_rate,
num_channels=1, num_channels=1,
) )
await self.push_frame(frame) await self.push_frame(frame)
@@ -328,7 +329,7 @@ class CartesiaHttpTTSService(TTSService):
voice_id: str, voice_id: str,
model: str = "sonic", model: str = "sonic",
base_url: str = "https://api.cartesia.ai", base_url: str = "https://api.cartesia.ai",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
encoding: str = "pcm_s16le", encoding: str = "pcm_s16le",
container: str = "raw", container: str = "raw",
params: InputParams = InputParams(), params: InputParams = InputParams(),
@@ -341,7 +342,7 @@ class CartesiaHttpTTSService(TTSService):
"output_format": { "output_format": {
"container": container, "container": container,
"encoding": encoding, "encoding": encoding,
"sample_rate": sample_rate, "sample_rate": 0,
}, },
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
@@ -360,6 +361,10 @@ class CartesiaHttpTTSService(TTSService):
def language_to_service_language(self, language: Language) -> str | None: def language_to_service_language(self, language: Language) -> str | None:
return language_to_cartesia_language(language) return language_to_cartesia_language(language)
async def start(self, frame: StartFrame):
await super().start(frame)
self._settings["output_format"]["sample_rate"] = self.sample_rate
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):
await super().stop(frame) await super().stop(frame)
await self._client.close() await self._client.close()
@@ -394,9 +399,7 @@ class CartesiaHttpTTSService(TTSService):
) )
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(
audio=output["audio"], audio=output["audio"], sample_rate=self.sample_rate, num_channels=1
sample_rate=self._settings["output_format"]["sample_rate"],
num_channels=1,
) )
yield frame yield frame
except Exception as e: except Exception as e:

View File

@@ -5,7 +5,7 @@
# #
import asyncio import asyncio
from typing import AsyncGenerator from typing import AsyncGenerator, Optional
from loguru import logger from loguru import logger
@@ -53,14 +53,13 @@ class DeepgramTTSService(TTSService):
*, *,
api_key: str, api_key: str,
voice: str = "aura-helios-en", voice: str = "aura-helios-en",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
encoding: str = "linear16", encoding: str = "linear16",
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._settings = { self._settings = {
"sample_rate": sample_rate,
"encoding": encoding, "encoding": encoding,
} }
self.set_voice(voice) self.set_voice(voice)
@@ -75,7 +74,7 @@ class DeepgramTTSService(TTSService):
options = SpeakOptions( options = SpeakOptions(
model=self._voice_id, model=self._voice_id,
encoding=self._settings["encoding"], encoding=self._settings["encoding"],
sample_rate=self._settings["sample_rate"], sample_rate=self.sample_rate,
container="none", container="none",
) )
@@ -103,9 +102,7 @@ class DeepgramTTSService(TTSService):
chunk = audio_buffer.read(chunk_size) chunk = audio_buffer.read(chunk_size)
if not chunk: if not chunk:
break break
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(audio=chunk, sample_rate=self.sample_rate, num_channels=1)
audio=chunk, sample_rate=self._settings["sample_rate"], num_channels=1
)
yield frame yield frame
yield TTSStoppedFrame() yield TTSStoppedFrame()
@@ -121,15 +118,16 @@ class DeepgramSTTService(STTService):
*, *,
api_key: str, api_key: str,
url: str = "", url: str = "",
live_options: LiveOptions = None, sample_rate: Optional[int] = None,
live_options: Optional[LiveOptions] = None,
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
default_options = LiveOptions( default_options = LiveOptions(
encoding="linear16", encoding="linear16",
language=Language.EN, language=Language.EN,
model="nova-2-general", model="nova-2-general",
sample_rate=16000,
channels=1, channels=1,
interim_results=True, interim_results=True,
smart_format=True, smart_format=True,
@@ -187,6 +185,7 @@ class DeepgramSTTService(STTService):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._settings["sample_rate"] = self.sample_rate
await self._connect() await self._connect()
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):

View File

@@ -104,17 +104,17 @@ def language_to_elevenlabs_language(language: Language) -> str | None:
return result return result
def sample_rate_from_output_format(output_format: str) -> int: def output_format_from_sample_rate(sample_rate: int) -> str:
match output_format: match sample_rate:
case "pcm_16000": case 16000:
return 16000 return "pcm_16000"
case "pcm_22050": case 22050:
return 22050 return "pcm_22050"
case "pcm_24000": case 24000:
return 24000 return "pcm_24000"
case "pcm_44100": case 44100:
return 44100 return "pcm_44100"
return 16000 return "pcm_16000"
def calculate_word_times( def calculate_word_times(
@@ -165,7 +165,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
voice_id: str, voice_id: str,
model: str = "eleven_flash_v2_5", model: str = "eleven_flash_v2_5",
url: str = "wss://api.elevenlabs.io", url: str = "wss://api.elevenlabs.io",
output_format: ElevenLabsOutputFormat = "pcm_24000", sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
@@ -189,7 +189,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
push_text_frames=False, push_text_frames=False,
push_stop_frames=True, push_stop_frames=True,
stop_frame_timeout_s=2.0, stop_frame_timeout_s=2.0,
sample_rate=sample_rate_from_output_format(output_format), sample_rate=sample_rate,
**kwargs, **kwargs,
) )
WebsocketService.__init__(self) WebsocketService.__init__(self)
@@ -197,11 +197,9 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
self._api_key = api_key self._api_key = api_key
self._url = url self._url = url
self._settings = { self._settings = {
"sample_rate": sample_rate_from_output_format(output_format),
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
else None, else None,
"output_format": output_format,
"optimize_streaming_latency": params.optimize_streaming_latency, "optimize_streaming_latency": params.optimize_streaming_latency,
"stability": params.stability, "stability": params.stability,
"similarity_boost": params.similarity_boost, "similarity_boost": params.similarity_boost,
@@ -211,6 +209,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
} }
self.set_model_name(model) self.set_model_name(model)
self.set_voice(voice_id) self.set_voice(voice_id)
self._output_format = "" # initialized in start()
self._voice_settings = self._set_voice_settings() self._voice_settings = self._set_voice_settings()
# Indicates if we have sent TTSStartedFrame. It will reset to False when # Indicates if we have sent TTSStartedFrame. It will reset to False when
@@ -254,7 +253,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
await self._disconnect() await self._disconnect()
await self._connect() await self._connect()
async def _update_settings(self, settings: Dict[str, Any]): async def _update_settings(self, settings: Mapping[str, Any]):
prev_voice = self._voice_id prev_voice = self._voice_id
await super()._update_settings(settings) await super()._update_settings(settings)
if not prev_voice == self._voice_id: if not prev_voice == self._voice_id:
@@ -264,6 +263,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._output_format = output_format_from_sample_rate(self.sample_rate)
await self._connect() await self._connect()
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):
@@ -322,7 +322,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
voice_id = self._voice_id voice_id = self._voice_id
model = self.model_name model = self.model_name
output_format = self._settings["output_format"] output_format = self._output_format
url = f"{self._url}/v1/text-to-speech/{voice_id}/stream-input?model_id={model}&output_format={output_format}&auto_mode={self._settings['auto_mode']}" url = f"{self._url}/v1/text-to-speech/{voice_id}/stream-input?model_id={model}&output_format={output_format}&auto_mode={self._settings['auto_mode']}"
if self._settings["optimize_streaming_latency"]: if self._settings["optimize_streaming_latency"]:
@@ -375,7 +375,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
self.start_word_timestamps() self.start_word_timestamps()
audio = base64.b64decode(msg["audio"]) audio = base64.b64decode(msg["audio"])
frame = TTSAudioRawFrame(audio, self._settings["sample_rate"], 1) frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
await self.push_frame(frame) await self.push_frame(frame)
if msg.get("alignment"): if msg.get("alignment"):
word_times = calculate_word_times(msg["alignment"], self._cumulative_time) word_times = calculate_word_times(msg["alignment"], self._cumulative_time)
@@ -428,7 +428,7 @@ class ElevenLabsHttpTTSService(TTSService):
aiohttp_session: aiohttp ClientSession aiohttp_session: aiohttp ClientSession
model: Model ID (default: "eleven_flash_v2_5" for low latency) model: Model ID (default: "eleven_flash_v2_5" for low latency)
base_url: API base URL base_url: API base URL
output_format: Audio output format (PCM) sample_rate: Output sample rate
params: Additional parameters for voice configuration params: Additional parameters for voice configuration
""" """
@@ -448,24 +448,21 @@ class ElevenLabsHttpTTSService(TTSService):
aiohttp_session: aiohttp.ClientSession, aiohttp_session: aiohttp.ClientSession,
model: str = "eleven_flash_v2_5", model: str = "eleven_flash_v2_5",
base_url: str = "https://api.elevenlabs.io", base_url: str = "https://api.elevenlabs.io",
output_format: ElevenLabsOutputFormat = "pcm_24000", sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate_from_output_format(output_format), **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._api_key = api_key self._api_key = api_key
self._base_url = base_url self._base_url = base_url
self._output_format = output_format
self._params = params self._params = params
self._session = aiohttp_session self._session = aiohttp_session
self._settings = { self._settings = {
"sample_rate": sample_rate_from_output_format(output_format),
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
else None, else None,
"output_format": output_format,
"optimize_streaming_latency": params.optimize_streaming_latency, "optimize_streaming_latency": params.optimize_streaming_latency,
"stability": params.stability, "stability": params.stability,
"similarity_boost": params.similarity_boost, "similarity_boost": params.similarity_boost,
@@ -474,6 +471,7 @@ class ElevenLabsHttpTTSService(TTSService):
} }
self.set_model_name(model) self.set_model_name(model)
self.set_voice(voice_id) self.set_voice(voice_id)
self._output_format = "" # initialized in start()
self._voice_settings = self._set_voice_settings() self._voice_settings = self._set_voice_settings()
def can_generate_metrics(self) -> bool: def can_generate_metrics(self) -> bool:
@@ -508,6 +506,10 @@ class ElevenLabsHttpTTSService(TTSService):
return voice_settings or None return voice_settings or None
async def start(self, frame: StartFrame):
await super().start(frame)
self._output_format = output_format_from_sample_rate(self.sample_rate)
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text using ElevenLabs streaming API. """Generate speech from text using ElevenLabs streaming API.
@@ -570,7 +572,7 @@ class ElevenLabsHttpTTSService(TTSService):
async for chunk in response.content: async for chunk in response.content:
if chunk: if chunk:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
yield TTSAudioRawFrame(chunk, self._settings["sample_rate"], 1) yield TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield TTSStoppedFrame() yield TTSStoppedFrame()

View File

@@ -56,7 +56,7 @@ class FishAudioTTSService(TTSService, WebsocketService):
api_key: str, api_key: str,
model: str, # This is the reference_id model: str, # This is the reference_id
output_format: FishAudioOutputFormat = "pcm", output_format: FishAudioOutputFormat = "pcm",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
@@ -70,7 +70,7 @@ class FishAudioTTSService(TTSService, WebsocketService):
self._started = False self._started = False
self._settings = { self._settings = {
"sample_rate": sample_rate, "sample_rate": 0,
"latency": params.latency, "latency": params.latency,
"format": output_format, "format": output_format,
"prosody": { "prosody": {
@@ -92,6 +92,7 @@ class FishAudioTTSService(TTSService, WebsocketService):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._settings["sample_rate"] = self.sample_rate
await self._connect() await self._connect()
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):
@@ -157,9 +158,7 @@ class FishAudioTTSService(TTSService, WebsocketService):
audio_data = msg.get("audio") audio_data = msg.get("audio")
# Only process larger chunks to remove msgpack overhead # Only process larger chunks to remove msgpack overhead
if audio_data and len(audio_data) > 1024: if audio_data and len(audio_data) > 1024:
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(audio_data, self.sample_rate, 1)
audio_data, self._settings["sample_rate"], 1
)
await self.push_frame(frame) await self.push_frame(frame)
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
continue continue

View File

@@ -48,7 +48,7 @@ class AudioInputMessage(BaseModel):
realtimeInput: RealtimeInput realtimeInput: RealtimeInput
@classmethod @classmethod
def from_raw_audio(cls, raw_audio: bytes, sample_rate=16000) -> "AudioInputMessage": def from_raw_audio(cls, raw_audio: bytes, sample_rate: int) -> "AudioInputMessage":
data = base64.b64encode(raw_audio).decode("utf-8") data = base64.b64encode(raw_audio).decode("utf-8")
return cls( return cls(
realtimeInput=RealtimeInput( realtimeInput=RealtimeInput(

View File

@@ -203,6 +203,8 @@ class GeminiMultimodalLiveLLMService(LLMService):
self._bot_audio_buffer = bytearray() self._bot_audio_buffer = bytearray()
self._bot_text_buffer = "" self._bot_text_buffer = ""
self._sample_rate = 24000
self._settings = { self._settings = {
"frequency_penalty": params.frequency_penalty, "frequency_penalty": params.frequency_penalty,
"max_tokens": params.max_tokens, "max_tokens": params.max_tokens,
@@ -521,7 +523,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
if self._audio_input_paused: if self._audio_input_paused:
return return
# Send all audio to Gemini # Send all audio to Gemini
evt = events.AudioInputMessage.from_raw_audio(frame.audio) evt = events.AudioInputMessage.from_raw_audio(frame.audio, frame.sample_rate)
await self.send_client_event(evt) await self.send_client_event(evt)
# Manage a buffer of audio to use for transcription # Manage a buffer of audio to use for transcription
audio = frame.audio audio = frame.audio
@@ -650,7 +652,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
inline_data = part.inlineData inline_data = part.inlineData
if not inline_data: if not inline_data:
return return
if inline_data.mimeType != "audio/pcm;rate=24000": if inline_data.mimeType != f"audio/pcm;rate={self._sample_rate}":
logger.warning(f"Unrecognized server_content format {inline_data.mimeType}") logger.warning(f"Unrecognized server_content format {inline_data.mimeType}")
return return
@@ -665,7 +667,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
self._bot_audio_buffer.extend(audio) self._bot_audio_buffer.extend(audio)
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(
audio=audio, audio=audio,
sample_rate=24000, sample_rate=self._sample_rate,
num_channels=1, num_channels=1,
) )
await self.push_frame(frame) await self.push_frame(frame)

View File

@@ -131,7 +131,6 @@ def language_to_gladia_language(language: Language) -> str | None:
class GladiaSTTService(STTService): class GladiaSTTService(STTService):
class InputParams(BaseModel): class InputParams(BaseModel):
sample_rate: Optional[int] = 16000
language: Optional[Language] = Language.EN language: Optional[Language] = Language.EN
endpointing: Optional[float] = 0.2 endpointing: Optional[float] = 0.2
maximum_duration_without_endpointing: Optional[int] = 10 maximum_duration_without_endpointing: Optional[int] = 10
@@ -144,17 +143,18 @@ class GladiaSTTService(STTService):
api_key: str, api_key: str,
url: str = "https://api.gladia.io/v2/live", url: str = "https://api.gladia.io/v2/live",
confidence: float = 0.5, confidence: float = 0.5,
sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._api_key = api_key self._api_key = api_key
self._url = url self._url = url
self._settings = { self._settings = {
"encoding": "wav/pcm", "encoding": "wav/pcm",
"bit_depth": 16, "bit_depth": 16,
"sample_rate": params.sample_rate, "sample_rate": 0,
"channels": 1, "channels": 1,
"language_config": { "language_config": {
"languages": [self.language_to_service_language(params.language)] "languages": [self.language_to_service_language(params.language)]
@@ -178,6 +178,7 @@ class GladiaSTTService(STTService):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._settings["sample_rate"] = self.sample_rate
response = await self._setup_gladia() response = await self._setup_gladia()
self._websocket = await websockets.connect(response["url"]) self._websocket = await websockets.connect(response["url"])
self._receive_task = self.create_task(self._receive_task_handler()) self._receive_task = self.create_task(self._receive_task_handler())

View File

@@ -883,14 +883,13 @@ class GoogleTTSService(TTSService):
credentials: Optional[str] = None, credentials: Optional[str] = None,
credentials_path: Optional[str] = None, credentials_path: Optional[str] = None,
voice_id: str = "en-US-Neural2-A", voice_id: str = "en-US-Neural2-A",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._settings = { self._settings = {
"sample_rate": sample_rate,
"pitch": params.pitch, "pitch": params.pitch,
"rate": params.rate, "rate": params.rate,
"volume": params.volume, "volume": params.volume,
@@ -996,7 +995,7 @@ class GoogleTTSService(TTSService):
) )
audio_config = texttospeech_v1.AudioConfig( audio_config = texttospeech_v1.AudioConfig(
audio_encoding=texttospeech_v1.AudioEncoding.LINEAR16, audio_encoding=texttospeech_v1.AudioEncoding.LINEAR16,
sample_rate_hertz=self._settings["sample_rate"], sample_rate_hertz=self.sample_rate,
) )
request = texttospeech_v1.SynthesizeSpeechRequest( request = texttospeech_v1.SynthesizeSpeechRequest(
@@ -1019,7 +1018,7 @@ class GoogleTTSService(TTSService):
if not chunk: if not chunk:
break break
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(chunk, self._settings["sample_rate"], 1) frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield frame yield frame
await asyncio.sleep(0) # Allow other tasks to run await asyncio.sleep(0) # Allow other tasks to run

View File

@@ -5,7 +5,7 @@
# #
import json import json
from typing import AsyncGenerator from typing import AsyncGenerator, Optional
from loguru import logger from loguru import logger
@@ -66,7 +66,7 @@ class LmntTTSService(TTSService, WebsocketService):
*, *,
api_key: str, api_key: str,
voice_id: str, voice_id: str,
sample_rate: int = 24000, sample_rate: Optional[int] = None,
language: Language = Language.EN, language: Language = Language.EN,
**kwargs, **kwargs,
): ):
@@ -81,7 +81,6 @@ class LmntTTSService(TTSService, WebsocketService):
self._api_key = api_key self._api_key = api_key
self._voice_id = voice_id self._voice_id = voice_id
self._settings = { self._settings = {
"sample_rate": sample_rate,
"language": self.language_to_service_language(language), "language": self.language_to_service_language(language),
"format": "raw", # Use raw format for direct PCM data "format": "raw", # Use raw format for direct PCM data
} }
@@ -132,7 +131,7 @@ class LmntTTSService(TTSService, WebsocketService):
"X-API-Key": self._api_key, "X-API-Key": self._api_key,
"voice": self._voice_id, "voice": self._voice_id,
"format": self._settings["format"], "format": self._settings["format"],
"sample_rate": self._settings["sample_rate"], "sample_rate": self.sample_rate,
"language": self._settings["language"], "language": self._settings["language"],
} }
@@ -175,7 +174,7 @@ class LmntTTSService(TTSService, WebsocketService):
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(
audio=message, audio=message,
sample_rate=self._settings["sample_rate"], sample_rate=self.sample_rate,
num_channels=1, num_channels=1,
) )
await self.push_frame(frame) await self.push_frame(frame)

View File

@@ -415,17 +415,14 @@ class OpenAITTSService(TTSService):
def __init__( def __init__(
self, self,
*, *,
api_key: str | None = None, api_key: Optional[str] = None,
voice: str = "alloy", voice: str = "alloy",
model: Literal["tts-1", "tts-1-hd"] = "tts-1", model: Literal["tts-1", "tts-1-hd"] = "tts-1",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._settings = {
"sample_rate": sample_rate,
}
self.set_model_name(model) self.set_model_name(model)
self.set_voice(voice) self.set_voice(voice)
@@ -465,7 +462,7 @@ class OpenAITTSService(TTSService):
async for chunk in r.iter_bytes(8192): async for chunk in r.iter_bytes(8192):
if len(chunk) > 0: if len(chunk) > 0:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(chunk, self._settings["sample_rate"], 1) frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield frame yield frame
yield TTSStoppedFrame() yield TTSStoppedFrame()
except BadRequestError as e: except BadRequestError as e:

View File

@@ -113,7 +113,7 @@ class PlayHTTTSService(TTSService, WebsocketService):
user_id: str, user_id: str,
voice_url: str, voice_url: str,
voice_engine: str = "Play3.0-mini", voice_engine: str = "Play3.0-mini",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
output_format: str = "wav", output_format: str = "wav",
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
@@ -132,7 +132,6 @@ class PlayHTTTSService(TTSService, WebsocketService):
self._request_id = None self._request_id = None
self._settings = { self._settings = {
"sample_rate": sample_rate,
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
else "english", else "english",
@@ -250,7 +249,7 @@ class PlayHTTTSService(TTSService, WebsocketService):
if message.startswith(b"RIFF"): if message.startswith(b"RIFF"):
continue continue
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(message, self._settings["sample_rate"], 1) frame = TTSAudioRawFrame(message, self.sample_rate, 1)
await self.push_frame(frame) await self.push_frame(frame)
else: else:
logger.debug(f"Received text message: {message}") logger.debug(f"Received text message: {message}")
@@ -301,7 +300,7 @@ class PlayHTTTSService(TTSService, WebsocketService):
"voice": self._voice_id, "voice": self._voice_id,
"voice_engine": self._settings["voice_engine"], "voice_engine": self._settings["voice_engine"],
"output_format": self._settings["output_format"], "output_format": self._settings["output_format"],
"sample_rate": self._settings["sample_rate"], "sample_rate": self.sample_rate,
"language": self._settings["language"], "language": self._settings["language"],
"speed": self._settings["speed"], "speed": self._settings["speed"],
"seed": self._settings["seed"], "seed": self._settings["seed"],
@@ -339,7 +338,7 @@ class PlayHTHttpTTSService(TTSService):
user_id: str, user_id: str,
voice_url: str, voice_url: str,
voice_engine: str = "Play3.0-mini-http", # Options: Play3.0-mini-http, Play3.0-mini-ws voice_engine: str = "Play3.0-mini-http", # Options: Play3.0-mini-http, Play3.0-mini-ws
sample_rate: int = 24000, sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
@@ -353,7 +352,6 @@ class PlayHTHttpTTSService(TTSService):
api_key=self._api_key, api_key=self._api_key,
) )
self._settings = { self._settings = {
"sample_rate": sample_rate,
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
if params.language if params.language
else "english", else "english",
@@ -377,7 +375,7 @@ class PlayHTHttpTTSService(TTSService):
self._options = TTSOptions( self._options = TTSOptions(
voice=self._voice_id, voice=self._voice_id,
language=playht_language, language=playht_language,
sample_rate=self._settings["sample_rate"], sample_rate=self.sample_rate,
format=self._settings["format"], format=self._settings["format"],
speed=self._settings["speed"], speed=self._settings["speed"],
seed=self._settings["seed"], seed=self._settings["seed"],
@@ -422,7 +420,7 @@ class PlayHTHttpTTSService(TTSService):
else: else:
if len(chunk): if len(chunk):
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(chunk, self._settings["sample_rate"], 1) frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield frame yield frame
yield TTSStoppedFrame() yield TTSStoppedFrame()
except Exception as e: except Exception as e:

View File

@@ -34,7 +34,7 @@ class RimeHttpTTSService(TTSService):
api_key: str, api_key: str,
voice_id: str = "eva", voice_id: str = "eva",
model: str = "mist", model: str = "mist",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
@@ -43,7 +43,6 @@ class RimeHttpTTSService(TTSService):
self._api_key = api_key self._api_key = api_key
self._base_url = "https://users.rime.ai/v1/rime-tts" self._base_url = "https://users.rime.ai/v1/rime-tts"
self._settings = { self._settings = {
"samplingRate": sample_rate,
"speedAlpha": params.speed_alpha, "speedAlpha": params.speed_alpha,
"reduceLatency": params.reduce_latency, "reduceLatency": params.reduce_latency,
"pauseBetweenBrackets": params.pause_between_brackets, "pauseBetweenBrackets": params.pause_between_brackets,
@@ -71,6 +70,7 @@ class RimeHttpTTSService(TTSService):
payload["text"] = text payload["text"] = text
payload["speaker"] = self._voice_id payload["speaker"] = self._voice_id
payload["modelId"] = self._model_name payload["modelId"] = self._model_name
payload["samplingRate"] = self.sample_rate
try: try:
await self.start_ttfb_metrics() await self.start_ttfb_metrics()
@@ -96,7 +96,7 @@ class RimeHttpTTSService(TTSService):
first_chunk = False first_chunk = False
if chunk: if chunk:
frame = TTSAudioRawFrame(chunk, self._settings["samplingRate"], 1) frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield frame yield frame
yield TTSStoppedFrame() yield TTSStoppedFrame()

View File

@@ -49,7 +49,7 @@ class FastPitchTTSService(TTSService):
api_key: str, api_key: str,
server: str = "grpc.nvcf.nvidia.com:443", server: str = "grpc.nvcf.nvidia.com:443",
voice_id: str = "English-US.Female-1", voice_id: str = "English-US.Female-1",
sample_rate: int = 24000, sample_rate: Optional[int] = None,
function_id: str = "0149dedb-2be8-4195-b9a0-e57e0e14f972", function_id: str = "0149dedb-2be8-4195-b9a0-e57e0e14f972",
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
@@ -57,7 +57,6 @@ class FastPitchTTSService(TTSService):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._api_key = api_key self._api_key = api_key
self._voice_id = voice_id self._voice_id = voice_id
self._sample_rate = sample_rate
self._language_code = params.language self._language_code = params.language
self._quality = params.quality self._quality = params.quality
@@ -87,7 +86,7 @@ class FastPitchTTSService(TTSService):
text, text,
self._voice_id, self._voice_id,
self._language_code, self._language_code,
sample_rate_hz=self._sample_rate, sample_rate_hz=self.sample_rate,
audio_prompt_file=None, audio_prompt_file=None,
quality=self._quality, quality=self._quality,
custom_dictionary={}, custom_dictionary={},
@@ -114,7 +113,7 @@ class FastPitchTTSService(TTSService):
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(
audio=resp.audio, audio=resp.audio,
sample_rate=self._sample_rate, sample_rate=self.sample_rate,
num_channels=1, num_channels=1,
) )
yield frame yield frame
@@ -136,10 +135,11 @@ class ParakeetSTTService(STTService):
api_key: str, api_key: str,
server: str = "grpc.nvcf.nvidia.com:443", server: str = "grpc.nvcf.nvidia.com:443",
function_id: str = "1598d209-5e27-4d3c-8079-4751568b1081", function_id: str = "1598d209-5e27-4d3c-8079-4751568b1081",
sample_rate: Optional[int] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
self._api_key = api_key self._api_key = api_key
self._profanity_filter = False self._profanity_filter = False
self._automatic_punctuation = False self._automatic_punctuation = False
@@ -154,7 +154,6 @@ class ParakeetSTTService(STTService):
self._stop_history_eou = -1 self._stop_history_eou = -1
self._stop_threshold_eou = -1.0 self._stop_threshold_eou = -1.0
self._custom_configuration = "" self._custom_configuration = ""
self._sample_rate: int = 16000
self.set_model_name("parakeet-ctc-1.1b-asr") self.set_model_name("parakeet-ctc-1.1b-asr")
@@ -166,6 +165,14 @@ class ParakeetSTTService(STTService):
self._asr_service = riva.client.ASRService(auth) self._asr_service = riva.client.ASRService(auth)
self._queue = asyncio.Queue()
def can_generate_metrics(self) -> bool:
return False
async def start(self, frame: StartFrame):
await super().start(frame)
config = riva.client.StreamingRecognitionConfig( config = riva.client.StreamingRecognitionConfig(
config=riva.client.RecognitionConfig( config=riva.client.RecognitionConfig(
encoding=riva.client.AudioEncoding.LINEAR_PCM, encoding=riva.client.AudioEncoding.LINEAR_PCM,
@@ -175,14 +182,16 @@ class ParakeetSTTService(STTService):
profanity_filter=self._profanity_filter, profanity_filter=self._profanity_filter,
enable_automatic_punctuation=self._automatic_punctuation, enable_automatic_punctuation=self._automatic_punctuation,
verbatim_transcripts=not self._no_verbatim_transcripts, verbatim_transcripts=not self._no_verbatim_transcripts,
sample_rate_hertz=self._sample_rate, sample_rate_hertz=self.sample_rate,
audio_channel_count=1, audio_channel_count=1,
), ),
interim_results=True, interim_results=True,
) )
riva.client.add_word_boosting_to_config( riva.client.add_word_boosting_to_config(
config, self._boosted_lm_words, self._boosted_lm_score config, self._boosted_lm_words, self._boosted_lm_score
) )
riva.client.add_endpoint_parameters_to_config( riva.client.add_endpoint_parameters_to_config(
config, config,
self._start_history, self._start_history,
@@ -193,15 +202,9 @@ class ParakeetSTTService(STTService):
self._stop_threshold_eou, self._stop_threshold_eou,
) )
riva.client.add_custom_configuration_to_config(config, self._custom_configuration) riva.client.add_custom_configuration_to_config(config, self._custom_configuration)
self._config = config self._config = config
self._queue = asyncio.Queue()
def can_generate_metrics(self) -> bool:
return False
async def start(self, frame: StartFrame):
await super().start(frame)
self._thread_task = self.create_task(self._thread_task_handler()) self._thread_task = self.create_task(self._thread_task_handler())
self._response_task = self.create_task(self._response_task_handler()) self._response_task = self.create_task(self._response_task_handler())
self._response_queue = asyncio.Queue() self._response_queue = asyncio.Queue()

View File

@@ -4,7 +4,7 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
from typing import Any, AsyncGenerator, Dict from typing import Any, AsyncGenerator, Dict, Optional
import aiohttp import aiohttp
from loguru import logger from loguru import logger
@@ -76,7 +76,7 @@ class XTTSService(TTSService):
base_url: str, base_url: str,
aiohttp_session: aiohttp.ClientSession, aiohttp_session: aiohttp.ClientSession,
language: Language = Language.EN, language: Language = Language.EN,
sample_rate: int = 24000, sample_rate: Optional[int] = None,
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(sample_rate=sample_rate, **kwargs)
@@ -164,18 +164,18 @@ class XTTSService(TTSService):
# XTTS uses 24000 so we need to resample to our desired rate. # XTTS uses 24000 so we need to resample to our desired rate.
resampled_audio = await self._resampler.resample( resampled_audio = await self._resampler.resample(
bytes(process_data), 24000, self._sample_rate bytes(process_data), 24000, self.sample_rate
) )
# Create the frame with the resampled audio # Create the frame with the resampled audio
frame = TTSAudioRawFrame(resampled_audio, self._sample_rate, 1) frame = TTSAudioRawFrame(resampled_audio, self.sample_rate, 1)
yield frame yield frame
# Process any remaining data in the buffer. # Process any remaining data in the buffer.
if len(buffer) > 0: if len(buffer) > 0:
resampled_audio = await self._resampler.resample( resampled_audio = await self._resampler.resample(
bytes(buffer), 24000, self._sample_rate bytes(buffer), 24000, self.sample_rate
) )
frame = TTSAudioRawFrame(resampled_audio, self._sample_rate, 1) frame = TTSAudioRawFrame(resampled_audio, self.sample_rate, 1)
yield frame yield frame
yield TTSStoppedFrame() yield TTSStoppedFrame()

View File

@@ -35,6 +35,9 @@ class BaseInputTransport(FrameProcessor):
self._params = params self._params = params
# Input sample rate. It will be initialized on StartFrame.
self._sample_rate = 0
# We read audio from a single queue one at a time and we then run VAD in # We read audio from a single queue one at a time and we then run VAD in
# a thread. Therefore, only one thread should be necessary. # a thread. Therefore, only one thread should be necessary.
self._executor = ThreadPoolExecutor(max_workers=1) self._executor = ThreadPoolExecutor(max_workers=1)
@@ -43,10 +46,23 @@ class BaseInputTransport(FrameProcessor):
# if passthrough is enabled. # if passthrough is enabled.
self._audio_task = None self._audio_task = None
@property
def sample_rate(self) -> int:
return self._sample_rate
@property
def vad_analyzer(self) -> VADAnalyzer | None:
return self._params.vad_analyzer
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
# Configure VAD analyzer.
if self._params.vad_enabled and self._params.vad_analyzer:
self._params.vad_analyzer.set_sample_rate(self._sample_rate)
# Start audio filter. # Start audio filter.
if self._params.audio_in_filter: if self._params.audio_in_filter:
await self._params.audio_in_filter.start(self._params.audio_in_sample_rate) await self._params.audio_in_filter.start(self._sample_rate)
# Create audio input queue and task if needed. # Create audio input queue and task if needed.
if self._params.audio_in_enabled or self._params.vad_enabled: if self._params.audio_in_enabled or self._params.vad_enabled:
self._audio_in_queue = asyncio.Queue() self._audio_in_queue = asyncio.Queue()
@@ -67,9 +83,6 @@ class BaseInputTransport(FrameProcessor):
await self.cancel_task(self._audio_task) await self.cancel_task(self._audio_task)
self._audio_task = None self._audio_task = None
def vad_analyzer(self) -> VADAnalyzer | None:
return self._params.vad_analyzer
async def push_audio_frame(self, frame: InputAudioRawFrame): async def push_audio_frame(self, frame: InputAudioRawFrame):
if self._params.audio_in_enabled or self._params.vad_enabled: if self._params.audio_in_enabled or self._params.vad_enabled:
await self._audio_in_queue.put(frame) await self._audio_in_queue.put(frame)
@@ -104,9 +117,8 @@ class BaseInputTransport(FrameProcessor):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
await self.stop(frame) await self.stop(frame)
elif isinstance(frame, VADParamsUpdateFrame): elif isinstance(frame, VADParamsUpdateFrame):
vad_analyzer = self.vad_analyzer() if self.vad_analyzer:
if vad_analyzer: self.vad_analyzer.set_params(frame.params)
vad_analyzer.set_params(frame.params)
elif isinstance(frame, FilterUpdateSettingsFrame) and self._params.audio_in_filter: elif isinstance(frame, FilterUpdateSettingsFrame) and self._params.audio_in_filter:
await self._params.audio_in_filter.process_frame(frame) await self._params.audio_in_filter.process_frame(frame)
# Other frames # Other frames
@@ -140,11 +152,10 @@ class BaseInputTransport(FrameProcessor):
async def _vad_analyze(self, audio_frame: InputAudioRawFrame) -> VADState: async def _vad_analyze(self, audio_frame: InputAudioRawFrame) -> VADState:
state = VADState.QUIET state = VADState.QUIET
vad_analyzer = self.vad_analyzer() if self.vad_analyzer:
if vad_analyzer:
logger.trace(f"{self}: analyzing VAD on {audio_frame}") logger.trace(f"{self}: analyzing VAD on {audio_frame}")
state = await self.get_event_loop().run_in_executor( state = await self.get_event_loop().run_in_executor(
self._executor, vad_analyzer.analyze_audio, audio_frame.audio self._executor, self.vad_analyzer.analyze_audio, audio_frame.audio
) )
logger.trace(f"{self}: done analyzing VAD on {audio_frame}") logger.trace(f"{self}: done analyzing VAD on {audio_frame}")
return state return state

View File

@@ -57,12 +57,11 @@ class BaseOutputTransport(FrameProcessor):
# framerate. # framerate.
self._camera_images = None self._camera_images = None
# We will write 20ms audio at a time. If we receive long audio frames we # Output sample rate. It will be initialized on StartFrame.
# will chunk them. This will help with interruption handling. self._sample_rate = 0
audio_bytes_10ms = (
int(self._params.audio_out_sample_rate / 100) * self._params.audio_out_channels * 2 # Chunk size that will be written. It will be computed on StartFrame
) self._audio_chunk_size = 0
self._audio_chunk_size = audio_bytes_10ms * 2
self._audio_buffer = bytearray() self._audio_buffer = bytearray()
self._stopped_event = asyncio.Event() self._stopped_event = asyncio.Event()
@@ -70,10 +69,21 @@ class BaseOutputTransport(FrameProcessor):
# Indicates if the bot is currently speaking. # Indicates if the bot is currently speaking.
self._bot_speaking = False self._bot_speaking = False
@property
def sample_rate(self) -> int:
return self._sample_rate
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
# We will write 20ms audio at a time. If we receive long audio frames we
# will chunk them. This will help with interruption handling.
audio_bytes_10ms = int(self._sample_rate / 100) * self._params.audio_out_channels * 2
self._audio_chunk_size = audio_bytes_10ms * 2
# Start audio mixer. # Start audio mixer.
if self._params.audio_out_mixer: if self._params.audio_out_mixer:
await self._params.audio_out_mixer.start(self._params.audio_out_sample_rate) await self._params.audio_out_mixer.start(self._sample_rate)
self._create_camera_task() self._create_camera_task()
self._create_sink_tasks() self._create_sink_tasks()
@@ -298,7 +308,7 @@ class BaseOutputTransport(FrameProcessor):
# Generate an audio frame with only the mixer's part. # Generate an audio frame with only the mixer's part.
frame = OutputAudioRawFrame( frame = OutputAudioRawFrame(
audio=await self._params.audio_out_mixer.mix(silence), audio=await self._params.audio_out_mixer.mix(silence),
sample_rate=self._params.audio_out_sample_rate, sample_rate=self._sample_rate,
num_channels=self._params.audio_out_channels, num_channels=self._params.audio_out_channels,
) )
yield frame yield frame

View File

@@ -31,12 +31,12 @@ class TransportParams(BaseModel):
camera_out_color_format: str = "RGB" camera_out_color_format: str = "RGB"
audio_out_enabled: bool = False audio_out_enabled: bool = False
audio_out_is_live: bool = False audio_out_is_live: bool = False
audio_out_sample_rate: int = 24000 audio_out_sample_rate: Optional[int] = None
audio_out_channels: int = 1 audio_out_channels: int = 1
audio_out_bitrate: int = 96000 audio_out_bitrate: int = 96000
audio_out_mixer: Optional[BaseAudioMixer] = None audio_out_mixer: Optional[BaseAudioMixer] = None
audio_in_enabled: bool = False audio_in_enabled: bool = False
audio_in_sample_rate: int = 16000 audio_in_sample_rate: Optional[int] = None
audio_in_channels: int = 1 audio_in_channels: int = 1
audio_in_filter: Optional[BaseAudioFilter] = None audio_in_filter: Optional[BaseAudioFilter] = None
vad_enabled: bool = False vad_enabled: bool = False

View File

@@ -28,35 +28,40 @@ except ModuleNotFoundError as e:
class LocalAudioInputTransport(BaseInputTransport): class LocalAudioInputTransport(BaseInputTransport):
def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams): def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio
self._in_stream = None
self._sample_rate = 0
sample_rate = self._params.audio_in_sample_rate async def start(self, frame: StartFrame):
num_frames = int(sample_rate / 100) * 2 # 20ms of audio await super().start(frame)
self._in_stream = py_audio.open( self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
format=py_audio.get_format_from_width(2), num_frames = int(self._sample_rate / 100) * 2 # 20ms of audio
channels=params.audio_in_channels,
rate=params.audio_in_sample_rate, self._in_stream = self._py_audio.open(
format=self._py_audio.get_format_from_width(2),
channels=self._params.audio_in_channels,
rate=self._sample_rate,
frames_per_buffer=num_frames, frames_per_buffer=num_frames,
stream_callback=self._audio_in_callback, stream_callback=self._audio_in_callback,
input=True, input=True,
) )
async def start(self, frame: StartFrame):
await super().start(frame)
self._in_stream.start_stream() self._in_stream.start_stream()
async def cleanup(self): async def cleanup(self):
await super().cleanup() await super().cleanup()
self._in_stream.stop_stream() if self._in_stream:
# This is not very pretty (taken from PyAudio docs). self._in_stream.stop_stream()
while self._in_stream.is_active(): # This is not very pretty (taken from PyAudio docs).
await asyncio.sleep(0.1) while self._in_stream.is_active():
self._in_stream.close() await asyncio.sleep(0.1)
self._in_stream.close()
self._in_stream = None
def _audio_in_callback(self, in_data, frame_count, time_info, status): def _audio_in_callback(self, in_data, frame_count, time_info, status):
frame = InputAudioRawFrame( frame = InputAudioRawFrame(
audio=in_data, audio=in_data,
sample_rate=self._params.audio_in_sample_rate, sample_rate=self._sample_rate,
num_channels=self._params.audio_in_channels, num_channels=self._params.audio_in_channels,
) )
@@ -68,32 +73,41 @@ class LocalAudioInputTransport(BaseInputTransport):
class LocalAudioOutputTransport(BaseOutputTransport): class LocalAudioOutputTransport(BaseOutputTransport):
def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams): def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio
self._out_stream = None
self._sample_rate = 0
# We only write audio frames from a single task, so only one thread # We only write audio frames from a single task, so only one thread
# should be necessary. # should be necessary.
self._executor = ThreadPoolExecutor(max_workers=1) self._executor = ThreadPoolExecutor(max_workers=1)
self._out_stream = py_audio.open(
format=py_audio.get_format_from_width(2),
channels=params.audio_out_channels,
rate=params.audio_out_sample_rate,
output=True,
)
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
self._out_stream = self._py_audio.open(
format=self._py_audio.get_format_from_width(2),
channels=self._params.audio_out_channels,
rate=self._sample_rate,
output=True,
)
self._out_stream.start_stream() self._out_stream.start_stream()
async def cleanup(self): async def cleanup(self):
await super().cleanup() await super().cleanup()
self._out_stream.stop_stream() if self._out_stream:
# This is not very pretty (taken from PyAudio docs). self._out_stream.stop_stream()
while self._out_stream.is_active(): # This is not very pretty (taken from PyAudio docs).
await asyncio.sleep(0.1) while self._out_stream.is_active():
self._out_stream.close() await asyncio.sleep(0.1)
self._out_stream.close()
async def write_raw_audio_frames(self, frames: bytes): async def write_raw_audio_frames(self, frames: bytes):
await self.get_event_loop().run_in_executor(self._executor, self._out_stream.write, frames) if self._out_stream:
await self.get_event_loop().run_in_executor(
self._executor, self._out_stream.write, frames
)
class LocalAudioTransport(BaseTransport): class LocalAudioTransport(BaseTransport):

View File

@@ -36,35 +36,39 @@ except ModuleNotFoundError as e:
class TkInputTransport(BaseInputTransport): class TkInputTransport(BaseInputTransport):
def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams): def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio
self._in_stream = None
self._sample_rate = 0
sample_rate = self._params.audio_in_sample_rate async def start(self, frame: StartFrame):
num_frames = int(sample_rate / 100) * 2 # 20ms of audio await super().start(frame)
self._in_stream = py_audio.open( self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
format=py_audio.get_format_from_width(2), num_frames = int(self._sample_rate / 100) * 2 # 20ms of audio
channels=params.audio_in_channels,
rate=params.audio_in_sample_rate, self._in_stream = self._py_audio.open(
format=self._py_audio.get_format_from_width(2),
channels=self._params.audio_in_channels,
rate=self._sample_rate,
frames_per_buffer=num_frames, frames_per_buffer=num_frames,
stream_callback=self._audio_in_callback, stream_callback=self._audio_in_callback,
input=True, input=True,
) )
async def start(self, frame: StartFrame):
await super().start(frame)
self._in_stream.start_stream() self._in_stream.start_stream()
async def cleanup(self): async def cleanup(self):
await super().cleanup() await super().cleanup()
self._in_stream.stop_stream() if self._in_stream:
# This is not very pretty (taken from PyAudio docs). self._in_stream.stop_stream()
while self._in_stream.is_active(): # This is not very pretty (taken from PyAudio docs).
await asyncio.sleep(0.1) while self._in_stream.is_active():
self._in_stream.close() await asyncio.sleep(0.1)
self._in_stream.close()
def _audio_in_callback(self, in_data, frame_count, time_info, status): def _audio_in_callback(self, in_data, frame_count, time_info, status):
frame = InputAudioRawFrame( frame = InputAudioRawFrame(
audio=in_data, audio=in_data,
sample_rate=self._params.audio_in_sample_rate, sample_rate=self._sample_rate,
num_channels=self._params.audio_in_channels, num_channels=self._params.audio_in_channels,
) )
@@ -76,18 +80,14 @@ class TkInputTransport(BaseInputTransport):
class TkOutputTransport(BaseOutputTransport): class TkOutputTransport(BaseOutputTransport):
def __init__(self, tk_root: tk.Tk, py_audio: pyaudio.PyAudio, params: TransportParams): def __init__(self, tk_root: tk.Tk, py_audio: pyaudio.PyAudio, params: TransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio
self._out_stream = None
self._sample_rate = 0
# We only write audio frames from a single task, so only one thread # We only write audio frames from a single task, so only one thread
# should be necessary. # should be necessary.
self._executor = ThreadPoolExecutor(max_workers=1) self._executor = ThreadPoolExecutor(max_workers=1)
self._out_stream = py_audio.open(
format=py_audio.get_format_from_width(2),
channels=params.audio_out_channels,
rate=params.audio_out_sample_rate,
output=True,
)
# Start with a neutral gray background. # Start with a neutral gray background.
array = np.ones((1024, 1024, 3)) * 128 array = np.ones((1024, 1024, 3)) * 128
data = f"P5 {1024} {1024} 255 ".encode() + array.astype(np.uint8).tobytes() data = f"P5 {1024} {1024} 255 ".encode() + array.astype(np.uint8).tobytes()
@@ -97,18 +97,31 @@ class TkOutputTransport(BaseOutputTransport):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
self._out_stream = self._py_audio.open(
format=self._py_audio.get_format_from_width(2),
channels=self._params.audio_out_channels,
rate=self._sample_rate,
output=True,
)
self._out_stream.start_stream() self._out_stream.start_stream()
async def cleanup(self): async def cleanup(self):
await super().cleanup() await super().cleanup()
self._out_stream.stop_stream() if self._out_stream:
# This is not very pretty (taken from PyAudio docs). self._out_stream.stop_stream()
while self._out_stream.is_active(): # This is not very pretty (taken from PyAudio docs).
await asyncio.sleep(0.1) while self._out_stream.is_active():
self._out_stream.close() await asyncio.sleep(0.1)
self._out_stream.close()
async def write_raw_audio_frames(self, frames: bytes): async def write_raw_audio_frames(self, frames: bytes):
await self.get_event_loop().run_in_executor(self._executor, self._out_stream.write, frames) if self._out_stream:
await self.get_event_loop().run_in_executor(
self._executor, self._out_stream.write, frames
)
async def write_frame_to_camera(self, frame: OutputImageRawFrame): async def write_frame_to_camera(self, frame: OutputImageRawFrame):
self.get_event_loop().call_soon(self._write_frame_to_tk, frame) self.get_event_loop().call_soon(self._write_frame_to_tk, frame)

View File

@@ -69,6 +69,7 @@ class FastAPIWebsocketInputTransport(BaseInputTransport):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
await self._params.serializer.setup(frame)
if self._params.session_timeout: if self._params.session_timeout:
self._monitor_websocket_task = self.create_task(self._monitor_websocket()) self._monitor_websocket_task = self.create_task(self._monitor_websocket())
await self._callbacks.on_client_connected(self._websocket) await self._callbacks.on_client_connected(self._websocket)
@@ -118,9 +119,19 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport):
self._websocket = websocket self._websocket = websocket
self._params = params self._params = params
self._send_interval = (self._audio_chunk_size / self._params.audio_out_sample_rate) / 2 # write_raw_audio_frames() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0 self._next_send_time = 0
async def start(self, frame: StartFrame):
await super().start(frame)
await self._params.serializer.setup(frame)
self._send_interval = (self._audio_chunk_size / self.sample_rate) / 2
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
@@ -136,7 +147,7 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport):
frame = OutputAudioRawFrame( frame = OutputAudioRawFrame(
audio=frames, audio=frames,
sample_rate=self._params.audio_out_sample_rate, sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels, num_channels=self._params.audio_out_channels,
) )

View File

@@ -126,6 +126,7 @@ class WebsocketClientInputTransport(BaseInputTransport):
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
await self._params.serializer.setup(frame)
await self._session.setup(frame) await self._session.setup(frame)
await self._session.connect() await self._session.connect()
@@ -154,11 +155,18 @@ class WebsocketClientOutputTransport(BaseOutputTransport):
self._session = session self._session = session
self._params = params self._params = params
self._send_interval = (self._audio_chunk_size / self._params.audio_out_sample_rate) / 2 # write_raw_audio_frames() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0 self._next_send_time = 0
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
self._send_interval = (self._audio_chunk_size / self.sample_rate) / 2
await self._params.serializer.setup(frame)
await self._session.setup(frame) await self._session.setup(frame)
await self._session.connect() await self._session.connect()
@@ -176,7 +184,7 @@ class WebsocketClientOutputTransport(BaseOutputTransport):
async def write_raw_audio_frames(self, frames: bytes): async def write_raw_audio_frames(self, frames: bytes):
frame = OutputAudioRawFrame( frame = OutputAudioRawFrame(
audio=frames, audio=frames,
sample_rate=self._params.audio_out_sample_rate, sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels, num_channels=self._params.audio_out_channels,
) )

View File

@@ -24,7 +24,6 @@ from pipecat.frames.frames import (
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.serializers.base_serializer import FrameSerializer from pipecat.serializers.base_serializer import FrameSerializer
from pipecat.serializers.protobuf import ProtobufFrameSerializer
from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
@@ -39,7 +38,7 @@ except ModuleNotFoundError as e:
class WebsocketServerParams(TransportParams): class WebsocketServerParams(TransportParams):
add_wav_header: bool = False add_wav_header: bool = False
serializer: FrameSerializer = ProtobufFrameSerializer() serializer: FrameSerializer
session_timeout: int | None = None session_timeout: int | None = None
@@ -67,20 +66,32 @@ class WebsocketServerInputTransport(BaseInputTransport):
self._websocket: websockets.WebSocketServerProtocol | None = None self._websocket: websockets.WebSocketServerProtocol | None = None
self._server_task = None
# This task will monitor the websocket connection periodically.
self._monitor_task = None
self._stop_server_event = asyncio.Event() self._stop_server_event = asyncio.Event()
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
await self._params.serializer.setup(frame)
self._server_task = self.create_task(self._server_task_handler()) self._server_task = self.create_task(self._server_task_handler())
async def stop(self, frame: EndFrame): async def stop(self, frame: EndFrame):
await super().stop(frame) await super().stop(frame)
self._stop_server_event.set() self._stop_server_event.set()
await self.wait_for_task(self._server_task) if self._monitor_task:
await self.cancel_task(self._monitor_task)
if self._server_task:
await self.wait_for_task(self._server_task)
async def cancel(self, frame: CancelFrame): async def cancel(self, frame: CancelFrame):
await super().cancel(frame) await super().cancel(frame)
await self.cancel_task(self._server_task) if self._monitor_task:
await self.cancel_task(self._monitor_task)
if self._server_task:
await self.cancel_task(self._server_task)
async def _server_task_handler(self): async def _server_task_handler(self):
logger.info(f"Starting websocket server on {self._host}:{self._port}") logger.info(f"Starting websocket server on {self._host}:{self._port}")
@@ -100,7 +111,9 @@ class WebsocketServerInputTransport(BaseInputTransport):
# Create a task to monitor the websocket connection # Create a task to monitor the websocket connection
if self._params.session_timeout: if self._params.session_timeout:
self.create_task(self._monitor_websocket(websocket)) self._monitor_task = self.create_task(
self._monitor_websocket(websocket, self._params.session_timeout)
)
# Handle incoming messages # Handle incoming messages
try: try:
@@ -125,10 +138,13 @@ class WebsocketServerInputTransport(BaseInputTransport):
logger.info(f"Client {websocket.remote_address} disconnected") logger.info(f"Client {websocket.remote_address} disconnected")
async def _monitor_websocket(self, websocket: websockets.WebSocketServerProtocol): async def _monitor_websocket(
"""Wait for self._params.session_timeout seconds, if the websocket is still open, trigger timeout event.""" self, websocket: websockets.WebSocketServerProtocol, session_timeout: int
):
"""Wait for session_timeout seconds, if the websocket is still open,
trigger timeout event."""
try: try:
await asyncio.sleep(self._params.session_timeout) await asyncio.sleep(session_timeout)
if not websocket.closed: if not websocket.closed:
await self._callbacks.on_session_timeout(websocket) await self._callbacks.on_session_timeout(websocket)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -144,7 +160,12 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
self._websocket: websockets.WebSocketServerProtocol | None = None self._websocket: websockets.WebSocketServerProtocol | None = None
self._send_interval = (self._audio_chunk_size / self._params.audio_out_sample_rate) / 2 # write_raw_audio_frames() is called quickly, as soon as we get audio
# (e.g. from the TTS), and since this is just a network connection we
# would be sending it to quickly. Instead, we want to block to emulate
# an audio device, this is what the send interval is. It will be
# computed on StartFrame.
self._send_interval = 0
self._next_send_time = 0 self._next_send_time = 0
async def set_client_connection(self, websocket: websockets.WebSocketServerProtocol | None): async def set_client_connection(self, websocket: websockets.WebSocketServerProtocol | None):
@@ -153,6 +174,11 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
logger.warning("Only one client allowed, using new connection") logger.warning("Only one client allowed, using new connection")
self._websocket = websocket self._websocket = websocket
async def start(self, frame: StartFrame):
await super().start(frame)
await self._params.serializer.setup(frame)
self._send_interval = (self._audio_chunk_size / self.sample_rate) / 2
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
@@ -168,7 +194,7 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
frame = OutputAudioRawFrame( frame = OutputAudioRawFrame(
audio=frames, audio=frames,
sample_rate=self._params.audio_out_sample_rate, sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels, num_channels=self._params.audio_out_channels,
) )
@@ -213,14 +239,13 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
class WebsocketServerTransport(BaseTransport): class WebsocketServerTransport(BaseTransport):
def __init__( def __init__(
self, self,
params: WebsocketServerParams,
host: str = "localhost", host: str = "localhost",
port: int = 8765, port: int = 8765,
params: WebsocketServerParams = WebsocketServerParams(),
input_name: str | None = None, input_name: str | None = None,
output_name: str | None = None, output_name: str | None = None,
loop: asyncio.AbstractEventLoop | None = None,
): ):
super().__init__(input_name=input_name, output_name=output_name, loop=loop) super().__init__(input_name=input_name, output_name=output_name)
self._host = host self._host = host
self._port = port self._port = port
self._params = params self._params = params

View File

@@ -71,11 +71,11 @@ class DailyTransportMessageUrgentFrame(TransportMessageUrgentFrame):
class WebRTCVADAnalyzer(VADAnalyzer): class WebRTCVADAnalyzer(VADAnalyzer):
def __init__(self, *, sample_rate=16000, num_channels=1, params: VADParams = VADParams()): def __init__(self, *, sample_rate: Optional[int] = None, params: VADParams = VADParams()):
super().__init__(sample_rate=sample_rate, num_channels=num_channels, params=params) super().__init__(sample_rate=sample_rate, params=params)
self._webrtc_vad = Daily.create_native_vad( self._webrtc_vad = Daily.create_native_vad(
reset_period_ms=VAD_RESET_PERIOD_MS, sample_rate=sample_rate, channels=num_channels reset_period_ms=VAD_RESET_PERIOD_MS, sample_rate=self.sample_rate, channels=1
) )
logger.debug("Loaded native WebRTC VAD") logger.debug("Loaded native WebRTC VAD")
@@ -222,33 +222,13 @@ class DailyTransportClient(EventHandler):
self._callback_queue = asyncio.Queue() self._callback_queue = asyncio.Queue()
self._callback_task = None self._callback_task = None
# Input and ouput sample rates. They will be initialize on setup().
self._in_sample_rate = 0
self._out_sample_rate = 0
self._camera: VirtualCameraDevice | None = None self._camera: VirtualCameraDevice | None = None
if self._params.camera_out_enabled:
self._camera = Daily.create_camera_device(
self._camera_name(),
width=self._params.camera_out_width,
height=self._params.camera_out_height,
color_format=self._params.camera_out_color_format,
)
self._mic: VirtualMicrophoneDevice | None = None self._mic: VirtualMicrophoneDevice | None = None
if self._params.audio_out_enabled:
self._mic = Daily.create_microphone_device(
self._mic_name(),
sample_rate=self._params.audio_out_sample_rate,
channels=self._params.audio_out_channels,
non_blocking=True,
)
self._speaker: VirtualSpeakerDevice | None = None self._speaker: VirtualSpeakerDevice | None = None
if self._params.audio_in_enabled or self._params.vad_enabled:
self._speaker = Daily.create_speaker_device(
self._speaker_name(),
sample_rate=self._params.audio_in_sample_rate,
channels=self._params.audio_in_channels,
non_blocking=True,
)
Daily.select_speaker_device(self._speaker_name())
def _camera_name(self): def _camera_name(self):
return f"camera-{self}" return f"camera-{self}"
@@ -281,7 +261,7 @@ class DailyTransportClient(EventHandler):
if not self._speaker: if not self._speaker:
return None return None
sample_rate = self._params.audio_in_sample_rate sample_rate = self._in_sample_rate
num_channels = self._params.audio_in_channels num_channels = self._params.audio_in_channels
num_frames = int(sample_rate / 100) * 2 # 20ms of audio num_frames = int(sample_rate / 100) * 2 # 20ms of audio
@@ -315,6 +295,34 @@ class DailyTransportClient(EventHandler):
self._camera.write_frame(frame.image) self._camera.write_frame(frame.image)
async def setup(self, frame: StartFrame): async def setup(self, frame: StartFrame):
self._in_sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
self._out_sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
if self._params.camera_out_enabled and not self._camera:
self._camera = Daily.create_camera_device(
self._camera_name(),
width=self._params.camera_out_width,
height=self._params.camera_out_height,
color_format=self._params.camera_out_color_format,
)
if self._params.audio_out_enabled and not self._mic:
self._mic = Daily.create_microphone_device(
self._mic_name(),
sample_rate=self._out_sample_rate,
channels=self._params.audio_out_channels,
non_blocking=True,
)
if (self._params.audio_in_enabled or self._params.vad_enabled) and not self._speaker:
self._speaker = Daily.create_speaker_device(
self._speaker_name(),
sample_rate=self._in_sample_rate,
channels=self._params.audio_in_channels,
non_blocking=True,
)
Daily.select_speaker_device(self._speaker_name())
if not self._task_manager: if not self._task_manager:
self._task_manager = frame.task_manager self._task_manager = frame.task_manager
self._callback_task = self._task_manager.create_task( self._callback_task = self._task_manager.create_task(
@@ -707,6 +715,7 @@ class DailyInputTransport(BaseInputTransport):
super().__init__(params, **kwargs) super().__init__(params, **kwargs)
self._client = client self._client = client
self._params = params
self._video_renderers = {} self._video_renderers = {}
@@ -715,11 +724,10 @@ class DailyInputTransport(BaseInputTransport):
self._audio_in_task = None self._audio_in_task = None
self._vad_analyzer: VADAnalyzer | None = params.vad_analyzer self._vad_analyzer: VADAnalyzer | None = params.vad_analyzer
if params.vad_enabled and not params.vad_analyzer:
self._vad_analyzer = WebRTCVADAnalyzer( @property
sample_rate=self._params.audio_in_sample_rate, def vad_analyzer(self) -> VADAnalyzer | None:
num_channels=self._params.audio_in_channels, return self._vad_analyzer
)
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
# Parent start. # Parent start.
@@ -728,6 +736,9 @@ class DailyInputTransport(BaseInputTransport):
await self._client.setup(frame) await self._client.setup(frame)
# Join the room. # Join the room.
await self._client.join() await self._client.join()
# Inialize WebRTC VAD if needed.
if self._params.vad_enabled and not self._params.vad_analyzer:
self._vad_analyzer = WebRTCVADAnalyzer(sample_rate=self.sample_rate)
# Create audio task. It reads audio frames from Daily and push them # Create audio task. It reads audio frames from Daily and push them
# internally for VAD processing. # internally for VAD processing.
if self._params.audio_in_enabled or self._params.vad_enabled: if self._params.audio_in_enabled or self._params.vad_enabled:
@@ -757,9 +768,6 @@ class DailyInputTransport(BaseInputTransport):
await super().cleanup() await super().cleanup()
await self._client.cleanup() await self._client.cleanup()
def vad_analyzer(self) -> VADAnalyzer | None:
return self._vad_analyzer
# #
# FrameProcessor # FrameProcessor
# #

View File

@@ -101,6 +101,7 @@ class LiveKitTransportClient:
return self._room return self._room
async def setup(self, frame: StartFrame): async def setup(self, frame: StartFrame):
self._out_sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
if not self._task_manager: if not self._task_manager:
self._task_manager = frame.task_manager self._task_manager = frame.task_manager
self._room = rtc.Room(loop=self._task_manager.get_event_loop()) self._room = rtc.Room(loop=self._task_manager.get_event_loop())
@@ -138,7 +139,7 @@ class LiveKitTransportClient:
# Set up audio source and track # Set up audio source and track
self._audio_source = rtc.AudioSource( self._audio_source = rtc.AudioSource(
self._params.audio_out_sample_rate, self._params.audio_out_channels self._out_sample_rate, self._params.audio_out_channels
) )
self._audio_track = rtc.LocalAudioTrack.create_audio_track( self._audio_track = rtc.LocalAudioTrack.create_audio_track(
"pipecat-audio", self._audio_source "pipecat-audio", self._audio_source
@@ -351,6 +352,10 @@ class LiveKitInputTransport(BaseInputTransport):
self._vad_analyzer: VADAnalyzer | None = params.vad_analyzer self._vad_analyzer: VADAnalyzer | None = params.vad_analyzer
self._resampler = create_default_resampler() self._resampler = create_default_resampler()
@property
def vad_analyzer(self) -> VADAnalyzer | None:
return self._vad_analyzer
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
await self._client.setup(frame) await self._client.setup(frame)
@@ -372,9 +377,6 @@ class LiveKitInputTransport(BaseInputTransport):
if self._audio_in_task and (self._params.audio_in_enabled or self._params.vad_enabled): if self._audio_in_task and (self._params.audio_in_enabled or self._params.vad_enabled):
await self.cancel_task(self._audio_in_task) await self.cancel_task(self._audio_in_task)
def vad_analyzer(self) -> VADAnalyzer | None:
return self._vad_analyzer
async def push_app_message(self, message: Any, sender: str): async def push_app_message(self, message: Any, sender: str):
frame = LiveKitTransportMessageUrgentFrame(message=message, participant_id=sender) frame = LiveKitTransportMessageUrgentFrame(message=message, participant_id=sender)
await self.push_frame(frame) await self.push_frame(frame)
@@ -401,12 +403,12 @@ class LiveKitInputTransport(BaseInputTransport):
audio_frame = audio_frame_event.frame audio_frame = audio_frame_event.frame
audio_data = await self._resampler.resample( audio_data = await self._resampler.resample(
audio_frame.data.tobytes(), audio_frame.sample_rate, self._params.audio_in_sample_rate audio_frame.data.tobytes(), audio_frame.sample_rate, self.sample_rate
) )
return AudioRawFrame( return AudioRawFrame(
audio=audio_data, audio=audio_data,
sample_rate=self._params.audio_in_sample_rate, sample_rate=self.sample_rate,
num_channels=audio_frame.num_channels, num_channels=audio_frame.num_channels,
) )
@@ -448,7 +450,7 @@ class LiveKitOutputTransport(BaseOutputTransport):
return rtc.AudioFrame( return rtc.AudioFrame(
data=pipecat_audio, data=pipecat_audio,
sample_rate=self._params.audio_out_sample_rate, sample_rate=self.sample_rate,
num_channels=self._params.audio_out_channels, num_channels=self._params.audio_out_channels,
samples_per_channel=samples_per_channel, samples_per_channel=samples_per_channel,
) )