From 1510fb4fc0276c0ff9370190cdf6345b2fcfeb04 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 1 Dec 2025 15:01:06 -0800 Subject: [PATCH 01/16] add Hathora STT and TTS services --- .../07af-interruptible-hathora.py | 137 +++++++++++ src/pipecat/services/hathora/__init__.py | 14 ++ src/pipecat/services/hathora/stt.py | 107 ++++++++ src/pipecat/services/hathora/tts.py | 229 ++++++++++++++++++ 4 files changed, 487 insertions(+) create mode 100644 examples/foundational/07af-interruptible-hathora.py create mode 100644 src/pipecat/services/hathora/__init__.py create mode 100644 src/pipecat/services/hathora/stt.py create mode 100644 src/pipecat/services/hathora/tts.py diff --git a/examples/foundational/07af-interruptible-hathora.py b/examples/foundational/07af-interruptible-hathora.py new file mode 100644 index 000000000..5a06b7bf0 --- /dev/null +++ b/examples/foundational/07af-interruptible-hathora.py @@ -0,0 +1,137 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams +from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.audio.vad.vad_analyzer import VADParams +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.hathora.stt import ParakeetSTTService +from pipecat.services.hathora.tts import ChatterboxTTSService, KokoroTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams + +load_dotenv(override=True) + +# We store functions so objects (e.g. SileroVADAnalyzer) don't get +# instantiated. The function will be called when the desired transport gets +# selected. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + turn_analyzer=LocalSmartTurnAnalyzerV3(), + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + turn_analyzer=LocalSmartTurnAnalyzerV3(), + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + logger.info(f"Starting bot") + + # See https://models.hathora.dev/model/nvidia-parakeet-tdt-0.6b-v3 + stt = ParakeetTDTSTTService( + base_url="https://app-1c7bebb9-6977-4101-9619-833b251b86d1.app.hathora.dev/v1/transcribe", + api_key=os.getenv("HATHORA_API_KEY") + ) + + # See https://models.hathora.dev/model/hexgrad-kokoro-82m + tts = KokoroTTSService( + base_url="https://app-01312daf-6e53-4b9d-a4ad-13039f35adc4.app.hathora.dev/synthesize", + api_key=os.getenv("HATHORA_API_KEY"), + ) + + # See https://models.hathora.dev/model/resemble-ai-chatterbox + # tts = ChatterboxTTSService( + # base_url="https://app-efbc8fe2-df55-4f96-bbe3-74f6ea9d986b.app.hathora.dev/v1/generate", + # api_key=os.getenv("HATHORA_API_KEY") + # ) + + # See https://models.hathora.dev/model/qwen3-30b-a3b + llm = OpenAILLMService( + base_url="https://app-362f7ca1-6975-4e18-a605-ab202bf2c315.app.hathora.dev/v1", + api_key=os.getenv("HATHORA_API_KEY"), + model=None, + ) + + messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.", + }, + ] + + context = LLMContext(messages) + context_aggregator = LLMContextAggregatorPair(context) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, + context_aggregator.user(), # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + context_aggregator.assistant(), # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/src/pipecat/services/hathora/__init__.py b/src/pipecat/services/hathora/__init__.py new file mode 100644 index 000000000..11b7dbb55 --- /dev/null +++ b/src/pipecat/services/hathora/__init__.py @@ -0,0 +1,14 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import sys + +from pipecat.services import DeprecatedModuleProxy + +from .stt import * +from .tts import * + +sys.modules[__name__] = DeprecatedModuleProxy(globals(), "hathora", "hathora.[stt,tts]") diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py new file mode 100644 index 000000000..ef4b209af --- /dev/null +++ b/src/pipecat/services/hathora/stt.py @@ -0,0 +1,107 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""[Hathora-hosted](https://models.hathora.dev) speech-to-text services.""" + +import os +from typing import Optional + +import aiohttp +from loguru import logger + +from pipecat.frames.frames import ( + ErrorFrame, + TranscriptionFrame, +) +from pipecat.services.stt_service import SegmentedSTTService +from pipecat.transcriptions.language import Language +from pipecat.utils.time import time_now_iso8601 + +class ParakeetTDTSTTService(SegmentedSTTService): + """Parakeet TDT is a multilingual automatic speech recognition model + with word-level timestamps. + + This service uses the Hathora-hosted Parakeet model via the HTTP API. + + [Documentation](https://models.hathora.dev/model/nvidia-parakeet-tdt-0.6b-v3) + """ + + def __init__( + self, + *, + base_url = None, + api_key = None, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + **kwargs, + ): + """Initialize the Hathora-hosted Parakeet STT service. + + Args: + base_url: Base URL for the Hathora Parakeet STT API. + api_key: API key for authentication with the Hathora service; + provisiion one [here](https://models.hathora.dev/tokens). + start_time: Start time in seconds for the time window. + end_time: End time in seconds for the time window. + """ + super().__init__( + **kwargs, + ) + self._base_url = base_url + self._api_key = api_key + self._start_time = start_time + self._end_time = end_time + + def can_generate_metrics(self) -> bool: + return True + + async def run_stt(self, audio: bytes): + try: + await self.start_processing_metrics() + await self.start_ttfb_metrics() + + url = f"{self._base_url}" + + url_query_params = [] + if self._start_time is not None: + url_query_params.append(f"start_time={self._start_time}") + if self._end_time is not None: + url_query_params.append(f"end_time={self._end_time}") + url_query_params.append(f"sample_rate={self.sample_rate}") + + if len(url_query_params) > 0: + url += "?" + "&".join(url_query_params) + + api_key = self._api_key or os.getenv("HATHORA_API_KEY") + + form_data = aiohttp.FormData() + form_data.add_field("file", audio, filename="audio.wav", content_type="application/octet-stream") + + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers={"Authorization": f"Bearer {api_key}"}, + data=form_data, + ) as resp: + response = await resp.json() + + if response and "text" in response: + text = response["text"].strip() + if text: # Only yield non-empty text + await self.stop_ttfb_metrics() + await self.stop_processing_metrics() + logger.debug(f"Transcription: [{text}]") + yield TranscriptionFrame( + text, + self._user_id, + time_now_iso8601(), + Language("en"), # TODO: the parakeet hathora API doesn't accept a language but says it's multilingual + result=response, + ) + + except Exception as e: + logger.error(f"Hathora error: {e}") + yield ErrorFrame(f"Hathora error: {str(e)}") diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py new file mode 100644 index 000000000..d9ec78793 --- /dev/null +++ b/src/pipecat/services/hathora/tts.py @@ -0,0 +1,229 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""[Hathora-hosted](https://models.hathora.dev) text-to-speech services.""" + +import io +import os +import wave +from typing import Optional, Tuple + +import aiohttp +from loguru import logger + +from pipecat.frames.frames import ( + ErrorFrame, + TTSAudioRawFrame, + TTSStartedFrame, + TTSStoppedFrame, +) +from pipecat.services.tts_service import TTSService + +def _decode_audio_payload( + audio_bytes: bytes, + *, + fallback_sample_rate: int, + fallback_channels: int = 1, +) -> Tuple[bytes, int, int]: + """Convert a WAV/PCM payload into raw PCM samples for TTSAudioRawFrame.""" + + try: + with wave.open(io.BytesIO(audio_bytes), "rb") as wav_reader: + channels = wav_reader.getnchannels() + sample_rate = wav_reader.getframerate() + frames = wav_reader.readframes(wav_reader.getnframes()) + return frames, sample_rate, channels + except (wave.Error, EOFError): + # If the payload is already raw PCM, just pass it through. + return audio_bytes, fallback_sample_rate, fallback_channels + +class KokoroTTSService(TTSService): + """Kokoro is an open-weight TTS model with 82 million parameters. + + This service uses the Hathora-hosted Kokoro model via the HTTP API. + + [Documentation](https://models.hathora.dev/model/hexgrad-kokoro-82m) + """ + + def __init__( + self, + *, + base_url = None, + api_key = None, + voice: Optional[str] = None, + speed: Optional[float] = None, + **kwargs, + ): + """Initialize the Hathora-hosted Kokoro TTS service. + + Args: + base_url: Base URL for the Hathora Kokoro TTS API, . + api_key: API key for authentication with the Hathora service; + provisiion one [here](https://models.hathora.dev/tokens). + voice: Voice to use for synthesis (see the + [Hathora docs](https://models.hathora.dev/model/hexgrad-kokoro-82m) + for the default value; [list of voices](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md)). + speed: Speech speed multiplier (0.5 = half speed, 2.0 = double speed, default: 1). + """ + super().__init__( + **kwargs, + ) + self._base_url = base_url + self._api_key = api_key + self._voice = voice + self._speed = speed + + def can_generate_metrics(self) -> bool: + return True + + async def run_tts(self, text: str): + try: + await self.start_processing_metrics() + await self.start_ttfb_metrics() + + url = f"{self._base_url}" + + api_key = self._api_key or os.getenv("HATHORA_API_KEY") + + payload = { + "text": text + } + + if self._voice is not None: + payload["voice"] = self._voice + if self._speed is not None: + payload["speed"] = self._speed + + yield TTSStartedFrame() + + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers={"Authorization": f"Bearer {api_key}", "Accept": "application/octet-stream"}, + json=payload, + ) as resp: + audio_data = await resp.read() + + pcm_audio, sample_rate, num_channels = _decode_audio_payload( + audio_data, + fallback_sample_rate=self.sample_rate or self._init_sample_rate or 24000, + ) + + await self.stop_ttfb_metrics() + + frame = TTSAudioRawFrame( + audio=pcm_audio, + sample_rate=sample_rate, + num_channels=num_channels, + ) + + yield frame + + except Exception as e: + logger.error(f"Hathora error: {e}") + yield ErrorFrame(f"Hathora error: {str(e)}") + finally: + await self.stop_ttfb_metrics() + await self.stop_processing_metrics() + yield TTSStoppedFrame() + +class ChatterboxTTSService(TTSService): + """Chatterbox is a public text-to-speech model optimized for natural and expressive voice synthesis. + + This service uses the Hathora-hosted Chatterbox model via the HTTP API. + + [Documentation](https://models.hathora.dev/model/resemble-ai-chatterbox) + """ + + def __init__( + self, + *, + base_url = None, + api_key = None, + exaggeration: Optional[float] = None, + audio_prompt: Optional[bytes] = None, + cfg_weight: Optional[float] = None, + **kwargs, + ): + """Initialize the Hathora-hosted Chatterbox TTS service. + + Args: + base_url: Base URL for the Hathora Chatterbox TTS API. + api_key: API key for authentication with the Hathora service; + provisiion one [here](https://models.hathora.dev/tokens). + exaggeration: Controls emotional intensity (default: 0.5). + audio_prompt: Reference audio file for voice cloning. + cfg_weight: Controls adherence to reference voice (default: 0.5). + """ + + super().__init__( + **kwargs, + ) + self._base_url = base_url + self._api_key = api_key + self._exaggeration = exaggeration + self._audio_prompt = audio_prompt + self._cfg_weight = cfg_weight + + def can_generate_metrics(self) -> bool: + return True + + async def run_tts(self, text: str): + try: + await self.start_ttfb_metrics() + + url = f"{self._base_url}" + + url_query_params = [] + if self._exaggeration is not None: + url_query_params.append(f"exaggeration={self._exaggeration}") + if self._cfg_weight is not None: + url_query_params.append(f"cfg_weight={self._cfg_weight}") + + if len(url_query_params) > 0: + url += "?" + "&".join(url_query_params) + + api_key = self._api_key or os.getenv("HATHORA_API_KEY") + + form_data = aiohttp.FormData() + form_data.add_field("text", text) + + if self._audio_prompt is not None: + form_data.add_field("audio_prompt", self._audio_prompt, filename="audio.wav", content_type="application/octet-stream") + + yield TTSStartedFrame() + + async with aiohttp.ClientSession() as session: + async with session.post( + url, + headers={"Authorization": f"Bearer {api_key}"}, + data=form_data, + ) as resp: + audio_data = await resp.read() + + await self.start_tts_usage_metrics(text) + + pcm_audio, sample_rate, num_channels = _decode_audio_payload( + audio_data, + fallback_sample_rate=self.sample_rate or self._init_sample_rate or 24000, + ) + + await self.stop_ttfb_metrics() + + frame = TTSAudioRawFrame( + audio=pcm_audio, + sample_rate=sample_rate, + num_channels=num_channels, + ) + + yield frame + + except Exception as e: + logger.error(f"Hathora error: {e}") + yield ErrorFrame(f"Hathora error: {str(e)}") + finally: + await self.stop_ttfb_metrics() + yield TTSStoppedFrame() From e5632a9339ae48a81977efe7e470d74cd2d07a02 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Wed, 17 Dec 2025 19:16:58 -0800 Subject: [PATCH 02/16] transition Hathora service to use the unified API and apply PR feedback add Hathora to root files Hathora run linter added hathora changelog --- README.md | 4 +- changelog/3169.added.md | 1 + env.example | 3 + ...thora.py => 07ag-interruptible-hathora.py} | 32 ++-- src/pipecat/services/hathora/__init__.py | 14 -- src/pipecat/services/hathora/stt.py | 108 ++++++----- src/pipecat/services/hathora/tts.py | 173 +++++------------- src/pipecat/services/hathora/utils.py | 22 +++ 8 files changed, 152 insertions(+), 205 deletions(-) create mode 100644 changelog/3169.added.md rename examples/foundational/{07af-interruptible-hathora.py => 07ag-interruptible-hathora.py} (80%) create mode 100644 src/pipecat/services/hathora/utils.py diff --git a/README.md b/README.md index 0fac27943..65a12634f 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout | Category | Services | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Gradium](https://docs.pipecat.ai/server/services/stt/gradium), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Sarvam](https://docs.pipecat.ai/server/services/stt/sarvam), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) | +| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Gradium](https://docs.pipecat.ai/server/services/stt/gradium), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [Hathora](https://docs.pipecat.ai/server/services/stt/hathora), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Sarvam](https://docs.pipecat.ai/server/services/stt/sarvam), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) | | LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) | -| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Camb AI](https://docs.pipecat.ai/server/services/tts/camb), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Gradium](https://docs.pipecat.ai/server/services/tts/gradium), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [Speechmatics](https://docs.pipecat.ai/server/services/tts/speechmatics), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) | +| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Camb AI](https://docs.pipecat.ai/server/services/tts/camb), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Gradium](https://docs.pipecat.ai/server/services/tts/gradium), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hathora](https://docs.pipecat.ai/server/services/tts/hathora), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [Speechmatics](https://docs.pipecat.ai/server/services/tts/speechmatics), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) | | Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/server/services/s2s/ultravox), | | Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local | | Serializers | [Exotel](https://docs.pipecat.ai/server/utilities/serializers/exotel), [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx), [Vonage](https://docs.pipecat.ai/server/utilities/serializers/vonage) | diff --git a/changelog/3169.added.md b/changelog/3169.added.md new file mode 100644 index 000000000..d9e4bb9b9 --- /dev/null +++ b/changelog/3169.added.md @@ -0,0 +1 @@ +- Added Hathora service to support Hathora-hosted TTS and STT models (only non-streaming) \ No newline at end of file diff --git a/env.example b/env.example index 41badb571..c7b734ad6 100644 --- a/env.example +++ b/env.example @@ -85,6 +85,9 @@ GROK_API_KEY=... # Groq GROQ_API_KEY=... +# Hathora +HATHORA_API_KEY=... + # Heygen HEYGEN_API_KEY=... HEYGEN_LIVE_AVATAR_API_KEY=... diff --git a/examples/foundational/07af-interruptible-hathora.py b/examples/foundational/07ag-interruptible-hathora.py similarity index 80% rename from examples/foundational/07af-interruptible-hathora.py rename to examples/foundational/07ag-interruptible-hathora.py index 5a06b7bf0..bcde037d0 100644 --- a/examples/foundational/07af-interruptible-hathora.py +++ b/examples/foundational/07ag-interruptible-hathora.py @@ -21,8 +21,8 @@ from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.hathora.stt import ParakeetSTTService -from pipecat.services.hathora.tts import ChatterboxTTSService, KokoroTTSService +from pipecat.services.hathora.stt import HathoraSTTService +from pipecat.services.hathora.tts import HathoraTTSService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -38,13 +38,19 @@ transport_params = { audio_in_enabled=True, audio_out_enabled=True, vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), + turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()), + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(), + turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()), ), } @@ -52,24 +58,14 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - # See https://models.hathora.dev/model/nvidia-parakeet-tdt-0.6b-v3 - stt = ParakeetTDTSTTService( - base_url="https://app-1c7bebb9-6977-4101-9619-833b251b86d1.app.hathora.dev/v1/transcribe", - api_key=os.getenv("HATHORA_API_KEY") + stt = HathoraSTTService( + model="nvidia-parakeet-tdt-0.6b-v3", ) - # See https://models.hathora.dev/model/hexgrad-kokoro-82m - tts = KokoroTTSService( - base_url="https://app-01312daf-6e53-4b9d-a4ad-13039f35adc4.app.hathora.dev/synthesize", - api_key=os.getenv("HATHORA_API_KEY"), + tts = HathoraTTSService( + model="hexgrad-kokoro-82m", ) - # See https://models.hathora.dev/model/resemble-ai-chatterbox - # tts = ChatterboxTTSService( - # base_url="https://app-efbc8fe2-df55-4f96-bbe3-74f6ea9d986b.app.hathora.dev/v1/generate", - # api_key=os.getenv("HATHORA_API_KEY") - # ) - # See https://models.hathora.dev/model/qwen3-30b-a3b llm = OpenAILLMService( base_url="https://app-362f7ca1-6975-4e18-a605-ab202bf2c315.app.hathora.dev/v1", diff --git a/src/pipecat/services/hathora/__init__.py b/src/pipecat/services/hathora/__init__.py index 11b7dbb55..e69de29bb 100644 --- a/src/pipecat/services/hathora/__init__.py +++ b/src/pipecat/services/hathora/__init__.py @@ -1,14 +0,0 @@ -# -# Copyright (c) 2024–2025, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -import sys - -from pipecat.services import DeprecatedModuleProxy - -from .stt import * -from .tts import * - -sys.modules[__name__] = DeprecatedModuleProxy(globals(), "hathora", "hathora.[stt,tts]") diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index ef4b209af..010f38683 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -6,102 +6,126 @@ """[Hathora-hosted](https://models.hathora.dev) speech-to-text services.""" +import base64 import os -from typing import Optional +from typing import AsyncGenerator, Optional import aiohttp -from loguru import logger from pipecat.frames.frames import ( ErrorFrame, + Frame, TranscriptionFrame, ) from pipecat.services.stt_service import SegmentedSTTService from pipecat.transcriptions.language import Language from pipecat.utils.time import time_now_iso8601 +from pipecat.utils.tracing.service_decorators import traced_stt -class ParakeetTDTSTTService(SegmentedSTTService): - """Parakeet TDT is a multilingual automatic speech recognition model - with word-level timestamps. +from .utils import ConfigOption - This service uses the Hathora-hosted Parakeet model via the HTTP API. - [Documentation](https://models.hathora.dev/model/nvidia-parakeet-tdt-0.6b-v3) +class HathoraSTTService(SegmentedSTTService): + """This service supports several different speech-to-text models hosted by Hathora. + + [Documentation](https://models.hathora.dev) """ def __init__( self, *, - base_url = None, - api_key = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, + model: str, + language: Optional[str] = None, + model_config: Optional[list[ConfigOption]] = None, + base_url: str = "https://api.models.hathora.dev/inference/v1/stt", + api_key: Optional[str] = None, **kwargs, ): - """Initialize the Hathora-hosted Parakeet STT service. + """Initialize the Hathora STT service. Args: - base_url: Base URL for the Hathora Parakeet STT API. + model: Model to use; find available models + [here](https://models.hathora.dev). + language: Language code (if supported by model). + model_config: Some models support additional config, refer to + [docs](https://models.hathora.dev) for each model to see + what is supported. + base_url: Base API URL for the Hathora STT service. api_key: API key for authentication with the Hathora service; - provisiion one [here](https://models.hathora.dev/tokens). - start_time: Start time in seconds for the time window. - end_time: End time in seconds for the time window. + provision one [here](https://models.hathora.dev/tokens). + **kwargs: Additional arguments passed to the parent class. """ super().__init__( **kwargs, ) + self._model = model + self._language = language + self._model_config = model_config self._base_url = base_url - self._api_key = api_key - self._start_time = start_time - self._end_time = end_time + self._api_key = api_key or os.getenv("HATHORA_API_KEY") - def can_generate_metrics(self) -> bool: - return True + @traced_stt + async def _handle_transcription( + self, transcript: str, is_final: bool, language: Optional[Language] = None + ): + """Handle a transcription result with tracing.""" + pass - async def run_stt(self, audio: bytes): + @traced_stt + async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: + """Run speech-to-text on the provided audio data. + + Args: + audio: Raw audio bytes to transcribe. + + Yields: + Frame: Frames containing transcription results (typically TextFrame). + """ try: await self.start_processing_metrics() await self.start_ttfb_metrics() url = f"{self._base_url}" - url_query_params = [] - if self._start_time is not None: - url_query_params.append(f"start_time={self._start_time}") - if self._end_time is not None: - url_query_params.append(f"end_time={self._end_time}") - url_query_params.append(f"sample_rate={self.sample_rate}") + payload = { + "model": self._model, + } - if len(url_query_params) > 0: - url += "?" + "&".join(url_query_params) + if self._language is not None: + payload["language"] = self._language + if self._model_config is not None: + payload["model_config"] = [ + {"name": option.name, "value": option.value} for option in self._model_config + ] - api_key = self._api_key or os.getenv("HATHORA_API_KEY") - - form_data = aiohttp.FormData() - form_data.add_field("file", audio, filename="audio.wav", content_type="application/octet-stream") + base64_audio = base64.b64encode(audio).decode("utf-8") + payload["audio"] = base64_audio async with aiohttp.ClientSession() as session: async with session.post( url, - headers={"Authorization": f"Bearer {api_key}"}, - data=form_data, + headers={"Authorization": f"Bearer {self._api_key}"}, + json=payload, ) as resp: response = await resp.json() if response and "text" in response: text = response["text"].strip() if text: # Only yield non-empty text - await self.stop_ttfb_metrics() - await self.stop_processing_metrics() - logger.debug(f"Transcription: [{text}]") + # Hathora's API currently doesn't return language info + # so we default to the requested language or "en" + response_language = self._language or "en" + await self._handle_transcription(text, True, response_language) yield TranscriptionFrame( text, self._user_id, time_now_iso8601(), - Language("en"), # TODO: the parakeet hathora API doesn't accept a language but says it's multilingual + Language(response_language), result=response, ) + await self.stop_ttfb_metrics() + await self.stop_processing_metrics() + except Exception as e: - logger.error(f"Hathora error: {e}") - yield ErrorFrame(f"Hathora error: {str(e)}") + yield ErrorFrame(error=f"Unknown error occurred: {e}") diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index d9ec78793..85843923d 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -9,18 +9,22 @@ import io import os import wave -from typing import Optional, Tuple +from typing import AsyncGenerator, Optional, Tuple import aiohttp -from loguru import logger from pipecat.frames.frames import ( ErrorFrame, + Frame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame, ) from pipecat.services.tts_service import TTSService +from pipecat.utils.tracing.service_decorators import traced_tts + +from .utils import ConfigOption + def _decode_audio_payload( audio_bytes: bytes, @@ -29,7 +33,6 @@ def _decode_audio_payload( fallback_channels: int = 1, ) -> Tuple[bytes, int, int]: """Convert a WAV/PCM payload into raw PCM samples for TTSAudioRawFrame.""" - try: with wave.open(io.BytesIO(audio_bytes), "rb") as wav_reader: channels = wav_reader.getnchannels() @@ -40,69 +43,82 @@ def _decode_audio_payload( # If the payload is already raw PCM, just pass it through. return audio_bytes, fallback_sample_rate, fallback_channels -class KokoroTTSService(TTSService): - """Kokoro is an open-weight TTS model with 82 million parameters. - This service uses the Hathora-hosted Kokoro model via the HTTP API. +class HathoraTTSService(TTSService): + """This service supports several different text-to-speech models hosted by Hathora. - [Documentation](https://models.hathora.dev/model/hexgrad-kokoro-82m) + [Documentation](https://models.hathora.dev) """ def __init__( self, *, - base_url = None, - api_key = None, + model: str, voice: Optional[str] = None, speed: Optional[float] = None, + model_config: Optional[list[ConfigOption]] = None, + base_url: str = "https://api.models.hathora.dev/inference/v1/tts", + api_key: Optional[str] = None, **kwargs, ): - """Initialize the Hathora-hosted Kokoro TTS service. + """Initialize the Hathora TTS service. Args: - base_url: Base URL for the Hathora Kokoro TTS API, . + model: Model to use; find available models + [here](https://models.hathora.dev). + voice: Voice to use for synthesis (if supported by model). + speed: Speech speed multiplier (if supported by model). + model_config: Some models support additional config, refer to + [docs](https://models.hathora.dev) for each model to see + what is supported. + base_url: Base API URL for the Hathora TTS service. api_key: API key for authentication with the Hathora service; - provisiion one [here](https://models.hathora.dev/tokens). - voice: Voice to use for synthesis (see the - [Hathora docs](https://models.hathora.dev/model/hexgrad-kokoro-82m) - for the default value; [list of voices](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md)). - speed: Speech speed multiplier (0.5 = half speed, 2.0 = double speed, default: 1). + provision one [here](https://models.hathora.dev/tokens). + **kwargs: Additional arguments passed to the parent class. """ super().__init__( **kwargs, ) - self._base_url = base_url - self._api_key = api_key + self._model = model self._voice = voice self._speed = speed + self._model_config = model_config + self._base_url = base_url + self._api_key = api_key or os.getenv("HATHORA_API_KEY") - def can_generate_metrics(self) -> bool: - return True + @traced_tts + async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + """Run text-to-speech synthesis on the provided text. - async def run_tts(self, text: str): + Args: + text: The text to synthesize into speech. + + Yields: + Frame: Audio frames containing the synthesized speech. + """ try: await self.start_processing_metrics() await self.start_ttfb_metrics() url = f"{self._base_url}" - api_key = self._api_key or os.getenv("HATHORA_API_KEY") - - payload = { - "text": text - } + payload = {"model": self._model, "text": text} if self._voice is not None: payload["voice"] = self._voice if self._speed is not None: payload["speed"] = self._speed + if self._model_config is not None: + payload["model_config"] = [ + {"name": option.name, "value": option.value} for option in self._model_config + ] yield TTSStartedFrame() async with aiohttp.ClientSession() as session: async with session.post( url, - headers={"Authorization": f"Bearer {api_key}", "Accept": "application/octet-stream"}, + headers={"Authorization": f"Bearer {self._api_key}"}, json=payload, ) as resp: audio_data = await resp.read() @@ -112,8 +128,6 @@ class KokoroTTSService(TTSService): fallback_sample_rate=self.sample_rate or self._init_sample_rate or 24000, ) - await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame( audio=pcm_audio, sample_rate=sample_rate, @@ -123,107 +137,8 @@ class KokoroTTSService(TTSService): yield frame except Exception as e: - logger.error(f"Hathora error: {e}") - yield ErrorFrame(f"Hathora error: {str(e)}") + yield ErrorFrame(error=f"Unknown error occurred: {e}") finally: await self.stop_ttfb_metrics() await self.stop_processing_metrics() yield TTSStoppedFrame() - -class ChatterboxTTSService(TTSService): - """Chatterbox is a public text-to-speech model optimized for natural and expressive voice synthesis. - - This service uses the Hathora-hosted Chatterbox model via the HTTP API. - - [Documentation](https://models.hathora.dev/model/resemble-ai-chatterbox) - """ - - def __init__( - self, - *, - base_url = None, - api_key = None, - exaggeration: Optional[float] = None, - audio_prompt: Optional[bytes] = None, - cfg_weight: Optional[float] = None, - **kwargs, - ): - """Initialize the Hathora-hosted Chatterbox TTS service. - - Args: - base_url: Base URL for the Hathora Chatterbox TTS API. - api_key: API key for authentication with the Hathora service; - provisiion one [here](https://models.hathora.dev/tokens). - exaggeration: Controls emotional intensity (default: 0.5). - audio_prompt: Reference audio file for voice cloning. - cfg_weight: Controls adherence to reference voice (default: 0.5). - """ - - super().__init__( - **kwargs, - ) - self._base_url = base_url - self._api_key = api_key - self._exaggeration = exaggeration - self._audio_prompt = audio_prompt - self._cfg_weight = cfg_weight - - def can_generate_metrics(self) -> bool: - return True - - async def run_tts(self, text: str): - try: - await self.start_ttfb_metrics() - - url = f"{self._base_url}" - - url_query_params = [] - if self._exaggeration is not None: - url_query_params.append(f"exaggeration={self._exaggeration}") - if self._cfg_weight is not None: - url_query_params.append(f"cfg_weight={self._cfg_weight}") - - if len(url_query_params) > 0: - url += "?" + "&".join(url_query_params) - - api_key = self._api_key or os.getenv("HATHORA_API_KEY") - - form_data = aiohttp.FormData() - form_data.add_field("text", text) - - if self._audio_prompt is not None: - form_data.add_field("audio_prompt", self._audio_prompt, filename="audio.wav", content_type="application/octet-stream") - - yield TTSStartedFrame() - - async with aiohttp.ClientSession() as session: - async with session.post( - url, - headers={"Authorization": f"Bearer {api_key}"}, - data=form_data, - ) as resp: - audio_data = await resp.read() - - await self.start_tts_usage_metrics(text) - - pcm_audio, sample_rate, num_channels = _decode_audio_payload( - audio_data, - fallback_sample_rate=self.sample_rate or self._init_sample_rate or 24000, - ) - - await self.stop_ttfb_metrics() - - frame = TTSAudioRawFrame( - audio=pcm_audio, - sample_rate=sample_rate, - num_channels=num_channels, - ) - - yield frame - - except Exception as e: - logger.error(f"Hathora error: {e}") - yield ErrorFrame(f"Hathora error: {str(e)}") - finally: - await self.stop_ttfb_metrics() - yield TTSStoppedFrame() diff --git a/src/pipecat/services/hathora/utils.py b/src/pipecat/services/hathora/utils.py new file mode 100644 index 000000000..6d8eb54b8 --- /dev/null +++ b/src/pipecat/services/hathora/utils.py @@ -0,0 +1,22 @@ +# +# Copyright (c) 2024–2025, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Utilities and types for [Hathora-hosted](https://models.hathora.dev) voice services.""" + +from dataclasses import dataclass + + +@dataclass +class ConfigOption: + """Extra configuration option passed into model_config for Hathora (if supported by model). + + Args: + name: Name of the configuration option. + value: Value of the configuration option. + """ + + name: str + value: str From e9f1d951d3db9c0550c5b50df331317e049c4f6d Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:16:06 -0800 Subject: [PATCH 03/16] Apply suggestions from code review Co-authored-by: Mark Backman --- examples/foundational/07ag-interruptible-hathora.py | 3 +++ src/pipecat/services/hathora/tts.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/foundational/07ag-interruptible-hathora.py b/examples/foundational/07ag-interruptible-hathora.py index bcde037d0..8fc989e62 100644 --- a/examples/foundational/07ag-interruptible-hathora.py +++ b/examples/foundational/07ag-interruptible-hathora.py @@ -100,6 +100,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): params=PipelineParams( enable_metrics=True, enable_usage_metrics=True, + turn_start_strategies=TurnStartStrategies( + bot=[TurnAnalyzerBotTurnStartStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + ), ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, ) diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 85843923d..53458be28 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -54,7 +54,7 @@ class HathoraTTSService(TTSService): self, *, model: str, - voice: Optional[str] = None, + voice_id: Optional[str] = None, speed: Optional[float] = None, model_config: Optional[list[ConfigOption]] = None, base_url: str = "https://api.models.hathora.dev/inference/v1/tts", From bcccb4cbb317a8569b714d6aa1cacaf28650d81a Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:20:26 -0800 Subject: [PATCH 04/16] put fallback sample_rate value in function arg --- src/pipecat/services/hathora/tts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 53458be28..1b1a4df14 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -29,7 +29,7 @@ from .utils import ConfigOption def _decode_audio_payload( audio_bytes: bytes, *, - fallback_sample_rate: int, + fallback_sample_rate: int = 24000, fallback_channels: int = 1, ) -> Tuple[bytes, int, int]: """Convert a WAV/PCM payload into raw PCM samples for TTSAudioRawFrame.""" @@ -125,7 +125,7 @@ class HathoraTTSService(TTSService): pcm_audio, sample_rate, num_channels = _decode_audio_payload( audio_data, - fallback_sample_rate=self.sample_rate or self._init_sample_rate or 24000, + fallback_sample_rate=self.sample_rate, ) frame = TTSAudioRawFrame( From 7be7fb49a35af1c3de23a25d78090b4f55dc18af Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:20:49 -0800 Subject: [PATCH 05/16] remove turn_analyzer args from transport params --- examples/foundational/07ag-interruptible-hathora.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/foundational/07ag-interruptible-hathora.py b/examples/foundational/07ag-interruptible-hathora.py index 8fc989e62..9519937d5 100644 --- a/examples/foundational/07ag-interruptible-hathora.py +++ b/examples/foundational/07ag-interruptible-hathora.py @@ -38,19 +38,16 @@ transport_params = { audio_in_enabled=True, audio_out_enabled=True, vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()), ), "twilio": lambda: FastAPIWebsocketParams( audio_in_enabled=True, audio_out_enabled=True, vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()), ), "webrtc": lambda: TransportParams( audio_in_enabled=True, audio_out_enabled=True, vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), - turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()), ), } From e7c83c19b65e4834a3c3a3642680370cb6ac7615 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:36:08 -0800 Subject: [PATCH 06/16] port turn_start_strategies to the newer user_turn_strategies --- .../07ag-interruptible-hathora.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/examples/foundational/07ag-interruptible-hathora.py b/examples/foundational/07ag-interruptible-hathora.py index 9519937d5..9a90efb35 100644 --- a/examples/foundational/07ag-interruptible-hathora.py +++ b/examples/foundational/07ag-interruptible-hathora.py @@ -18,7 +18,10 @@ from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.hathora.stt import HathoraSTTService @@ -27,6 +30,8 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_stop import TurnAnalyzerUserTurnStopStrategy +from pipecat.turns.user_turn_strategies import UserTurnStrategies load_dotenv(override=True) @@ -78,7 +83,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ] context = LLMContext(messages) - context_aggregator = LLMContextAggregatorPair(context) + context_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + user_turn_strategies=UserTurnStrategies( + stop=[TurnAnalyzerUserTurnStopStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] + ), + ), + ) pipeline = Pipeline( [ @@ -97,9 +109,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): params=PipelineParams( enable_metrics=True, enable_usage_metrics=True, - turn_start_strategies=TurnStartStrategies( - bot=[TurnAnalyzerBotTurnStartStrategy(turn_analyzer=LocalSmartTurnAnalyzerV3())] - ), ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, ) From ba25b279d689e2f2753b841ca49f12e3be1213d5 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:38:11 -0800 Subject: [PATCH 07/16] fix issues with PR suggestions --- src/pipecat/services/hathora/tts.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 1b1a4df14..2a82c7dbc 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -78,15 +78,14 @@ class HathoraTTSService(TTSService): """ super().__init__( **kwargs, + voice_id=voice_id, ) self._model = model - self._voice = voice self._speed = speed self._model_config = model_config self._base_url = base_url self._api_key = api_key or os.getenv("HATHORA_API_KEY") - @traced_tts async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: """Run text-to-speech synthesis on the provided text. From 6c7e38639197263627d9e3c4e739b201bec0c047 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:48:55 -0800 Subject: [PATCH 08/16] remove traced_stt from run_stt --- src/pipecat/services/hathora/stt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index 010f38683..f87f15fdf 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -71,7 +71,6 @@ class HathoraSTTService(SegmentedSTTService): """Handle a transcription result with tracing.""" pass - @traced_stt async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: """Run speech-to-text on the provided audio data. From 1b3b67779c70fab223d5058109dbe978ae11fdac Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 10:57:27 -0800 Subject: [PATCH 09/16] switch hathora services to use `InputParams` pattern --- src/pipecat/services/hathora/stt.py | 40 ++++++++++++++--------- src/pipecat/services/hathora/tts.py | 50 +++++++++++++++++------------ 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index f87f15fdf..a0829b455 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -11,6 +11,7 @@ import os from typing import AsyncGenerator, Optional import aiohttp +from pydantic import BaseModel from pipecat.frames.frames import ( ErrorFrame, @@ -31,14 +32,27 @@ class HathoraSTTService(SegmentedSTTService): [Documentation](https://models.hathora.dev) """ + class InputParams(BaseModel): + """Optional input parameters for Hathora STT configuration. + + Parameters: + language: Language code (if supported by model). + model_config: Some models support additional config, refer to + [docs](https://models.hathora.dev) for each model to see + what is supported. + base_url: Base API URL for the Hathora STT service. + """ + + language: Optional[str] = None + model_config: Optional[list[ConfigOption]] = None + base_url: str = "https://api.models.hathora.dev/inference/v1/stt", + def __init__( self, *, model: str, - language: Optional[str] = None, - model_config: Optional[list[ConfigOption]] = None, - base_url: str = "https://api.models.hathora.dev/inference/v1/stt", api_key: Optional[str] = None, + params: Optional[InputParams] = None, **kwargs, ): """Initialize the Hathora STT service. @@ -46,23 +60,17 @@ class HathoraSTTService(SegmentedSTTService): Args: model: Model to use; find available models [here](https://models.hathora.dev). - language: Language code (if supported by model). - model_config: Some models support additional config, refer to - [docs](https://models.hathora.dev) for each model to see - what is supported. - base_url: Base API URL for the Hathora STT service. api_key: API key for authentication with the Hathora service; provision one [here](https://models.hathora.dev/tokens). + params: Configuration parameters. **kwargs: Additional arguments passed to the parent class. """ super().__init__( **kwargs, ) self._model = model - self._language = language - self._model_config = model_config - self._base_url = base_url self._api_key = api_key or os.getenv("HATHORA_API_KEY") + self._params = params or HathoraSTTService.InputParams() @traced_stt async def _handle_transcription( @@ -84,17 +92,17 @@ class HathoraSTTService(SegmentedSTTService): await self.start_processing_metrics() await self.start_ttfb_metrics() - url = f"{self._base_url}" + url = f"{self._params.base_url}" payload = { "model": self._model, } - if self._language is not None: - payload["language"] = self._language - if self._model_config is not None: + if self._params.language is not None: + payload["language"] = self._params.language + if self._params.model_config is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._model_config + {"name": option.name, "value": option.value} for option in self._params.model_config ] base64_audio = base64.b64encode(audio).decode("utf-8") diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 2a82c7dbc..f1d35c9fb 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -12,6 +12,7 @@ import wave from typing import AsyncGenerator, Optional, Tuple import aiohttp +from pydantic import BaseModel from pipecat.frames.frames import ( ErrorFrame, @@ -50,15 +51,28 @@ class HathoraTTSService(TTSService): [Documentation](https://models.hathora.dev) """ + class InputParams(BaseModel): + """Optional input parameters for Hathora TTS configuration. + + Parameters: + speed: Speech speed multiplier (if supported by model). + model_config: Some models support additional config, refer to + [docs](https://models.hathora.dev) for each model to see + what is supported. + base_url: Base API URL for the Hathora TTS service. + """ + + speed: Optional[float] = None + model_config: Optional[list[ConfigOption]] = None + base_url: str = "https://api.models.hathora.dev/inference/v1/tts", + def __init__( self, *, model: str, voice_id: Optional[str] = None, - speed: Optional[float] = None, - model_config: Optional[list[ConfigOption]] = None, - base_url: str = "https://api.models.hathora.dev/inference/v1/tts", api_key: Optional[str] = None, + params: Optional[InputParams] = None, **kwargs, ): """Initialize the Hathora TTS service. @@ -66,26 +80,22 @@ class HathoraTTSService(TTSService): Args: model: Model to use; find available models [here](https://models.hathora.dev). - voice: Voice to use for synthesis (if supported by model). - speed: Speech speed multiplier (if supported by model). - model_config: Some models support additional config, refer to - [docs](https://models.hathora.dev) for each model to see - what is supported. - base_url: Base API URL for the Hathora TTS service. + voice_id: Voice to use for synthesis (if supported by model). api_key: API key for authentication with the Hathora service; provision one [here](https://models.hathora.dev/tokens). + params: Configuration parameters. **kwargs: Additional arguments passed to the parent class. """ super().__init__( **kwargs, - voice_id=voice_id, ) self._model = model - self._speed = speed - self._model_config = model_config - self._base_url = base_url self._api_key = api_key or os.getenv("HATHORA_API_KEY") + self._params = params or HathoraTTSService.InputParams() + self.set_voice(voice_id) + + @traced_tts async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: """Run text-to-speech synthesis on the provided text. @@ -99,17 +109,17 @@ class HathoraTTSService(TTSService): await self.start_processing_metrics() await self.start_ttfb_metrics() - url = f"{self._base_url}" + url = f"{self._params.base_url}" payload = {"model": self._model, "text": text} - if self._voice is not None: - payload["voice"] = self._voice - if self._speed is not None: - payload["speed"] = self._speed - if self._model_config is not None: + if self._voice_id is not None: + payload["voice"] = self._voice_id + if self._params.speed is not None: + payload["speed"] = self._params.speed + if self._params.model_config is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._model_config + {"name": option.name, "value": option.value} for option in self._params.model_config ] yield TTSStartedFrame() From e77bdf66f9205030c3e9820f05eb24e63424e49d Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 11:13:48 -0800 Subject: [PATCH 10/16] add can_generate_metrics functions --- src/pipecat/services/hathora/stt.py | 8 ++++++++ src/pipecat/services/hathora/tts.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index a0829b455..d36923ce4 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -72,6 +72,14 @@ class HathoraSTTService(SegmentedSTTService): self._api_key = api_key or os.getenv("HATHORA_API_KEY") self._params = params or HathoraSTTService.InputParams() + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True + """ + return True + @traced_stt async def _handle_transcription( self, transcript: str, is_final: bool, language: Optional[Language] = None diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index f1d35c9fb..44bf271a0 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -95,6 +95,14 @@ class HathoraTTSService(TTSService): self.set_voice(voice_id) + def can_generate_metrics(self) -> bool: + """Check if this service can generate processing metrics. + + Returns: + True + """ + return True + @traced_tts async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: """Run text-to-speech synthesis on the provided text. From 67fdb0b659919fc4d1838c0471d918bbebf69da8 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 11:15:43 -0800 Subject: [PATCH 11/16] use parent _settings dict instead of self._params pattern --- src/pipecat/services/hathora/stt.py | 23 ++++++++++++++++------- src/pipecat/services/hathora/tts.py | 20 ++++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index d36923ce4..3f3dee6f7 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -70,7 +70,16 @@ class HathoraSTTService(SegmentedSTTService): ) self._model = model self._api_key = api_key or os.getenv("HATHORA_API_KEY") - self._params = params or HathoraSTTService.InputParams() + self._base_url = params.base_url + + params = params or HathoraSTTService.InputParams() + + self._settings = { + "language": params.language, + "model_config": params.model_config, + } + + self.set_model_name(model) def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -100,17 +109,17 @@ class HathoraSTTService(SegmentedSTTService): await self.start_processing_metrics() await self.start_ttfb_metrics() - url = f"{self._params.base_url}" + url = f"{self._base_url}" payload = { "model": self._model, } - if self._params.language is not None: - payload["language"] = self._params.language - if self._params.model_config is not None: + if self._settings["language"] is not None: + payload["language"] = self._settings["language"] + if self._settings["model_config"] is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._params.model_config + {"name": option.name, "value": option.value} for option in self._settings["model_config"] ] base64_audio = base64.b64encode(audio).decode("utf-8") @@ -129,7 +138,7 @@ class HathoraSTTService(SegmentedSTTService): if text: # Only yield non-empty text # Hathora's API currently doesn't return language info # so we default to the requested language or "en" - response_language = self._language or "en" + response_language = self._settings["language"] or "en" await self._handle_transcription(text, True, response_language) yield TranscriptionFrame( text, diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 44bf271a0..efc412d07 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -91,8 +91,16 @@ class HathoraTTSService(TTSService): ) self._model = model self._api_key = api_key or os.getenv("HATHORA_API_KEY") - self._params = params or HathoraTTSService.InputParams() + self._base_url = params.base_url + params = params or HathoraTTSService.InputParams() + + self._settings = { + "speed": params.speed, + "model_config": params.model_config, + } + + self.set_model_name(model) self.set_voice(voice_id) def can_generate_metrics(self) -> bool: @@ -117,17 +125,17 @@ class HathoraTTSService(TTSService): await self.start_processing_metrics() await self.start_ttfb_metrics() - url = f"{self._params.base_url}" + url = f"{self.base_url}" payload = {"model": self._model, "text": text} if self._voice_id is not None: payload["voice"] = self._voice_id - if self._params.speed is not None: - payload["speed"] = self._params.speed - if self._params.model_config is not None: + if self._settings["speed"] is not None: + payload["speed"] = self._settings["speed"] + if self._settings["model_config"] is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._params.model_config + {"name": option.name, "value": option.value} for option in self._settings["model_config"] ] yield TTSStartedFrame() From d2df324f298a02c71892cfdff16a5370081f2b17 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Mon, 5 Jan 2026 11:49:52 -0800 Subject: [PATCH 12/16] fix some bugs after testing changes --- src/pipecat/services/hathora/stt.py | 10 +++++----- src/pipecat/services/hathora/tts.py | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index 3f3dee6f7..0608cb39e 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -37,14 +37,14 @@ class HathoraSTTService(SegmentedSTTService): Parameters: language: Language code (if supported by model). - model_config: Some models support additional config, refer to + config: Some models support additional config, refer to [docs](https://models.hathora.dev) for each model to see what is supported. base_url: Base API URL for the Hathora STT service. """ language: Optional[str] = None - model_config: Optional[list[ConfigOption]] = None + config: Optional[list[ConfigOption]] = None base_url: str = "https://api.models.hathora.dev/inference/v1/stt", def __init__( @@ -76,7 +76,7 @@ class HathoraSTTService(SegmentedSTTService): self._settings = { "language": params.language, - "model_config": params.model_config, + "config": params.config, } self.set_model_name(model) @@ -117,9 +117,9 @@ class HathoraSTTService(SegmentedSTTService): if self._settings["language"] is not None: payload["language"] = self._settings["language"] - if self._settings["model_config"] is not None: + if self._settings["config"] is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._settings["model_config"] + {"name": option.name, "value": option.value} for option in self._settings["config"] ] base64_audio = base64.b64encode(audio).decode("utf-8") diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index efc412d07..749cb46c8 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -56,14 +56,14 @@ class HathoraTTSService(TTSService): Parameters: speed: Speech speed multiplier (if supported by model). - model_config: Some models support additional config, refer to + config: Some models support additional config, refer to [docs](https://models.hathora.dev) for each model to see what is supported. base_url: Base API URL for the Hathora TTS service. """ speed: Optional[float] = None - model_config: Optional[list[ConfigOption]] = None + config: Optional[list[ConfigOption]] = None base_url: str = "https://api.models.hathora.dev/inference/v1/tts", def __init__( @@ -97,7 +97,7 @@ class HathoraTTSService(TTSService): self._settings = { "speed": params.speed, - "model_config": params.model_config, + "config": params.config, } self.set_model_name(model) @@ -125,7 +125,7 @@ class HathoraTTSService(TTSService): await self.start_processing_metrics() await self.start_ttfb_metrics() - url = f"{self.base_url}" + url = f"{self._base_url}" payload = {"model": self._model, "text": text} @@ -133,9 +133,9 @@ class HathoraTTSService(TTSService): payload["voice"] = self._voice_id if self._settings["speed"] is not None: payload["speed"] = self._settings["speed"] - if self._settings["model_config"] is not None: + if self._settings["config"] is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._settings["model_config"] + {"name": option.name, "value": option.value} for option in self._settings["config"] ] yield TTSStartedFrame() From 2249f3d67322cf73ebac1473136b996c5bd7a920 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Sat, 10 Jan 2026 15:34:35 -0800 Subject: [PATCH 13/16] add requested changes from code review --- examples/foundational/07ag-interruptible-hathora.py | 1 - src/pipecat/services/hathora/stt.py | 10 +++++++--- src/pipecat/services/hathora/tts.py | 11 +++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/foundational/07ag-interruptible-hathora.py b/examples/foundational/07ag-interruptible-hathora.py index 9a90efb35..f4bd169b1 100644 --- a/examples/foundational/07ag-interruptible-hathora.py +++ b/examples/foundational/07ag-interruptible-hathora.py @@ -9,7 +9,6 @@ import os from dotenv import load_dotenv from loguru import logger -from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3 from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.vad_analyzer import VADParams diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index 0608cb39e..b9de7ac8d 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -40,18 +40,18 @@ class HathoraSTTService(SegmentedSTTService): config: Some models support additional config, refer to [docs](https://models.hathora.dev) for each model to see what is supported. - base_url: Base API URL for the Hathora STT service. """ language: Optional[str] = None config: Optional[list[ConfigOption]] = None - base_url: str = "https://api.models.hathora.dev/inference/v1/stt", def __init__( self, *, model: str, + sample_rate: Optional[int] = None, api_key: Optional[str] = None, + base_url: str = "https://api.models.hathora.dev/inference/v1/stt", params: Optional[InputParams] = None, **kwargs, ): @@ -60,17 +60,21 @@ class HathoraSTTService(SegmentedSTTService): Args: model: Model to use; find available models [here](https://models.hathora.dev). + sample_rate: The sample rate for audio input. If None, will be determined + from the start frame. api_key: API key for authentication with the Hathora service; provision one [here](https://models.hathora.dev/tokens). + base_url: Base API URL for the Hathora STT service. params: Configuration parameters. **kwargs: Additional arguments passed to the parent class. """ super().__init__( + sample_rate=sample_rate, **kwargs, ) self._model = model self._api_key = api_key or os.getenv("HATHORA_API_KEY") - self._base_url = params.base_url + self._base_url = base_url params = params or HathoraSTTService.InputParams() diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 749cb46c8..43c1cfeca 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -59,19 +59,19 @@ class HathoraTTSService(TTSService): config: Some models support additional config, refer to [docs](https://models.hathora.dev) for each model to see what is supported. - base_url: Base API URL for the Hathora TTS service. """ speed: Optional[float] = None config: Optional[list[ConfigOption]] = None - base_url: str = "https://api.models.hathora.dev/inference/v1/tts", def __init__( self, *, model: str, voice_id: Optional[str] = None, + sample_rate: Optional[int] = None, api_key: Optional[str] = None, + base_url: str = "https://api.models.hathora.dev/inference/v1/tts", params: Optional[InputParams] = None, **kwargs, ): @@ -81,17 +81,20 @@ class HathoraTTSService(TTSService): model: Model to use; find available models [here](https://models.hathora.dev). voice_id: Voice to use for synthesis (if supported by model). + sample_rate: Output sample rate for generated audio. api_key: API key for authentication with the Hathora service; provision one [here](https://models.hathora.dev/tokens). + base_url: Base API URL for the Hathora TTS service. params: Configuration parameters. **kwargs: Additional arguments passed to the parent class. """ super().__init__( + sample_rate=sample_rate, **kwargs, ) self._model = model self._api_key = api_key or os.getenv("HATHORA_API_KEY") - self._base_url = params.base_url + self._base_url = base_url params = params or HathoraTTSService.InputParams() @@ -155,7 +158,7 @@ class HathoraTTSService(TTSService): frame = TTSAudioRawFrame( audio=pcm_audio, - sample_rate=sample_rate, + sample_rate=self.sample_rate, num_channels=num_channels, ) From f48a567873bd9982e254db73bb6cfbbffde990c2 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Sat, 17 Jan 2026 10:30:47 -0800 Subject: [PATCH 14/16] run the linter --- src/pipecat/services/hathora/stt.py | 3 ++- src/pipecat/services/hathora/tts.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pipecat/services/hathora/stt.py b/src/pipecat/services/hathora/stt.py index b9de7ac8d..8aadb6b65 100644 --- a/src/pipecat/services/hathora/stt.py +++ b/src/pipecat/services/hathora/stt.py @@ -123,7 +123,8 @@ class HathoraSTTService(SegmentedSTTService): payload["language"] = self._settings["language"] if self._settings["config"] is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._settings["config"] + {"name": option.name, "value": option.value} + for option in self._settings["config"] ] base64_audio = base64.b64encode(audio).decode("utf-8") diff --git a/src/pipecat/services/hathora/tts.py b/src/pipecat/services/hathora/tts.py index 43c1cfeca..e59c4ad46 100644 --- a/src/pipecat/services/hathora/tts.py +++ b/src/pipecat/services/hathora/tts.py @@ -138,7 +138,8 @@ class HathoraTTSService(TTSService): payload["speed"] = self._settings["speed"] if self._settings["config"] is not None: payload["model_config"] = [ - {"name": option.name, "value": option.value} for option in self._settings["config"] + {"name": option.name, "value": option.value} + for option in self._settings["config"] ] yield TTSStartedFrame() From a3d206050df5c0f5f6653171aaadb9fe1fd44878 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Sat, 17 Jan 2026 10:31:08 -0800 Subject: [PATCH 15/16] move hathora example as requested --- ...7ag-interruptible-hathora.py => 07zh-interruptible-hathora.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/foundational/{07ag-interruptible-hathora.py => 07zh-interruptible-hathora.py} (100%) diff --git a/examples/foundational/07ag-interruptible-hathora.py b/examples/foundational/07zh-interruptible-hathora.py similarity index 100% rename from examples/foundational/07ag-interruptible-hathora.py rename to examples/foundational/07zh-interruptible-hathora.py From dc8ea615d96f922195d7fd5ca41e0f875409ff57 Mon Sep 17 00:00:00 2001 From: Mike Seese Date: Sat, 17 Jan 2026 10:33:58 -0800 Subject: [PATCH 16/16] add hathora to run-release-evals.py --- scripts/evals/run-release-evals.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/evals/run-release-evals.py b/scripts/evals/run-release-evals.py index 85834f7fd..3da64dd43 100644 --- a/scripts/evals/run-release-evals.py +++ b/scripts/evals/run-release-evals.py @@ -137,6 +137,7 @@ TESTS_07 = [ # ("07zd-interruptible-aicoustics.py", EVAL_SIMPLE_MATH), ("07ze-interruptible-hume.py", EVAL_SIMPLE_MATH), ("07zf-interruptible-gradium.py", EVAL_SIMPLE_MATH), + ("07zh-interruptible-hathora.py", EVAL_SIMPLE_MATH), # Needs a local XTTS docker instance running. # ("07i-interruptible-xtts.py", EVAL_SIMPLE_MATH), # Needs a Krisp license.