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 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
|
- Updated `pyproject.toml` to once again pin `numba` to `>=0.61.2` in order to
|
||||||
resolve package versioning issues.
|
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
|
### Performance
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ openai = [ "websockets>=13.1,<15.0" ]
|
|||||||
openpipe = [ "openpipe~=4.50.0" ]
|
openpipe = [ "openpipe~=4.50.0" ]
|
||||||
openrouter = []
|
openrouter = []
|
||||||
perplexity = []
|
perplexity = []
|
||||||
playht = [ "pyht>=0.1.6", "websockets>=13.1,<15.0" ]
|
playht = [ "websockets>=13.1,<15.0" ]
|
||||||
qwen = []
|
qwen = []
|
||||||
rime = [ "websockets>=13.1,<15.0" ]
|
rime = [ "websockets>=13.1,<15.0" ]
|
||||||
riva = [ "nvidia-riva-client~=2.21.1" ]
|
riva = [ "nvidia-riva-client~=2.21.1" ]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import struct
|
import struct
|
||||||
import uuid
|
import uuid
|
||||||
|
import warnings
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -37,14 +38,11 @@ from pipecat.transcriptions.language import Language
|
|||||||
from pipecat.utils.tracing.service_decorators import traced_tts
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
try:
|
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.asyncio.client import connect as websocket_connect
|
||||||
from websockets.protocol import State
|
from websockets.protocol import State
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
logger.error(f"Exception: {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}")
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -429,7 +427,8 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
voice_url: str,
|
voice_url: str,
|
||||||
voice_engine: str = "Play3.0-mini",
|
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,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -441,40 +440,46 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
user_id: PlayHT user ID for authentication.
|
user_id: PlayHT user ID for authentication.
|
||||||
voice_url: URL of the voice to use for synthesis.
|
voice_url: URL of the voice to use for synthesis.
|
||||||
voice_engine: Voice engine to use. Defaults to "Play3.0-mini".
|
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.
|
sample_rate: Audio sample rate. If None, uses default.
|
||||||
params: Additional input parameters for voice customization.
|
params: Additional input parameters for voice customization.
|
||||||
**kwargs: Additional arguments passed to parent TTSService.
|
**kwargs: Additional arguments passed to parent TTSService.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
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()
|
params = params or PlayHTHttpTTSService.InputParams()
|
||||||
|
|
||||||
self._user_id = user_id
|
self._user_id = user_id
|
||||||
self._api_key = api_key
|
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)
|
# Check if voice_engine contains protocol information (backward compatibility)
|
||||||
if "-http" in voice_engine:
|
if "-http" in voice_engine:
|
||||||
# Extract the base engine name
|
# Extract the base engine name
|
||||||
voice_engine = voice_engine.replace("-http", "")
|
voice_engine = voice_engine.replace("-http", "")
|
||||||
protocol = "http"
|
|
||||||
elif "-ws" in voice_engine:
|
elif "-ws" in voice_engine:
|
||||||
# Extract the base engine name
|
# Extract the base engine name
|
||||||
voice_engine = voice_engine.replace("-ws", "")
|
voice_engine = voice_engine.replace("-ws", "")
|
||||||
protocol = "ws"
|
|
||||||
|
|
||||||
self._settings = {
|
self._settings = {
|
||||||
"language": self.language_to_service_language(params.language)
|
"language": self.language_to_service_language(params.language)
|
||||||
if params.language
|
if params.language
|
||||||
else "english",
|
else "english",
|
||||||
"format": Format.FORMAT_WAV,
|
"output_format": output_format,
|
||||||
"voice_engine": voice_engine,
|
"voice_engine": voice_engine,
|
||||||
"protocol": protocol,
|
|
||||||
"speed": params.speed,
|
"speed": params.speed,
|
||||||
"seed": params.seed,
|
"seed": params.seed,
|
||||||
}
|
}
|
||||||
@@ -490,26 +495,6 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
await super().start(frame)
|
await super().start(frame)
|
||||||
self._settings["sample_rate"] = self.sample_rate
|
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:
|
def can_generate_metrics(self) -> bool:
|
||||||
"""Check if this service can generate processing metrics.
|
"""Check if this service can generate processing metrics.
|
||||||
|
|
||||||
@@ -542,41 +527,78 @@ class PlayHTHttpTTSService(TTSService):
|
|||||||
logger.debug(f"{self}: Generating TTS [{text}]")
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
options = self._create_options()
|
|
||||||
|
|
||||||
await self.start_ttfb_metrics()
|
await self.start_ttfb_metrics()
|
||||||
|
|
||||||
playht_gen = self._client.tts(
|
# Prepare the request payload
|
||||||
text,
|
payload = {
|
||||||
voice_engine=self._settings["voice_engine"],
|
"text": text,
|
||||||
protocol=self._settings["protocol"],
|
"voice": self._voice_id,
|
||||||
options=options,
|
"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)
|
await self.start_tts_usage_metrics(text)
|
||||||
|
|
||||||
yield TTSStartedFrame()
|
yield TTSStartedFrame()
|
||||||
|
|
||||||
b = bytearray()
|
async with aiohttp.ClientSession() as session:
|
||||||
in_header = True
|
async with session.post(
|
||||||
async for chunk in playht_gen:
|
"https://api.play.ht/api/v2/tts/stream",
|
||||||
# skip the RIFF header.
|
headers=headers,
|
||||||
if in_header:
|
json=payload,
|
||||||
b.extend(chunk)
|
) as response:
|
||||||
if len(b) <= 36:
|
if response.status not in (200, 201):
|
||||||
continue
|
error_text = await response.text()
|
||||||
else:
|
raise Exception(f"PlayHT API error {response.status}: {error_text}")
|
||||||
fh = io.BytesIO(b)
|
|
||||||
fh.seek(36)
|
in_header = True
|
||||||
(data, size) = struct.unpack("<4sI", fh.read(8))
|
buffer = b""
|
||||||
while data != b"data":
|
|
||||||
fh.read(size)
|
CHUNK_SIZE = self.chunk_size
|
||||||
(data, size) = struct.unpack("<4sI", fh.read(8))
|
|
||||||
in_header = False
|
async for chunk in response.content.iter_chunked(CHUNK_SIZE):
|
||||||
elif len(chunk) > 0:
|
if len(chunk) == 0:
|
||||||
await self.stop_ttfb_metrics()
|
continue
|
||||||
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
|
|
||||||
yield frame
|
# 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:
|
except Exception as e:
|
||||||
logger.error(f"{self} error generating TTS: {e}")
|
logger.error(f"{self} error generating TTS: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
19
uv.lock
generated
19
uv.lock
generated
@@ -4266,7 +4266,6 @@ openpipe = [
|
|||||||
{ name = "openpipe" },
|
{ name = "openpipe" },
|
||||||
]
|
]
|
||||||
playht = [
|
playht = [
|
||||||
{ name = "pyht" },
|
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
rime = [
|
rime = [
|
||||||
@@ -4402,7 +4401,6 @@ requires-dist = [
|
|||||||
{ name = "pyaudio", marker = "extra == 'local'", specifier = "~=0.2.14" },
|
{ name = "pyaudio", marker = "extra == 'local'", specifier = "~=0.2.14" },
|
||||||
{ name = "pydantic", specifier = ">=2.10.6,<3" },
|
{ name = "pydantic", specifier = ">=2.10.6,<3" },
|
||||||
{ name = "pygobject", marker = "extra == 'gstreamer'", specifier = "~=3.50.0" },
|
{ 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 = "pyloudnorm", specifier = "~=0.1.1" },
|
||||||
{ name = "python-dotenv", marker = "extra == 'runner'", specifier = ">=1.0.0,<2.0.0" },
|
{ name = "python-dotenv", marker = "extra == 'runner'", specifier = ">=1.0.0,<2.0.0" },
|
||||||
{ name = "pyvips", extras = ["binary"], marker = "extra == 'moondream'", specifier = "~=3.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" }
|
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]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user