Align Together STT/TTS services with Pipecat patterns
STT: - Add Settings class alias and 4-step init pattern - Add resampler to convert pipeline audio to 16kHz for Together API - Add keepalive support and _update_settings with reconnect - Pass language to transcription frames - Remove unnecessary OpenAI-Beta header TTS: - Add Settings class alias and 4-step init pattern - Use push_start_frame=True for base class audio context management - Route audio through append_to_audio_context instead of push_frame - Track pending commits for proper audio context lifecycle - Replace _handle_interruption with on_audio_context_interrupted - Add _update_settings with reconnect - Guard against stale audio after interruption
This commit is contained in:
@@ -21,7 +21,6 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||||||
)
|
)
|
||||||
from pipecat.runner.types import RunnerArguments
|
from pipecat.runner.types import RunnerArguments
|
||||||
from pipecat.runner.utils import create_transport
|
from pipecat.runner.utils import create_transport
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
|
||||||
from pipecat.services.together.llm import TogetherLLMService
|
from pipecat.services.together.llm import TogetherLLMService
|
||||||
from pipecat.services.together.stt import TogetherSTTService
|
from pipecat.services.together.stt import TogetherSTTService
|
||||||
from pipecat.services.together.tts import TogetherTTSService
|
from pipecat.services.together.tts import TogetherTTSService
|
||||||
@@ -55,12 +54,17 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
|||||||
|
|
||||||
stt = TogetherSTTService(api_key=os.getenv("TOGETHER_API_KEY"))
|
stt = TogetherSTTService(api_key=os.getenv("TOGETHER_API_KEY"))
|
||||||
|
|
||||||
tts = TogetherTTSService(api_key=os.getenv("TOGETHER_API_KEY"))
|
tts = TogetherTTSService(
|
||||||
|
api_key=os.getenv("TOGETHER_API_KEY"),
|
||||||
|
settings=TogetherTTSService.Settings(
|
||||||
|
voice="tara",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
llm = TogetherLLMService(
|
llm = TogetherLLMService(
|
||||||
api_key=os.getenv("TOGETHER_API_KEY"),
|
api_key=os.getenv("TOGETHER_API_KEY"),
|
||||||
settings=TogetherLLMService.Settings(
|
settings=TogetherLLMService.Settings(
|
||||||
model="Qwen/Qwen3.5-9B",
|
model="openai/gpt-oss-120b",
|
||||||
system_instruction="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.",
|
system_instruction="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.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
83
examples/foundational/13n-together-transcription.py
Normal file
83
examples/foundational/13n-together-transcription.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024-2026, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.frames.frames import Frame, TranscriptionFrame
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineTask
|
||||||
|
from pipecat.processors.audio.vad_processor import VADProcessor
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
from pipecat.runner.types import RunnerArguments
|
||||||
|
from pipecat.runner.utils import create_transport
|
||||||
|
from pipecat.services.together.stt import TogetherSTTService
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionLogger(FrameProcessor):
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
if isinstance(frame, TranscriptionFrame):
|
||||||
|
print(f"Transcription: {frame.text}")
|
||||||
|
|
||||||
|
# Push all frames through
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
# We use lambdas to defer transport parameter creation until the transport
|
||||||
|
# type is selected at runtime.
|
||||||
|
transport_params = {
|
||||||
|
"daily": lambda: DailyParams(audio_in_enabled=True),
|
||||||
|
"twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True),
|
||||||
|
"webrtc": lambda: TransportParams(audio_in_enabled=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||||
|
logger.info(f"Starting bot")
|
||||||
|
|
||||||
|
stt = TogetherSTTService(api_key=os.getenv("TOGETHER_API_KEY"))
|
||||||
|
|
||||||
|
tl = TranscriptionLogger()
|
||||||
|
vad_processor = VADProcessor(vad_analyzer=SileroVADAnalyzer())
|
||||||
|
|
||||||
|
pipeline = Pipeline([transport.input(), vad_processor, stt, tl])
|
||||||
|
|
||||||
|
task = PipelineTask(
|
||||||
|
pipeline,
|
||||||
|
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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()
|
||||||
@@ -56,7 +56,9 @@ class TogetherLLMService(OpenAILLMService):
|
|||||||
**kwargs: Additional keyword arguments passed to OpenAILLMService.
|
**kwargs: Additional keyword arguments passed to OpenAILLMService.
|
||||||
"""
|
"""
|
||||||
# 1. Initialize default_settings with hardcoded defaults
|
# 1. Initialize default_settings with hardcoded defaults
|
||||||
default_settings = self.Settings(model=model)
|
default_settings = self.Settings(
|
||||||
|
model="openai/gpt-oss-120b",
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Apply direct init arg overrides (deprecated)
|
# 2. Apply direct init arg overrides (deprecated)
|
||||||
if model is not None:
|
if model is not None:
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.audio.utils import create_stream_resampler
|
||||||
from pipecat.services.settings import STTSettings
|
from pipecat.services.settings import STTSettings
|
||||||
from pipecat.services.stt_latency import TOGETHER_TTFS_P99
|
from pipecat.services.stt_latency import TOGETHER_TTFS_P99
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ from pipecat.transcriptions.language import Language
|
|||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
from pipecat.utils.tracing.service_decorators import traced_stt
|
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||||
|
|
||||||
|
# Together requires 16 kHz 16-bit mono PCM input.
|
||||||
|
_TOGETHER_SAMPLE_RATE = 16000
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TogetherSTTSettings(STTSettings):
|
class TogetherSTTSettings(STTSettings):
|
||||||
@@ -50,8 +54,7 @@ class TogetherSTTSettings(STTSettings):
|
|||||||
language: Language of the audio input.
|
language: Language of the audio input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model: str = "openai/whisper-large-v3"
|
pass
|
||||||
language: Language = Language.EN
|
|
||||||
|
|
||||||
|
|
||||||
class TogetherSTTService(WebsocketSTTService):
|
class TogetherSTTService(WebsocketSTTService):
|
||||||
@@ -59,18 +62,26 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
|
|
||||||
Provides real-time speech recognition using Together AI's WebSocket API
|
Provides real-time speech recognition using Together AI's WebSocket API
|
||||||
with OpenAI-compatible speech-to-text endpoints.
|
with OpenAI-compatible speech-to-text endpoints.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
stt = TogetherSTTService(
|
||||||
|
api_key="...",
|
||||||
|
settings=TogetherSTTService.Settings(
|
||||||
|
model="openai/whisper-large-v3",
|
||||||
|
),
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_settings: TogetherSTTSettings
|
Settings = TogetherSTTSettings
|
||||||
|
_settings: Settings
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
model: str = "openai/whisper-large-v3",
|
base_url: str = "wss://api.together.ai/v1",
|
||||||
language: Language = Language.EN,
|
settings: Optional[Settings] = None,
|
||||||
sample_rate: int = 16000,
|
|
||||||
base_url: str = "wss://api.together.xyz/v1",
|
|
||||||
ttfs_p99_latency: float = TOGETHER_TTFS_P99,
|
ttfs_p99_latency: float = TOGETHER_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -78,27 +89,34 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Together AI API key for authentication.
|
api_key: Together AI API key for authentication.
|
||||||
model: Together AI transcription model. Defaults to "openai/whisper-large-v3".
|
|
||||||
language: Language of the audio input. Defaults to English.
|
|
||||||
sample_rate: Audio sample rate (default: 16000). Together AI requires 16kHz input.
|
|
||||||
base_url: The URL of the Together AI WebSocket API.
|
base_url: The URL of the Together AI WebSocket API.
|
||||||
|
settings: Runtime-updatable settings for model and language configuration.
|
||||||
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the parent WebsocketSTTService.
|
**kwargs: Additional arguments passed to the parent WebsocketSTTService.
|
||||||
"""
|
"""
|
||||||
|
# Hardcoded defaults
|
||||||
|
default_settings = self.Settings(
|
||||||
|
model="openai/whisper-large-v3",
|
||||||
|
language=Language.EN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply settings delta
|
||||||
|
if settings is not None:
|
||||||
|
default_settings.apply_update(settings)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
|
||||||
ttfs_p99_latency=ttfs_p99_latency,
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
settings=TogetherSTTSettings(
|
keepalive_timeout=20,
|
||||||
model=model,
|
keepalive_interval=5,
|
||||||
language=language,
|
settings=default_settings,
|
||||||
),
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
|
self._resampler = create_stream_resampler()
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if this service can generate processing metrics.
|
"""Check if this service can generate processing metrics.
|
||||||
@@ -108,6 +126,39 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _update_settings(self, delta: STTSettings) -> dict[str, Any]:
|
||||||
|
"""Apply a settings delta and reconnect to apply changes.
|
||||||
|
|
||||||
|
Together passes model/language as URL query params, so a reconnect
|
||||||
|
is needed to apply changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delta: A settings delta with updated values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping changed field names to their previous values.
|
||||||
|
"""
|
||||||
|
changed = await super()._update_settings(delta)
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return changed
|
||||||
|
|
||||||
|
# Reconnect to apply updated settings (they become WS URL params)
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
async def _send_keepalive(self, silence: bytes):
|
||||||
|
"""Send silent audio to keep the Together AI connection alive.
|
||||||
|
|
||||||
|
Wraps silence in the ``input_audio_buffer.append`` JSON protocol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
silence: Silent 16-bit mono PCM audio bytes.
|
||||||
|
"""
|
||||||
|
await self._send_audio(silence)
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
"""Start the Together AI STT service.
|
"""Start the Together AI STT service.
|
||||||
|
|
||||||
@@ -193,7 +244,6 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
)
|
)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self._api_key}",
|
"Authorization": f"Bearer {self._api_key}",
|
||||||
"OpenAI-Beta": "realtime=v1",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self._websocket = await websocket_connect(url, additional_headers=headers)
|
self._websocket = await websocket_connect(url, additional_headers=headers)
|
||||||
@@ -225,11 +275,18 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
async def _send_audio(self, audio: bytes):
|
async def _send_audio(self, audio: bytes):
|
||||||
"""Send audio data via ``input_audio_buffer.append``.
|
"""Send audio data via ``input_audio_buffer.append``.
|
||||||
|
|
||||||
|
Resamples from the pipeline sample rate to 16 kHz if needed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio: Raw audio bytes at the pipeline sample rate.
|
audio: Raw audio bytes at the pipeline sample rate.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not self._disconnecting and self._websocket:
|
if not self._disconnecting and self._websocket:
|
||||||
|
audio = await self._resampler.resample(
|
||||||
|
audio, self.sample_rate, _TOGETHER_SAMPLE_RATE
|
||||||
|
)
|
||||||
|
if not audio:
|
||||||
|
return
|
||||||
payload = base64.b64encode(audio).decode("utf-8")
|
payload = base64.b64encode(audio).decode("utf-8")
|
||||||
await self._websocket.send(
|
await self._websocket.send(
|
||||||
json.dumps({"type": "input_audio_buffer.append", "audio": payload})
|
json.dumps({"type": "input_audio_buffer.append", "audio": payload})
|
||||||
@@ -330,6 +387,7 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
delta,
|
delta,
|
||||||
self._user_id,
|
self._user_id,
|
||||||
time_now_iso8601(),
|
time_now_iso8601(),
|
||||||
|
self._settings.language,
|
||||||
result=evt,
|
result=evt,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -347,6 +405,7 @@ class TogetherSTTService(WebsocketSTTService):
|
|||||||
transcript,
|
transcript,
|
||||||
self._user_id,
|
self._user_id,
|
||||||
time_now_iso8601(),
|
time_now_iso8601(),
|
||||||
|
self._settings.language,
|
||||||
result=evt,
|
result=evt,
|
||||||
finalized=True,
|
finalized=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -27,13 +27,10 @@ from pipecat.frames.frames import (
|
|||||||
CancelFrame,
|
CancelFrame,
|
||||||
EndFrame,
|
EndFrame,
|
||||||
Frame,
|
Frame,
|
||||||
InterruptionFrame,
|
|
||||||
StartFrame,
|
StartFrame,
|
||||||
TTSAudioRawFrame,
|
TTSAudioRawFrame,
|
||||||
TTSStartedFrame,
|
|
||||||
TTSStoppedFrame,
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
|
||||||
from pipecat.services.tts_service import WebsocketTTSService
|
from pipecat.services.tts_service import WebsocketTTSService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
@@ -44,15 +41,9 @@ class TogetherTTSSettings(TTSSettings):
|
|||||||
"""Settings for the Together AI TTS service.
|
"""Settings for the Together AI TTS service.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
model: Together AI TTS model to use.
|
|
||||||
voice: Voice to use for synthesis.
|
|
||||||
language: Language of the text input.
|
|
||||||
max_partial_length: Maximum partial text length for streaming.
|
max_partial_length: Maximum partial text length for streaming.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model: str = "canopylabs/orpheus-3b-0.1-ft"
|
|
||||||
language: Optional[Language] = Language.EN
|
|
||||||
voice: Optional[str] = "tara"
|
|
||||||
max_partial_length: Optional[int] = None
|
max_partial_length: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -63,40 +54,42 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
Supports streaming synthesis with configurable voice and model options.
|
Supports streaming synthesis with configurable voice and model options.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_settings: TogetherTTSSettings
|
Settings = TogetherTTSSettings
|
||||||
|
_settings: Settings
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
model: str = "canopylabs/orpheus-3b-0.1-ft",
|
|
||||||
voice: str = "tara",
|
|
||||||
language: Optional[Language] = Language.EN,
|
|
||||||
max_partial_length: Optional[int] = None,
|
|
||||||
url: str = "wss://api.together.ai/v1/audio/speech/websocket",
|
url: str = "wss://api.together.ai/v1/audio/speech/websocket",
|
||||||
sample_rate: Optional[int] = 24000,
|
sample_rate: Optional[int] = None,
|
||||||
|
settings: Optional[Settings] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Together AI TTS service.
|
"""Initialize the Together AI TTS service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Together AI API key for authentication.
|
api_key: Together AI API key for authentication.
|
||||||
model: Together AI TTS model. Defaults to "canopylabs/orpheus-3b-0.1-ft".
|
|
||||||
voice: Voice to use for synthesis. Defaults to "tara".
|
|
||||||
language: Language of the text input. Defaults to English.
|
|
||||||
max_partial_length: Maximum partial text length for streaming.
|
|
||||||
url: WebSocket URL for Together AI TTS API.
|
url: WebSocket URL for Together AI TTS API.
|
||||||
sample_rate: Audio sample rate (default: 24000).
|
sample_rate: Audio sample rate (default: 24000).
|
||||||
|
settings: Runtime-updatable settings for model, voice, and language configuration.
|
||||||
**kwargs: Additional arguments passed to the parent service.
|
**kwargs: Additional arguments passed to the parent service.
|
||||||
"""
|
"""
|
||||||
|
# Hardcoded defaults
|
||||||
|
default_settings = self.Settings(
|
||||||
|
model="canopylabs/orpheus-3b-0.1-ft",
|
||||||
|
voice="tara",
|
||||||
|
language=Language.EN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply settings delta
|
||||||
|
if settings is not None:
|
||||||
|
default_settings.apply_update(settings)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
settings=TogetherTTSSettings(
|
push_start_frame=True,
|
||||||
model=model,
|
settings=default_settings,
|
||||||
voice=voice,
|
|
||||||
language=language,
|
|
||||||
max_partial_length=max_partial_length,
|
|
||||||
),
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,6 +98,8 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
self._session_id = None
|
self._session_id = None
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._context_id: Optional[str] = None
|
self._context_id: Optional[str] = None
|
||||||
|
self._pending_commits = 0
|
||||||
|
self._flush_context_id: Optional[str] = None
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if this service can generate processing metrics.
|
"""Check if this service can generate processing metrics.
|
||||||
@@ -114,6 +109,29 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]:
|
||||||
|
"""Apply a settings delta and reconnect to apply changes.
|
||||||
|
|
||||||
|
Together passes model/voice as URL query params, so a reconnect
|
||||||
|
is needed to apply changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delta: A settings delta with updated values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping changed field names to their previous values.
|
||||||
|
"""
|
||||||
|
changed = await super()._update_settings(delta)
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return changed
|
||||||
|
|
||||||
|
# Reconnect to apply updated settings (they become WS URL params)
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
def _build_websocket_url(self) -> str:
|
def _build_websocket_url(self) -> str:
|
||||||
"""Build the WebSocket URL with query parameters."""
|
"""Build the WebSocket URL with query parameters."""
|
||||||
url = f"{self._url}?model={self._settings.model}&voice={self._settings.voice}"
|
url = f"{self._url}?model={self._settings.model}&voice={self._settings.voice}"
|
||||||
@@ -153,7 +171,7 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
"""Connect to the transcription endpoint and start receiving."""
|
"""Connect to the TTS endpoint and start receiving."""
|
||||||
await super()._connect()
|
await super()._connect()
|
||||||
await self._connect_websocket()
|
await self._connect_websocket()
|
||||||
if self._websocket and not self._receive_task:
|
if self._websocket and not self._receive_task:
|
||||||
@@ -231,12 +249,22 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
async def flush_audio(self, context_id: Optional[str] = None):
|
async def flush_audio(self, context_id: Optional[str] = None):
|
||||||
"""Flush any pending audio and finalize the current context.
|
"""Flush any pending audio and finalize the current context.
|
||||||
|
|
||||||
|
If all server-side commits have been completed, closes the audio context
|
||||||
|
immediately. Otherwise, marks the context for deferred closure so
|
||||||
|
``_handle_audio_done`` can close it when the last commit finishes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context_id: Pipecat TTS context (unused for Together; required for
|
context_id: Pipecat TTS context to flush.
|
||||||
compatibility with :meth:`TTSService.on_turn_context_completed`).
|
|
||||||
"""
|
"""
|
||||||
logger.trace(f"{self}: flushing audio (context_id={context_id})")
|
ctx_id = context_id or self._context_id
|
||||||
await self._ws_send({"type": "input_text_buffer.commit"})
|
if not ctx_id or not self.audio_context_available(ctx_id):
|
||||||
|
return
|
||||||
|
logger.trace(f"{self}: flushing audio (context_id={ctx_id})")
|
||||||
|
if self._pending_commits == 0:
|
||||||
|
await self.append_to_audio_context(ctx_id, TTSStoppedFrame(context_id=ctx_id))
|
||||||
|
await self.remove_audio_context(ctx_id)
|
||||||
|
else:
|
||||||
|
self._flush_context_id = ctx_id
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Server event handling
|
# Server event handling
|
||||||
@@ -307,10 +335,11 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
Args:
|
Args:
|
||||||
evt: The delta event from the server.
|
evt: The delta event from the server.
|
||||||
"""
|
"""
|
||||||
|
if not self._context_id or not self.audio_context_available(self._context_id):
|
||||||
|
return
|
||||||
delta = evt.get("delta")
|
delta = evt.get("delta")
|
||||||
if delta:
|
if delta:
|
||||||
try:
|
try:
|
||||||
await self.stop_ttfb_metrics()
|
|
||||||
audio_chunk = base64.b64decode(delta)
|
audio_chunk = base64.b64decode(delta)
|
||||||
frame = TTSAudioRawFrame(
|
frame = TTSAudioRawFrame(
|
||||||
audio=audio_chunk,
|
audio=audio_chunk,
|
||||||
@@ -318,19 +347,25 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
num_channels=1,
|
num_channels=1,
|
||||||
context_id=self._context_id,
|
context_id=self._context_id,
|
||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.append_to_audio_context(self._context_id, frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error processing audio delta: {e}")
|
logger.error(f"{self} error processing audio delta: {e}")
|
||||||
|
|
||||||
async def _handle_audio_done(self, evt: dict):
|
async def _handle_audio_done(self, evt: dict):
|
||||||
"""Handle audio output completion for a speech segment.
|
"""Handle audio output completion for a speech segment.
|
||||||
|
|
||||||
|
Decrements the pending commit counter and closes the audio context
|
||||||
|
if a flush was requested and this was the last pending commit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
evt: The done event from the server.
|
evt: The done event from the server.
|
||||||
"""
|
"""
|
||||||
|
if not self._context_id or not self.audio_context_available(self._context_id):
|
||||||
|
return
|
||||||
item_id = evt.get("item_id")
|
item_id = evt.get("item_id")
|
||||||
logger.debug(f"{self} audio generation complete for: {item_id}")
|
logger.debug(f"{self} audio generation complete for: {item_id}")
|
||||||
await self.push_frame(TTSStoppedFrame(context_id=self._context_id))
|
self._pending_commits = max(0, self._pending_commits - 1)
|
||||||
|
await self._maybe_close_context()
|
||||||
|
|
||||||
async def _handle_tts_failed(self, evt: dict):
|
async def _handle_tts_failed(self, evt: dict):
|
||||||
"""Handle a TTS failure.
|
"""Handle a TTS failure.
|
||||||
@@ -339,8 +374,9 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
evt: The failed event containing error details.
|
evt: The failed event containing error details.
|
||||||
"""
|
"""
|
||||||
error = evt.get("error", {})
|
error = evt.get("error", {})
|
||||||
|
self._pending_commits = max(0, self._pending_commits - 1)
|
||||||
await self.push_error(error_msg=f"TTS error: {error}")
|
await self.push_error(error_msg=f"TTS error: {error}")
|
||||||
await self.push_frame(TTSStoppedFrame(context_id=self._context_id))
|
await self._maybe_close_context()
|
||||||
|
|
||||||
async def _handle_error(self, evt: dict):
|
async def _handle_error(self, evt: dict):
|
||||||
"""Handle a fatal error from the TTS session.
|
"""Handle a fatal error from the TTS session.
|
||||||
@@ -358,19 +394,27 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
await self.push_error(error_msg=msg)
|
await self.push_error(error_msg=msg)
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
|
async def _maybe_close_context(self):
|
||||||
|
"""Close the audio context if a flush was requested and no commits remain."""
|
||||||
|
if self._pending_commits == 0 and self._flush_context_id:
|
||||||
|
ctx_id = self._flush_context_id
|
||||||
|
self._flush_context_id = None
|
||||||
|
await self.append_to_audio_context(ctx_id, TTSStoppedFrame(context_id=ctx_id))
|
||||||
|
await self.remove_audio_context(ctx_id)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Interruption handling
|
# Interruption handling
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
async def on_audio_context_interrupted(self, context_id: str):
|
||||||
"""Handle interruption by canceling current generation.
|
"""Cancel current generation when the bot is interrupted.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
frame: The interruption frame.
|
context_id: The ID of the audio context that was interrupted.
|
||||||
direction: Frame processing direction.
|
|
||||||
"""
|
"""
|
||||||
await super()._handle_interruption(frame, direction)
|
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
|
self._pending_commits = 0
|
||||||
|
self._flush_context_id = None
|
||||||
await self._ws_send({"type": "input_text_buffer.clear"})
|
await self._ws_send({"type": "input_text_buffer.clear"})
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -381,12 +425,15 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Together AI's streaming API.
|
"""Generate speech from text using Together AI's streaming API.
|
||||||
|
|
||||||
|
Audio frames are delivered asynchronously via the WebSocket receive
|
||||||
|
loop and routed through the audio context managed by the base class.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize into speech.
|
text: The text to synthesize into speech.
|
||||||
context_id: The context ID for tracking audio frames.
|
context_id: The context ID for tracking audio frames.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: None (audio arrives via WebSocket callbacks).
|
||||||
"""
|
"""
|
||||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
|
|
||||||
@@ -399,9 +446,7 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._context_id = context_id
|
self._context_id = context_id
|
||||||
|
self._pending_commits += 1
|
||||||
await self.start_ttfb_metrics()
|
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._ws_send({"type": "input_text_buffer.append", "text": text})
|
await self._ws_send({"type": "input_text_buffer.append", "text": text})
|
||||||
@@ -409,6 +454,7 @@ class TogetherTTSService(WebsocketTTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} error sending message: {e}")
|
logger.error(f"{self} error sending message: {e}")
|
||||||
|
self._pending_commits -= 1
|
||||||
yield TTSStoppedFrame(context_id=context_id)
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
|
|||||||
Reference in New Issue
Block a user