Merge pull request #3287 from ashotbagh/feature/asyncai-multicontext-wss
Fix TTFB metric and add multi-context WebSocket support for Async TTS
This commit is contained in:
1
changelog/3287.changed.md
Normal file
1
changelog/3287.changed.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Enhanced interruption handling in `AsyncAITTSService` by supporting multi-context WebSocket sessions for more robust context management.
|
||||||
1
changelog/3287.fixed.md
Normal file
1
changelog/3287.fixed.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Corrected TTFB metric calculation in `AsyncAIHttpTTSService`.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -27,7 +28,7 @@ from pipecat.frames.frames import (
|
|||||||
TTSStoppedFrame,
|
TTSStoppedFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.tts_service import InterruptibleTTSService, TTSService
|
from pipecat.services.tts_service import AudioContextTTSService, TTSService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ def language_to_async_language(language: Language) -> Optional[str]:
|
|||||||
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
|
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
|
||||||
|
|
||||||
|
|
||||||
class AsyncAITTSService(InterruptibleTTSService):
|
class AsyncAITTSService(AudioContextTTSService):
|
||||||
"""Async TTS service with WebSocket streaming.
|
"""Async TTS service with WebSocket streaming.
|
||||||
|
|
||||||
Provides text-to-speech using Async's streaming WebSocket API.
|
Provides text-to-speech using Async's streaming WebSocket API.
|
||||||
@@ -148,6 +149,7 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_task = None
|
||||||
self._started = False
|
self._started = False
|
||||||
|
self._context_id = 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.
|
||||||
@@ -168,8 +170,8 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
"""
|
"""
|
||||||
return language_to_async_language(language)
|
return language_to_async_language(language)
|
||||||
|
|
||||||
def _build_msg(self, text: str = "", force: bool = False) -> str:
|
def _build_msg(self, text: str = "", context_id: str = "", force: bool = False) -> str:
|
||||||
msg = {"transcript": text, "force": force}
|
msg = {"transcript": text, "context_id": context_id, "force": force}
|
||||||
return json.dumps(msg)
|
return json.dumps(msg)
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
@@ -253,11 +255,16 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
|
|
||||||
if self._websocket:
|
if self._websocket:
|
||||||
logger.debug("Disconnecting from Async")
|
logger.debug("Disconnecting from Async")
|
||||||
|
# Close all contexts and the socket
|
||||||
|
if self._context_id:
|
||||||
|
await self._websocket.send(json.dumps({"terminate": True}))
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
|
logger.debug("Disconnected from Async")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
|
self._context_id = None
|
||||||
self._started = False
|
self._started = False
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -268,10 +275,10 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio."""
|
"""Flush any pending audio."""
|
||||||
if not self._websocket:
|
if not self._context_id or not self._websocket:
|
||||||
return
|
return
|
||||||
logger.trace(f"{self}: flushing audio")
|
logger.trace(f"{self}: flushing audio")
|
||||||
msg = self._build_msg(text=" ", force=True)
|
msg = self._build_msg(text=" ", context_id=self._context_id, force=True)
|
||||||
await self._websocket.send(msg)
|
await self._websocket.send(msg)
|
||||||
|
|
||||||
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
@@ -291,35 +298,75 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
if not msg:
|
if not msg:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif msg.get("audio"):
|
received_ctx_id = msg.get("context_id")
|
||||||
|
# Handle final messages first, regardless of context availability
|
||||||
|
# At the moment, this message is received AFTER the close_context message is
|
||||||
|
# sent, so it doesn't serve any functional purpose. For now, we'll just log it.
|
||||||
|
if msg.get("final") is True:
|
||||||
|
logger.trace(f"Received final message for context {received_ctx_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this message belongs to the current context.
|
||||||
|
if not self.audio_context_available(received_ctx_id):
|
||||||
|
if self._context_id == received_ctx_id:
|
||||||
|
logger.debug(
|
||||||
|
f"Received a delayed message, recreating the context: {self._context_id}"
|
||||||
|
)
|
||||||
|
await self.create_audio_context(self._context_id)
|
||||||
|
else:
|
||||||
|
# This can happen if a message is received _after_ we have closed a context
|
||||||
|
# due to user interruption but _before_ the `isFinal` message for the context
|
||||||
|
# is received.
|
||||||
|
logger.debug(f"Ignoring message from unavailable context: {received_ctx_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg.get("audio"):
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(
|
audio = base64.b64decode(msg["audio"])
|
||||||
audio=base64.b64decode(msg["audio"]),
|
frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
|
||||||
sample_rate=self.sample_rate,
|
await self.append_to_audio_context(received_ctx_id, frame)
|
||||||
num_channels=1,
|
|
||||||
)
|
|
||||||
await self.push_frame(frame)
|
|
||||||
elif msg.get("error_code"):
|
|
||||||
await self.push_frame(TTSStoppedFrame())
|
|
||||||
await self.stop_all_metrics()
|
|
||||||
await self.push_error(error_msg=f"Error: {msg['message']}")
|
|
||||||
else:
|
|
||||||
await self.push_error(error_msg=f"Unknown message type: {msg}")
|
|
||||||
|
|
||||||
async def _keepalive_task_handler(self):
|
async def _keepalive_task_handler(self):
|
||||||
"""Send periodic keepalive messages to maintain WebSocket connection."""
|
"""Send periodic keepalive messages to maintain WebSocket connection."""
|
||||||
KEEPALIVE_SLEEP = 3
|
KEEPALIVE_SLEEP = 10
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(KEEPALIVE_SLEEP)
|
await asyncio.sleep(KEEPALIVE_SLEEP)
|
||||||
try:
|
try:
|
||||||
if self._websocket and self._websocket.state is State.OPEN:
|
if self._websocket and self._websocket.state is State.OPEN:
|
||||||
keepalive_message = {"transcript": " "}
|
if self._context_id:
|
||||||
logger.trace("Sending keepalive message")
|
keepalive_message = {
|
||||||
|
"transcript": " ",
|
||||||
|
"context_id": self._context_id,
|
||||||
|
}
|
||||||
|
logger.trace("Sending keepalive message")
|
||||||
|
else:
|
||||||
|
# It's possible to have a user interruption which clears the context
|
||||||
|
# without generating a new TTS response. In this case, we'll just send
|
||||||
|
# an empty message to keep the connection alive.
|
||||||
|
keepalive_message = {"transcript": " "}
|
||||||
|
logger.trace("Sending keepalive without context")
|
||||||
await self._websocket.send(json.dumps(keepalive_message))
|
await self._websocket.send(json.dumps(keepalive_message))
|
||||||
except websockets.ConnectionClosed as e:
|
except websockets.ConnectionClosed as e:
|
||||||
logger.warning(f"{self} keepalive error: {e}")
|
logger.warning(f"{self} keepalive error: {e}")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
|
"""Handle interruption by closing the current context."""
|
||||||
|
await super()._handle_interruption(frame, direction)
|
||||||
|
|
||||||
|
# Close the current context when interrupted without closing the websocket
|
||||||
|
if self._context_id and self._websocket:
|
||||||
|
try:
|
||||||
|
await self._websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{"context_id": self._context_id, "close_context": True, "transcript": ""}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error closing context on interruption: {e}")
|
||||||
|
self._context_id = None
|
||||||
|
self._started = False
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Async API websocket endpoint.
|
"""Generate speech from text using Async API websocket endpoint.
|
||||||
@@ -336,21 +383,29 @@ class AsyncAITTSService(InterruptibleTTSService):
|
|||||||
if not self._websocket or self._websocket.state is State.CLOSED:
|
if not self._websocket or self._websocket.state is State.CLOSED:
|
||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
if not self._started:
|
|
||||||
await self.start_ttfb_metrics()
|
|
||||||
yield TTSStartedFrame()
|
|
||||||
self._started = True
|
|
||||||
|
|
||||||
msg = self._build_msg(text=text, force=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._get_websocket().send(msg)
|
if not self._started:
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_ttfb_metrics()
|
||||||
|
yield TTSStartedFrame()
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
if not self._context_id:
|
||||||
|
self._context_id = str(uuid.uuid4())
|
||||||
|
if not self.audio_context_available(self._context_id):
|
||||||
|
await self.create_audio_context(self._context_id)
|
||||||
|
|
||||||
|
msg = self._build_msg(text=text, force=True, context_id=self._context_id)
|
||||||
|
await self._get_websocket().send(msg)
|
||||||
|
await self.start_tts_usage_metrics(text)
|
||||||
|
else:
|
||||||
|
if self._websocket and self._context_id:
|
||||||
|
msg = self._build_msg(text=text, force=True, context_id=self._context_id)
|
||||||
|
await self._get_websocket().send(msg)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame()
|
||||||
await self._disconnect()
|
self._started = False
|
||||||
await self._connect()
|
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -490,7 +545,14 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
await self.push_error(error_msg=f"Async API error: {error_text}")
|
await self.push_error(error_msg=f"Async API error: {error_text}")
|
||||||
raise Exception(f"Async API returned status {response.status}: {error_text}")
|
raise Exception(f"Async API returned status {response.status}: {error_text}")
|
||||||
|
|
||||||
audio_data = await response.read()
|
# Read streaming bytes; stop TTFB on the *first* received chunk
|
||||||
|
buffer = bytearray()
|
||||||
|
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
buffer.extend(chunk)
|
||||||
|
audio_data = bytes(buffer)
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user