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

485 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import dataclasses
import time
from collections.abc import AsyncGenerator, AsyncIterable
from dataclasses import dataclass
from typing import Any, ClassVar, Literal
from livekit import rtc
from .. import utils
from .._exceptions import APIConnectionError
from ..log import logger
from ..types import DEFAULT_API_CONNECT_OPTIONS, USERDATA_TIMED_TRANSCRIPT, APIConnectOptions
from ..utils import aio
from .stream_adapter import StreamAdapter
from .tts import (
TTS,
AudioEmitter,
ChunkedStream,
SynthesizedAudio,
SynthesizeStream,
TTSCapabilities,
)
# don't retry when using the fallback adapter
DEFAULT_FALLBACK_API_CONNECT_OPTIONS = APIConnectOptions(
max_retry=0, timeout=DEFAULT_API_CONNECT_OPTIONS.timeout
)
@dataclass
class _TTSStatus:
available: bool
recovering_task: asyncio.Task[None] | None
needs_resampling: bool
@dataclass
class AvailabilityChangedEvent:
tts: TTS
available: bool
class FallbackAdapter(
TTS[Literal["tts_availability_changed"]],
):
"""Agent Fallback Adapter for TTS. Manages multiple STT instances with automatic fallback
when the primary provider fails.
"""
def __init__(
self,
tts: list[TTS],
*,
max_retry_per_tts: int = 2,
sample_rate: int | None = None,
) -> None:
"""
Initialize a FallbackAdapter that manages multiple TTS instances.
Args:
tts (list[TTS]): A list of TTS instances to use for fallback.
max_retry_per_tts (int, optional): Maximum number of retries per TTS instance. Defaults to 2.
sample_rate (int | None, optional): Desired sample rate for the synthesized audio. If None, uses the maximum sample rate among the TTS instances.
Raises:
ValueError: If less than one TTS instance is provided.
ValueError: If TTS instances have different numbers of channels.
""" # noqa: E501
if len(tts) < 1:
raise ValueError("at least one TTS instance must be provided.")
if len({t.num_channels for t in tts}) != 1:
raise ValueError("all TTS must have the same number of channels")
if sample_rate is None:
sample_rate = max(t.sample_rate for t in tts)
num_channels = tts[0].num_channels
super().__init__(
capabilities=TTSCapabilities(
streaming=any(t.capabilities.streaming for t in tts),
aligned_transcript=all(t.capabilities.aligned_transcript for t in tts),
),
sample_rate=sample_rate,
num_channels=num_channels,
)
self._tts_instances = tts
self._max_retry_per_tts = max_retry_per_tts
self._status: list[_TTSStatus] = []
for t in tts:
needs_resampling = sample_rate != t.sample_rate
if needs_resampling:
logger.info(f"resampling {t.label} from {t.sample_rate}Hz to {sample_rate}Hz")
self._status.append(
_TTSStatus(available=True, recovering_task=None, needs_resampling=needs_resampling)
)
t.on("metrics_collected", self._on_metrics_collected)
@property
def model(self) -> str:
return "FallbackAdapter"
@property
def provider(self) -> str:
return "livekit"
def synthesize(
self, text: str, *, conn_options: APIConnectOptions = DEFAULT_FALLBACK_API_CONNECT_OPTIONS
) -> FallbackChunkedStream:
return FallbackChunkedStream(tts=self, input_text=text, conn_options=conn_options)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_FALLBACK_API_CONNECT_OPTIONS
) -> FallbackSynthesizeStream:
return FallbackSynthesizeStream(tts=self, conn_options=conn_options)
def prewarm(self) -> None:
if self._tts_instances:
self._tts_instances[0].prewarm()
def _on_metrics_collected(self, *args: Any, **kwargs: Any) -> None:
self.emit("metrics_collected", *args, **kwargs)
async def aclose(self) -> None:
for tts_status in self._status:
if tts_status.recovering_task is not None:
await aio.cancel_and_wait(tts_status.recovering_task)
for t in self._tts_instances:
t.off("metrics_collected", self._on_metrics_collected)
class FallbackChunkedStream(ChunkedStream):
_tts_request_span_name: ClassVar[str] = "tts_fallback_adapter"
def __init__(
self, *, tts: FallbackAdapter, input_text: str, conn_options: APIConnectOptions
) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._fallback_adapter = tts
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SynthesizedAudio]) -> None:
pass # do nothing
async def _try_synthesize(
self, *, tts: TTS, recovering: bool = False
) -> AsyncGenerator[SynthesizedAudio, None]:
try:
async with tts.synthesize(
self._input_text,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_tts,
timeout=self._conn_options.timeout,
retry_interval=self._conn_options.retry_interval,
),
) as stream:
async for audio in stream:
yield audio
except Exception as e:
if recovering:
logger.warning("%s recovery failed: %s", tts.label, e, extra={"streamed": False})
raise
logger.warning(
f"{tts.label} error, switching to next TTS",
extra={"streamed": False},
)
raise
def _try_recovery(self, tts: TTS) -> None:
assert isinstance(self._tts, FallbackAdapter)
tts_status = self._tts._status[self._tts._tts_instances.index(tts)]
if tts_status.recovering_task is None or tts_status.recovering_task.done():
async def _recover_tts_task(tts: TTS) -> None:
try:
async for _ in self._try_synthesize(tts=tts, recovering=True):
pass
tts_status.available = True
logger.info(f"tts.FallbackAdapter, {tts.label} recovered")
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=True),
)
except Exception: # exceptions already logged inside _try_synthesize
return
tts_status.recovering_task = asyncio.create_task(_recover_tts_task(tts))
async def _run(self, output_emitter: AudioEmitter) -> None:
assert isinstance(self._tts, FallbackAdapter)
start_time = time.time()
all_failed = all(not tts_status.available for tts_status in self._tts._status)
if all_failed:
logger.error("all TTSs are unavailable, retrying..")
output_emitter.initialize(
request_id=utils.shortuuid(),
sample_rate=self._tts.sample_rate,
num_channels=self._tts.num_channels,
mime_type="audio/pcm",
)
for i, tts in enumerate(self._tts._tts_instances):
tts_status = self._tts._status[i]
if tts_status.available or all_failed:
try:
resampler = (
rtc.AudioResampler(
input_rate=tts.sample_rate,
output_rate=self._tts.sample_rate,
)
if tts_status.needs_resampling
else None
)
async for synthesized_audio in self._try_synthesize(tts=tts, recovering=False):
if texts := synthesized_audio.frame.userdata.get(USERDATA_TIMED_TRANSCRIPT):
output_emitter.push_timed_transcript(texts)
if resampler is not None:
for rf in resampler.push(synthesized_audio.frame):
output_emitter.push(rf.data.tobytes())
else:
output_emitter.push(synthesized_audio.frame.data.tobytes())
if resampler is not None:
for rf in resampler.flush():
output_emitter.push(rf.data.tobytes())
return
except Exception: # exceptions already logged inside _try_synthesize
if tts_status.available:
tts_status.available = False
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=False),
)
if output_emitter.pushed_duration() > 0.0:
logger.warning(
f"{tts.label} already synthesized of audio, ignoring fallback"
)
return
self._try_recovery(tts)
raise APIConnectionError(
f"all TTSs failed ({[tts.label for tts in self._tts._tts_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
class FallbackSynthesizeStream(SynthesizeStream):
_tts_request_span_name: ClassVar[str] = "tts_fallback_adapter"
def __init__(self, *, tts: FallbackAdapter, conn_options: APIConnectOptions):
super().__init__(tts=tts, conn_options=conn_options)
self._fallback_adapter = tts
self._pushed_tokens: list[str] = []
async def _metrics_monitor_task(self, event_aiter: AsyncIterable[SynthesizedAudio]) -> None:
pass # do nothing
async def _try_synthesize(
self,
*,
tts: TTS,
input_ch: aio.ChanReceiver[str | SynthesizeStream._FlushSentinel],
conn_options: APIConnectOptions,
recovering: bool = False,
) -> AsyncGenerator[SynthesizedAudio, None]:
# If TTS doesn't support streaming, wrap it with StreamAdapter
if tts.capabilities.streaming:
stream = tts.stream(conn_options=conn_options)
else:
from .. import tokenize
wrapped_tts = StreamAdapter(
tts=tts,
sentence_tokenizer=tokenize.blingfire.SentenceTokenizer(retain_format=True),
)
stream = wrapped_tts.stream(conn_options=conn_options)
@utils.log_exceptions(logger=logger)
async def _forward_input_task() -> None:
try:
async for data in input_ch:
if isinstance(data, str):
stream.push_text(data)
elif isinstance(data, self._FlushSentinel):
stream.flush()
finally:
stream.end_input()
input_task = asyncio.create_task(_forward_input_task())
def _capture_started_time() -> None:
# ttfb measures the fallback adapter as a whole: anchor on the first time a
# sentence was handed to any underlying TTS — even one that failed before
# emitting audio — and never overwrite it when falling back to another TTS
if not recovering and not self._started_time and stream._started_time:
self._started_time = stream._started_time
try:
async with stream:
async for audio in stream:
_capture_started_time()
yield audio
except Exception as e:
if recovering:
logger.warning(
"%s recovery failed: %s",
tts.label,
e,
extra={"streamed": True},
)
raise
logger.exception(
f"{tts.label} error, switching to next TTS",
extra={"streamed": True},
)
raise
finally:
_capture_started_time()
await utils.aio.cancel_and_wait(input_task)
async def _run(self, output_emitter: AudioEmitter) -> None:
start_time = time.time()
all_failed = all(not tts_status.available for tts_status in self._fallback_adapter._status)
if all_failed:
logger.error("all TTSs are unavailable, retrying..")
new_input_ch: aio.Chan[str | SynthesizeStream._FlushSentinel] | None = None
output_emitter.initialize(
request_id=utils.shortuuid(),
sample_rate=self._fallback_adapter.sample_rate,
num_channels=self._fallback_adapter.num_channels,
mime_type="audio/pcm",
stream=True,
)
output_emitter.start_segment(segment_id=utils.shortuuid())
async def _forward_input_task() -> None:
nonlocal new_input_ch
async for data in self._input_ch:
if new_input_ch:
new_input_ch.send_nowait(data)
if isinstance(data, str) and data:
self._pushed_tokens.append(data)
if new_input_ch:
new_input_ch.close()
input_task = asyncio.create_task(_forward_input_task())
try:
for i, tts in enumerate(self._fallback_adapter._tts_instances):
tts_status = self._fallback_adapter._status[i]
if tts_status.available or all_failed:
try:
new_input_ch = aio.Chan[str | SynthesizeStream._FlushSentinel]()
for text in self._pushed_tokens:
new_input_ch.send_nowait(text)
if input_task.done():
new_input_ch.close()
resampler = (
rtc.AudioResampler(
input_rate=tts.sample_rate,
output_rate=self._fallback_adapter.sample_rate,
)
if tts_status.needs_resampling
else None
)
async for synthesized_audio in self._try_synthesize(
tts=tts,
input_ch=new_input_ch,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=self._fallback_adapter._max_retry_per_tts,
timeout=self._conn_options.timeout,
retry_interval=self._conn_options.retry_interval,
),
recovering=False,
):
if texts := synthesized_audio.frame.userdata.get(
USERDATA_TIMED_TRANSCRIPT
):
output_emitter.push_timed_transcript(texts)
if resampler is not None:
for resampled_frame in resampler.push(synthesized_audio.frame):
output_emitter.push(resampled_frame.data.tobytes())
if synthesized_audio.is_final:
for resampled_frame in resampler.flush():
output_emitter.push(resampled_frame.data.tobytes())
else:
output_emitter.push(synthesized_audio.frame.data.tobytes())
return
except Exception:
if tts_status.available:
tts_status.available = False
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=False),
)
if output_emitter.pushed_duration() > 0.0:
logger.warning(
f"{tts.label} already synthesized of audio, ignoring the current segment for the tts fallback" # noqa: E501
)
return
self._try_recovery(tts)
raise APIConnectionError(
f"all TTSs failed ({[tts.label for tts in self._fallback_adapter._tts_instances]}) after {time.time() - start_time} seconds" # noqa: E501
)
finally:
await utils.aio.cancel_and_wait(input_task)
def _try_recovery(self, tts: TTS) -> None:
assert isinstance(self._tts, FallbackAdapter)
retry_text = self._pushed_tokens.copy()
if not retry_text:
return
tts_status = self._tts._status[self._tts._tts_instances.index(tts)]
if tts_status.recovering_task is None or tts_status.recovering_task.done():
async def _recover_tts_task(tts: TTS) -> None:
try:
input_ch = aio.Chan[str | SynthesizeStream._FlushSentinel]()
for t in retry_text:
input_ch.send_nowait(t)
input_ch.close()
async for _ in self._try_synthesize(
tts=tts,
input_ch=input_ch,
recovering=True,
conn_options=dataclasses.replace(
self._conn_options,
max_retry=0,
timeout=self._conn_options.timeout,
retry_interval=self._conn_options.retry_interval,
),
):
pass
tts_status.available = True
logger.info(f"tts.FallbackAdapter, {tts.label} recovered")
self._tts.emit(
"tts_availability_changed",
AvailabilityChangedEvent(tts=tts, available=True),
)
except Exception:
return
tts_status.recovering_task = asyncio.create_task(_recover_tts_task(tts))