chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Speechify plugin for LiveKit Agents
|
||||
|
||||
See https://docs.livekit.io/agents/integrations/tts/speechify/ for more information.
|
||||
"""
|
||||
|
||||
from .models import TTSEncoding, TTSModels
|
||||
from .tts import DEFAULT_VOICE_ID, TTS, Voice
|
||||
from .version import __version__
|
||||
|
||||
__all__ = [
|
||||
"TTS",
|
||||
"Voice",
|
||||
"TTSEncoding",
|
||||
"TTSModels",
|
||||
"DEFAULT_VOICE_ID",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
from livekit.agents import Plugin
|
||||
|
||||
from .log import logger
|
||||
|
||||
|
||||
class SpeechifyPlugin(Plugin):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(__name__, __version__, __package__, logger)
|
||||
|
||||
|
||||
Plugin.register_plugin(SpeechifyPlugin())
|
||||
|
||||
# Cleanup docs of unexported modules
|
||||
_module = dir()
|
||||
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
||||
|
||||
__pdoc__ = {}
|
||||
|
||||
for n in NOT_IN_ALL:
|
||||
__pdoc__[n] = False
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copyright 2024 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("livekit.plugins.speechify")
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright 2024 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from typing import Literal
|
||||
|
||||
TTSModels = Literal[
|
||||
"simba-english",
|
||||
"simba-multilingual",
|
||||
]
|
||||
|
||||
TTSEncoding = Literal[
|
||||
"mp3_24000",
|
||||
"wav_48000",
|
||||
"ogg_24000",
|
||||
"aac_24000",
|
||||
]
|
||||
|
||||
VoiceType = Literal["shared", "personal"]
|
||||
Gender = Literal["male", "female", "neutral"]
|
||||
@@ -0,0 +1,292 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
import aiohttp
|
||||
|
||||
from livekit.agents import (
|
||||
APIConnectionError,
|
||||
APIConnectOptions,
|
||||
APIError,
|
||||
APIStatusError,
|
||||
APITimeoutError,
|
||||
tts,
|
||||
utils,
|
||||
)
|
||||
from livekit.agents.types import (
|
||||
DEFAULT_API_CONNECT_OPTIONS,
|
||||
NOT_GIVEN,
|
||||
NotGivenOr,
|
||||
)
|
||||
from livekit.agents.utils import is_given
|
||||
|
||||
from .models import Gender, TTSEncoding, TTSModels, VoiceType
|
||||
|
||||
_DefaultEncoding: TTSEncoding = "ogg_24000"
|
||||
|
||||
|
||||
def _sample_rate_from_encoding(output_encoding: TTSEncoding) -> int:
|
||||
split = output_encoding.split("_")
|
||||
return int(split[1])
|
||||
|
||||
|
||||
def _audio_format_from_encoding(encoding: TTSEncoding) -> str:
|
||||
split = encoding.split("_")
|
||||
return split[0]
|
||||
|
||||
|
||||
DEFAULT_VOICE_ID = "jack"
|
||||
API_BASE_URL_V1 = "https://api.sws.speechify.com/v1"
|
||||
AUTHORIZATION_HEADER = "Authorization"
|
||||
CALLER_HEADER = "x-caller"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Voice:
|
||||
id: str
|
||||
type: VoiceType
|
||||
display_name: str
|
||||
gender: Gender
|
||||
avatar_image: str | None
|
||||
models: list[TTSModels]
|
||||
locale: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TTSOptions:
|
||||
base_url: NotGivenOr[str]
|
||||
token: str
|
||||
voice_id: str
|
||||
encoding: TTSEncoding
|
||||
language: NotGivenOr[str]
|
||||
model: NotGivenOr[TTSModels]
|
||||
loudness_normalization: NotGivenOr[bool]
|
||||
text_normalization: NotGivenOr[bool]
|
||||
follow_redirects: bool
|
||||
sample_rate: int
|
||||
|
||||
|
||||
class TTS(tts.TTS):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
voice_id: NotGivenOr[str] = DEFAULT_VOICE_ID,
|
||||
encoding: NotGivenOr[TTSEncoding] = NOT_GIVEN,
|
||||
model: NotGivenOr[TTSModels] = NOT_GIVEN,
|
||||
base_url: NotGivenOr[str] = NOT_GIVEN,
|
||||
api_key: NotGivenOr[str] = NOT_GIVEN,
|
||||
language: NotGivenOr[str] = NOT_GIVEN,
|
||||
loudness_normalization: NotGivenOr[bool] = NOT_GIVEN,
|
||||
text_normalization: NotGivenOr[bool] = NOT_GIVEN,
|
||||
http_session: aiohttp.ClientSession | None = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new instance of Speechify TTS.
|
||||
|
||||
Args:
|
||||
voice_id (NotGivenOr[str]): Voice ID. Defaults to `cliff`.
|
||||
encoding (NotGivenOr[TTSEncoding]): Audio encoding to use. Optional. Defaults to `wav_48000`.
|
||||
model (NotGivenOr[TTSModels]): TTS model to use. Optional.
|
||||
base_url (NotGivenOr[str]): Custom base URL for the API. Optional.
|
||||
api_key (NotGivenOr[str]): Speechify API key. Can be set via argument or `SPEECHIFY_API_KEY` environment variable
|
||||
language (NotGivenOr[str]): Language code for the TTS model. Optional.
|
||||
loudness_normalization (NotGivenOr[bool]): Whether to normalize the loudness of the audio. Optional.
|
||||
text_normalization (NotGivenOr[bool]): Whether to normalize the text. Optional.
|
||||
http_session (aiohttp.ClientSession | None): Custom HTTP session for API requests. Optional.
|
||||
follow_redirects (bool): Whether to follow redirects in HTTP requests. Defaults to True.
|
||||
""" # noqa: E501
|
||||
|
||||
if not is_given(encoding):
|
||||
encoding = _DefaultEncoding
|
||||
|
||||
super().__init__(
|
||||
capabilities=tts.TTSCapabilities(
|
||||
streaming=False,
|
||||
),
|
||||
sample_rate=_sample_rate_from_encoding(encoding),
|
||||
num_channels=1,
|
||||
)
|
||||
|
||||
speechify_token = api_key if is_given(api_key) else os.environ.get("SPEECHIFY_API_KEY")
|
||||
if not (speechify_token):
|
||||
raise ValueError(
|
||||
"Speechify API key is required, either as argument or set SPEECHIFY_API_KEY environment variable" # noqa: E501
|
||||
)
|
||||
|
||||
self._opts = _TTSOptions(
|
||||
model=model,
|
||||
voice_id=voice_id or DEFAULT_VOICE_ID,
|
||||
language=language,
|
||||
base_url=base_url if is_given(base_url) else API_BASE_URL_V1,
|
||||
token=speechify_token,
|
||||
follow_redirects=follow_redirects,
|
||||
encoding=encoding,
|
||||
sample_rate=_sample_rate_from_encoding(encoding),
|
||||
loudness_normalization=loudness_normalization,
|
||||
text_normalization=text_normalization,
|
||||
)
|
||||
self._session = http_session
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
return self._opts.model if is_given(self._opts.model) else "unknown"
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return "Speechify"
|
||||
|
||||
def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
if not self._session:
|
||||
self._session = utils.http_context.http_session()
|
||||
|
||||
return self._session
|
||||
|
||||
async def list_voices(self) -> list[Voice]:
|
||||
async with self._ensure_session().get(
|
||||
f"{self._opts.base_url}/voices", headers=_get_headers(self._opts.token)
|
||||
) as resp:
|
||||
return await resp.json() # type: ignore
|
||||
|
||||
def update_options(
|
||||
self,
|
||||
*,
|
||||
voice_id: NotGivenOr[str] = NOT_GIVEN,
|
||||
model: NotGivenOr[TTSModels] = NOT_GIVEN,
|
||||
language: NotGivenOr[str] = NOT_GIVEN,
|
||||
loudness_normalization: NotGivenOr[bool] = NOT_GIVEN,
|
||||
text_normalization: NotGivenOr[bool] = NOT_GIVEN,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
voice_id (NotGivenOr[str]): Voice ID.
|
||||
model (NotGivenOr[TTSModels | str]): TTS model to use.
|
||||
language (NotGivenOr[str]): Language code for the TTS model.
|
||||
"""
|
||||
if is_given(model):
|
||||
self._opts.model = model
|
||||
if is_given(voice_id):
|
||||
self._opts.voice_id = voice_id
|
||||
if is_given(language):
|
||||
self._opts.language = language
|
||||
if is_given(loudness_normalization):
|
||||
self._opts.loudness_normalization = loudness_normalization
|
||||
if is_given(text_normalization):
|
||||
self._opts.text_normalization = text_normalization
|
||||
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
||||
) -> ChunkedStream:
|
||||
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
|
||||
|
||||
|
||||
class ChunkedStream(tts.ChunkedStream):
|
||||
"""Synthesize using the chunked api endpoint"""
|
||||
|
||||
def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None:
|
||||
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
|
||||
self._tts: TTS = tts
|
||||
self._opts = replace(tts._opts)
|
||||
|
||||
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
||||
data = {
|
||||
"input": self._input_text,
|
||||
"voice_id": self._opts.voice_id,
|
||||
"language": self._opts.language if is_given(self._opts.language) else None,
|
||||
"model": self._opts.model if is_given(self._opts.model) else None,
|
||||
"audio_format": _audio_format_from_encoding(self._opts.encoding),
|
||||
"options": {
|
||||
"loudness_normalization": self._opts.loudness_normalization
|
||||
if is_given(self._opts.loudness_normalization)
|
||||
else None,
|
||||
"text_normalization": self._opts.text_normalization
|
||||
if is_given(self._opts.text_normalization)
|
||||
else None,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
async with self._tts._ensure_session().post(
|
||||
_synthesize_url(self._opts),
|
||||
headers=_get_headers(self._opts.token, encoding=self._opts.encoding),
|
||||
json=data,
|
||||
timeout=aiohttp.ClientTimeout(connect=self._conn_options.timeout, total=30),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
if not resp.content_type.startswith("audio/"):
|
||||
content = await resp.text()
|
||||
raise APIError(message="Speechify returned non-audio data", body=content)
|
||||
|
||||
output_emitter.initialize(
|
||||
request_id=utils.shortuuid(),
|
||||
sample_rate=self._opts.sample_rate,
|
||||
num_channels=1,
|
||||
mime_type=f"audio/{_audio_format_from_encoding(self._opts.encoding)}",
|
||||
)
|
||||
|
||||
async for chunk, _ in resp.content.iter_chunks():
|
||||
output_emitter.push(chunk)
|
||||
|
||||
output_emitter.flush()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise APITimeoutError() from None
|
||||
except aiohttp.ClientResponseError as e:
|
||||
raise APIStatusError(
|
||||
message=e.message,
|
||||
status_code=e.status,
|
||||
request_id=None,
|
||||
body=None,
|
||||
) from None
|
||||
except Exception as e:
|
||||
raise APIConnectionError() from e
|
||||
|
||||
|
||||
def _synthesize_url(opts: _TTSOptions) -> str:
|
||||
"""Construct the Speechify stream URL."""
|
||||
return f"{opts.base_url}/audio/stream"
|
||||
|
||||
|
||||
def _get_headers(token: str, *, encoding: TTSEncoding | None = None) -> dict[str, str]:
|
||||
"""Construct the headers for the Speechify API."""
|
||||
headers = {
|
||||
AUTHORIZATION_HEADER: f"Bearer {token}" if not token.startswith("Bearer ") else token
|
||||
}
|
||||
|
||||
if encoding:
|
||||
accept = ""
|
||||
format = _audio_format_from_encoding(encoding)
|
||||
if format == "ogg":
|
||||
accept = "audio/ogg"
|
||||
elif format == "mp3":
|
||||
accept = "audio/mpeg"
|
||||
elif format == "aac":
|
||||
accept = "audio/aac"
|
||||
|
||||
# docs does not specify mime type for wav
|
||||
# https://docs.sws.speechify.com/v1/api-reference/api-reference/tts/audio/stream
|
||||
|
||||
if accept:
|
||||
headers["Accept"] = accept
|
||||
headers[CALLER_HEADER] = "livekit"
|
||||
return headers
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2024 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
__version__ = "1.6.5"
|
||||
Reference in New Issue
Block a user