From c4506523ab294bfcacc9fa38173d10122218082c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 10 Aug 2025 14:59:58 -0400 Subject: [PATCH 1/2] Refactor PlayHTHttpTTSService to use aiohttp --- CHANGELOG.md | 9 +- pyproject.toml | 2 +- src/pipecat/services/playht/tts.py | 146 ++++++++++++++++------------- uv.lock | 19 ---- 4 files changed, 92 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b41e318cb..cea2742e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated Moondream to revision `2025-01-09`. +- Updated `PlayHTHttpTTSService` to no longer use the `pyht` client to remove + compatibility issues with other packages. Now you can use the PlayHT HTTP + service with other services, like GoogleLLMService. + - Updated `pyproject.toml` to once again pin `numba` to `>=0.61.2` in order to resolve package versioning issues. -- Updated the `STTMuteFilter` to include `VADUserStartedSpeakingFrame` and `VADUserStoppedSpeakingFrame` in the list of frames to filter when the filtering is on. + +- Updated the `STTMuteFilter` to include `VADUserStartedSpeakingFrame` and + `VADUserStoppedSpeakingFrame` in the list of frames to filter when the + filtering is on. ### Performance diff --git a/pyproject.toml b/pyproject.toml index 3a2d596a4..1f9b9165c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ openai = [ "websockets>=13.1,<15.0" ] openpipe = [ "openpipe~=4.50.0" ] openrouter = [] perplexity = [] -playht = [ "pyht>=0.1.6", "websockets>=13.1,<15.0" ] +playht = [ "websockets>=13.1,<15.0" ] qwen = [] rime = [ "websockets>=13.1,<15.0" ] riva = [ "nvidia-riva-client~=2.21.1" ] diff --git a/src/pipecat/services/playht/tts.py b/src/pipecat/services/playht/tts.py index f7d0574ed..03a853d60 100644 --- a/src/pipecat/services/playht/tts.py +++ b/src/pipecat/services/playht/tts.py @@ -14,6 +14,7 @@ import io import json import struct import uuid +import warnings from typing import AsyncGenerator, Optional import aiohttp @@ -37,14 +38,11 @@ from pipecat.transcriptions.language import Language from pipecat.utils.tracing.service_decorators import traced_tts try: - from pyht.async_client import AsyncClient - from pyht.client import Format, TTSOptions - from pyht.client import Language as PlayHTLanguage from websockets.asyncio.client import connect as websocket_connect from websockets.protocol import State except ModuleNotFoundError as e: logger.error(f"Exception: {e}") - logger.error("In order to use PlayHT, you need to `pip install pipecat-ai[playht]`.") + logger.error("In order to use PlayHTTTSService, you need to `pip install pipecat-ai[playht]`.") raise Exception(f"Missing module: {e}") @@ -429,7 +427,8 @@ class PlayHTHttpTTSService(TTSService): user_id: str, voice_url: str, voice_engine: str = "Play3.0-mini", - protocol: str = "http", # Options: http, ws + protocol: Optional[str] = None, + output_format: str = "wav", sample_rate: Optional[int] = None, params: Optional[InputParams] = None, **kwargs, @@ -441,40 +440,46 @@ class PlayHTHttpTTSService(TTSService): user_id: PlayHT user ID for authentication. voice_url: URL of the voice to use for synthesis. voice_engine: Voice engine to use. Defaults to "Play3.0-mini". - protocol: Protocol to use ("http" or "ws"). Defaults to "http". + protocol: Protocol to use ("http" or "ws"). + + .. deprecated:: 0.0.80 + This parameter no longer has any effect and will be removed in a future version. + Use PlayHTTTSService for WebSocket or PlayHTHttpTTSService for HTTP. + + output_format: Audio output format. Defaults to "wav". sample_rate: Audio sample rate. If None, uses default. params: Additional input parameters for voice customization. **kwargs: Additional arguments passed to parent TTSService. """ super().__init__(sample_rate=sample_rate, **kwargs) + # Warn about deprecated protocol parameter if explicitly provided + if protocol is not None: + warnings.warn( + "The 'protocol' parameter is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + params = params or PlayHTHttpTTSService.InputParams() self._user_id = user_id self._api_key = api_key - self._client = AsyncClient( - user_id=self._user_id, - api_key=self._api_key, - ) - # Check if voice_engine contains protocol information (backward compatibility) if "-http" in voice_engine: # Extract the base engine name voice_engine = voice_engine.replace("-http", "") - protocol = "http" elif "-ws" in voice_engine: # Extract the base engine name voice_engine = voice_engine.replace("-ws", "") - protocol = "ws" self._settings = { "language": self.language_to_service_language(params.language) if params.language else "english", - "format": Format.FORMAT_WAV, + "output_format": output_format, "voice_engine": voice_engine, - "protocol": protocol, "speed": params.speed, "seed": params.seed, } @@ -490,26 +495,6 @@ class PlayHTHttpTTSService(TTSService): await super().start(frame) self._settings["sample_rate"] = self.sample_rate - def _create_options(self) -> TTSOptions: - """Create TTSOptions object from current settings.""" - language_str = self._settings["language"] - playht_language = None - if language_str: - # Convert string to PlayHT Language enum - for lang in PlayHTLanguage: - if lang.value == language_str: - playht_language = lang - break - - return TTSOptions( - voice=self._voice_id, - language=playht_language, - sample_rate=self.sample_rate, - format=self._settings["format"], - speed=self._settings["speed"], - seed=self._settings["seed"], - ) - def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -542,41 +527,76 @@ class PlayHTHttpTTSService(TTSService): logger.debug(f"{self}: Generating TTS [{text}]") try: - options = self._create_options() - await self.start_ttfb_metrics() - playht_gen = self._client.tts( - text, - voice_engine=self._settings["voice_engine"], - protocol=self._settings["protocol"], - options=options, - ) + # Prepare the request payload + payload = { + "text": text, + "voice": self._voice_id, + "voice_engine": self._settings["voice_engine"], + "output_format": self._settings["output_format"], + "sample_rate": self.sample_rate, + "language": self._settings["language"], + } + + # Add optional parameters if they exist + if self._settings["speed"] is not None: + payload["speed"] = self._settings["speed"] + if self._settings["seed"] is not None: + payload["seed"] = self._settings["seed"] + + headers = { + "Authorization": f"Bearer {self._api_key}", + "X-User-Id": self._user_id, + "Content-Type": "application/json", + "Accept": "*/*", + } await self.start_tts_usage_metrics(text) yield TTSStartedFrame() - b = bytearray() - in_header = True - async for chunk in playht_gen: - # skip the RIFF header. - if in_header: - b.extend(chunk) - if len(b) <= 36: - continue - else: - fh = io.BytesIO(b) - fh.seek(36) - (data, size) = struct.unpack("<4sI", fh.read(8)) - while data != b"data": - fh.read(size) - (data, size) = struct.unpack("<4sI", fh.read(8)) - in_header = False - elif len(chunk) > 0: - await self.stop_ttfb_metrics() - frame = TTSAudioRawFrame(chunk, self.sample_rate, 1) - yield frame + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.play.ht/api/v2/tts/stream", + headers=headers, + json=payload, + ) as response: + if response.status not in (200, 201): + error_text = await response.text() + raise Exception(f"PlayHT API error {response.status}: {error_text}") + + in_header = True + buffer = b"" + + async for chunk in response.content.iter_chunked(8192): + if len(chunk) == 0: + continue + + # Skip the RIFF header + if in_header: + buffer += chunk + if len(buffer) <= 36: + continue + else: + fh = io.BytesIO(buffer) + fh.seek(36) + (data, size) = struct.unpack("<4sI", fh.read(8)) + while data != b"data": + fh.read(size) + (data, size) = struct.unpack("<4sI", fh.read(8)) + # Extract audio data after header + audio_data = buffer[fh.tell() :] + if len(audio_data) > 0: + await self.stop_ttfb_metrics() + frame = TTSAudioRawFrame(audio_data, self.sample_rate, 1) + yield frame + in_header = False + elif len(chunk) > 0: + await self.stop_ttfb_metrics() + frame = TTSAudioRawFrame(chunk, self.sample_rate, 1) + yield frame + except Exception as e: logger.error(f"{self} error generating TTS: {e}") finally: diff --git a/uv.lock b/uv.lock index 751cf5fd2..9aafe2782 100644 --- a/uv.lock +++ b/uv.lock @@ -4266,7 +4266,6 @@ openpipe = [ { name = "openpipe" }, ] playht = [ - { name = "pyht" }, { name = "websockets" }, ] rime = [ @@ -4402,7 +4401,6 @@ requires-dist = [ { name = "pyaudio", marker = "extra == 'local'", specifier = "~=0.2.14" }, { name = "pydantic", specifier = ">=2.10.6,<3" }, { name = "pygobject", marker = "extra == 'gstreamer'", specifier = "~=3.50.0" }, - { name = "pyht", marker = "extra == 'playht'", specifier = ">=0.1.6" }, { name = "pyloudnorm", specifier = "~=0.1.1" }, { name = "python-dotenv", marker = "extra == 'runner'", specifier = ">=1.0.0,<2.0.0" }, { name = "pyvips", extras = ["binary"], marker = "extra == 'moondream'", specifier = "~=3.0.0" }, @@ -5148,23 +5146,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/eb/53106840011df907781891a4968a35cfde42aef0e80f74c060367402a468/pygobject-3.50.1.tar.gz", hash = "sha256:a4df4e7adef7f4f01685a763d138eac9396585bfc68a7d31bbe4fbca2de0d7cb", size = 1081846, upload-time = "2025-05-25T14:53:01.761Z" } -[[package]] -name = "pyht" -version = "0.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "filelock" }, - { name = "grpcio" }, - { name = "protobuf" }, - { name = "requests" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c8/04/26710ad6f87443c4edd6b49afe623644b4c33927ef8d1e830561f80b6dae/pyht-0.1.6.tar.gz", hash = "sha256:1827dc9fccf4da7d5c8d55f60e117bf50f0db19f442488799a9b70e0a607fd5a", size = 28222, upload-time = "2024-11-14T10:29:04.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c1/76d67ddae6581b75a12dc1e45a65ab907a76023156947d600da04cb41f5c/pyht-0.1.6-py3-none-any.whl", hash = "sha256:f6c29d150e7bec71fd1a0a74d156e6260953109b0666b460fbe245bf6451337b", size = 29428, upload-time = "2024-11-14T10:29:02.466Z" }, -] - [[package]] name = "pyjwt" version = "2.10.1" From be6ea0dbf6971ce6e7a47538d0d8973cb7d2bb71 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 11 Aug 2025 19:57:09 -0400 Subject: [PATCH 2/2] Code review feedback --- src/pipecat/services/playht/tts.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pipecat/services/playht/tts.py b/src/pipecat/services/playht/tts.py index 03a853d60..3c03e8335 100644 --- a/src/pipecat/services/playht/tts.py +++ b/src/pipecat/services/playht/tts.py @@ -454,7 +454,7 @@ class PlayHTHttpTTSService(TTSService): super().__init__(sample_rate=sample_rate, **kwargs) # Warn about deprecated protocol parameter if explicitly provided - if protocol is not None: + if protocol: warnings.warn( "The 'protocol' parameter is deprecated and will be removed in a future version.", DeprecationWarning, @@ -569,7 +569,9 @@ class PlayHTHttpTTSService(TTSService): in_header = True buffer = b"" - async for chunk in response.content.iter_chunked(8192): + CHUNK_SIZE = self.chunk_size + + async for chunk in response.content.iter_chunked(CHUNK_SIZE): if len(chunk) == 0: continue