Merge pull request #2409 from pipecat-ai/mb/refactor-playht-http
Refactor PlayHTHttpTTSService to use aiohttp
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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
19
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user