181 lines
5.6 KiB
Python
181 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from dataclasses import dataclass, replace
|
|
|
|
import aiohttp
|
|
|
|
from livekit.agents import (
|
|
APIConnectionError,
|
|
APIConnectOptions,
|
|
APIStatusError,
|
|
APITimeoutError,
|
|
tokenize,
|
|
tts,
|
|
utils,
|
|
)
|
|
from livekit.agents.types import (
|
|
DEFAULT_API_CONNECT_OPTIONS,
|
|
NOT_GIVEN,
|
|
NotGivenOr,
|
|
)
|
|
from livekit.agents.utils import is_given
|
|
|
|
from .utils import get_tts_url
|
|
|
|
BASE_URL = "https://preview.tts.speechmatics.com"
|
|
NUM_CHANNELS = 1
|
|
DEFAULT_VOICE = "sarah"
|
|
|
|
|
|
@dataclass
|
|
class _TTSOptions:
|
|
voice: str
|
|
sample_rate: int
|
|
word_tokenizer: tokenize.WordTokenizer
|
|
base_url: str
|
|
api_key: str
|
|
|
|
|
|
class TTS(tts.TTS):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
voice: str = DEFAULT_VOICE,
|
|
sample_rate: int = 16000,
|
|
api_key: str | None = None,
|
|
base_url: str = BASE_URL,
|
|
word_tokenizer: NotGivenOr[tokenize.WordTokenizer] = NOT_GIVEN,
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
) -> None:
|
|
"""
|
|
Create a new instance of Speechmatics TTS.
|
|
|
|
Args:
|
|
voice (str): Voice model to use for synthesis. Options: "sarah", "theo", "megan". Defaults to "sarah".
|
|
sample_rate (int): Sample rate of audio. Defaults to 16000.
|
|
api_key (str): Speechmatics API key. If not provided, will look for SPEECHMATICS_API_KEY in environment.
|
|
base_url (str): Base URL for Speechmatics TTS API. Defaults to "https://preview.tts.speechmatics.com"
|
|
word_tokenizer (tokenize.WordTokenizer): Tokenizer for processing text. Defaults to `livekit.agents.tokenize.basic.WordTokenizer`.
|
|
http_session (aiohttp.ClientSession): Optional aiohttp session to use for requests.
|
|
"""
|
|
super().__init__(
|
|
capabilities=tts.TTSCapabilities(streaming=False),
|
|
sample_rate=sample_rate,
|
|
num_channels=NUM_CHANNELS,
|
|
)
|
|
|
|
api_key = api_key or os.environ.get("SPEECHMATICS_API_KEY")
|
|
if not api_key:
|
|
raise ValueError(
|
|
"Speechmatics API key required. Set SPEECHMATICS_API_KEY or provide api_key."
|
|
)
|
|
|
|
if not is_given(word_tokenizer):
|
|
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
|
|
|
|
self._opts = _TTSOptions(
|
|
voice=voice,
|
|
sample_rate=sample_rate,
|
|
word_tokenizer=word_tokenizer,
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
)
|
|
self._session = http_session
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return "unknown"
|
|
|
|
@property
|
|
def provider(self) -> str:
|
|
return "Speechmatics"
|
|
|
|
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
if not self._session:
|
|
self._session = utils.http_context.http_session()
|
|
return self._session
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
voice: NotGivenOr[str] = NOT_GIVEN,
|
|
) -> None:
|
|
"""
|
|
Args:
|
|
voice (str): Voice model to use for synthesis.
|
|
"""
|
|
if is_given(voice):
|
|
self._opts.voice = voice
|
|
|
|
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):
|
|
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:
|
|
try:
|
|
headers = {
|
|
"Authorization": f"Bearer {self._opts.api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
payload = {
|
|
"text": self._input_text,
|
|
}
|
|
|
|
url = get_tts_url(self._opts.base_url, self._opts.voice, self._opts.sample_rate)
|
|
|
|
async with self._tts._ensure_session().post(
|
|
url,
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
|
|
output_emitter.initialize(
|
|
request_id=utils.shortuuid(),
|
|
sample_rate=self._opts.sample_rate,
|
|
num_channels=NUM_CHANNELS,
|
|
mime_type="audio/pcm",
|
|
)
|
|
|
|
# Process the response in streaming chunks
|
|
buffer = b""
|
|
|
|
async for data, _ in resp.content.iter_chunks():
|
|
if not data:
|
|
continue
|
|
|
|
buffer += data
|
|
|
|
# Emit all complete 2-byte int16 samples from buffer
|
|
if len(buffer) >= 2:
|
|
complete_samples = len(buffer) // 2
|
|
complete_bytes = complete_samples * 2
|
|
|
|
audio_data = buffer[:complete_bytes]
|
|
buffer = buffer[complete_bytes:] # Keep remaining bytes for next iteration
|
|
|
|
output_emitter.push(audio_data)
|
|
|
|
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
|