Switch Gradium TTS to AudioContextWordTTSService for multiplexing
Use client_req_id-based multiplexing instead of disconnecting and reconnecting the websocket on every interruption. This follows the same pattern used by Cartesia, ElevenLabs, and other services via AudioContextWordTTSService. Key changes: - Base class: InterruptibleWordTTSService -> AudioContextWordTTSService - Add close_ws_on_eos: False to setup message to keep connection alive - Add client_req_id to text, end_of_stream messages for demultiplexing - Route audio via append_to_audio_context() instead of push_frame() - Silently drop messages for cancelled/unknown contexts on interruption - Add _handle_interruption() that resets context without reconnecting - Remove no-op push_frame() override
This commit is contained in:
@@ -16,13 +16,14 @@ from pipecat.frames.frames import (
|
|||||||
EndFrame,
|
EndFrame,
|
||||||
ErrorFrame,
|
ErrorFrame,
|
||||||
Frame,
|
Frame,
|
||||||
|
InterruptionFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
TTSAudioRawFrame,
|
TTSAudioRawFrame,
|
||||||
TTSStartedFrame,
|
TTSStartedFrame,
|
||||||
TTSStoppedFrame,
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.tts_service import InterruptibleWordTTSService
|
from pipecat.services.tts_service import AudioContextWordTTSService
|
||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -37,7 +38,7 @@ except ModuleNotFoundError as e:
|
|||||||
SAMPLE_RATE = 48000
|
SAMPLE_RATE = 48000
|
||||||
|
|
||||||
|
|
||||||
class GradiumTTSService(InterruptibleWordTTSService):
|
class GradiumTTSService(AudioContextWordTTSService):
|
||||||
"""Text-to-Speech service using Gradium's websocket API."""
|
"""Text-to-Speech service using Gradium's websocket API."""
|
||||||
|
|
||||||
class InputParams(BaseModel):
|
class InputParams(BaseModel):
|
||||||
@@ -71,7 +72,6 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
params: Additional configuration parameters.
|
params: Additional configuration parameters.
|
||||||
**kwargs: Additional arguments passed to parent class.
|
**kwargs: Additional arguments passed to parent class.
|
||||||
"""
|
"""
|
||||||
# Initialize with parent class settings for proper frame handling
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
push_stop_frames=True,
|
push_stop_frames=True,
|
||||||
pause_frame_processing=True,
|
pause_frame_processing=True,
|
||||||
@@ -95,7 +95,7 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
|
|
||||||
# State tracking
|
# State tracking
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._current_context_id: Optional[str] = None
|
self._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.
|
||||||
@@ -126,7 +126,10 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
|
|
||||||
def _build_msg(self, text: str = "") -> dict:
|
def _build_msg(self, text: str = "") -> dict:
|
||||||
"""Build JSON message for Gradium API."""
|
"""Build JSON message for Gradium API."""
|
||||||
return {"text": text, "type": "text"}
|
msg = {"text": text, "type": "text"}
|
||||||
|
if self._context_id:
|
||||||
|
msg["client_req_id"] = self._context_id
|
||||||
|
return msg
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
"""Start the service and establish websocket connection.
|
"""Start the service and establish websocket connection.
|
||||||
@@ -197,6 +200,7 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
"type": "setup",
|
"type": "setup",
|
||||||
"output_format": "pcm",
|
"output_format": "pcm",
|
||||||
"voice_id": self._voice_id,
|
"voice_id": self._voice_id,
|
||||||
|
"close_ws_on_eos": False,
|
||||||
}
|
}
|
||||||
if self._json_config is not None:
|
if self._json_config is not None:
|
||||||
setup_msg["json_config"] = self._json_config
|
setup_msg["json_config"] = self._json_config
|
||||||
@@ -234,18 +238,35 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio synthesis."""
|
"""Flush any pending audio synthesis."""
|
||||||
if not self._websocket:
|
if not self._context_id or not self._websocket:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
msg = {"type": "end_of_stream"}
|
msg = {"type": "end_of_stream", "client_req_id": self._context_id}
|
||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
self._context_id = None
|
||||||
except ConnectionClosedOK:
|
except ConnectionClosedOK:
|
||||||
logger.debug(f"{self}: connection closed normally during flush")
|
logger.debug(f"{self}: connection closed normally during flush")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self} exception: {e}")
|
logger.error(f"{self} exception: {e}")
|
||||||
|
|
||||||
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
|
"""Handle interruption by resetting context state.
|
||||||
|
|
||||||
|
The parent AudioContextTTSService._handle_interruption() cancels the audio context
|
||||||
|
task and creates a new one. We reset _context_id so the next run_tts() creates a
|
||||||
|
fresh context. No websocket reconnection needed — audio from the old client_req_id
|
||||||
|
will be silently dropped since the audio context no longer exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The interruption frame.
|
||||||
|
direction: The direction of the frame.
|
||||||
|
"""
|
||||||
|
await super()._handle_interruption(frame, direction)
|
||||||
|
await self.stop_all_metrics()
|
||||||
|
self._context_id = None
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
"""Process incoming websocket messages."""
|
"""Process incoming websocket messages, demultiplexing by client_req_id."""
|
||||||
# TODO(laurent): This should not be necessary as it should happen when
|
# TODO(laurent): This should not be necessary as it should happen when
|
||||||
# receiving the messages but this does not seem to always be the case
|
# receiving the messages but this does not seem to always be the case
|
||||||
# and that may lead to a busy polling loop.
|
# and that may lead to a busy polling loop.
|
||||||
@@ -253,41 +274,35 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
raise ConnectionClosedOK(None, None)
|
raise ConnectionClosedOK(None, None)
|
||||||
async for message in self._get_websocket():
|
async for message in self._get_websocket():
|
||||||
msg = json.loads(message)
|
msg = json.loads(message)
|
||||||
|
ctx_id = msg.get("client_req_id")
|
||||||
|
|
||||||
if msg["type"] == "audio":
|
if msg["type"] == "audio":
|
||||||
# Process audio chunk
|
if not ctx_id or not self.audio_context_available(ctx_id):
|
||||||
|
continue
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
await self.start_word_timestamps()
|
await self.start_word_timestamps()
|
||||||
frame = TTSAudioRawFrame(
|
frame = TTSAudioRawFrame(
|
||||||
audio=base64.b64decode(msg["audio"]),
|
audio=base64.b64decode(msg["audio"]),
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
context_id=self._current_context_id,
|
context_id=ctx_id,
|
||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.append_to_audio_context(ctx_id, frame)
|
||||||
|
|
||||||
elif msg["type"] == "text":
|
elif msg["type"] == "text":
|
||||||
if self._current_context_id:
|
if ctx_id and self.audio_context_available(ctx_id):
|
||||||
await self.add_word_timestamps(
|
await self.add_word_timestamps([(msg["text"], msg["start_s"])], ctx_id)
|
||||||
[(msg["text"], msg["start_s"])], self._current_context_id
|
|
||||||
)
|
|
||||||
elif msg["type"] == "end_of_stream":
|
elif msg["type"] == "end_of_stream":
|
||||||
await self.push_frame(TTSStoppedFrame())
|
if ctx_id and self.audio_context_available(ctx_id):
|
||||||
|
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
|
||||||
|
await self.remove_audio_context(ctx_id)
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
|
|
||||||
elif msg["type"] == "error":
|
elif msg["type"] == "error":
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame(context_id=ctx_id))
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
await self.push_error(error_msg=f"Error: {msg['message']}")
|
await self.push_error(error_msg=f"Error: {msg.get('message', msg)}")
|
||||||
|
|
||||||
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
|
||||||
"""Push frame and handle end-of-turn conditions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: The frame to push.
|
|
||||||
direction: The direction to push the frame.
|
|
||||||
"""
|
|
||||||
await super().push_frame(frame, direction)
|
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
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]:
|
||||||
@@ -300,16 +315,18 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
"""
|
"""
|
||||||
_state = self._websocket.state if self._websocket is not None else None
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
logger.debug(f"{self}: Generating TTS [{text}] {_state}")
|
|
||||||
try:
|
try:
|
||||||
if not self._websocket or self._websocket.state is State.CLOSED:
|
if not self._websocket or self._websocket.state is State.CLOSED:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._current_context_id = context_id
|
if not self._context_id:
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
await self.start_ttfb_metrics()
|
||||||
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
self._context_id = context_id
|
||||||
|
await self.create_audio_context(self._context_id)
|
||||||
|
|
||||||
msg = self._build_msg(text=text)
|
msg = self._build_msg(text=text)
|
||||||
await self._get_websocket().send(json.dumps(msg))
|
await self._get_websocket().send(json.dumps(msg))
|
||||||
|
|||||||
Reference in New Issue
Block a user