Files
2026-07-13 13:39:38 +08:00

293 lines
9.9 KiB
Python

# 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