Update Camb TTS to 48kHz sample rate

This commit is contained in:
Neil Ruaro
2026-01-13 00:43:46 +09:00
parent 641d17007f
commit e76a3d04f0
3 changed files with 86 additions and 25 deletions

View File

@@ -4,12 +4,13 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
"""Camb.ai MARS TTS example with local audio (microphone/speakers). """Camb.ai TTS example with local audio (microphone/speakers).
This example demonstrates: This example demonstrates:
- Basic TTS synthesis with Camb.ai MARS - Camb.ai MARS TTS with streaming audio
- Local audio input/output (no WebRTC or Daily needed) - Local audio input/output (no WebRTC or Daily needed)
- Handling interruptions - TTFB metrics tracking
- End-to-end latency measurement (user speech → AI response)
Requirements: Requirements:
- CAMB_API_KEY environment variable - CAMB_API_KEY environment variable
@@ -17,23 +18,29 @@ Requirements:
- DEEPGRAM_API_KEY environment variable (for STT) - DEEPGRAM_API_KEY environment variable (for STT)
Usage: Usage:
export CAMB_API_KEY=your_camb_api_key python 07zb-interruptible-camb-local.py
export OPENAI_API_KEY=your_openai_api_key python 07zb-interruptible-camb-local.py --voice-id 147320
export DEEPGRAM_API_KEY=your_deepgram_api_key
python 07zb-interruptible-camb-local.py [--voice-id VOICE_ID]
""" """
import argparse import argparse
import asyncio import asyncio
import os import os
import sys import sys
import time
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import LLMRunFrame from pipecat.frames.frames import (
BotStartedSpeakingFrame,
Frame,
LLMFullResponseStartFrame,
LLMRunFrame,
TTSStartedFrame,
UserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import TTFBMetricsData from pipecat.metrics.metrics import TTFBMetricsData
from pipecat.observers.loggers.metrics_log_observer import MetricsLogObserver from pipecat.observers.loggers.metrics_log_observer import MetricsLogObserver
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
@@ -43,23 +50,73 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair, LLMContextAggregatorPair,
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.camb.tts import CambTTSService from pipecat.services.camb.tts import CambTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams
class LatencyTracker(FrameProcessor):
"""Tracks end-to-end latency from user speech to AI audio response."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._user_stopped_time: float = 0
self._llm_start_time: float = 0
self._tts_start_time: float = 0
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, UserStoppedSpeakingFrame):
self._user_stopped_time = time.time()
logger.info("⏱️ User stopped speaking - timer started")
elif isinstance(frame, LLMFullResponseStartFrame):
self._llm_start_time = time.time()
if self._user_stopped_time > 0:
stt_latency = (self._llm_start_time - self._user_stopped_time) * 1000
logger.info(f"⏱️ STT latency: {stt_latency:.0f}ms")
elif isinstance(frame, TTSStartedFrame):
self._tts_start_time = time.time()
if self._llm_start_time > 0:
llm_latency = (self._tts_start_time - self._llm_start_time) * 1000
logger.info(f"⏱️ LLM TTFB: {llm_latency:.0f}ms")
elif isinstance(frame, BotStartedSpeakingFrame):
if self._user_stopped_time > 0:
total_latency = (time.time() - self._user_stopped_time) * 1000
tts_latency = (time.time() - self._tts_start_time) * 1000 if self._tts_start_time > 0 else 0
logger.info(f"⏱️ TTS TTFB: {tts_latency:.0f}ms")
logger.info(f"⏱️ ✨ TOTAL END-TO-END LATENCY: {total_latency:.0f}ms")
# Reset for next turn
self._user_stopped_time = 0
self._llm_start_time = 0
self._tts_start_time = 0
await self.push_frame(frame, direction)
load_dotenv(override=True) load_dotenv(override=True)
logger.remove(0) logger.remove(0)
logger.add(sys.stderr, level="DEBUG") logger.add(sys.stderr, level="DEBUG")
# Default voice
DEFAULT_VOICE_ID = 147320
async def main(voice_id: int): async def main(voice_id: int):
sample_rate = 48000
# Local audio transport - uses your microphone and speakers # Local audio transport - uses your microphone and speakers
# Increase audio_out_10ms_chunks for larger buffer (default is 4 = 40ms)
transport = LocalAudioTransport( transport = LocalAudioTransport(
LocalAudioTransportParams( LocalAudioTransportParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
audio_out_10ms_chunks=10, # 100ms buffer for smoother playback
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
) )
) )
@@ -67,7 +124,7 @@ async def main(voice_id: int):
# Deepgram STT for speech recognition # Deepgram STT for speech recognition
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
# Camb.ai TTS with MARS-flash model (uses official SDK) # Camb.ai TTS (48kHz output)
tts = CambTTSService( tts = CambTTSService(
api_key=os.getenv("CAMB_API_KEY"), api_key=os.getenv("CAMB_API_KEY"),
voice_id=voice_id, voice_id=voice_id,
@@ -81,7 +138,7 @@ async def main(voice_id: int):
messages = [ messages = [
{ {
"role": "system", "role": "system",
"content": """You are a helpful voice assistant powered by Camb.ai's MARS "content": """You are a helpful voice assistant powered by Camb.ai
text-to-speech technology. Keep your responses concise and conversational since text-to-speech technology. Keep your responses concise and conversational since
they will be spoken aloud. Avoid special characters, emojis, or bullet points.""", they will be spoken aloud. Avoid special characters, emojis, or bullet points.""",
}, },
@@ -91,26 +148,28 @@ they will be spoken aloud. Avoid special characters, emojis, or bullet points.""
context = LLMContext(messages) context = LLMContext(messages)
context_aggregator = LLMContextAggregatorPair(context) context_aggregator = LLMContextAggregatorPair(context)
# Latency tracker for end-to-end timing
latency_tracker = LatencyTracker()
# Build the pipeline # Build the pipeline
pipeline = Pipeline( pipeline = Pipeline(
[ [
transport.input(), # Microphone input transport.input(), # Microphone input
stt, # Speech-to-text stt, # Speech-to-text
latency_tracker, # Track latency at various stages
context_aggregator.user(), # User context context_aggregator.user(), # User context
llm, # Language model llm, # Language model
tts, # Camb.ai TTS tts, # TTS
transport.output(), # Speaker output transport.output(), # Speaker output
context_aggregator.assistant(), # Assistant context context_aggregator.assistant(), # Assistant context
] ]
) )
# Create pipeline task # Create pipeline task with TTFB tracking
# Use 24kHz sample rate to match Camb.ai TTS output
# Add MetricsLogObserver to track TTFB metrics
task = PipelineTask( task = PipelineTask(
pipeline, pipeline,
params=PipelineParams( params=PipelineParams(
audio_out_sample_rate=24000, audio_out_sample_rate=sample_rate,
enable_metrics=True, enable_metrics=True,
enable_usage_metrics=True, enable_usage_metrics=True,
), ),
@@ -136,12 +195,12 @@ they will be spoken aloud. Avoid special characters, emojis, or bullet points.""
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Camb.ai TTS example with local audio") parser = argparse.ArgumentParser(description="Camb.ai TTS with local audio")
parser.add_argument( parser.add_argument(
"--voice-id", "--voice-id",
type=int, type=int,
default=147320, default=DEFAULT_VOICE_ID,
help="Camb.ai voice ID to use (default: 147320)", help=f"Camb.ai voice ID (default: {DEFAULT_VOICE_ID})",
) )
args = parser.parse_args() args = parser.parse_args()
asyncio.run(main(args.voice_id)) asyncio.run(main(args.voice_id))

View File

@@ -13,7 +13,7 @@ Features:
- MARS models: mars-flash, mars-pro, mars-instruct - MARS models: mars-flash, mars-pro, mars-instruct
- 140+ languages supported - 140+ languages supported
- Real-time streaming via official SDK - Real-time streaming via official SDK
- 24kHz audio output - 48kHz audio output
- Voice customization (instructions for mars-instruct) - Voice customization (instructions for mars-instruct)
""" """
@@ -41,7 +41,7 @@ from pipecat.utils.tracing.service_decorators import traced_tts
DEFAULT_VOICE_ID = 147320 DEFAULT_VOICE_ID = 147320
DEFAULT_LANGUAGE = "en-us" DEFAULT_LANGUAGE = "en-us"
DEFAULT_MODEL = "mars-flash" # Faster inference DEFAULT_MODEL = "mars-flash" # Faster inference
DEFAULT_SAMPLE_RATE = 24000 # 24kHz DEFAULT_SAMPLE_RATE = 48000 # 48kHz
DEFAULT_TIMEOUT = 60.0 # Seconds (minimum recommended by Camb.ai) DEFAULT_TIMEOUT = 60.0 # Seconds (minimum recommended by Camb.ai)
MIN_TEXT_LENGTH = 3 MIN_TEXT_LENGTH = 3
MAX_TEXT_LENGTH = 3000 MAX_TEXT_LENGTH = 3000
@@ -133,6 +133,8 @@ class CambTTSService(TTSService):
Converts text to speech using Camb.ai's MARS TTS models with support for Converts text to speech using Camb.ai's MARS TTS models with support for
multiple languages. Provides custom instructions support for the mars-instruct model. multiple languages. Provides custom instructions support for the mars-instruct model.
All models output 48kHz audio.
Example:: Example::
# Basic usage with defaults # Basic usage with defaults
@@ -145,13 +147,13 @@ class CambTTSService(TTSService):
model="mars-pro", model="mars-pro",
) )
# For mars-instruct with custom instructions: # mars-instruct with custom instructions
tts = CambTTSService( tts = CambTTSService(
api_key="your-api-key", api_key="your-api-key",
model="mars-instruct", model="mars-instruct",
params=CambTTSService.InputParams( params=CambTTSService.InputParams(
user_instructions="Speak with excitement and energy" user_instructions="Speak with excitement and energy"
) ),
) )
""" """
@@ -191,7 +193,7 @@ class CambTTSService(TTSService):
model: TTS model to use. Options: "mars-flash", "mars-pro", "mars-instruct". model: TTS model to use. Options: "mars-flash", "mars-pro", "mars-instruct".
Defaults to DEFAULT_MODEL (mars-flash, fastest). Defaults to DEFAULT_MODEL (mars-flash, fastest).
timeout: Request timeout in seconds. Defaults to DEFAULT_TIMEOUT (60s). timeout: Request timeout in seconds. Defaults to DEFAULT_TIMEOUT (60s).
sample_rate: Audio sample rate in Hz. If None, uses DEFAULT_SAMPLE_RATE (24kHz). sample_rate: Audio sample rate in Hz. If None, uses DEFAULT_SAMPLE_RATE (48kHz).
params: Additional voice parameters. If None, uses defaults. params: Additional voice parameters. If None, uses defaults.
**kwargs: Additional arguments passed to parent TTSService. **kwargs: Additional arguments passed to parent TTSService.
""" """
@@ -241,7 +243,7 @@ class CambTTSService(TTSService):
frame: The start frame containing initialization parameters. frame: The start frame containing initialization parameters.
""" """
await super().start(frame) await super().start(frame)
# Use Camb.ai's native sample rate if not specified # Use 48kHz sample rate if not explicitly specified
if not self._init_sample_rate: if not self._init_sample_rate:
self._sample_rate = DEFAULT_SAMPLE_RATE self._sample_rate = DEFAULT_SAMPLE_RATE
self._settings["sample_rate"] = self._sample_rate self._settings["sample_rate"] = self._sample_rate

View File

@@ -75,7 +75,7 @@ async def test_run_camb_tts_success():
audio_frames = [f for f in frames if isinstance(f, TTSAudioRawFrame)] audio_frames = [f for f in frames if isinstance(f, TTSAudioRawFrame)]
assert len(audio_frames) > 0, "Should have at least one audio frame" assert len(audio_frames) > 0, "Should have at least one audio frame"
# Verify sample rate matches Camb.ai's output # Verify sample rate matches 48kHz output
for a_frame in audio_frames: for a_frame in audio_frames:
assert a_frame.sample_rate == DEFAULT_SAMPLE_RATE assert a_frame.sample_rate == DEFAULT_SAMPLE_RATE
assert a_frame.num_channels == 1, "Should be mono audio" assert a_frame.num_channels == 1, "Should be mono audio"