diff --git a/src/pipecat/services/inworld/tts.py b/src/pipecat/services/inworld/tts.py index 620a4b702..bf9a5d6ee 100644 --- a/src/pipecat/services/inworld/tts.py +++ b/src/pipecat/services/inworld/tts.py @@ -7,6 +7,7 @@ """Inworld's text-to-speech service implementations.""" import base64 +import io import json import uuid import warnings @@ -15,7 +16,6 @@ from typing import AsyncGenerator, List, Optional, Union import aiohttp from loguru import logger from pydantic import BaseModel, Field -import io, json, base64 from pipecat.frames.frames import ( CancelFrame, @@ -89,7 +89,7 @@ class InworldHttpTTSService(TTSService): """ language: Optional[Language] = Language.EN - voice_id: str = "Ashley" ## QUESTION: How to make this modifyable/how to modify? + voice_id: str = "Ashley" ## QUESTION: How to make this modifyable/how to modify? # QUESTION: What about speed, pitch, and temperature?? def __init__( @@ -109,9 +109,8 @@ class InworldHttpTTSService(TTSService): Args: api_key: Inworld API key for authentication. aiohttp_session: Shared aiohttp session for HTTP requests. - voice_id: ID of the voice to use for synthesis. - model: TTS model to use (e.g., "sonic-2"). - endpoint_url: Base URL for Inworld HTTP API. + model: TTS model to use (e.g., "inworld-tts-1"). + base_url: Base URL for Inworld HTTP API. sample_rate: Audio sample rate. If None, uses default. encoding: Audio encoding format. params: Additional input parameters for voice customization. @@ -138,7 +137,6 @@ class InworldHttpTTSService(TTSService): self.set_voice(params.voice_id) self.set_model_name(model) - def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -187,6 +185,8 @@ class InworldHttpTTSService(TTSService): async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: """Generate speech from text using Inworld's HTTP API. + This implementation streams audio chunk by chunk as it's received. + Args: text: The text to synthesize into speech. @@ -213,52 +213,134 @@ class InworldHttpTTSService(TTSService): yield TTSStartedFrame() - async with self._session.post(self._base_url, json=payload, headers=headers) as response: + # A flag to ensure we only strip the header from the very first chunk. + is_first_chunk = True + + async with self._session.post( + self._base_url, json=payload, headers=headers + ) as response: if response.status != 200: error_text = await response.text() logger.error(f"Inworld API error: {error_text}") await self.push_error(ErrorFrame(f"Inworld API error: {error_text}")) return - raw_audio_data = io.BytesIO() - + # Process the stream line by line. async for line in response.content.iter_lines(): - line_str = line.decode('utf-8').strip() + line_str = line.decode("utf-8").strip() if not line_str: continue - + try: chunk = json.loads(line_str) if "result" in chunk and "audioContent" in chunk["result"]: audio_chunk = base64.b64decode(chunk["result"]["audioContent"]) - # Skip WAV header if present (first 44 bytes) - if len(audio_chunk) > 44 and audio_chunk.startswith(b"RIFF"): + audio_data = audio_chunk + + # Correctly strip the header only from the first chunk. + if ( + is_first_chunk + and len(audio_chunk) > 44 + and audio_chunk.startswith(b"RIFF") + ): audio_data = audio_chunk[44:] - else: - audio_data = audio_chunk - raw_audio_data.write(audio_data) + is_first_chunk = False # Unset the flag. + + # Yield each audio frame as it's processed. + yield TTSAudioRawFrame( + audio=audio_data, + sample_rate=self.sample_rate, + num_channels=1, + ) + except json.JSONDecodeError: continue await self.start_tts_usage_metrics(text) - audio_bytes = raw_audio_data.getvalue() - if not audio_bytes: - logger.error("No audio data received from Inworld API") - await self.push_error(ErrorFrame("No audio data received")) - return - - frame = TTSAudioRawFrame( - audio=audio_bytes, - sample_rate=self.sample_rate, - num_channels=1, - ) - - yield frame - except Exception as e: logger.error(f"{self} exception: {e}") await self.push_error(ErrorFrame(f"Error generating TTS: {e}")) finally: await self.stop_ttfb_metrics() yield TTSStoppedFrame() + + # @traced_tts + # async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + # """Generate speech from text using Inworld's HTTP API. + + # Args: + # text: The text to synthesize into speech. + + # Yields: + # Frame: Audio frames containing the synthesized speech. + # """ + # logger.debug(f"{self}: Generating TTS [{text}]") + + # payload = { + # "text": text, + # "voiceId": self._settings["voiceId"], + # "modelId": self._settings["modelId"], + # "audio_config": self._settings["audio_config"], + # "language": self._settings["language"], + # } + + # headers = { + # "Authorization": f"Basic {self._api_key}", + # "Content-Type": "application/json", + # } + + # try: + # await self.start_ttfb_metrics() + + # yield TTSStartedFrame() + + # async with self._session.post(self._base_url, json=payload, headers=headers) as response: + # if response.status != 200: + # error_text = await response.text() + # logger.error(f"Inworld API error: {error_text}") + # await self.push_error(ErrorFrame(f"Inworld API error: {error_text}")) + # return + + # raw_audio_data = io.BytesIO() + + # async for line in response.content.iter_lines(): + # line_str = line.decode('utf-8').strip() + # if not line_str: + # continue + + # try: + # chunk = json.loads(line_str) + # if "result" in chunk and "audioContent" in chunk["result"]: + # audio_chunk = base64.b64decode(chunk["result"]["audioContent"]) + # # Skip WAV header if present (first 44 bytes) + # if len(audio_chunk) > 44 and audio_chunk.startswith(b"RIFF"): + # audio_data = audio_chunk[44:] + # else: + # audio_data = audio_chunk + # raw_audio_data.write(audio_data) + # except json.JSONDecodeError: + # continue + + # await self.start_tts_usage_metrics(text) + + # audio_bytes = raw_audio_data.getvalue() + # if not audio_bytes: + # logger.error("No audio data received from Inworld API") + # await self.push_error(ErrorFrame("No audio data received")) + # return + + # frame = TTSAudioRawFrame( + # audio=audio_bytes, + # sample_rate=self.sample_rate, + # num_channels=1, + # ) + + # yield frame + + # except Exception as e: + # logger.error(f"{self} exception: {e}") + # await self.push_error(ErrorFrame(f"Error generating TTS: {e}")) + # finally: + # await self.stop_ttfb_metrics() + # yield TTSStoppedFrame()