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

388 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright 2025 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 base64
import json
import os
from dataclasses import dataclass, replace
from typing import Any
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
LanguageCode,
create_api_error_from_http,
tts,
utils,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
from livekit.agents.voice.io import TimedString
from .models import TTSEncoding, TTSModels
from .version import __version__
NUM_CHANNELS = 1
SMALLEST_BASE_URL = "https://api.smallest.ai/waves/v1"
SMALLEST_WS_URL = "wss://api.smallest.ai/waves/v1/tts/live"
@dataclass
class _TTSOptions:
model: TTSModels | str
api_key: str
voice_id: str
sample_rate: int
speed: float
language: LanguageCode
output_format: TTSEncoding | str
word_timestamps: bool
base_url: str
ws_url: str
class TTS(tts.TTS):
def __init__(
self,
*,
api_key: str | None = None,
model: TTSModels | str = "lightning_v3.1_pro",
voice_id: str | None = None,
sample_rate: int = 24000,
speed: float = 1.0,
language: str = "en",
output_format: TTSEncoding | str = "pcm",
word_timestamps: bool = False,
base_url: str = SMALLEST_BASE_URL,
ws_url: str = SMALLEST_WS_URL,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""
Create a new instance of Smallest AI Lightning TTS.
Args:
api_key: Your Smallest AI API key.
model: The TTS model to use. Use "lightning_v3.1" for the standard model with
217 voices across 12 languages, or "lightning_v3.1_pro" (default) for the
premium pool with curated American, British, and Indian voices at 44.1 kHz.
voice_id: The voice ID to use for synthesis. Defaults to "meher" for
"lightning_v3.1_pro" and "sophia" for all other models. Pro voices must be
paired with "lightning_v3.1_pro"; standard voices with "lightning_v3.1".
sample_rate: Sample rate for the audio output. Both models are natively 44.1 kHz;
supported rates are 8000, 16000, 24000, and 44100.
speed: Speed of the speech synthesis (0.52.0).
language: Language of the text to be synthesized. Use "auto" for automatic
detection and code-switching. Pro supports "en", "hi", and "auto" only.
output_format: Output format for HTTP synthesize() calls ("pcm", "mp3", "wav",
"ulaw", "alaw"). WebSocket streaming always returns PCM.
word_timestamps: Request per-word timing events from the server and emit them
as timed transcript entries alongside audio. Applies to WebSocket streaming
only; HTTP synthesize() returns raw audio without word events. Disabled by
default. Supported on base-queue English + Hindi voices (meher, devansh,
kartik, maithili, liam, avery); other voices silently emit no word events.
base_url: Base URL for the Smallest AI HTTP API.
ws_url: WebSocket URL for low-latency streaming synthesis.
http_session: An existing aiohttp ClientSession to use.
"""
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=True,
aligned_transcript=word_timestamps,
),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
api_key = api_key or os.environ.get("SMALLEST_API_KEY")
if not api_key:
raise ValueError(
"Smallest.ai API key is required, either as argument or set"
" SMALLEST_API_KEY environment variable"
)
if voice_id is None:
voice_id = "meher" if model == "lightning_v3.1_pro" else "sophia"
self._opts = _TTSOptions(
model=model,
api_key=api_key,
voice_id=voice_id,
sample_rate=sample_rate,
speed=speed,
language=LanguageCode(language),
output_format=output_format,
word_timestamps=word_timestamps,
base_url=base_url,
ws_url=ws_url,
)
self._session = http_session
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
max_session_duration=3600,
mark_refreshed_on_get=False,
)
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "SmallestAI"
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
return await asyncio.wait_for(
self._ensure_session().ws_connect(
self._opts.ws_url,
headers={
"Authorization": f"Bearer {self._opts.api_key}",
"X-Source": "livekit",
"X-LiveKit-Version": __version__,
},
),
timeout,
)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
await ws.close()
def update_options(
self,
*,
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
voice_id: NotGivenOr[str] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
sample_rate: NotGivenOr[int] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
output_format: NotGivenOr[TTSEncoding | str] = NOT_GIVEN,
word_timestamps: NotGivenOr[bool] = NOT_GIVEN,
) -> None:
"""Update TTS options."""
if is_given(model):
self._opts.model = model
if is_given(voice_id):
self._opts.voice_id = voice_id
if is_given(speed):
self._opts.speed = speed
if is_given(sample_rate):
self._opts.sample_rate = sample_rate
if is_given(language):
self._opts.language = LanguageCode(language)
if is_given(output_format):
self._opts.output_format = output_format
if is_given(word_timestamps):
self._opts.word_timestamps = word_timestamps
self._capabilities.aligned_transcript = word_timestamps
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SynthesizeStream:
return SynthesizeStream(tts=self, conn_options=conn_options)
def prewarm(self) -> None:
self._pool.prewarm()
async def aclose(self) -> None:
await self._pool.aclose()
class ChunkedStream(tts.ChunkedStream):
"""HTTP-based synthesis — used when synthesize() is called directly."""
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:
data = _to_smallest_options(self._opts)
data["text"] = self._input_text
headers = {
"Authorization": f"Bearer {self._opts.api_key}",
"Content-Type": "application/json",
"X-Source": "livekit",
"X-LiveKit-Version": __version__,
}
async with self._tts._ensure_session().post(
f"{self._opts.base_url}/tts",
headers=headers,
json=data,
timeout=aiohttp.ClientTimeout(total=self._conn_options.timeout),
) as resp:
if resp.status >= 400:
body = await resp.text()
raise create_api_error_from_http(body, status=resp.status)
output_emitter.initialize(
request_id=utils.shortuuid(),
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
mime_type=f"audio/{self._opts.output_format}",
)
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 create_api_error_from_http(e.message, status=e.status) from None
except APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
class SynthesizeStream(tts.SynthesizeStream):
"""WebSocket-based streaming synthesis — primary path used by the agent pipeline."""
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions) -> None:
super().__init__(tts=tts, conn_options=conn_options)
self._tts: TTS = tts
self._opts = replace(tts._opts)
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
output_emitter.initialize(
request_id=utils.shortuuid(),
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
mime_type="audio/pcm",
stream=True,
)
try:
text_buffer = ""
async for data in self._input_ch:
if isinstance(data, str):
text_buffer += data
elif isinstance(data, self._FlushSentinel):
if text_buffer.strip():
await self._run_ws(text_buffer.strip(), output_emitter)
text_buffer = ""
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 APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
async def _run_ws(self, text: str, output_emitter: tts.AudioEmitter) -> None:
segment_id = utils.shortuuid()
output_emitter.start_segment(segment_id=segment_id)
payload: dict[str, Any] = {
"model": self._opts.model,
"voice_id": self._opts.voice_id,
"text": text,
"sample_rate": self._opts.sample_rate,
"speed": self._opts.speed,
"language": self._opts.language.language
if isinstance(self._opts.language, LanguageCode)
else self._opts.language,
}
if self._opts.word_timestamps:
payload["word_timestamps"] = True
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
self._acquire_time = self._tts._pool.last_acquire_time
self._connection_reused = self._tts._pool.last_connection_reused
self._mark_started()
await ws.send_str(json.dumps(payload))
while True:
msg = await ws.receive(timeout=self._conn_options.timeout)
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
raise APIStatusError(
"SmallestAI WebSocket closed unexpectedly",
status_code=ws.close_code or -1,
body=str(msg.data),
)
if msg.type != aiohttp.WSMsgType.TEXT:
continue
event = json.loads(msg.data)
status = event.get("status")
if status == "chunk":
audio_b64 = event.get("data", {}).get("audio")
if audio_b64:
output_emitter.push(base64.b64decode(audio_b64))
elif status == "word_timestamp":
data = event.get("data", {})
word = data.get("word")
start = data.get("start")
end = data.get("end")
if word is not None and start is not None and end is not None:
output_emitter.push_timed_transcript(
TimedString(text=word, start_time=start, end_time=end)
)
elif status == "complete":
output_emitter.end_segment()
break
elif status == "error":
raise APIConnectionError(
f"SmallestAI TTS error: {event.get('message', 'unknown error')}"
)
def _to_smallest_options(opts: _TTSOptions) -> dict[str, Any]:
return {
"model": opts.model,
"voice_id": opts.voice_id,
"sample_rate": opts.sample_rate,
"speed": opts.speed,
"language": opts.language.language
if isinstance(opts.language, LanguageCode)
else opts.language,
"output_format": opts.output_format,
}