Refactored audio context management in TTS services to improve encapsulation and reduce code duplication
This commit is contained in:
@@ -9,7 +9,6 @@
|
|||||||
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
|
||||||
@@ -148,7 +147,6 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_task = None
|
||||||
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.
|
||||||
@@ -255,7 +253,7 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
if self._websocket:
|
if self._websocket:
|
||||||
logger.debug("Disconnecting from Async")
|
logger.debug("Disconnecting from Async")
|
||||||
# Close all contexts and the socket
|
# Close all contexts and the socket
|
||||||
if self._context_id:
|
if self.has_active_audio_context():
|
||||||
await self._websocket.send(json.dumps({"terminate": True}))
|
await self._websocket.send(json.dumps({"terminate": True}))
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
logger.debug("Disconnected from Async")
|
logger.debug("Disconnected from Async")
|
||||||
@@ -263,7 +261,7 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
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
|
await self.remove_active_audio_context()
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
def _get_websocket(self):
|
def _get_websocket(self):
|
||||||
@@ -271,26 +269,13 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
return self._websocket
|
return self._websocket
|
||||||
raise Exception("Websocket not connected")
|
raise Exception("Websocket not connected")
|
||||||
|
|
||||||
def create_context_id(self) -> str:
|
|
||||||
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A unique string identifier for the TTS context.
|
|
||||||
"""
|
|
||||||
# If a context ID does not exist, create a new one.
|
|
||||||
# If an ID exists, continue using the current ID.
|
|
||||||
# When interruptions happen, user speech results in
|
|
||||||
# an interruption, which resets the context ID.
|
|
||||||
if not self._context_id:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
return self._context_id
|
|
||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio."""
|
"""Flush any pending audio."""
|
||||||
if not self._context_id or not self._websocket:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if not 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=" ", context_id=self._context_id, force=True)
|
msg = self._build_msg(text=" ", context_id=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):
|
||||||
@@ -318,11 +303,11 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
|
|
||||||
# Check if this message belongs to the current context.
|
# Check if this message belongs to the current context.
|
||||||
if not self.audio_context_available(received_ctx_id):
|
if not self.audio_context_available(received_ctx_id):
|
||||||
if self._context_id == received_ctx_id:
|
if self.get_active_audio_context_id() == received_ctx_id:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Received a delayed message, recreating the context: {self._context_id}"
|
f"Received a delayed message, recreating the context: {received_ctx_id}"
|
||||||
)
|
)
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(received_ctx_id)
|
||||||
else:
|
else:
|
||||||
# This can happen if a message is received _after_ we have closed a context
|
# 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
|
# due to user interruption but _before_ the `isFinal` message for the context
|
||||||
@@ -343,10 +328,11 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
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:
|
||||||
if self._context_id:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if context_id:
|
||||||
keepalive_message = {
|
keepalive_message = {
|
||||||
"transcript": " ",
|
"transcript": " ",
|
||||||
"context_id": self._context_id,
|
"context_id": context_id,
|
||||||
}
|
}
|
||||||
logger.trace("Sending keepalive message")
|
logger.trace("Sending keepalive message")
|
||||||
else:
|
else:
|
||||||
@@ -362,19 +348,16 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
|
|
||||||
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
"""Handle interruption by closing the current context."""
|
"""Handle interruption by closing the current context."""
|
||||||
|
context_id = self.get_active_audio_context_id()
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
|
|
||||||
# Close the current context when interrupted without closing the websocket
|
# Close the current context when interrupted without closing the websocket
|
||||||
if self._context_id and self._websocket:
|
if context_id and self._websocket:
|
||||||
try:
|
try:
|
||||||
await self._websocket.send(
|
await self._websocket.send(
|
||||||
json.dumps(
|
json.dumps({"context_id": context_id, "close_context": True, "transcript": ""})
|
||||||
{"context_id": self._context_id, "close_context": True, "transcript": ""}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error closing context on interruption: {e}")
|
logger.error(f"Error closing context on interruption: {e}")
|
||||||
self._context_id = None
|
|
||||||
|
|
||||||
@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]:
|
||||||
@@ -394,16 +377,13 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._context_id:
|
if not self.has_active_audio_context():
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
if not self.audio_context_available(context_id):
|
||||||
|
await self.create_audio_context(context_id)
|
||||||
|
|
||||||
self._context_id = context_id
|
msg = self._build_msg(text=text, force=True, context_id=context_id)
|
||||||
|
|
||||||
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._get_websocket().send(msg)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import uuid
|
|
||||||
import warnings
|
import warnings
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import AsyncGenerator, List, Literal, Optional
|
from typing import AsyncGenerator, List, Literal, Optional
|
||||||
@@ -307,7 +306,6 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
self.set_model_name(model)
|
self.set_model_name(model)
|
||||||
self.set_voice(voice_id)
|
self.set_voice(voice_id)
|
||||||
|
|
||||||
self._context_id = None
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
@@ -430,7 +428,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
msg = {
|
msg = {
|
||||||
"transcript": text,
|
"transcript": text,
|
||||||
"continue": continue_transcript,
|
"continue": continue_transcript,
|
||||||
"context_id": self._context_id,
|
"context_id": self.get_active_audio_context_id(),
|
||||||
"model_id": self.model_name,
|
"model_id": self.model_name,
|
||||||
"voice": voice_config,
|
"voice": voice_config,
|
||||||
"output_format": self._settings["output_format"],
|
"output_format": self._settings["output_format"],
|
||||||
@@ -523,7 +521,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
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._context_id = None
|
await self.remove_active_audio_context()
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -533,35 +531,22 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
raise Exception("Websocket not connected")
|
raise Exception("Websocket not connected")
|
||||||
|
|
||||||
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
|
context_id = self.get_active_audio_context_id()
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
if self._context_id:
|
if context_id:
|
||||||
cancel_msg = json.dumps({"context_id": self._context_id, "cancel": True})
|
cancel_msg = json.dumps({"context_id": context_id, "cancel": True})
|
||||||
await self._get_websocket().send(cancel_msg)
|
await self._get_websocket().send(cancel_msg)
|
||||||
self._context_id = None
|
|
||||||
|
|
||||||
def create_context_id(self) -> str:
|
|
||||||
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A unique string identifier for the TTS context.
|
|
||||||
"""
|
|
||||||
# If a context ID does not exist, create a new one.
|
|
||||||
# If an ID exists, continue using the current ID.
|
|
||||||
# When interruptions happen, user speech results in
|
|
||||||
# an interruption, which resets the context ID.
|
|
||||||
if not self._context_id:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
return self._context_id
|
|
||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio and finalize the current context."""
|
"""Flush any pending audio and finalize the current context."""
|
||||||
if not self._context_id or not self._websocket:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if not 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="", continue_transcript=False)
|
msg = self._build_msg(text="", continue_transcript=False)
|
||||||
await self._websocket.send(msg)
|
await self._websocket.send(msg)
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
|
|
||||||
async def _process_messages(self):
|
async def _process_messages(self):
|
||||||
async for message in self._get_websocket():
|
async for message in self._get_websocket():
|
||||||
@@ -593,7 +578,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
await self.push_frame(TTSStoppedFrame(context_id=ctx_id))
|
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}")
|
await self.push_error(error_msg=f"Error: {msg}")
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
else:
|
else:
|
||||||
await self.push_error(error_msg=f"Error, unknown message type: {msg}")
|
await self.push_error(error_msg=f"Error, unknown message type: {msg}")
|
||||||
|
|
||||||
@@ -622,11 +607,10 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
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._context_id:
|
if not self.has_active_audio_context():
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._context_id = context_id
|
await self.create_audio_context(context_id)
|
||||||
await self.create_audio_context(self._context_id)
|
|
||||||
|
|
||||||
msg = self._build_msg(text=text)
|
msg = self._build_msg(text=text)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ with support for streaming audio, word timestamps, and voice customization.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import uuid
|
|
||||||
from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union
|
from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -343,7 +342,6 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
self._partial_word_start_time = 0.0
|
self._partial_word_start_time = 0.0
|
||||||
|
|
||||||
# Context management for v1 multi API
|
# Context management for v1 multi API
|
||||||
self._context_id = None
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_task = None
|
||||||
|
|
||||||
@@ -411,18 +409,19 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
)
|
)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
elif voice_settings_changed and self._context_id:
|
elif voice_settings_changed and self.has_active_audio_context():
|
||||||
# Voice settings can be updated by closing current context
|
# Voice settings can be updated by closing current context
|
||||||
# so new one gets created with updated voice settings
|
# so new one gets created with updated voice settings
|
||||||
logger.debug(f"Voice settings changed, closing current context to apply changes")
|
logger.debug(f"Voice settings changed, closing current context to apply changes")
|
||||||
|
context_id = self.get_active_audio_context_id()
|
||||||
try:
|
try:
|
||||||
if self._websocket:
|
if self._websocket:
|
||||||
await self._websocket.send(
|
await self._websocket.send(
|
||||||
json.dumps({"context_id": self._context_id, "close_context": True})
|
json.dumps({"context_id": context_id, "close_context": True})
|
||||||
)
|
)
|
||||||
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)
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
"""Start the ElevenLabs TTS service.
|
"""Start the ElevenLabs TTS service.
|
||||||
@@ -454,10 +453,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio and finalize the current context."""
|
"""Flush any pending audio and finalize the current context."""
|
||||||
if not self._context_id or not self._websocket:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if not context_id or not self._websocket:
|
||||||
return
|
return
|
||||||
logger.trace(f"{self}: flushing audio")
|
logger.trace(f"{self}: flushing audio")
|
||||||
msg = {"context_id": self._context_id, "flush": True}
|
msg = {"context_id": context_id, "flush": True}
|
||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
|
||||||
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
@@ -470,7 +470,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
||||||
if isinstance(frame, TTSStoppedFrame):
|
if isinstance(frame, TTSStoppedFrame):
|
||||||
await self.add_word_timestamps([("Reset", 0)], self._context_id)
|
await self.add_word_timestamps([("Reset", 0)], self.get_active_audio_context_id())
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
await super()._connect()
|
await super()._connect()
|
||||||
@@ -545,14 +545,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
if self._websocket:
|
if self._websocket:
|
||||||
logger.debug("Disconnecting from ElevenLabs")
|
logger.debug("Disconnecting from ElevenLabs")
|
||||||
# Close all contexts and the socket
|
# Close all contexts and the socket
|
||||||
if self._context_id:
|
if self.has_active_audio_context():
|
||||||
await self._websocket.send(json.dumps({"close_socket": True}))
|
await self._websocket.send(json.dumps({"close_socket": True}))
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
logger.debug("Disconnected from ElevenLabs")
|
logger.debug("Disconnected from ElevenLabs")
|
||||||
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._context_id = None
|
await self.remove_active_audio_context()
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -563,11 +563,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
"""Handle interruption by closing the current context."""
|
"""Handle interruption by closing the current context."""
|
||||||
|
# Close the current context when interrupted without closing the websocket
|
||||||
|
context_id = self.get_active_audio_context_id()
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
|
|
||||||
# Close the current context when interrupted without closing the websocket
|
if context_id and self._websocket:
|
||||||
if self._context_id and self._websocket:
|
logger.trace(f"Closing context {context_id} due to interruption")
|
||||||
logger.trace(f"Closing context {self._context_id} due to interruption")
|
|
||||||
try:
|
try:
|
||||||
# ElevenLabs requires that Pipecat manages the contexts and closes them
|
# ElevenLabs requires that Pipecat manages the contexts and closes them
|
||||||
# when they're not longer in use. Since an InterruptionFrame is pushed
|
# when they're not longer in use. Since an InterruptionFrame is pushed
|
||||||
@@ -576,11 +577,10 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
# Note: We do not need to call remove_audio_context here, as the context is
|
# Note: We do not need to call remove_audio_context here, as the context is
|
||||||
# automatically reset when super ()._handle_interruption is called.
|
# automatically reset when super ()._handle_interruption is called.
|
||||||
await self._websocket.send(
|
await self._websocket.send(
|
||||||
json.dumps({"context_id": self._context_id, "close_context": True})
|
json.dumps({"context_id": context_id, "close_context": True})
|
||||||
)
|
)
|
||||||
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)
|
||||||
self._context_id = None
|
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
self._partial_word_start_time = 0.0
|
self._partial_word_start_time = 0.0
|
||||||
|
|
||||||
@@ -600,11 +600,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
# Check if this message belongs to the current context.
|
# Check if this message belongs to the current context.
|
||||||
if not self.audio_context_available(received_ctx_id):
|
if not self.audio_context_available(received_ctx_id):
|
||||||
if self._context_id == received_ctx_id:
|
if self.get_active_audio_context_id() == received_ctx_id:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Received a delayed message, recreating the context: {self._context_id}"
|
f"Received a delayed message, recreating the context: {received_ctx_id}"
|
||||||
)
|
)
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(received_ctx_id)
|
||||||
else:
|
else:
|
||||||
# This can happen if a message is received _after_ we have closed a context
|
# 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
|
# due to user interruption but _before_ the `isFinal` message for the context
|
||||||
@@ -657,13 +657,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
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:
|
||||||
if self._context_id:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if context_id:
|
||||||
# Send keepalive with context ID to keep the connection alive
|
# Send keepalive with context ID to keep the connection alive
|
||||||
keepalive_message = {
|
keepalive_message = {
|
||||||
"text": "",
|
"text": "",
|
||||||
"context_id": self._context_id,
|
"context_id": context_id,
|
||||||
}
|
}
|
||||||
logger.trace(f"Sending keepalive for context {self._context_id}")
|
logger.trace(f"Sending keepalive for context {context_id}")
|
||||||
else:
|
else:
|
||||||
# It's possible to have a user interruption which clears the context
|
# 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
|
# without generating a new TTS response. In this case, we'll just send
|
||||||
@@ -677,24 +678,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
async def _send_text(self, text: str):
|
async def _send_text(self, text: str):
|
||||||
"""Send text to the WebSocket for synthesis."""
|
"""Send text to the WebSocket for synthesis."""
|
||||||
if self._websocket and self._context_id:
|
context_id = self.get_active_audio_context_id()
|
||||||
msg = {"text": text, "context_id": self._context_id}
|
if self._websocket and context_id:
|
||||||
|
msg = {"text": text, "context_id": context_id}
|
||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
|
||||||
def create_context_id(self) -> str:
|
|
||||||
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A unique string identifier for the TTS context.
|
|
||||||
"""
|
|
||||||
# If a context ID does not exist, create a new one.
|
|
||||||
# If an ID exists, continue using the current ID.
|
|
||||||
# When interruptions happens, user speech results in
|
|
||||||
# an interruption, which resets the context ID.
|
|
||||||
if not self._context_id:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
return self._context_id
|
|
||||||
|
|
||||||
@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]:
|
||||||
"""Generate speech from text using ElevenLabs' streaming WebSocket API.
|
"""Generate speech from text using ElevenLabs' streaming WebSocket API.
|
||||||
@@ -713,19 +701,18 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._context_id:
|
if not self.has_active_audio_context():
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._context_id = context_id
|
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
self._partial_word_start_time = 0.0
|
self._partial_word_start_time = 0.0
|
||||||
|
|
||||||
if not self.audio_context_available(self._context_id):
|
if not self.audio_context_available(context_id):
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(context_id)
|
||||||
|
|
||||||
# Initialize context with voice settings and pronunciation dictionaries
|
# Initialize context with voice settings and pronunciation dictionaries
|
||||||
msg = {"text": " ", "context_id": self._context_id}
|
msg = {"text": " ", "context_id": context_id}
|
||||||
if self._voice_settings:
|
if self._voice_settings:
|
||||||
msg["voice_settings"] = self._voice_settings
|
msg["voice_settings"] = self._voice_settings
|
||||||
if self._pronunciation_dictionary_locators:
|
if self._pronunciation_dictionary_locators:
|
||||||
@@ -734,7 +721,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
for locator in self._pronunciation_dictionary_locators
|
for locator in self._pronunciation_dictionary_locators
|
||||||
]
|
]
|
||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
logger.trace(f"Created new context {self._context_id}")
|
logger.trace(f"Created new context {context_id}")
|
||||||
|
|
||||||
await self._send_text(text)
|
await self._send_text(text)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import uuid
|
|
||||||
from typing import Any, AsyncGenerator, Mapping, Optional
|
from typing import Any, AsyncGenerator, Mapping, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -97,7 +96,6 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
# State tracking
|
# State tracking
|
||||||
self._receive_task = None
|
self._receive_task = 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.
|
||||||
@@ -129,8 +127,9 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
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."""
|
||||||
msg = {"text": text, "type": "text"}
|
msg = {"text": text, "type": "text"}
|
||||||
if self._context_id:
|
context_id = self.get_active_audio_context_id()
|
||||||
msg["client_req_id"] = self._context_id
|
if context_id:
|
||||||
|
msg["client_req_id"] = context_id
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
@@ -229,6 +228,7 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
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:
|
||||||
|
await self.remove_active_audio_context()
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -240,12 +240,13 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio synthesis."""
|
"""Flush any pending audio synthesis."""
|
||||||
if not self._context_id or not self._websocket:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if not context_id or not self._websocket:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
msg = {"type": "end_of_stream", "client_req_id": self._context_id}
|
msg = {"type": "end_of_stream", "client_req_id": context_id}
|
||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
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:
|
||||||
@@ -265,7 +266,6 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
"""
|
"""
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
self._context_id = None
|
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
"""Process incoming websocket messages, demultiplexing by client_req_id."""
|
"""Process incoming websocket messages, demultiplexing by client_req_id."""
|
||||||
@@ -306,20 +306,6 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
await self.push_error(error_msg=f"Error: {msg.get('message', msg)}")
|
await self.push_error(error_msg=f"Error: {msg.get('message', msg)}")
|
||||||
|
|
||||||
def create_context_id(self) -> str:
|
|
||||||
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A unique string identifier for the TTS context.
|
|
||||||
"""
|
|
||||||
# If a context ID does not exist, create a new one.
|
|
||||||
# If an ID exists, continue using the current ID.
|
|
||||||
# When interruptions happens, user speech results in
|
|
||||||
# an interruption, which resets the context ID.
|
|
||||||
if not self._context_id:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
return self._context_id
|
|
||||||
|
|
||||||
@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]:
|
||||||
"""Generate speech from text using Gradium's streaming API.
|
"""Generate speech from text using Gradium's streaming API.
|
||||||
@@ -338,11 +324,10 @@ class GradiumTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._context_id:
|
if not self.has_active_audio_context():
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._context_id = context_id
|
await self.create_audio_context(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))
|
||||||
|
|||||||
@@ -517,7 +517,6 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_task = None
|
||||||
self._context_id = None
|
|
||||||
|
|
||||||
# Track cumulative time across generations for monotonic timestamps within a turn.
|
# Track cumulative time across generations for monotonic timestamps within a turn.
|
||||||
# When auto_mode is enabled, the server controls generations and timestamps reset
|
# When auto_mode is enabled, the server controls generations and timestamps reset
|
||||||
@@ -573,9 +572,10 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
keeping the context open for subsequent text. The context is only
|
keeping the context open for subsequent text. The context is only
|
||||||
closed on interruption, disconnect, or end of session.
|
closed on interruption, disconnect, or end of session.
|
||||||
"""
|
"""
|
||||||
if self._context_id and self._websocket:
|
context_id = self.get_active_audio_context_id()
|
||||||
logger.trace(f"Flushing audio for context {self._context_id}")
|
if context_id and self._websocket:
|
||||||
await self._send_flush(self._context_id)
|
logger.trace(f"Flushing audio for context {context_id}")
|
||||||
|
await self._send_flush(context_id)
|
||||||
|
|
||||||
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
"""Push a frame and handle state changes.
|
"""Push a frame and handle state changes.
|
||||||
@@ -640,7 +640,7 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
frame: The interruption frame.
|
frame: The interruption frame.
|
||||||
direction: The direction of the interruption.
|
direction: The direction of the interruption.
|
||||||
"""
|
"""
|
||||||
old_context_id = self._context_id
|
old_context_id = self.get_active_audio_context_id()
|
||||||
logger.trace(f"{self}: Handling interruption, old context: {old_context_id}")
|
logger.trace(f"{self}: Handling interruption, old context: {old_context_id}")
|
||||||
|
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
@@ -652,7 +652,6 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
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)
|
||||||
|
|
||||||
self._context_id = None
|
|
||||||
self._cumulative_time = 0.0
|
self._cumulative_time = 0.0
|
||||||
self._generation_end_time = 0.0
|
self._generation_end_time = 0.0
|
||||||
logger.trace(f"{self}: Interruption handled, context reset to None")
|
logger.trace(f"{self}: Interruption handled, context reset to None")
|
||||||
@@ -736,9 +735,10 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
if self._websocket:
|
if self._websocket:
|
||||||
logger.debug("Disconnecting from Inworld WebSocket TTS")
|
logger.debug("Disconnecting from Inworld WebSocket TTS")
|
||||||
if self._context_id:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if context_id:
|
||||||
try:
|
try:
|
||||||
await self._send_close_context(self._context_id)
|
await self._send_close_context(context_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
await self._websocket.close()
|
await self._websocket.close()
|
||||||
@@ -746,7 +746,7 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
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._context_id = None
|
await self.remove_active_audio_context()
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._cumulative_time = 0.0
|
self._cumulative_time = 0.0
|
||||||
self._generation_end_time = 0.0
|
self._generation_end_time = 0.0
|
||||||
@@ -772,7 +772,7 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
]
|
]
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{self}: Received message types={msg_types}, ctx_id={ctx_id}, "
|
f"{self}: Received message types={msg_types}, ctx_id={ctx_id}, "
|
||||||
f"current_ctx={self._context_id}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}"
|
f"current_ctx={self.get_active_audio_context_id()}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for errors
|
# Check for errors
|
||||||
@@ -784,7 +784,9 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
# Handle "Context not found" error (code 5)
|
# Handle "Context not found" error (code 5)
|
||||||
# This can happen when a keepalive message is sent but no context is available.
|
# This can happen when a keepalive message is sent but no context is available.
|
||||||
if error_code == 5 and "not found" in error_msg.lower():
|
if error_code == 5 and "not found" in error_msg.lower():
|
||||||
logger.debug(f"{self}: Context {ctx_id or self._context_id} not found.")
|
logger.debug(
|
||||||
|
f"{self}: Context {ctx_id or self.get_active_audio_context_id()} not found."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# For other errors, push error frame
|
# For other errors, push error frame
|
||||||
@@ -799,11 +801,9 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
# If the context isn't available but matches our current context ID,
|
# If the context isn't available but matches our current context ID,
|
||||||
# recreate it (handles race conditions during interruption recovery).
|
# recreate it (handles race conditions during interruption recovery).
|
||||||
if ctx_id and not self.audio_context_available(ctx_id):
|
if ctx_id and not self.audio_context_available(ctx_id):
|
||||||
if self._context_id == ctx_id:
|
if self.get_active_audio_context_id() == ctx_id:
|
||||||
logger.trace(
|
logger.trace(f"{self}: Recreating audio context for current context: {ctx_id}")
|
||||||
f"{self}: Recreating audio context for current context: {self._context_id}"
|
await self.create_audio_context(ctx_id)
|
||||||
)
|
|
||||||
await self.create_audio_context(self._context_id)
|
|
||||||
else:
|
else:
|
||||||
# This is a message from an old/closed context - skip it
|
# This is a message from an old/closed context - skip it
|
||||||
logger.trace(f"{self}: Skipping message from unavailable context: {ctx_id}")
|
logger.trace(f"{self}: Skipping message from unavailable context: {ctx_id}")
|
||||||
@@ -849,8 +849,8 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
logger.trace(f"{self}: Context closed on server: {ctx_id}")
|
logger.trace(f"{self}: Context closed on server: {ctx_id}")
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
# Only reset if this is our current context
|
# Only reset if this is our current context
|
||||||
if ctx_id == self._context_id:
|
if ctx_id == self.get_active_audio_context_id():
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
if ctx_id and self.audio_context_available(ctx_id):
|
if ctx_id and self.audio_context_available(ctx_id):
|
||||||
await self.remove_audio_context(ctx_id)
|
await self.remove_audio_context(ctx_id)
|
||||||
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
|
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
|
||||||
@@ -862,12 +862,13 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
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:
|
||||||
if self._context_id:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if context_id:
|
||||||
keepalive_message = {
|
keepalive_message = {
|
||||||
"send_text": {"text": ""},
|
"send_text": {"text": ""},
|
||||||
"contextId": self._context_id,
|
"contextId": context_id,
|
||||||
}
|
}
|
||||||
logger.trace(f"Sending keepalive for context {self._context_id}")
|
logger.trace(f"Sending keepalive for context {context_id}")
|
||||||
else:
|
else:
|
||||||
keepalive_message = {"send_text": {"text": ""}}
|
keepalive_message = {"send_text": {"text": ""}}
|
||||||
logger.trace("Sending keepalive without context")
|
logger.trace("Sending keepalive without context")
|
||||||
@@ -938,20 +939,6 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
msg = {"close_context": {}, "contextId": context_id}
|
msg = {"close_context": {}, "contextId": context_id}
|
||||||
await self.send_with_retry(json.dumps(msg), self._report_error)
|
await self.send_with_retry(json.dumps(msg), self._report_error)
|
||||||
|
|
||||||
def create_context_id(self) -> str:
|
|
||||||
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A unique string identifier for the TTS context.
|
|
||||||
"""
|
|
||||||
# If a context ID does not exist, create a new one.
|
|
||||||
# If an ID exists, continue using the current ID.
|
|
||||||
# When interruptions happen, user speech results in
|
|
||||||
# an interruption, which resets the context ID.
|
|
||||||
if not self._context_id:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
return self._context_id
|
|
||||||
|
|
||||||
@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]:
|
||||||
"""Generate TTS audio for the given text using the Inworld WebSocket TTS service.
|
"""Generate TTS audio for the given text using the Inworld WebSocket TTS service.
|
||||||
@@ -970,19 +957,13 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._context_id:
|
if not self.has_active_audio_context():
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._context_id = context_id
|
await self.create_audio_context(context_id)
|
||||||
logger.trace(f"{self}: Creating new context {self._context_id}")
|
await self._send_context(context_id)
|
||||||
await self.create_audio_context(self._context_id)
|
|
||||||
await self._send_context(self._context_id)
|
|
||||||
elif not self.audio_context_available(self._context_id):
|
|
||||||
# Context exists on server but local tracking was removed
|
|
||||||
logger.trace(f"{self}: Recreating local audio context {self._context_id}")
|
|
||||||
await self.create_audio_context(self._context_id)
|
|
||||||
|
|
||||||
await self._send_text(self._context_id, text)
|
await self._send_text(context_id, text)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ from pipecat.frames.frames import (
|
|||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.tts_service import AudioContextWordTTSService
|
from pipecat.services.tts_service import AudioContextWordTTSService
|
||||||
from pipecat.transcriptions.language import Language
|
|
||||||
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
|
|
||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -70,6 +68,7 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
|
reuse_context_id_within_turn=False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ using Rime's API for streaming and batch audio synthesis.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import uuid
|
|
||||||
from typing import Any, AsyncGenerator, Mapping, Optional
|
from typing import Any, AsyncGenerator, Mapping, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -167,7 +166,6 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
self._settings = self._build_settings()
|
self._settings = self._build_settings()
|
||||||
|
|
||||||
# State tracking
|
# State tracking
|
||||||
self._context_id = None # Tracks current turn
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._cumulative_time = 0 # Accumulates time across messages
|
self._cumulative_time = 0 # Accumulates time across messages
|
||||||
self._extra_msg_fields = {} # Extra fields for next message
|
self._extra_msg_fields = {} # Extra fields for next message
|
||||||
@@ -339,7 +337,7 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
def _build_msg(self, text: str = "") -> dict:
|
def _build_msg(self, text: str = "") -> dict:
|
||||||
"""Build JSON message for Rime API."""
|
"""Build JSON message for Rime API."""
|
||||||
msg = {"text": text, "contextId": self._context_id}
|
msg = {"text": text, "contextId": self.get_active_audio_context_id()}
|
||||||
if self._extra_msg_fields:
|
if self._extra_msg_fields:
|
||||||
msg |= self._extra_msg_fields
|
msg |= self._extra_msg_fields
|
||||||
self._extra_msg_fields = {}
|
self._extra_msg_fields = {}
|
||||||
@@ -427,7 +425,7 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e)
|
await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
self._context_id = None
|
await self.remove_active_audio_context()
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -439,11 +437,11 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
"""Handle interruption by clearing current context."""
|
"""Handle interruption by clearing current context."""
|
||||||
|
context_id = self.get_active_audio_context_id()
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
if self._context_id:
|
if context_id:
|
||||||
await self._get_websocket().send(json.dumps(self._build_clear_msg()))
|
await self._get_websocket().send(json.dumps(self._build_clear_msg()))
|
||||||
self._context_id = None
|
|
||||||
|
|
||||||
def _calculate_word_times(self, words: list, starts: list, ends: list) -> list:
|
def _calculate_word_times(self, words: list, starts: list, ends: list) -> list:
|
||||||
"""Calculate word timing pairs with proper spacing and punctuation.
|
"""Calculate word timing pairs with proper spacing and punctuation.
|
||||||
@@ -474,28 +472,15 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
return word_pairs
|
return word_pairs
|
||||||
|
|
||||||
def create_context_id(self) -> str:
|
|
||||||
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A unique string identifier for the TTS context.
|
|
||||||
"""
|
|
||||||
# If a context ID does not exist, create a new one.
|
|
||||||
# If an ID exists, continue using the current ID.
|
|
||||||
# When interruptions happen, user speech results in
|
|
||||||
# an interruption, which resets the context ID.
|
|
||||||
if not self._context_id:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
return self._context_id
|
|
||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio synthesis."""
|
"""Flush any pending audio synthesis."""
|
||||||
if not self._context_id or not self._websocket:
|
context_id = self.get_active_audio_context_id()
|
||||||
|
if not context_id or not self._websocket:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.trace(f"{self}: flushing audio")
|
logger.trace(f"{self}: flushing audio")
|
||||||
await self._get_websocket().send(json.dumps({"operation": "flush"}))
|
await self._get_websocket().send(json.dumps({"operation": "flush"}))
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
"""Process incoming websocket messages."""
|
"""Process incoming websocket messages."""
|
||||||
@@ -537,7 +522,7 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame())
|
||||||
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['message']}")
|
||||||
self._context_id = None
|
self.reset_active_audio_context()
|
||||||
|
|
||||||
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
"""Push frame and handle end-of-turn conditions.
|
"""Push frame and handle end-of-turn conditions.
|
||||||
@@ -568,12 +553,11 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._context_id:
|
if not self.has_active_audio_context():
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame(context_id=context_id)
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
self._context_id = context_id
|
await self.create_audio_context(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))
|
||||||
|
|||||||
@@ -1042,14 +1042,23 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
audio from context ID "A" will be played first.
|
audio from context ID "A" will be played first.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, reconnect_on_error: bool = True, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
reuse_context_id_within_turn: bool = True,
|
||||||
|
reconnect_on_error: bool = True,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
"""Initialize the Audio Context TTS service.
|
"""Initialize the Audio Context TTS service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
reuse_context_id_within_turn: Whether the service should reuse context IDs within the same turn.
|
||||||
reconnect_on_error: Whether to automatically reconnect on websocket errors.
|
reconnect_on_error: Whether to automatically reconnect on websocket errors.
|
||||||
**kwargs: Additional arguments passed to the parent WebsocketTTSService.
|
**kwargs: Additional arguments passed to the parent WebsocketTTSService.
|
||||||
"""
|
"""
|
||||||
super().__init__(reconnect_on_error=reconnect_on_error, **kwargs)
|
super().__init__(reconnect_on_error=reconnect_on_error, **kwargs)
|
||||||
|
self._reuse_context_id_within_turn = reuse_context_id_within_turn
|
||||||
|
self._context_id = None
|
||||||
self._contexts: Dict[str, asyncio.Queue] = {}
|
self._contexts: Dict[str, asyncio.Queue] = {}
|
||||||
self._audio_context_task = None
|
self._audio_context_task = None
|
||||||
|
|
||||||
@@ -1059,6 +1068,10 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
Args:
|
Args:
|
||||||
context_id: Unique identifier for the audio context.
|
context_id: Unique identifier for the audio context.
|
||||||
"""
|
"""
|
||||||
|
# Set the context ID if not already set
|
||||||
|
if not self._context_id:
|
||||||
|
self._context_id = context_id
|
||||||
|
|
||||||
await self._contexts_queue.put(context_id)
|
await self._contexts_queue.put(context_id)
|
||||||
self._contexts[context_id] = asyncio.Queue()
|
self._contexts[context_id] = asyncio.Queue()
|
||||||
logger.trace(f"{self} created audio context {context_id}")
|
logger.trace(f"{self} created audio context {context_id}")
|
||||||
@@ -1091,6 +1104,32 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"{self} unable to remove context {context_id}")
|
logger.warning(f"{self} unable to remove context {context_id}")
|
||||||
|
|
||||||
|
def has_active_audio_context(self) -> bool:
|
||||||
|
"""Check if there is an active audio context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if an active audio context exists, False otherwise.
|
||||||
|
"""
|
||||||
|
return self._context_id is not None and self.audio_context_available(self._context_id)
|
||||||
|
|
||||||
|
def get_active_audio_context_id(self) -> Optional[str]:
|
||||||
|
"""Get the active audio context ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The active context ID, or None if no context is active.
|
||||||
|
"""
|
||||||
|
return self._context_id
|
||||||
|
|
||||||
|
async def remove_active_audio_context(self):
|
||||||
|
"""Remove the active audio context."""
|
||||||
|
if self._context_id:
|
||||||
|
await self.remove_audio_context(self._context_id)
|
||||||
|
self.reset_active_audio_context()
|
||||||
|
|
||||||
|
def reset_active_audio_context(self):
|
||||||
|
"""Reset the active audio context."""
|
||||||
|
self._context_id = None
|
||||||
|
|
||||||
def audio_context_available(self, context_id: str) -> bool:
|
def audio_context_available(self, context_id: str) -> bool:
|
||||||
"""Check whether the given audio context is registered.
|
"""Check whether the given audio context is registered.
|
||||||
|
|
||||||
@@ -1102,6 +1141,20 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
"""
|
"""
|
||||||
return context_id in self._contexts
|
return context_id in self._contexts
|
||||||
|
|
||||||
|
def create_context_id(self) -> str:
|
||||||
|
"""Generate or reuse a context ID based on concurrent TTS support.
|
||||||
|
|
||||||
|
If _reuse_context_id_within_turn is False and a context already exists,
|
||||||
|
the existing context ID is returned. Otherwise, a new unique context
|
||||||
|
ID is generated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A context ID string for the TTS request.
|
||||||
|
"""
|
||||||
|
if self._reuse_context_id_within_turn and self._context_id:
|
||||||
|
return self._context_id
|
||||||
|
return super().create_context_id()
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
"""Start the audio context TTS service.
|
"""Start the audio context TTS service.
|
||||||
|
|
||||||
@@ -1137,6 +1190,7 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
await self._stop_audio_context_task()
|
await self._stop_audio_context_task()
|
||||||
|
self.reset_active_audio_context()
|
||||||
self._create_audio_context_task()
|
self._create_audio_context_task()
|
||||||
|
|
||||||
def _create_audio_context_task(self):
|
def _create_audio_context_task(self):
|
||||||
@@ -1155,6 +1209,7 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
running = True
|
running = True
|
||||||
while running:
|
while running:
|
||||||
context_id = await self._contexts_queue.get()
|
context_id = await self._contexts_queue.get()
|
||||||
|
self._context_id = context_id
|
||||||
|
|
||||||
if context_id:
|
if context_id:
|
||||||
# Process the audio context until the context doesn't have more
|
# Process the audio context until the context doesn't have more
|
||||||
@@ -1163,11 +1218,15 @@ class AudioContextTTSService(WebsocketTTSService):
|
|||||||
|
|
||||||
# We just finished processing the context, so we can safely remove it.
|
# We just finished processing the context, so we can safely remove it.
|
||||||
del self._contexts[context_id]
|
del self._contexts[context_id]
|
||||||
|
self.reset_active_audio_context()
|
||||||
|
|
||||||
# Append some silence between sentences.
|
# Append some silence between sentences.
|
||||||
silence = b"\x00" * self.sample_rate
|
silence = b"\x00" * self.sample_rate
|
||||||
frame = TTSAudioRawFrame(
|
frame = TTSAudioRawFrame(
|
||||||
audio=silence, sample_rate=self.sample_rate, num_channels=1
|
audio=silence,
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user