Refactored all 30+ TTS service implementations to support context tracking
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._started = False
|
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
|
|
||||||
def can_generate_metrics(self) -> bool:
|
def can_generate_metrics(self) -> bool:
|
||||||
@@ -265,7 +263,6 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
finally:
|
finally:
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._started = False
|
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
def _get_websocket(self):
|
def _get_websocket(self):
|
||||||
@@ -289,8 +286,6 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
direction: The direction to push the frame.
|
direction: The direction to push the frame.
|
||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
|
||||||
self._started = False
|
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
async for message in self._get_websocket():
|
async for message in self._get_websocket():
|
||||||
@@ -323,7 +318,7 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
if msg.get("audio"):
|
if msg.get("audio"):
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
audio = base64.b64decode(msg["audio"])
|
audio = base64.b64decode(msg["audio"])
|
||||||
frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id)
|
||||||
await self.append_to_audio_context(received_ctx_id, frame)
|
await self.append_to_audio_context(received_ctx_id, frame)
|
||||||
|
|
||||||
async def _keepalive_task_handler(self):
|
async def _keepalive_task_handler(self):
|
||||||
@@ -365,14 +360,14 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
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
|
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, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Async API websocket endpoint.
|
"""Generate speech from text using Async API websocket endpoint.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -384,28 +379,21 @@ class AsyncAITTSService(AudioContextTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
|
||||||
self._started = True
|
|
||||||
|
|
||||||
if not self._context_id:
|
if not self._context_id:
|
||||||
self._context_id = str(uuid.uuid4())
|
self._context_id = context_id
|
||||||
if not self.audio_context_available(self._context_id):
|
if not self.audio_context_available(self._context_id):
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(self._context_id)
|
||||||
|
|
||||||
msg = self._build_msg(text=text, force=True, context_id=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)
|
||||||
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(context_id=context_id)
|
||||||
self._started = False
|
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -510,11 +498,12 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
self._settings["output_format"]["sample_rate"] = self.sample_rate
|
self._settings["output_format"]["sample_rate"] = self.sample_rate
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Async's HTTP streaming API.
|
"""Generate speech from text using Async's HTTP streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -531,7 +520,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
"output_format": self._settings["output_format"],
|
"output_format": self._settings["output_format"],
|
||||||
"language": self._settings["language"],
|
"language": self._settings["language"],
|
||||||
}
|
}
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
headers = {
|
headers = {
|
||||||
"version": self._api_version,
|
"version": self._api_version,
|
||||||
"x-api-key": self._api_key,
|
"x-api-key": self._api_key,
|
||||||
@@ -560,6 +549,7 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
audio=audio_data,
|
audio=audio_data,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield frame
|
yield frame
|
||||||
@@ -568,4 +558,4 @@ class AsyncAIHttpTTSService(TTSService):
|
|||||||
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.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -253,11 +253,12 @@ class AWSPollyTTSService(TTSService):
|
|||||||
return ssml
|
return ssml
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using AWS Polly.
|
"""Generate speech from text using AWS Polly.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -298,7 +299,7 @@ class AWSPollyTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
CHUNK_SIZE = self.chunk_size
|
CHUNK_SIZE = self.chunk_size
|
||||||
|
|
||||||
@@ -306,16 +307,16 @@ class AWSPollyTTSService(TTSService):
|
|||||||
chunk = audio_data[i : i + CHUNK_SIZE]
|
chunk = audio_data[i : i + CHUNK_SIZE]
|
||||||
if len(chunk) > 0:
|
if len(chunk) > 0:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1, context_id=context_id)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
except (BotoCoreError, ClientError) as error:
|
except (BotoCoreError, ClientError) as error:
|
||||||
error_message = f"AWS Polly TTS error: {str(error)}"
|
error_message = f"AWS Polly TTS error: {str(error)}"
|
||||||
yield ErrorFrame(error=error_message)
|
yield ErrorFrame(error=error_message)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
|
|
||||||
class PollyTTSService(AWSPollyTTSService):
|
class PollyTTSService(AWSPollyTTSService):
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
self._audio_queue = asyncio.Queue()
|
self._audio_queue = asyncio.Queue()
|
||||||
self._word_boundary_queue = asyncio.Queue()
|
self._word_boundary_queue = asyncio.Queue()
|
||||||
self._word_processor_task = None
|
self._word_processor_task = None
|
||||||
self._started = False
|
|
||||||
self._first_chunk = True
|
self._first_chunk = True
|
||||||
self._cumulative_audio_offset: float = 0.0 # Cumulative audio duration in seconds
|
self._cumulative_audio_offset: float = 0.0 # Cumulative audio duration in seconds
|
||||||
self._current_sentence_base_offset: float = 0.0 # Base offset for current sentence
|
self._current_sentence_base_offset: float = 0.0 # Base offset for current sentence
|
||||||
@@ -287,6 +286,9 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
)
|
)
|
||||||
self._last_word: Optional[str] = None # Track last word for punctuation merging
|
self._last_word: Optional[str] = None # Track last word for punctuation merging
|
||||||
self._last_timestamp: Optional[float] = None # Track last timestamp
|
self._last_timestamp: Optional[float] = None # Track last timestamp
|
||||||
|
self._current_context_id: Optional[str] = (
|
||||||
|
None # Track current context_id for word timestamps
|
||||||
|
)
|
||||||
|
|
||||||
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.
|
||||||
@@ -478,7 +480,10 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
word, timestamp_seconds = await self._word_boundary_queue.get()
|
word, timestamp_seconds = await self._word_boundary_queue.get()
|
||||||
await self.add_word_timestamps([(word, timestamp_seconds)])
|
if self._current_context_id:
|
||||||
|
await self.add_word_timestamps(
|
||||||
|
[(word, timestamp_seconds)], self._current_context_id
|
||||||
|
)
|
||||||
self._word_boundary_queue.task_done()
|
self._word_boundary_queue.task_done()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
@@ -536,12 +541,11 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
||||||
self._reset_state()
|
self._reset_state()
|
||||||
if isinstance(frame, TTSStoppedFrame):
|
if isinstance(frame, TTSStoppedFrame) and self._current_context_id:
|
||||||
await self.add_word_timestamps([("Reset", 0)])
|
await self.add_word_timestamps([("Reset", 0)], self._current_context_id)
|
||||||
|
|
||||||
def _reset_state(self):
|
def _reset_state(self):
|
||||||
"""Reset TTS state between turns."""
|
"""Reset TTS state between turns."""
|
||||||
self._started = False
|
|
||||||
self._first_chunk = True
|
self._first_chunk = True
|
||||||
self._cumulative_audio_offset = 0.0
|
self._cumulative_audio_offset = 0.0
|
||||||
self._current_sentence_base_offset = 0.0
|
self._current_sentence_base_offset = 0.0
|
||||||
@@ -549,6 +553,7 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
self._current_sentence_max_word_offset = 0.0
|
self._current_sentence_max_word_offset = 0.0
|
||||||
self._last_word = None
|
self._last_word = None
|
||||||
self._last_timestamp = None
|
self._last_timestamp = None
|
||||||
|
self._current_context_id = None
|
||||||
|
|
||||||
async def flush_audio(self):
|
async def flush_audio(self):
|
||||||
"""Flush any pending audio data."""
|
"""Flush any pending audio data."""
|
||||||
@@ -592,11 +597,12 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
break
|
break
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Azure's streaming synthesis.
|
"""Generate speech from text using Azure's streaming synthesis.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing synthesized speech data.
|
Frame: Audio frames containing synthesized speech data.
|
||||||
@@ -615,11 +621,10 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
self._first_chunk = True
|
||||||
self._started = True
|
self._current_context_id = context_id
|
||||||
self._first_chunk = True
|
|
||||||
|
|
||||||
# Capture base offset BEFORE starting synthesis to avoid race conditions
|
# Capture base offset BEFORE starting synthesis to avoid race conditions
|
||||||
# Word boundary callbacks will use this value
|
# Word boundary callbacks will use this value
|
||||||
@@ -647,6 +652,7 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
audio=chunk,
|
audio=chunk,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
@@ -662,7 +668,7 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
|
|||||||
|
|
||||||
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(context_id=context_id)
|
||||||
self._reset_state()
|
self._reset_state()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -738,11 +744,12 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Azure's HTTP synthesis API.
|
"""Generate speech from text using Azure's HTTP synthesis API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the complete synthesized speech.
|
Frame: Audio frames containing the complete synthesized speech.
|
||||||
@@ -758,14 +765,15 @@ class AzureHttpTTSService(TTSService, AzureBaseTTSService):
|
|||||||
if result.reason == ResultReason.SynthesizingAudioCompleted:
|
if result.reason == ResultReason.SynthesizingAudioCompleted:
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
# Azure always sends a 44-byte header. Strip it off.
|
# Azure always sends a 44-byte header. Strip it off.
|
||||||
yield TTSAudioRawFrame(
|
yield TTSAudioRawFrame(
|
||||||
audio=result.audio_data[44:],
|
audio=result.audio_data[44:],
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
elif result.reason == ResultReason.Canceled:
|
elif result.reason == ResultReason.Canceled:
|
||||||
cancellation_details = result.cancellation_details
|
cancellation_details = result.cancellation_details
|
||||||
logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}")
|
logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}")
|
||||||
|
|||||||
@@ -259,11 +259,12 @@ class CambTTSService(TTSService):
|
|||||||
self._sample_rate = MODEL_SAMPLE_RATES.get(self.model_name, 22050)
|
self._sample_rate = MODEL_SAMPLE_RATES.get(self.model_name, 22050)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Camb.ai's TTS API.
|
"""Generate speech from text using Camb.ai's TTS API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize into speech (max 3000 characters).
|
text: The text to synthesize into speech (max 3000 characters).
|
||||||
|
context_id: The context ID for tracking audio frames.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -292,7 +293,7 @@ class CambTTSService(TTSService):
|
|||||||
tts_kwargs["user_instructions"] = self._settings["user_instructions"]
|
tts_kwargs["user_instructions"] = self._settings["user_instructions"]
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
assert self._client is not None, "Camb.ai TTS service not initialized"
|
assert self._client is not None, "Camb.ai TTS service not initialized"
|
||||||
|
|
||||||
@@ -312,6 +313,7 @@ class CambTTSService(TTSService):
|
|||||||
audio=aligned_audio,
|
audio=aligned_audio,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Yield any remaining complete samples
|
# Yield any remaining complete samples
|
||||||
@@ -322,9 +324,10 @@ class CambTTSService(TTSService):
|
|||||||
audio=aligned_audio,
|
audio=aligned_audio,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield ErrorFrame(error=f"Camb.ai TTS error: {e}")
|
yield ErrorFrame(error=f"Camb.ai TTS error: {e}")
|
||||||
finally:
|
finally:
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -554,16 +553,17 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
msg = json.loads(message)
|
msg = json.loads(message)
|
||||||
if not msg or not self.audio_context_available(msg["context_id"]):
|
if not msg or not self.audio_context_available(msg["context_id"]):
|
||||||
continue
|
continue
|
||||||
|
ctx_id = msg["context_id"]
|
||||||
if msg["type"] == "done":
|
if msg["type"] == "done":
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)])
|
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
|
||||||
await self.remove_audio_context(msg["context_id"])
|
await self.remove_audio_context(ctx_id)
|
||||||
elif msg["type"] == "timestamps":
|
elif msg["type"] == "timestamps":
|
||||||
# Process the timestamps based on language before adding them
|
# Process the timestamps based on language before adding them
|
||||||
processed_timestamps = self._process_word_timestamps_for_language(
|
processed_timestamps = self._process_word_timestamps_for_language(
|
||||||
msg["word_timestamps"]["words"], msg["word_timestamps"]["start"]
|
msg["word_timestamps"]["words"], msg["word_timestamps"]["start"]
|
||||||
)
|
)
|
||||||
await self.add_word_timestamps(processed_timestamps)
|
await self.add_word_timestamps(processed_timestamps, ctx_id)
|
||||||
elif msg["type"] == "chunk":
|
elif msg["type"] == "chunk":
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
await self.start_word_timestamps()
|
await self.start_word_timestamps()
|
||||||
@@ -571,10 +571,11 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
audio=base64.b64decode(msg["data"]),
|
audio=base64.b64decode(msg["data"]),
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=ctx_id,
|
||||||
)
|
)
|
||||||
await self.append_to_audio_context(msg["context_id"], frame)
|
await self.append_to_audio_context(ctx_id, frame)
|
||||||
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}")
|
await self.push_error(error_msg=f"Error: {msg}")
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
@@ -590,11 +591,12 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect_websocket()
|
await self._connect_websocket()
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Cartesia's streaming API.
|
"""Generate speech from text using Cartesia's streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -607,8 +609,8 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
if not self._context_id:
|
if not self._context_id:
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._context_id = str(uuid.uuid4())
|
self._context_id = context_id
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(self._context_id)
|
||||||
|
|
||||||
msg = self._build_msg(text=text)
|
msg = self._build_msg(text=text)
|
||||||
@@ -618,7 +620,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
@@ -761,11 +763,12 @@ class CartesiaHttpTTSService(TTSService):
|
|||||||
await self._client.close()
|
await self._client.close()
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Cartesia's HTTP API.
|
"""Generate speech from text using Cartesia's HTTP API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -808,7 +811,7 @@ class CartesiaHttpTTSService(TTSService):
|
|||||||
if self._settings["pronunciation_dict_id"]:
|
if self._settings["pronunciation_dict_id"]:
|
||||||
payload["pronunciation_dict_id"] = self._settings["pronunciation_dict_id"]
|
payload["pronunciation_dict_id"] = self._settings["pronunciation_dict_id"]
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
session = await self._client._get_session()
|
session = await self._client._get_session()
|
||||||
|
|
||||||
@@ -834,6 +837,7 @@ class CartesiaHttpTTSService(TTSService):
|
|||||||
audio=audio_data,
|
audio=audio_data,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield frame
|
yield frame
|
||||||
@@ -842,4 +846,4 @@ class CartesiaHttpTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class DeepgramTTSService(WebsocketTTSService):
|
|||||||
self.set_voice(voice)
|
self.set_voice(voice)
|
||||||
|
|
||||||
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 the service can generate metrics.
|
"""Check if the service can generate metrics.
|
||||||
@@ -211,6 +212,7 @@ class DeepgramTTSService(WebsocketTTSService):
|
|||||||
logger.error(f"{self} exception: {e}")
|
logger.error(f"{self} exception: {e}")
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
await self.push_error(ErrorFrame(error=f"{self} error: {e}"))
|
||||||
finally:
|
finally:
|
||||||
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -242,7 +244,7 @@ class DeepgramTTSService(WebsocketTTSService):
|
|||||||
if isinstance(message, bytes):
|
if isinstance(message, bytes):
|
||||||
# Binary message contains audio data
|
# Binary message contains audio data
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(message, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(message, self.sample_rate, 1, context_id=self._context_id)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
elif isinstance(message, str):
|
elif isinstance(message, str):
|
||||||
# Text message contains metadata or control messages
|
# Text message contains metadata or control messages
|
||||||
@@ -283,11 +285,12 @@ class DeepgramTTSService(WebsocketTTSService):
|
|||||||
logger.error(f"{self} error sending Flush message: {e}")
|
logger.error(f"{self} error sending Flush message: {e}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Deepgram's WebSocket TTS API.
|
"""Generate speech from text using Deepgram's WebSocket TTS API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech, plus start/stop frames.
|
Frame: Audio frames containing the synthesized speech, plus start/stop frames.
|
||||||
@@ -302,7 +305,9 @@ class DeepgramTTSService(WebsocketTTSService):
|
|||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
# Store context_id for use in _receive_messages
|
||||||
|
self._context_id = context_id
|
||||||
|
|
||||||
# Send text message to Deepgram
|
# Send text message to Deepgram
|
||||||
# Note: We don't send Flush here - that should only be sent when the
|
# Note: We don't send Flush here - that should only be sent when the
|
||||||
@@ -366,11 +371,12 @@ class DeepgramHttpTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Deepgram's TTS API.
|
"""Generate speech from text using Deepgram's TTS API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech, plus start/stop frames.
|
Frame: Audio frames containing the synthesized speech, plus start/stop frames.
|
||||||
@@ -404,7 +410,7 @@ class DeepgramHttpTTSService(TTSService):
|
|||||||
raise Exception(f"HTTP {response.status}: {error_text}")
|
raise Exception(f"HTTP {response.status}: {error_text}")
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
CHUNK_SIZE = self.chunk_size
|
CHUNK_SIZE = self.chunk_size
|
||||||
|
|
||||||
@@ -419,9 +425,10 @@ class DeepgramHttpTTSService(TTSService):
|
|||||||
audio=chunk,
|
audio=chunk,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield ErrorFrame(f"Error getting audio: {str(e)}")
|
yield ErrorFrame(f"Error getting audio: {str(e)}")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -337,9 +336,6 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
self._voice_settings = self._set_voice_settings()
|
self._voice_settings = self._set_voice_settings()
|
||||||
self._pronunciation_dictionary_locators = params.pronunciation_dictionary_locators
|
self._pronunciation_dictionary_locators = params.pronunciation_dictionary_locators
|
||||||
|
|
||||||
# Indicates if we have sent TTSStartedFrame. It will reset to False when
|
|
||||||
# there's an interruption or TTSStoppedFrame.
|
|
||||||
self._started = False
|
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
# Track partial words that span across alignment chunks
|
# Track partial words that span across alignment chunks
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
@@ -426,7 +422,6 @@ class ElevenLabsTTSService(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._context_id = None
|
||||||
self._started = False
|
|
||||||
|
|
||||||
async def start(self, frame: StartFrame):
|
async def start(self, frame: StartFrame):
|
||||||
"""Start the ElevenLabs TTS service.
|
"""Start the ElevenLabs TTS service.
|
||||||
@@ -473,9 +468,8 @@ 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)):
|
||||||
self._started = False
|
|
||||||
if isinstance(frame, TTSStoppedFrame):
|
if isinstance(frame, TTSStoppedFrame):
|
||||||
await self.add_word_timestamps([("Reset", 0)])
|
await self.add_word_timestamps([("Reset", 0)], self._context_id)
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
await super()._connect()
|
await super()._connect()
|
||||||
@@ -557,7 +551,6 @@ class ElevenLabsTTSService(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._started = False
|
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
@@ -587,7 +580,6 @@ class ElevenLabsTTSService(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._context_id = None
|
||||||
self._started = False
|
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
self._partial_word_start_time = 0.0
|
self._partial_word_start_time = 0.0
|
||||||
|
|
||||||
@@ -624,7 +616,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
await self.start_word_timestamps()
|
await self.start_word_timestamps()
|
||||||
|
|
||||||
audio = base64.b64decode(msg["audio"])
|
audio = base64.b64decode(msg["audio"])
|
||||||
frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id)
|
||||||
await self.append_to_audio_context(received_ctx_id, frame)
|
await self.append_to_audio_context(received_ctx_id, frame)
|
||||||
|
|
||||||
if msg.get("alignment"):
|
if msg.get("alignment"):
|
||||||
@@ -639,7 +631,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if word_times:
|
if word_times:
|
||||||
await self.add_word_timestamps(word_times)
|
await self.add_word_timestamps(word_times, received_ctx_id)
|
||||||
|
|
||||||
# Calculate the actual end time of this audio chunk
|
# Calculate the actual end time of this audio chunk
|
||||||
char_start_times_ms = alignment.get("charStartTimesMs", [])
|
char_start_times_ms = alignment.get("charStartTimesMs", [])
|
||||||
@@ -689,11 +681,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -705,41 +698,37 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
self._cumulative_time = 0
|
||||||
self._started = True
|
self._partial_word = ""
|
||||||
self._cumulative_time = 0
|
self._partial_word_start_time = 0.0
|
||||||
self._partial_word = ""
|
# If a context ID does not exist, use the provided one.
|
||||||
self._partial_word_start_time = 0.0
|
# If an ID exists, that means the Pipeline doesn't allow
|
||||||
# If a context ID does not exist, create a new one and
|
# user interruptions, so continue using the current ID.
|
||||||
# register it. If an ID exists, that means the Pipeline
|
# When interruptions are allowed, user speech results in
|
||||||
# doesn't allow user interruptions, so continue using the
|
# an interruption, which resets the context ID.
|
||||||
# current ID. When interruptions are allowed, user speech
|
if not self._context_id:
|
||||||
# results in an interruption, which resets the context ID.
|
self._context_id = context_id
|
||||||
if not self._context_id:
|
if not self.audio_context_available(self._context_id):
|
||||||
self._context_id = str(uuid.uuid4())
|
await self.create_audio_context(self._context_id)
|
||||||
if not self.audio_context_available(self._context_id):
|
|
||||||
await self.create_audio_context(self._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": self._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:
|
||||||
msg["pronunciation_dictionary_locators"] = [
|
msg["pronunciation_dictionary_locators"] = [
|
||||||
locator.model_dump()
|
locator.model_dump() 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 {self._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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
self._started = False
|
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -840,7 +829,6 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
|
|
||||||
# Track cumulative time to properly sequence word timestamps across utterances
|
# Track cumulative time to properly sequence word timestamps across utterances
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
self._started = False
|
|
||||||
|
|
||||||
# Store previous text for context within a turn
|
# Store previous text for context within a turn
|
||||||
self._previous_text = ""
|
self._previous_text = ""
|
||||||
@@ -879,7 +867,6 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
def _reset_state(self):
|
def _reset_state(self):
|
||||||
"""Reset internal state variables."""
|
"""Reset internal state variables."""
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
self._started = False
|
|
||||||
self._previous_text = ""
|
self._previous_text = ""
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
self._partial_word_start_time = 0.0
|
self._partial_word_start_time = 0.0
|
||||||
@@ -976,7 +963,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
return word_times
|
return word_times
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using ElevenLabs streaming API with timestamps.
|
"""Generate speech from text using ElevenLabs streaming API with timestamps.
|
||||||
|
|
||||||
Makes a request to the ElevenLabs API to generate audio and timing data.
|
Makes a request to the ElevenLabs API to generate audio and timing data.
|
||||||
@@ -985,6 +972,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to convert to speech.
|
text: Text to convert to speech.
|
||||||
|
context_id: The context ID for tracking audio frames.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio and control frames containing the synthesized speech.
|
Frame: Audio and control frames containing the synthesized speech.
|
||||||
@@ -1048,11 +1036,9 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
# Start TTS sequence if not already started
|
# Start TTS sequence
|
||||||
if not self._started:
|
await self.start_word_timestamps()
|
||||||
await self.start_word_timestamps()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
|
||||||
self._started = True
|
|
||||||
|
|
||||||
# Track the duration of this utterance based on the last character's end time
|
# Track the duration of this utterance based on the last character's end time
|
||||||
utterance_duration = 0
|
utterance_duration = 0
|
||||||
@@ -1069,7 +1055,9 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
if data and "audio_base64" in data:
|
if data and "audio_base64" in data:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
audio = base64.b64decode(data["audio_base64"])
|
audio = base64.b64decode(data["audio_base64"])
|
||||||
yield TTSAudioRawFrame(audio, self.sample_rate, 1)
|
yield TTSAudioRawFrame(
|
||||||
|
audio, self.sample_rate, 1, context_id=context_id
|
||||||
|
)
|
||||||
|
|
||||||
# Process alignment if present
|
# Process alignment if present
|
||||||
if data and "alignment" in data:
|
if data and "alignment" in data:
|
||||||
@@ -1085,7 +1073,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
# Calculate word timestamps
|
# Calculate word timestamps
|
||||||
word_times = self.calculate_word_times(alignment)
|
word_times = self.calculate_word_times(alignment)
|
||||||
if word_times:
|
if word_times:
|
||||||
await self.add_word_timestamps(word_times)
|
await self.add_word_timestamps(word_times, context_id)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.warning(f"Failed to parse JSON from stream: {e}")
|
logger.warning(f"Failed to parse JSON from stream: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -1097,7 +1085,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
|
|||||||
# since this is the end of the utterance
|
# since this is the end of the utterance
|
||||||
if self._partial_word:
|
if self._partial_word:
|
||||||
final_word_time = [(self._partial_word, self._partial_word_start_time)]
|
final_word_time = [(self._partial_word, self._partial_word_start_time)]
|
||||||
await self.add_word_timestamps(final_word_time)
|
await self.add_word_timestamps(final_word_time, context_id)
|
||||||
self._partial_word = ""
|
self._partial_word = ""
|
||||||
self._partial_word_start_time = 0.0
|
self._partial_word_start_time = 0.0
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._request_id = None
|
self._request_id = None
|
||||||
self._started = False
|
|
||||||
|
|
||||||
self._settings = {
|
self._settings = {
|
||||||
"sample_rate": 0,
|
"sample_rate": 0,
|
||||||
@@ -249,7 +248,6 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
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._request_id = None
|
self._request_id = None
|
||||||
self._started = False
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -291,11 +289,12 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
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)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Fish Audio's streaming API.
|
"""Generate speech from text using Fish Audio's streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames and control frames for the synthesized speech.
|
Frame: Audio frames and control frames for the synthesized speech.
|
||||||
@@ -308,7 +307,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
if not self._request_id:
|
if not self._request_id:
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._request_id = str(uuid.uuid4())
|
self._request_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Send the text
|
# Send the text
|
||||||
@@ -325,7 +324,7 @@ class FishAudioTTSService(InterruptibleTTSService):
|
|||||||
await self._get_websocket().send(ormsgpack.packb(flush_message))
|
await self._get_websocket().send(ormsgpack.packb(flush_message))
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
|
|||||||
@@ -682,11 +682,12 @@ class GoogleHttpTTSService(TTSService):
|
|||||||
return ssml
|
return ssml
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Google's HTTP TTS API.
|
"""Generate speech from text using Google's HTTP TTS API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -731,7 +732,7 @@ class GoogleHttpTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
# Skip the first 44 bytes to remove the WAV header
|
# Skip the first 44 bytes to remove the WAV header
|
||||||
audio_content = response.audio_content[44:]
|
audio_content = response.audio_content[44:]
|
||||||
@@ -743,10 +744,10 @@ class GoogleHttpTTSService(TTSService):
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1, context_id=context_id)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"TTS generation error: {str(e)}"
|
error_message = f"TTS generation error: {str(e)}"
|
||||||
@@ -828,6 +829,7 @@ class GoogleBaseTTSService(TTSService):
|
|||||||
self,
|
self,
|
||||||
streaming_config: texttospeech_v1.StreamingSynthesizeConfig,
|
streaming_config: texttospeech_v1.StreamingSynthesizeConfig,
|
||||||
text: str,
|
text: str,
|
||||||
|
context_id: str,
|
||||||
prompt: Optional[str] = None,
|
prompt: Optional[str] = None,
|
||||||
) -> AsyncGenerator[Frame, None]:
|
) -> AsyncGenerator[Frame, None]:
|
||||||
"""Shared streaming synthesis logic.
|
"""Shared streaming synthesis logic.
|
||||||
@@ -835,6 +837,7 @@ class GoogleBaseTTSService(TTSService):
|
|||||||
Args:
|
Args:
|
||||||
streaming_config: The streaming configuration.
|
streaming_config: The streaming configuration.
|
||||||
text: The text to synthesize.
|
text: The text to synthesize.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
prompt: Optional prompt for style instructions (Gemini only).
|
prompt: Optional prompt for style instructions (Gemini only).
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
@@ -856,7 +859,7 @@ class GoogleBaseTTSService(TTSService):
|
|||||||
streaming_responses = await self._client.streaming_synthesize(request_generator())
|
streaming_responses = await self._client.streaming_synthesize(request_generator())
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
audio_buffer = b""
|
audio_buffer = b""
|
||||||
first_chunk_for_ttfb = False
|
first_chunk_for_ttfb = False
|
||||||
@@ -876,12 +879,12 @@ class GoogleBaseTTSService(TTSService):
|
|||||||
while len(audio_buffer) >= CHUNK_SIZE:
|
while len(audio_buffer) >= CHUNK_SIZE:
|
||||||
piece = audio_buffer[:CHUNK_SIZE]
|
piece = audio_buffer[:CHUNK_SIZE]
|
||||||
audio_buffer = audio_buffer[CHUNK_SIZE:]
|
audio_buffer = audio_buffer[CHUNK_SIZE:]
|
||||||
yield TTSAudioRawFrame(piece, self.sample_rate, 1)
|
yield TTSAudioRawFrame(piece, self.sample_rate, 1, context_id=context_id)
|
||||||
|
|
||||||
if audio_buffer:
|
if audio_buffer:
|
||||||
yield TTSAudioRawFrame(audio_buffer, self.sample_rate, 1)
|
yield TTSAudioRawFrame(audio_buffer, self.sample_rate, 1, context_id=context_id)
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
|
|
||||||
class GoogleTTSService(GoogleBaseTTSService):
|
class GoogleTTSService(GoogleBaseTTSService):
|
||||||
@@ -976,11 +979,12 @@ class GoogleTTSService(GoogleBaseTTSService):
|
|||||||
await super()._update_settings(settings)
|
await super()._update_settings(settings)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate streaming speech from text using Google's streaming API.
|
"""Generate streaming speech from text using Google's streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech as it's generated.
|
Frame: Audio frames containing the synthesized speech as it's generated.
|
||||||
@@ -1014,7 +1018,7 @@ class GoogleTTSService(GoogleBaseTTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use base class streaming logic
|
# Use base class streaming logic
|
||||||
async for frame in self._stream_tts(streaming_config, text):
|
async for frame in self._stream_tts(streaming_config, text, context_id):
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1213,11 +1217,12 @@ class GeminiTTSService(GoogleBaseTTSService):
|
|||||||
await super()._update_settings(settings)
|
await super()._update_settings(settings)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate streaming speech from text using Gemini TTS models.
|
"""Generate streaming speech from text using Gemini TTS models.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize into speech. Can include markup tags
|
text: The text to synthesize into speech.
|
||||||
|
context_id: The context ID for tracking audio frames. Can include markup tags
|
||||||
like [sigh], [laughing], [whispering] for expressive control.
|
like [sigh], [laughing], [whispering] for expressive control.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
@@ -1267,7 +1272,9 @@ class GeminiTTSService(GoogleBaseTTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use base class streaming logic with prompt support
|
# Use base class streaming logic with prompt support
|
||||||
async for frame in self._stream_tts(streaming_config, text, self._settings["prompt"]):
|
async for frame in self._stream_tts(
|
||||||
|
streaming_config, text, context_id, self._settings["prompt"]
|
||||||
|
):
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
|
|
||||||
# State tracking
|
# State tracking
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
|
self._current_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.
|
||||||
@@ -261,11 +262,15 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
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,
|
||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
|
|
||||||
elif msg["type"] == "text":
|
elif msg["type"] == "text":
|
||||||
await self.add_word_timestamps([(msg["text"], msg["start_s"])])
|
if self._current_context_id:
|
||||||
|
await self.add_word_timestamps(
|
||||||
|
[(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())
|
await self.push_frame(TTSStoppedFrame())
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
@@ -285,11 +290,12 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: 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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to convert to speech.
|
text: The text to convert to speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -302,14 +308,15 @@ class GradiumTTSService(InterruptibleWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield TTSStartedFrame()
|
self._current_context_id = context_id
|
||||||
|
yield TTSStartedFrame(context_id=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))
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -112,11 +112,12 @@ class GroqTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Groq's TTS API.
|
"""Generate speech from text using Groq's TTS API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech data.
|
Frame: Audio frames containing the synthesized speech data.
|
||||||
@@ -124,7 +125,7 @@ class GroqTTSService(TTSService):
|
|||||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
measuring_ttfb = True
|
measuring_ttfb = True
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self._client.audio.speech.create(
|
response = await self._client.audio.speech.create(
|
||||||
@@ -144,8 +145,8 @@ class GroqTTSService(TTSService):
|
|||||||
frame_rate = w.getframerate()
|
frame_rate = w.getframerate()
|
||||||
num_frames = w.getnframes()
|
num_frames = w.getnframes()
|
||||||
bytes = w.readframes(num_frames)
|
bytes = w.readframes(num_frames)
|
||||||
yield TTSAudioRawFrame(bytes, frame_rate, channels)
|
yield TTSAudioRawFrame(bytes, frame_rate, channels, context_id=context_id)
|
||||||
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(context_id=context_id)
|
||||||
|
|||||||
@@ -115,11 +115,12 @@ class HathoraTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Run text-to-speech synthesis on the provided text.
|
"""Run text-to-speech synthesis on the provided text.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -142,7 +143,7 @@ class HathoraTTSService(TTSService):
|
|||||||
for option in self._settings["config"]
|
for option in self._settings["config"]
|
||||||
]
|
]
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
@@ -161,6 +162,7 @@ class HathoraTTSService(TTSService):
|
|||||||
audio=pcm_audio,
|
audio=pcm_audio,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=num_channels,
|
num_channels=num_channels,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield frame
|
yield frame
|
||||||
@@ -170,4 +172,4 @@ class HathoraTTSService(TTSService):
|
|||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
await self.stop_processing_metrics()
|
await self.stop_processing_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -209,11 +209,12 @@ class HumeTTSService(WordTTSService):
|
|||||||
await super().update_setting(key, value)
|
await super().update_setting(key, value)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Hume TTS with word timestamps.
|
"""Generate speech from text using Hume TTS with word timestamps.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to be synthesized.
|
text: The text to be synthesized.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An async generator that yields `Frame` objects, including
|
An async generator that yields `Frame` objects, including
|
||||||
@@ -245,7 +246,7 @@ class HumeTTSService(WordTTSService):
|
|||||||
# Start TTS sequence if not already started
|
# Start TTS sequence if not already started
|
||||||
if not self._started:
|
if not self._started:
|
||||||
await self.start_word_timestamps()
|
await self.start_word_timestamps()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._started = True
|
self._started = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -281,6 +282,7 @@ class HumeTTSService(WordTTSService):
|
|||||||
audio=self._audio_bytes,
|
audio=self._audio_bytes,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
yield frame
|
yield frame
|
||||||
self._audio_bytes = b""
|
self._audio_bytes = b""
|
||||||
@@ -297,7 +299,9 @@ class HumeTTSService(WordTTSService):
|
|||||||
utterance_duration = max(utterance_duration, word_end_time)
|
utterance_duration = max(utterance_duration, word_end_time)
|
||||||
|
|
||||||
# Add word timestamp
|
# Add word timestamp
|
||||||
await self.add_word_timestamps([(timestamp.text, word_start_time)])
|
await self.add_word_timestamps(
|
||||||
|
[(timestamp.text, word_start_time)], context_id
|
||||||
|
)
|
||||||
|
|
||||||
# Flush any remaining audio bytes
|
# Flush any remaining audio bytes
|
||||||
if self._audio_bytes:
|
if self._audio_bytes:
|
||||||
@@ -305,6 +309,7 @@ class HumeTTSService(WordTTSService):
|
|||||||
audio=self._audio_bytes,
|
audio=self._audio_bytes,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield frame
|
yield frame
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ Inworld’s text-to-speech (TTS) models offer ultra-realistic, context-aware spe
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import uuid
|
|
||||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -125,7 +124,6 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
if params.speaking_rate is not None:
|
if params.speaking_rate is not None:
|
||||||
self._settings["audioConfig"]["speakingRate"] = params.speaking_rate
|
self._settings["audioConfig"]["speakingRate"] = params.speaking_rate
|
||||||
|
|
||||||
self._started = False
|
|
||||||
self._cumulative_time = 0.0
|
self._cumulative_time = 0.0
|
||||||
|
|
||||||
self.set_voice(voice_id)
|
self.set_voice(voice_id)
|
||||||
@@ -173,7 +171,6 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (InterruptionFrame, TTSStoppedFrame)):
|
if isinstance(frame, (InterruptionFrame, TTSStoppedFrame)):
|
||||||
self._started = False
|
|
||||||
self._cumulative_time = 0.0
|
self._cumulative_time = 0.0
|
||||||
if isinstance(frame, TTSStoppedFrame):
|
if isinstance(frame, TTSStoppedFrame):
|
||||||
await self.add_word_timestamps([("Reset", 0)])
|
await self.add_word_timestamps([("Reset", 0)])
|
||||||
@@ -214,11 +211,12 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
return (word_times, chunk_end_time)
|
return (word_times, chunk_end_time)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate TTS audio for the given text.
|
"""Generate TTS audio for the given text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to generate TTS audio for.
|
text: The text to generate TTS audio for.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An asynchronous generator of frames.
|
An asynchronous generator of frames.
|
||||||
@@ -246,10 +244,8 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
try:
|
try:
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
|
|
||||||
if not self._started:
|
await self.start_word_timestamps()
|
||||||
await self.start_word_timestamps()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
|
||||||
self._started = True
|
|
||||||
|
|
||||||
async with self._session.post(
|
async with self._session.post(
|
||||||
self._base_url, json=payload, headers=headers
|
self._base_url, json=payload, headers=headers
|
||||||
@@ -261,10 +257,10 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self._streaming:
|
if self._streaming:
|
||||||
async for frame in self._process_streaming_response(response):
|
async for frame in self._process_streaming_response(response, context_id):
|
||||||
yield frame
|
yield frame
|
||||||
else:
|
else:
|
||||||
async for frame in self._process_non_streaming_response(response):
|
async for frame in self._process_non_streaming_response(response, context_id):
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
@@ -276,12 +272,13 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
|
|
||||||
async def _process_streaming_response(
|
async def _process_streaming_response(
|
||||||
self, response: aiohttp.ClientResponse
|
self, response: aiohttp.ClientResponse, context_id: str
|
||||||
) -> AsyncGenerator[Frame, None]:
|
) -> AsyncGenerator[Frame, None]:
|
||||||
"""Process a streaming response from the Inworld API.
|
"""Process a streaming response from the Inworld API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: The response from the Inworld API.
|
response: The response from the Inworld API.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
An asynchronous generator of frames.
|
An asynchronous generator of frames.
|
||||||
@@ -306,7 +303,7 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
if "result" in chunk_data and "audioContent" in chunk_data["result"]:
|
if "result" in chunk_data and "audioContent" in chunk_data["result"]:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
async for frame in self._process_audio_chunk(
|
async for frame in self._process_audio_chunk(
|
||||||
base64.b64decode(chunk_data["result"]["audioContent"])
|
base64.b64decode(chunk_data["result"]["audioContent"]), context_id
|
||||||
):
|
):
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
@@ -314,7 +311,7 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
timestamp_info = chunk_data["result"]["timestampInfo"]
|
timestamp_info = chunk_data["result"]["timestampInfo"]
|
||||||
word_times, chunk_end_time = self._calculate_word_times(timestamp_info)
|
word_times, chunk_end_time = self._calculate_word_times(timestamp_info)
|
||||||
if word_times:
|
if word_times:
|
||||||
await self.add_word_timestamps(word_times)
|
await self.add_word_timestamps(word_times, context_id)
|
||||||
# Track the maximum end time across all chunks
|
# Track the maximum end time across all chunks
|
||||||
utterance_duration = max(utterance_duration, chunk_end_time)
|
utterance_duration = max(utterance_duration, chunk_end_time)
|
||||||
|
|
||||||
@@ -327,12 +324,13 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
self._cumulative_time += utterance_duration
|
self._cumulative_time += utterance_duration
|
||||||
|
|
||||||
async def _process_non_streaming_response(
|
async def _process_non_streaming_response(
|
||||||
self, response: aiohttp.ClientResponse
|
self, response: aiohttp.ClientResponse, context_id: str
|
||||||
) -> AsyncGenerator[Frame, None]:
|
) -> AsyncGenerator[Frame, None]:
|
||||||
"""Process a non-streaming response from the Inworld API.
|
"""Process a non-streaming response from the Inworld API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: The response from the Inworld API.
|
response: The response from the Inworld API.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An asynchronous generator of frames.
|
An asynchronous generator of frames.
|
||||||
@@ -349,7 +347,7 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
timestamp_info = response_data["timestampInfo"]
|
timestamp_info = response_data["timestampInfo"]
|
||||||
word_times, chunk_end_time = self._calculate_word_times(timestamp_info)
|
word_times, chunk_end_time = self._calculate_word_times(timestamp_info)
|
||||||
if word_times:
|
if word_times:
|
||||||
await self.add_word_timestamps(word_times)
|
await self.add_word_timestamps(word_times, context_id)
|
||||||
utterance_duration = chunk_end_time
|
utterance_duration = chunk_end_time
|
||||||
|
|
||||||
audio_data = base64.b64decode(response_data["audioContent"])
|
audio_data = base64.b64decode(response_data["audioContent"])
|
||||||
@@ -363,20 +361,21 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
if chunk:
|
if chunk:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSAudioRawFrame(
|
yield TTSAudioRawFrame(
|
||||||
audio=chunk,
|
audio=chunk, sample_rate=self.sample_rate, num_channels=1, context_id=context_id
|
||||||
sample_rate=self.sample_rate,
|
|
||||||
num_channels=1,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# After processing all audio, add the utterance duration to cumulative time
|
# After processing all audio, add the utterance duration to cumulative time
|
||||||
if utterance_duration > 0:
|
if utterance_duration > 0:
|
||||||
self._cumulative_time += utterance_duration
|
self._cumulative_time += utterance_duration
|
||||||
|
|
||||||
async def _process_audio_chunk(self, audio_chunk: bytes) -> AsyncGenerator[Frame, None]:
|
async def _process_audio_chunk(
|
||||||
|
self, audio_chunk: bytes, context_id: str
|
||||||
|
) -> AsyncGenerator[Frame, None]:
|
||||||
"""Process an audio chunk from the Inworld API.
|
"""Process an audio chunk from the Inworld API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_chunk: The audio chunk to process.
|
audio_chunk: The audio chunk to process.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An asynchronous generator of frames.
|
An asynchronous generator of frames.
|
||||||
@@ -394,6 +393,7 @@ class InworldHttpTTSService(WordTTSService):
|
|||||||
audio=audio_data,
|
audio=audio_data,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -500,7 +500,6 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_task = None
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._started = False
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -569,7 +568,6 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
||||||
self._started = False
|
|
||||||
logger.trace(
|
logger.trace(
|
||||||
f"{self}: Resetting timestamp tracking due to {type(frame).__name__} - "
|
f"{self}: Resetting timestamp tracking due to {type(frame).__name__} - "
|
||||||
f"cumulative_time was {self._cumulative_time}"
|
f"cumulative_time was {self._cumulative_time}"
|
||||||
@@ -637,7 +635,6 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
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._context_id = None
|
||||||
self._started = False
|
|
||||||
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")
|
||||||
@@ -726,7 +723,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)
|
||||||
finally:
|
finally:
|
||||||
self._started = False
|
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._cumulative_time = 0.0
|
self._cumulative_time = 0.0
|
||||||
@@ -801,7 +797,7 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
audio = base64.b64decode(audio_b64)
|
audio = base64.b64decode(audio_b64)
|
||||||
if len(audio) > 44 and audio.startswith(b"RIFF"):
|
if len(audio) > 44 and audio.startswith(b"RIFF"):
|
||||||
audio = audio[44:]
|
audio = audio[44:]
|
||||||
frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=ctx_id)
|
||||||
|
|
||||||
if ctx_id:
|
if ctx_id:
|
||||||
await self.append_to_audio_context(ctx_id, frame)
|
await self.append_to_audio_context(ctx_id, frame)
|
||||||
@@ -811,7 +807,7 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
if timestamp_info:
|
if timestamp_info:
|
||||||
word_times = self._calculate_word_times(timestamp_info)
|
word_times = self._calculate_word_times(timestamp_info)
|
||||||
if word_times:
|
if word_times:
|
||||||
await self.add_word_timestamps(word_times)
|
await self.add_word_timestamps(word_times, ctx_id)
|
||||||
|
|
||||||
# Handle context created confirmation
|
# Handle context created confirmation
|
||||||
if "contextCreated" in result:
|
if "contextCreated" in result:
|
||||||
@@ -832,10 +828,9 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
# 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._context_id:
|
||||||
self._context_id = None
|
self._context_id = None
|
||||||
self._started = False
|
|
||||||
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)])
|
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
|
||||||
|
|
||||||
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."""
|
||||||
@@ -917,11 +912,12 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
await self.send_with_retry(json.dumps(msg), self._report_error)
|
await self.send_with_retry(json.dumps(msg), self._report_error)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: 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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to generate TTS audio for.
|
text: The text to generate TTS audio for.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An asynchronous generator of frames.
|
An asynchronous generator of frames.
|
||||||
@@ -933,28 +929,25 @@ class InworldTTSService(AudioContextWordTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
|
||||||
self._started = True
|
|
||||||
|
|
||||||
if not self._context_id:
|
if not self._context_id:
|
||||||
self._context_id = str(uuid.uuid4())
|
self._context_id = context_id
|
||||||
logger.trace(f"{self}: Creating new context {self._context_id}")
|
logger.trace(f"{self}: Creating new context {self._context_id}")
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(self._context_id)
|
||||||
await self._send_context(self._context_id)
|
await self._send_context(self._context_id)
|
||||||
elif not self.audio_context_available(self._context_id):
|
elif not self.audio_context_available(self._context_id):
|
||||||
# Context exists on server but local tracking was removed
|
# Context exists on server but local tracking was removed
|
||||||
logger.trace(f"{self}: Recreating local audio context {self._context_id}")
|
logger.trace(f"{self}: Recreating local audio context {self._context_id}")
|
||||||
await self.create_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(self._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:
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
self._started = False
|
|
||||||
return
|
return
|
||||||
yield None
|
yield None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -143,13 +143,14 @@ class KokoroTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Synthesize speech from text using kokoro-onnx.
|
"""Synthesize speech from text using kokoro-onnx.
|
||||||
|
|
||||||
Uses the async streaming API to generate audio frames.
|
Uses the async streaming API to generate audio frames.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize.
|
text: The text to synthesize.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
@@ -157,7 +158,7 @@ class KokoroTTSService(TTSService):
|
|||||||
try:
|
try:
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
stream = self._kokoro.create_stream(
|
stream = self._kokoro.create_stream(
|
||||||
text, voice=self._voice_id, lang=self._lang_code, speed=1.0
|
text, voice=self._voice_id, lang=self._lang_code, speed=1.0
|
||||||
@@ -172,10 +173,13 @@ class KokoroTTSService(TTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
yield TTSAudioRawFrame(
|
yield TTSAudioRawFrame(
|
||||||
audio=audio_data, sample_rate=self.sample_rate, num_channels=1
|
audio=audio_data,
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
"language": self.language_to_service_language(language),
|
"language": self.language_to_service_language(language),
|
||||||
"format": "raw", # Use raw format for direct PCM data
|
"format": "raw", # Use raw format for direct PCM data
|
||||||
}
|
}
|
||||||
self._started = False
|
|
||||||
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.
|
||||||
@@ -170,8 +170,6 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
direction: The direction to push the frame.
|
direction: The direction to push the frame.
|
||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
|
||||||
self._started = False
|
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
"""Connect to LMNT WebSocket and start receive task."""
|
"""Connect to LMNT WebSocket and start receive task."""
|
||||||
@@ -236,7 +234,7 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.push_error(error_msg=f"Error disconnecting from LMNT: {e}", exception=e)
|
await self.push_error(error_msg=f"Error disconnecting from LMNT: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
self._started = False
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -262,6 +260,7 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
audio=message,
|
audio=message,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=self._context_id,
|
||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
else:
|
else:
|
||||||
@@ -276,11 +275,12 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
logger.error(f"Invalid JSON message: {message}")
|
logger.error(f"Invalid JSON message: {message}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate TTS audio from text using LMNT's streaming API.
|
"""Generate TTS audio from text using LMNT's streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -292,10 +292,10 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
# Store context_id for use in _receive_messages
|
||||||
yield TTSStartedFrame()
|
self._context_id = context_id
|
||||||
self._started = True
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
# Send text to LMNT
|
# Send text to LMNT
|
||||||
await self._get_websocket().send(json.dumps({"text": text}))
|
await self._get_websocket().send(json.dumps({"text": text}))
|
||||||
@@ -304,7 +304,7 @@ class LmntTTSService(InterruptibleTTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -284,11 +284,12 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
logger.debug(f"MiniMax TTS initialized with sample_rate: {self.sample_rate}")
|
logger.debug(f"MiniMax TTS initialized with sample_rate: {self.sample_rate}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate TTS audio from text using MiniMax's streaming API.
|
"""Generate TTS audio from text using MiniMax's streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -318,7 +319,7 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
# Process the streaming response
|
# Process the streaming response
|
||||||
buffer = bytearray()
|
buffer = bytearray()
|
||||||
@@ -377,6 +378,7 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
audio=audio_chunk,
|
audio=audio_chunk,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -394,4 +396,4 @@ class MiniMaxHttpTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}", exception=e)
|
yield ErrorFrame(error=f"Unknown error occurred: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -135,13 +135,11 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
}
|
}
|
||||||
self.set_voice(voice_id)
|
self.set_voice(voice_id)
|
||||||
|
|
||||||
# Indicates if we have sent TTSStartedFrame. It will reset to False when
|
|
||||||
# there's an interruption or TTSStoppedFrame.
|
|
||||||
self._started = False
|
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_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.
|
||||||
@@ -213,8 +211,6 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
direction: The direction to push the frame.
|
direction: The direction to push the frame.
|
||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
|
||||||
self._started = False
|
|
||||||
|
|
||||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
"""Process frames with special handling for speech control.
|
"""Process frames with special handling for speech control.
|
||||||
@@ -230,7 +226,7 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
# processing more frames until we receive a BotStoppedSpeakingFrame.
|
# processing more frames until we receive a BotStoppedSpeakingFrame.
|
||||||
if isinstance(frame, TTSSpeakFrame):
|
if isinstance(frame, TTSSpeakFrame):
|
||||||
await self.pause_processing_frames()
|
await self.pause_processing_frames()
|
||||||
elif isinstance(frame, LLMFullResponseEndFrame) and self._started:
|
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||||
await self.pause_processing_frames()
|
await self.pause_processing_frames()
|
||||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||||
await self.resume_processing_frames()
|
await self.resume_processing_frames()
|
||||||
@@ -304,7 +300,7 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
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._started = False
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -317,7 +313,9 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
audio = base64.b64decode(msg["data"]["audio"])
|
audio = base64.b64decode(msg["data"]["audio"])
|
||||||
frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(
|
||||||
|
audio, self.sample_rate, 1, context_id=self._context_id
|
||||||
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
|
|
||||||
async def _keepalive_task_handler(self):
|
async def _keepalive_task_handler(self):
|
||||||
@@ -342,11 +340,12 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
await self._websocket.send(json.dumps(msg))
|
await self._websocket.send(json.dumps(msg))
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Neuphonic's streaming API.
|
"""Generate speech from text using Neuphonic's streaming API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize into speech.
|
text: The text to synthesize into speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -358,17 +357,17 @@ class NeuphonicTTSService(InterruptibleTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
# Store context_id for use in _receive_messages
|
||||||
yield TTSStartedFrame()
|
self._context_id = context_id
|
||||||
self._started = True
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
|
|
||||||
await self._send_text(text)
|
await self._send_text(text)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
@@ -502,11 +501,12 @@ class NeuphonicHttpTTSService(TTSService):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Neuphonic streaming API.
|
"""Generate speech from text using Neuphonic streaming API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to convert to speech.
|
text: The text to convert to speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech and status information.
|
Frame: Audio frames containing the synthesized speech and status information.
|
||||||
@@ -542,7 +542,7 @@ class NeuphonicHttpTTSService(TTSService):
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
# Process SSE stream line by line
|
# Process SSE stream line by line
|
||||||
async for line in response.content:
|
async for line in response.content:
|
||||||
@@ -564,7 +564,9 @@ class NeuphonicHttpTTSService(TTSService):
|
|||||||
audio_bytes = base64.b64decode(audio_b64)
|
audio_bytes = base64.b64decode(audio_b64)
|
||||||
|
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSAudioRawFrame(audio_bytes, self.sample_rate, 1)
|
yield TTSAudioRawFrame(
|
||||||
|
audio_bytes, self.sample_rate, 1, context_id=context_id
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
@@ -578,4 +580,4 @@ class NeuphonicHttpTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -153,11 +153,12 @@ class NvidiaTTSService(TTSService):
|
|||||||
logger.debug(f"Initialized NvidiaTTSService with model: {self.model_name}")
|
logger.debug(f"Initialized NvidiaTTSService with model: {self.model_name}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using NVIDIA Riva TTS.
|
"""Generate speech from text using NVIDIA Riva TTS.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech data.
|
Frame: Audio frames containing the synthesized speech data.
|
||||||
@@ -193,7 +194,7 @@ class NvidiaTTSService(TTSService):
|
|||||||
assert self._config is not None, "Synthesis configuration not created"
|
assert self._config is not None, "Synthesis configuration not created"
|
||||||
|
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
|
|
||||||
@@ -205,12 +206,13 @@ class NvidiaTTSService(TTSService):
|
|||||||
audio=resp.audio,
|
audio=resp.audio,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError as e:
|
||||||
logger.error(f"{self} timeout waiting for audio response")
|
logger.error(f"{self} timeout waiting for audio response")
|
||||||
yield ErrorFrame(error=f"{self} error: {e}")
|
yield ErrorFrame(error=f"{self} error: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -168,11 +168,12 @@ class OpenAITTSService(TTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using OpenAI's TTS API.
|
"""Generate speech from text using OpenAI's TTS API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech data.
|
Frame: Audio frames containing the synthesized speech data.
|
||||||
@@ -212,12 +213,12 @@ class OpenAITTSService(TTSService):
|
|||||||
|
|
||||||
CHUNK_SIZE = self.chunk_size
|
CHUNK_SIZE = self.chunk_size
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
async for chunk in r.iter_bytes(CHUNK_SIZE):
|
async for chunk in r.iter_bytes(CHUNK_SIZE):
|
||||||
if len(chunk) > 0:
|
if len(chunk) > 0:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1, context_id=context_id)
|
||||||
yield frame
|
yield frame
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
except BadRequestError as e:
|
except BadRequestError as e:
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
|
|||||||
@@ -86,11 +86,12 @@ class PiperTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Piper.
|
"""Generate speech from text using Piper.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to convert to speech.
|
text: The text to convert to speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech and status frames.
|
Frame: Audio frames containing the synthesized speech and status frames.
|
||||||
@@ -116,11 +117,12 @@ class PiperTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
async for frame in self._stream_audio_frames_from_iterator(
|
async for frame in self._stream_audio_frames_from_iterator(
|
||||||
async_iterator(self._voice.synthesize(text)),
|
async_iterator(self._voice.synthesize(text)),
|
||||||
in_sample_rate=self._voice.config.sample_rate,
|
in_sample_rate=self._voice.config.sample_rate,
|
||||||
|
context_id=context_id,
|
||||||
):
|
):
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield frame
|
yield frame
|
||||||
@@ -130,7 +132,7 @@ class PiperTTSService(TTSService):
|
|||||||
finally:
|
finally:
|
||||||
logger.debug(f"{self}: Finished TTS [{text}]")
|
logger.debug(f"{self}: Finished TTS [{text}]")
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
|
|
||||||
# This assumes a running TTS service running:
|
# This assumes a running TTS service running:
|
||||||
@@ -184,11 +186,12 @@ class PiperHttpTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Piper's HTTP API.
|
"""Generate speech from text using Piper's HTTP API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to convert to speech.
|
text: The text to convert to speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech and status frames.
|
Frame: Audio frames containing the synthesized speech and status frames.
|
||||||
@@ -215,12 +218,14 @@ class PiperHttpTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
CHUNK_SIZE = self.chunk_size
|
CHUNK_SIZE = self.chunk_size
|
||||||
|
|
||||||
async for frame in self._stream_audio_frames_from_iterator(
|
async for frame in self._stream_audio_frames_from_iterator(
|
||||||
response.content.iter_chunked(CHUNK_SIZE), strip_wav_header=True
|
response.content.iter_chunked(CHUNK_SIZE),
|
||||||
|
strip_wav_header=True,
|
||||||
|
context_id=context_id,
|
||||||
):
|
):
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield frame
|
yield frame
|
||||||
@@ -228,4 +233,4 @@ class PiperHttpTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ supporting both WebSocket streaming and HTTP-based synthesis.
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import struct
|
import struct
|
||||||
import uuid
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
@@ -169,7 +168,7 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
self._user_id = user_id
|
self._user_id = user_id
|
||||||
self._websocket_url = None
|
self._websocket_url = None
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._request_id = None
|
self._context_id = None
|
||||||
|
|
||||||
self._settings = {
|
self._settings = {
|
||||||
"language": self.language_to_service_language(params.language)
|
"language": self.language_to_service_language(params.language)
|
||||||
@@ -285,7 +284,7 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
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._request_id = None
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -328,7 +327,7 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
"""Handle interruption by stopping metrics and clearing request ID."""
|
"""Handle interruption by stopping metrics and clearing request ID."""
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
self._request_id = None
|
self._context_id = None
|
||||||
|
|
||||||
async def _receive_messages(self):
|
async def _receive_messages(self):
|
||||||
"""Receive messages from PlayHT websocket."""
|
"""Receive messages from PlayHT websocket."""
|
||||||
@@ -338,7 +337,7 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
if message.startswith(b"RIFF"):
|
if message.startswith(b"RIFF"):
|
||||||
continue
|
continue
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(message, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(message, self.sample_rate, 1, context_id=self._context_id)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Received text message: {message}")
|
logger.debug(f"Received text message: {message}")
|
||||||
@@ -349,20 +348,21 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
logger.debug(f"Started processing request: {msg.get('request_id')}")
|
logger.debug(f"Started processing request: {msg.get('request_id')}")
|
||||||
elif msg.get("type") == "end":
|
elif msg.get("type") == "end":
|
||||||
# Handle end of stream
|
# Handle end of stream
|
||||||
if "request_id" in msg and msg["request_id"] == self._request_id:
|
if "request_id" in msg and msg["request_id"] == self._context_id:
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame(context_id=self._context_id))
|
||||||
self._request_id = None
|
self._context_id = None
|
||||||
elif "error" in msg:
|
elif "error" in msg:
|
||||||
await self.push_error(error_msg=f"Error: {msg['error']}")
|
await self.push_error(error_msg=f"Error: {msg['error']}")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error(f"Invalid JSON message: {message}")
|
logger.error(f"Invalid JSON message: {message}")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate TTS audio from text using PlayHT's WebSocket API.
|
"""Generate TTS audio from text using PlayHT's WebSocket API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -374,10 +374,10 @@ class PlayHTTTSService(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._request_id:
|
if not self._context_id:
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._request_id = str(uuid.uuid4())
|
self._context_id = context_id
|
||||||
|
|
||||||
tts_command = {
|
tts_command = {
|
||||||
"text": text,
|
"text": text,
|
||||||
@@ -388,7 +388,7 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
"language": self._settings["language"],
|
"language": self._settings["language"],
|
||||||
"speed": self._settings["speed"],
|
"speed": self._settings["speed"],
|
||||||
"seed": self._settings["seed"],
|
"seed": self._settings["seed"],
|
||||||
"request_id": self._request_id,
|
"request_id": self._context_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -396,7 +396,7 @@ class PlayHTTTSService(InterruptibleTTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
@@ -540,11 +540,12 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
return language_to_playht_language(language)
|
return language_to_playht_language(language)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate TTS audio from text using PlayHT's HTTP API.
|
"""Generate TTS audio from text using PlayHT's HTTP API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -579,7 +580,7 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
@@ -616,16 +617,20 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
audio_data = buffer[fh.tell() :]
|
audio_data = buffer[fh.tell() :]
|
||||||
if len(audio_data) > 0:
|
if len(audio_data) > 0:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(audio_data, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(
|
||||||
|
audio_data, self.sample_rate, 1, context_id=context_id
|
||||||
|
)
|
||||||
yield frame
|
yield frame
|
||||||
in_header = False
|
in_header = False
|
||||||
elif len(chunk) > 0:
|
elif len(chunk) > 0:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(
|
||||||
|
chunk, self.sample_rate, 1, context_id=context_id
|
||||||
|
)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
@@ -84,9 +84,11 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._request_id_counter = 0
|
self._request_id_counter = 0
|
||||||
self._current_request_id = None
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
|
|
||||||
|
# Map request_id to context_id for tracking TTS requests
|
||||||
|
self._request_id_to_context: dict[int, str] = {}
|
||||||
|
|
||||||
# Per-request audio buffers to handle concurrent TTS requests
|
# Per-request audio buffers to handle concurrent TTS requests
|
||||||
# ResembleAI may send odd-length data even for PCM_16, so buffering helps us
|
# ResembleAI may send odd-length data even for PCM_16, so buffering helps us
|
||||||
# create properly aligned frames while maintaining smooth audio output
|
# create properly aligned frames while maintaining smooth audio output
|
||||||
@@ -122,7 +124,7 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
"voice_uuid": self._voice_id,
|
"voice_uuid": self._voice_id,
|
||||||
"data": text,
|
"data": text,
|
||||||
"binary_response": False, # Use JSON frames to get timestamps
|
"binary_response": False, # Use JSON frames to get timestamps
|
||||||
"request_id": self._request_id_counter,
|
"request_id": self._request_id_counter, # ResembleAI only accepts number
|
||||||
"output_format": self._settings["output_format"],
|
"output_format": self._settings["output_format"],
|
||||||
"sample_rate": self._settings["sample_rate"],
|
"sample_rate": self._settings["sample_rate"],
|
||||||
"precision": self._settings["precision"],
|
"precision": self._settings["precision"],
|
||||||
@@ -202,10 +204,10 @@ class ResembleAITTSService(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._current_request_id = None
|
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
self._audio_buffers.clear()
|
self._audio_buffers.clear()
|
||||||
self._playback_started.clear()
|
self._playback_started.clear()
|
||||||
|
self._request_id_to_context.clear()
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
def _get_websocket(self):
|
def _get_websocket(self):
|
||||||
@@ -230,18 +232,12 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
"""
|
"""
|
||||||
await super()._handle_interruption(frame, direction)
|
await super()._handle_interruption(frame, direction)
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
# Note: Resemble AI doesn't have an explicit cancel mechanism,
|
|
||||||
# but we can stop processing by resetting our current request_id
|
|
||||||
self._current_request_id = None
|
|
||||||
|
|
||||||
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._current_request_id:
|
|
||||||
return
|
|
||||||
logger.trace(f"{self}: flushing audio")
|
logger.trace(f"{self}: flushing audio")
|
||||||
# For Resemble AI, we just wait for the audio_end message
|
# For Resemble AI, we just wait for the audio_end message
|
||||||
# which is handled in _process_messages
|
# which is handled in _process_messages
|
||||||
self._current_request_id = None
|
|
||||||
|
|
||||||
async def _process_messages(self):
|
async def _process_messages(self):
|
||||||
"""Process incoming WebSocket messages from Resemble AI."""
|
"""Process incoming WebSocket messages from Resemble AI."""
|
||||||
@@ -259,10 +255,10 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
request_id = msg.get("request_id")
|
request_id = msg.get("request_id")
|
||||||
|
|
||||||
# Convert request_id to string for audio context tracking
|
# Convert request_id to string for audio context tracking
|
||||||
request_id_str = str(request_id)
|
context_id = self._request_id_to_context.get(request_id, str(request_id))
|
||||||
|
|
||||||
# Check if this message belongs to a valid audio context
|
# Check if this message belongs to a valid audio context
|
||||||
if not self.audio_context_available(request_id_str):
|
if not self.audio_context_available(context_id):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if msg_type == "audio":
|
if msg_type == "audio":
|
||||||
@@ -279,20 +275,20 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Get or create buffer for this request
|
# Get or create buffer for this request
|
||||||
if request_id_str not in self._audio_buffers:
|
if context_id not in self._audio_buffers:
|
||||||
self._audio_buffers[request_id_str] = bytearray()
|
self._audio_buffers[context_id] = bytearray()
|
||||||
self._playback_started[request_id_str] = False
|
self._playback_started[context_id] = False
|
||||||
buffer = self._audio_buffers[request_id_str]
|
buffer = self._audio_buffers[context_id]
|
||||||
|
|
||||||
# Add to buffer
|
# Add to buffer
|
||||||
buffer.extend(audio_bytes)
|
buffer.extend(audio_bytes)
|
||||||
|
|
||||||
# Wait for jitter buffer to fill before starting playback
|
# Wait for jitter buffer to fill before starting playback
|
||||||
# This absorbs network latency gaps (ResembleAI sends in bursts)
|
# This absorbs network latency gaps (ResembleAI sends in bursts)
|
||||||
if not self._playback_started.get(request_id_str, False):
|
if not self._playback_started.get(context_id, False):
|
||||||
if len(buffer) < self._jitter_buffer_bytes:
|
if len(buffer) < self._jitter_buffer_bytes:
|
||||||
continue
|
continue
|
||||||
self._playback_started[request_id_str] = True
|
self._playback_started[context_id] = True
|
||||||
|
|
||||||
# Send complete (even-byte) chunks for PCM_16 alignment
|
# Send complete (even-byte) chunks for PCM_16 alignment
|
||||||
while len(buffer) >= self._buffer_threshold_bytes:
|
while len(buffer) >= self._buffer_threshold_bytes:
|
||||||
@@ -301,8 +297,8 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
chunk_size -= 1
|
chunk_size -= 1
|
||||||
|
|
||||||
chunk_to_send = bytes(buffer[:chunk_size])
|
chunk_to_send = bytes(buffer[:chunk_size])
|
||||||
self._audio_buffers[request_id_str] = buffer[chunk_size:]
|
self._audio_buffers[context_id] = buffer[chunk_size:]
|
||||||
buffer = self._audio_buffers[request_id_str]
|
buffer = self._audio_buffers[context_id]
|
||||||
|
|
||||||
if len(chunk_to_send) == 0:
|
if len(chunk_to_send) == 0:
|
||||||
continue
|
continue
|
||||||
@@ -311,8 +307,9 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
audio=chunk_to_send,
|
audio=chunk_to_send,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
await self.append_to_audio_context(request_id_str, frame)
|
await self.append_to_audio_context(context_id, frame)
|
||||||
|
|
||||||
# Process timestamps if available
|
# Process timestamps if available
|
||||||
timestamps = msg.get("audio_timestamps", {})
|
timestamps = msg.get("audio_timestamps", {})
|
||||||
@@ -328,13 +325,13 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
word_times.append((char, start_time))
|
word_times.append((char, start_time))
|
||||||
|
|
||||||
if word_times:
|
if word_times:
|
||||||
await self.add_word_timestamps(word_times)
|
await self.add_word_timestamps(word_times, context_id)
|
||||||
|
|
||||||
elif msg_type == "audio_end":
|
elif msg_type == "audio_end":
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
# Flush remaining buffer, ensuring even length for PCM_16
|
# Flush remaining buffer, ensuring even length for PCM_16
|
||||||
buffer = self._audio_buffers.get(request_id_str, bytearray())
|
buffer = self._audio_buffers.get(context_id, bytearray())
|
||||||
if buffer:
|
if buffer:
|
||||||
remaining = bytes(buffer)
|
remaining = bytes(buffer)
|
||||||
# PCM_16 requires even number of bytes
|
# PCM_16 requires even number of bytes
|
||||||
@@ -345,20 +342,21 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
audio=remaining,
|
audio=remaining,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
await self.append_to_audio_context(request_id_str, frame)
|
await self.append_to_audio_context(context_id, frame)
|
||||||
|
|
||||||
# Clean up buffer and playback tracking for this request
|
# Clean up buffer and playback tracking for this request
|
||||||
if request_id_str in self._audio_buffers:
|
if context_id in self._audio_buffers:
|
||||||
del self._audio_buffers[request_id_str]
|
del self._audio_buffers[context_id]
|
||||||
if request_id_str in self._playback_started:
|
if context_id in self._playback_started:
|
||||||
del self._playback_started[request_id_str]
|
del self._playback_started[context_id]
|
||||||
|
# Clean up request_id mapping
|
||||||
|
if request_id in self._request_id_to_context:
|
||||||
|
del self._request_id_to_context[request_id]
|
||||||
|
|
||||||
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)])
|
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], context_id)
|
||||||
await self.remove_audio_context(request_id_str)
|
await self.remove_audio_context(context_id)
|
||||||
# Clear current request if this was it
|
|
||||||
if self._current_request_id == request_id:
|
|
||||||
self._current_request_id = None
|
|
||||||
|
|
||||||
elif msg_type == "error":
|
elif msg_type == "error":
|
||||||
error_name = msg.get("error_name", "Unknown")
|
error_name = msg.get("error_name", "Unknown")
|
||||||
@@ -369,19 +367,15 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Clean up buffer and playback tracking for this request
|
# Clean up buffer and playback tracking for this request
|
||||||
if request_id_str in self._audio_buffers:
|
if context_id in self._audio_buffers:
|
||||||
del self._audio_buffers[request_id_str]
|
del self._audio_buffers[context_id]
|
||||||
if request_id_str in self._playback_started:
|
if context_id in self._playback_started:
|
||||||
del self._playback_started[request_id_str]
|
del self._playback_started[context_id]
|
||||||
|
|
||||||
await self.push_frame(TTSStoppedFrame())
|
await self.push_frame(TTSStoppedFrame(context_id=context_id))
|
||||||
await self.stop_all_metrics()
|
await self.stop_all_metrics()
|
||||||
await self.push_error(ErrorFrame(error=f"{self} error: {error_name} - {error_msg}"))
|
await self.push_error(ErrorFrame(error=f"{self} error: {error_name} - {error_msg}"))
|
||||||
|
|
||||||
# Clear current request if this was it
|
|
||||||
if self._current_request_id == request_id:
|
|
||||||
self._current_request_id = None
|
|
||||||
|
|
||||||
# Check if this is an unrecoverable error (connection-level failure)
|
# Check if this is an unrecoverable error (connection-level failure)
|
||||||
if status_code in [401, 403]:
|
if status_code in [401, 403]:
|
||||||
# Close and reconnect for auth errors
|
# Close and reconnect for auth errors
|
||||||
@@ -402,11 +396,12 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
await self._connect_websocket()
|
await self._connect_websocket()
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Resemble AI's streaming API.
|
"""Generate speech from text using Resemble AI's streaming API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to synthesize into speech.
|
text: The text to synthesize into speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -417,15 +412,13 @@ class ResembleAITTSService(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._current_request_id:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
yield TTSStartedFrame()
|
|
||||||
# Track the current request_id we're processing
|
|
||||||
self._current_request_id = self._request_id_counter
|
|
||||||
|
|
||||||
# Create audio context using request_id (converted to string)
|
# Map request_id to context_id for tracking
|
||||||
request_id_str = str(self._request_id_counter)
|
self._request_id_to_context[self._request_id_counter] = context_id
|
||||||
await self.create_audio_context(request_id_str)
|
|
||||||
|
await self.create_audio_context(context_id)
|
||||||
|
|
||||||
msg = self._build_msg(text=text)
|
msg = self._build_msg(text=text)
|
||||||
|
|
||||||
@@ -434,7 +427,7 @@ class ResembleAITTSService(AudioContextWordTTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -387,6 +386,7 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
if not msg or not self.audio_context_available(msg["contextId"]):
|
if not msg or not self.audio_context_available(msg["contextId"]):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
context_id = msg["contextId"]
|
||||||
if msg["type"] == "chunk":
|
if msg["type"] == "chunk":
|
||||||
# Process audio chunk
|
# Process audio chunk
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
@@ -395,8 +395,9 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
audio=base64.b64decode(msg["data"]),
|
audio=base64.b64decode(msg["data"]),
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
await self.append_to_audio_context(msg["contextId"], frame)
|
await self.append_to_audio_context(context_id, frame)
|
||||||
|
|
||||||
elif msg["type"] == "timestamps":
|
elif msg["type"] == "timestamps":
|
||||||
# Process word timing information
|
# Process word timing information
|
||||||
@@ -409,7 +410,7 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
# Calculate word timing pairs
|
# Calculate word timing pairs
|
||||||
word_pairs = self._calculate_word_times(words, starts, ends)
|
word_pairs = self._calculate_word_times(words, starts, ends)
|
||||||
if word_pairs:
|
if word_pairs:
|
||||||
await self.add_word_timestamps(word_pairs)
|
await self.add_word_timestamps(word_pairs, context_id=context_id)
|
||||||
self._cumulative_time = ends[-1] + self._cumulative_time
|
self._cumulative_time = ends[-1] + self._cumulative_time
|
||||||
logger.debug(f"Updated cumulative time to: {self._cumulative_time}")
|
logger.debug(f"Updated cumulative time to: {self._cumulative_time}")
|
||||||
|
|
||||||
@@ -432,11 +433,12 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
await self.add_word_timestamps([("Reset", 0)])
|
await self.add_word_timestamps([("Reset", 0)])
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Rime's streaming API.
|
"""Generate speech from text using Rime's streaming API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text to convert to speech.
|
text: The text to convert to speech.
|
||||||
|
context_id: Unique identifier for this TTS context.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -449,9 +451,9 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
try:
|
try:
|
||||||
if not self._context_id:
|
if not self._context_id:
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
self._cumulative_time = 0
|
self._cumulative_time = 0
|
||||||
self._context_id = str(uuid.uuid4())
|
self._context_id = context_id
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(self._context_id)
|
||||||
|
|
||||||
msg = self._build_msg(text=text)
|
msg = self._build_msg(text=text)
|
||||||
@@ -459,7 +461,7 @@ class RimeTTSService(AudioContextWordTTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
@@ -558,11 +560,12 @@ class RimeHttpTTSService(TTSService):
|
|||||||
return language_to_rime_language(language)
|
return language_to_rime_language(language)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Rime's HTTP API.
|
"""Generate speech from text using Rime's HTTP API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -601,13 +604,14 @@ class RimeHttpTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
CHUNK_SIZE = self.chunk_size
|
CHUNK_SIZE = self.chunk_size
|
||||||
|
|
||||||
async for frame in self._stream_audio_frames_from_iterator(
|
async for frame in self._stream_audio_frames_from_iterator(
|
||||||
response.content.iter_chunked(CHUNK_SIZE),
|
response.content.iter_chunked(CHUNK_SIZE),
|
||||||
strip_wav_header=need_to_strip_wav_header,
|
strip_wav_header=need_to_strip_wav_header,
|
||||||
|
context_id=context_id,
|
||||||
):
|
):
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield frame
|
yield frame
|
||||||
@@ -616,7 +620,7 @@ class RimeHttpTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
|
|
||||||
class RimeNonJsonTTSService(InterruptibleTTSService):
|
class RimeNonJsonTTSService(InterruptibleTTSService):
|
||||||
@@ -716,8 +720,8 @@ class RimeNonJsonTTSService(InterruptibleTTSService):
|
|||||||
if params.extra:
|
if params.extra:
|
||||||
self._settings.update(params.extra)
|
self._settings.update(params.extra)
|
||||||
|
|
||||||
self._started = False
|
|
||||||
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.
|
||||||
@@ -767,8 +771,6 @@ class RimeNonJsonTTSService(InterruptibleTTSService):
|
|||||||
direction: The direction to push the frame.
|
direction: The direction to push the frame.
|
||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
|
||||||
self._started = False
|
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self):
|
||||||
"""Establish WebSocket connection and start receive task."""
|
"""Establish WebSocket connection and start receive task."""
|
||||||
@@ -817,7 +819,7 @@ class RimeNonJsonTTSService(InterruptibleTTSService):
|
|||||||
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._started = False
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -847,17 +849,19 @@ class RimeNonJsonTTSService(InterruptibleTTSService):
|
|||||||
audio=message,
|
audio=message,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=self._context_id,
|
||||||
)
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.push_error(error_msg=f"Error: {e}", exception=e)
|
await self.push_error(error_msg=f"Error: {e}", exception=e)
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Rime's streaming API.
|
"""Generate speech from text using Rime's streaming API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -867,17 +871,17 @@ class RimeNonJsonTTSService(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()
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
# Store context_id for use in _receive_messages
|
||||||
yield TTSStartedFrame()
|
self._context_id = context_id
|
||||||
self._started = True
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
# Send bare text (not JSON)
|
# Send bare text (not JSON)
|
||||||
await self._get_websocket().send(text)
|
await self._get_websocket().send(text)
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -462,11 +462,12 @@ class SarvamHttpTTSService(TTSService):
|
|||||||
self._settings["sample_rate"] = self.sample_rate
|
self._settings["sample_rate"] = self.sample_rate
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Sarvam AI's API.
|
"""Generate speech from text using Sarvam AI's API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -503,7 +504,7 @@ class SarvamHttpTTSService(TTSService):
|
|||||||
|
|
||||||
url = f"{self._base_url}/text-to-speech"
|
url = f"{self._base_url}/text-to-speech"
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
async with self._session.post(url, json=payload, headers=headers) as response:
|
async with self._session.post(url, json=payload, headers=headers) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
@@ -533,6 +534,7 @@ class SarvamHttpTTSService(TTSService):
|
|||||||
audio=audio_data,
|
audio=audio_data,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield frame
|
yield frame
|
||||||
@@ -541,7 +543,7 @@ class SarvamHttpTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Error generating TTS: {e}", exception=e)
|
yield ErrorFrame(error=f"Error generating TTS: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
|
|
||||||
class SarvamTTSService(InterruptibleTTSService):
|
class SarvamTTSService(InterruptibleTTSService):
|
||||||
@@ -789,9 +791,9 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
elif params.temperature != 0.6:
|
elif params.temperature != 0.6:
|
||||||
logger.warning(f"temperature parameter is ignored for {model}")
|
logger.warning(f"temperature parameter is ignored for {model}")
|
||||||
|
|
||||||
self._started = False
|
|
||||||
self._receive_task = None
|
self._receive_task = None
|
||||||
self._keepalive_task = None
|
self._keepalive_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.
|
||||||
@@ -859,8 +861,6 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
direction: The direction to push the frame.
|
direction: The direction to push the frame.
|
||||||
"""
|
"""
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
|
|
||||||
self._started = False
|
|
||||||
|
|
||||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
"""Process a frame and flush audio if it's the end of a full response."""
|
"""Process a frame and flush audio if it's the end of a full response."""
|
||||||
@@ -956,7 +956,7 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await self.push_error(error_msg=f"Error closing websocket: {e}", exception=e)
|
await self.push_error(error_msg=f"Error closing websocket: {e}", exception=e)
|
||||||
finally:
|
finally:
|
||||||
self._started = False
|
self._context_id = None
|
||||||
self._websocket = None
|
self._websocket = None
|
||||||
await self._call_event_handler("on_disconnected")
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
@@ -974,7 +974,9 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
# Check for interruption before processing audio
|
# Check for interruption before processing audio
|
||||||
await self.stop_ttfb_metrics()
|
await self.stop_ttfb_metrics()
|
||||||
audio = base64.b64decode(msg["data"]["audio"])
|
audio = base64.b64decode(msg["data"]["audio"])
|
||||||
frame = TTSAudioRawFrame(audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(
|
||||||
|
audio, self.sample_rate, 1, context_id=self._context_id
|
||||||
|
)
|
||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
elif msg.get("type") == "error":
|
elif msg.get("type") == "error":
|
||||||
error_msg = msg["data"]["message"]
|
error_msg = msg["data"]["message"]
|
||||||
@@ -984,7 +986,6 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
if "too long" in error_msg.lower() or "timeout" in error_msg.lower():
|
if "too long" in error_msg.lower() or "timeout" in error_msg.lower():
|
||||||
logger.warning("Connection timeout detected, service may need restart")
|
logger.warning("Connection timeout detected, service may need restart")
|
||||||
|
|
||||||
self._started = False
|
|
||||||
await self.push_frame(ErrorFrame(error=f"TTS Error: {error_msg}"))
|
await self.push_frame(ErrorFrame(error=f"TTS Error: {error_msg}"))
|
||||||
|
|
||||||
async def _keepalive_task_handler(self):
|
async def _keepalive_task_handler(self):
|
||||||
@@ -1009,16 +1010,17 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
logger.warning("WebSocket not ready, cannot send text")
|
logger.warning("WebSocket not ready, cannot send text")
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech audio frames from input text using Sarvam TTS.
|
"""Generate speech audio frames from input text using Sarvam TTS.
|
||||||
|
|
||||||
Sends text over WebSocket for synthesis and yields corresponding audio or status frames.
|
Sends text over WebSocket for synthesis and yields corresponding audio or status frames.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text input to synthesize.
|
text: The text input to synthesize.
|
||||||
|
context_id: The context ID for tracking audio frames.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame objects including TTSStartedFrame, TTSAudioRawFrame(s), or TTSStoppedFrame.
|
Frame objects including TTSStartedFrame, TTSAudioRawFrame(s, context_id=context_id), or TTSStoppedFrame.
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Generating TTS: [{text}]")
|
logger.debug(f"Generating TTS: [{text}]")
|
||||||
|
|
||||||
@@ -1027,15 +1029,15 @@ class SarvamTTSService(InterruptibleTTSService):
|
|||||||
await self._connect()
|
await self._connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._started:
|
await self.start_ttfb_metrics()
|
||||||
await self.start_ttfb_metrics()
|
# Store context_id for use in _receive_messages
|
||||||
yield TTSStartedFrame()
|
self._context_id = context_id
|
||||||
self._started = True
|
yield TTSStartedFrame(context_id=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)
|
||||||
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(context_id=context_id)
|
||||||
await self._disconnect()
|
await self._disconnect()
|
||||||
await self._connect()
|
await self._connect()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -106,11 +106,12 @@ class SpeechmaticsTTSService(TTSService):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using Speechmatics' HTTP API.
|
"""Generate speech from text using Speechmatics' HTTP API.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -187,7 +188,7 @@ class SpeechmaticsTTSService(TTSService):
|
|||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
# Emit the TTS started frame
|
# Emit the TTS started frame
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
# Process the response in streaming chunks
|
# Process the response in streaming chunks
|
||||||
first_chunk = True
|
first_chunk = True
|
||||||
@@ -216,6 +217,7 @@ class SpeechmaticsTTSService(TTSService):
|
|||||||
audio=audio_data,
|
audio=audio_data,
|
||||||
sample_rate=self.sample_rate,
|
sample_rate=self.sample_rate,
|
||||||
num_channels=1,
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Successfully processed the response, break out of retry loop
|
# Successfully processed the response, break out of retry loop
|
||||||
@@ -225,7 +227,7 @@ class SpeechmaticsTTSService(TTSService):
|
|||||||
yield ErrorFrame(error=f"Error generating TTS: {e}")
|
yield ErrorFrame(error=f"Error generating TTS: {e}")
|
||||||
finally:
|
finally:
|
||||||
# Emit the TTS stopped frame
|
# Emit the TTS stopped frame
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|
||||||
|
|
||||||
def _get_endpoint_url(base_url: str, voice: str, sample_rate: int) -> str:
|
def _get_endpoint_url(base_url: str, voice: str, sample_rate: int) -> str:
|
||||||
|
|||||||
@@ -148,11 +148,12 @@ class XTTSService(TTSService):
|
|||||||
self._studio_speakers = await r.json()
|
self._studio_speakers = await r.json()
|
||||||
|
|
||||||
@traced_tts
|
@traced_tts
|
||||||
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
"""Generate speech from text using XTTS streaming server.
|
"""Generate speech from text using XTTS streaming server.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Frame: Audio frames containing the synthesized speech.
|
Frame: Audio frames containing the synthesized speech.
|
||||||
@@ -186,7 +187,7 @@ class XTTSService(TTSService):
|
|||||||
|
|
||||||
await self.start_tts_usage_metrics(text)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame(context_id=context_id)
|
||||||
|
|
||||||
CHUNK_SIZE = self.chunk_size
|
CHUNK_SIZE = self.chunk_size
|
||||||
|
|
||||||
@@ -211,7 +212,9 @@ class XTTSService(TTSService):
|
|||||||
bytes(process_data), 24000, self.sample_rate
|
bytes(process_data), 24000, self.sample_rate
|
||||||
)
|
)
|
||||||
# Create the frame with the resampled audio
|
# Create the frame with the resampled audio
|
||||||
frame = TTSAudioRawFrame(resampled_audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(
|
||||||
|
resampled_audio, self.sample_rate, 1, context_id=context_id
|
||||||
|
)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
# Process any remaining data in the buffer.
|
# Process any remaining data in the buffer.
|
||||||
@@ -219,7 +222,9 @@ class XTTSService(TTSService):
|
|||||||
resampled_audio = await self._resampler.resample(
|
resampled_audio = await self._resampler.resample(
|
||||||
bytes(buffer), 24000, self.sample_rate
|
bytes(buffer), 24000, self.sample_rate
|
||||||
)
|
)
|
||||||
frame = TTSAudioRawFrame(resampled_audio, self.sample_rate, 1)
|
frame = TTSAudioRawFrame(
|
||||||
|
resampled_audio, self.sample_rate, 1, context_id=context_id
|
||||||
|
)
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
yield TTSStoppedFrame()
|
yield TTSStoppedFrame(context_id=context_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user