Merge pull request #2409 from pipecat-ai/mb/refactor-playht-http

Refactor PlayHTHttpTTSService to use aiohttp
This commit is contained in:
Mark Backman
2025-08-11 18:20:58 -07:00
committed by GitHub
4 changed files with 94 additions and 84 deletions

View File

@@ -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

View File

@@ -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" ]

View File

@@ -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:
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,78 @@ 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""
CHUNK_SIZE = self.chunk_size
async for chunk in response.content.iter_chunked(CHUNK_SIZE):
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:

19
uv.lock generated
View File

@@ -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"