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

510 lines
19 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.
from __future__ import annotations
import asyncio
import os
import weakref
from dataclasses import dataclass, replace
from typing import Any
import aiohttp
import msgpack
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 .log import logger
from .models import LatencyMode, OutputFormat, TTSModels
from .version import __version__
DEFAULT_MODEL: TTSModels = "s2.1-pro"
DEFAULT_VOICE_ID = "933563129e564b19a115bedd57b7406a"
DEFAULT_BASE_URL = "https://api.fish.audio"
NUM_CHANNELS = 1
USER_AGENT = f"livekit-plugins-fishaudio/{__version__}"
# Hold the first N audio chunks before releasing any audio, so playout starts with
# enough buffered to ride out the gap after Fish's small first chunk (else jitter
# underruns it into a crackle). Internal stopgap for Fish's cold-start pacing; drop
# to 1 once inference streams the opening chunks smoothly. Values <= 1 disable it.
_PREBUFFER_CHUNKS = 2
# Fish Audio's default sample rate per output format. Opus only supports 48 kHz;
# the other formats default to 24 kHz, which matches the previous plugin default.
_DEFAULT_SAMPLE_RATE: dict[OutputFormat, int] = {
"opus": 48000,
"pcm": 24000,
"wav": 24000,
"mp3": 32000,
}
@dataclass
class _TTSOptions:
model: TTSModels | str
output_format: OutputFormat
sample_rate: int
voice_id: NotGivenOr[str]
base_url: str
api_key: str
latency_mode: LatencyMode
chunk_length: int
speed: NotGivenOr[float]
volume: NotGivenOr[float]
def get_http_url(self, path: str) -> str:
return f"{self.base_url}{path}"
def get_ws_url(self, path: str) -> str:
return f"{self.base_url.replace('http', 'ws', 1)}{path}"
class TTS(tts.TTS):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
model: TTSModels | str = DEFAULT_MODEL,
voice_id: NotGivenOr[str] = DEFAULT_VOICE_ID,
output_format: OutputFormat = "wav",
sample_rate: NotGivenOr[int] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
latency_mode: LatencyMode = "balanced",
chunk_length: int = 100,
speed: NotGivenOr[float] = NOT_GIVEN,
volume: NotGivenOr[float] = NOT_GIVEN,
tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""
Create a new instance of Fish Audio TTS.
See https://docs.fish.audio/api-reference/endpoint/websocket/tts-live for more details
on the Fish Audio Live TTS WebSocket API.
Args:
api_key (NotGivenOr[str]): Fish Audio API key. Reads ``FISH_API_KEY`` if unset.
model (TTSModels | str): TTS model to use. Defaults to ``"s2.1-pro"``.
voice_id (NotGivenOr[str]): Voice model ID. Fish Audio's API refers to this
as ``reference_id``; it's the same value either way.
output_format (OutputFormat): Audio output format. Defaults to ``"wav"``.
sample_rate (int): Audio sample rate in Hz.
base_url (NotGivenOr[str]): Custom base URL. Defaults to ``https://api.fish.audio``.
latency_mode (LatencyMode): Streaming latency mode. ``"normal"``, ``"balanced"``,
or ``"low"``. Defaults to ``"balanced"``.
chunk_length (int): Upper bound on text Fish buffers before auto-synthesizing
(100300). With sentence-level flushing this is only hit by sentences longer
than ``chunk_length``; otherwise audio is produced when each sentence is
flushed. Defaults to 100.
speed (NotGivenOr[float]): Speaking rate multiplier (Fish ``prosody.speed``).
``1.0`` is normal; below 1.0 is slower, above is faster. Unset uses the
voice's natural pace.
volume (NotGivenOr[float]): Loudness adjustment in decibels (Fish
``prosody.volume``). ``0`` is the voice's natural level. Unset leaves it
unchanged.
tokenizer (tokenize.SentenceTokenizer): Sentence tokenizer used to detect
sentence boundaries. Defaults to ``tokenize.blingfire.SentenceTokenizer()``.
http_session (aiohttp.ClientSession | None): Optional aiohttp session.
"""
if is_given(sample_rate):
if output_format == "opus" and sample_rate != 48000:
raise ValueError(
"Fish Audio only supports 48000 Hz for opus output; "
f"got sample_rate={sample_rate}"
)
resolved_sample_rate = sample_rate
else:
resolved_sample_rate = _DEFAULT_SAMPLE_RATE[output_format]
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=resolved_sample_rate,
num_channels=NUM_CHANNELS,
)
fish_api_key = api_key if is_given(api_key) else os.getenv("FISH_API_KEY")
if not fish_api_key:
raise ValueError(
"Fish Audio API key is required, either as argument or set "
"FISH_API_KEY environment variable"
)
if not 100 <= chunk_length <= 300:
raise ValueError("chunk_length must be between 100 and 300")
self._opts = _TTSOptions(
model=model,
output_format=output_format,
sample_rate=resolved_sample_rate,
voice_id=voice_id,
base_url=base_url if is_given(base_url) else DEFAULT_BASE_URL,
api_key=fish_api_key,
latency_mode=latency_mode,
chunk_length=chunk_length,
speed=speed,
volume=volume,
)
self._session = http_session
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
max_session_duration=300,
mark_refreshed_on_get=True,
)
# min_sentence_len=1 emits each sentence as soon as the next one starts,
# rather than batching short sentences together — minimizes TTFB on the
# first sentence and keeps Fish synthesizing continuously.
self._sentence_tokenizer = (
tokenizer
if is_given(tokenizer)
else tokenize.blingfire.SentenceTokenizer(min_sentence_len=1)
)
self._streams = weakref.WeakSet[SynthesizeStream]()
@property
def model(self) -> TTSModels | str:
return self._opts.model
@property
def provider(self) -> str:
return "FishAudio"
@property
def output_format(self) -> OutputFormat:
return self._opts.output_format
@property
def voice_id(self) -> NotGivenOr[str]:
return self._opts.voice_id
@property
def latency_mode(self) -> LatencyMode:
return self._opts.latency_mode
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:
session = self._ensure_session()
return await asyncio.wait_for(
session.ws_connect(
self._opts.get_ws_url("/v1/tts/live"),
headers={
"Authorization": f"Bearer {self._opts.api_key}",
"User-Agent": USER_AGENT,
"model": self._opts.model,
},
heartbeat=30.0,
),
timeout,
)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
await ws.close()
def prewarm(self) -> None:
self._pool.prewarm()
def update_options(
self,
*,
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
voice_id: NotGivenOr[str] = NOT_GIVEN,
latency_mode: NotGivenOr[LatencyMode] = NOT_GIVEN,
chunk_length: NotGivenOr[int] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
volume: NotGivenOr[float] = NOT_GIVEN,
) -> None:
if is_given(model) and model != self._opts.model:
self._opts.model = model
# The model is sent as a connection header at ws-handshake time, not in the
# per-request body, so a pooled socket keeps the old model. Drop pooled
# connections so the next stream reconnects with the new model. Other
# options ride in the per-request body and need no reconnect.
self._pool.invalidate()
if is_given(voice_id):
self._opts.voice_id = voice_id
if is_given(latency_mode):
self._opts.latency_mode = latency_mode
if is_given(chunk_length):
if not 100 <= chunk_length <= 300:
raise ValueError("chunk_length must be between 100 and 300")
self._opts.chunk_length = chunk_length
if is_given(speed):
self._opts.speed = speed
if is_given(volume):
self._opts.volume = volume
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:
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await self._pool.aclose()
def _build_tts_request(opts: _TTSOptions, *, text: str = "") -> dict[str, Any]:
# Send the same field set the upstream Fish Audio Python SDK sends so the
# server doesn't fall back to its own (larger) defaults — in particular the
# docs default of `chunk_length=300` produces large bursts that leave audible
# gaps between Fish's chunk boundaries.
# `prosody` stays None unless the caller set speed/volume, so the default
# request is byte-for-byte unchanged.
prosody: dict[str, float] | None = None
if is_given(opts.speed) or is_given(opts.volume):
prosody = {}
if is_given(opts.speed):
prosody["speed"] = opts.speed
if is_given(opts.volume):
prosody["volume"] = opts.volume
return {
"text": text,
"chunk_length": opts.chunk_length,
"format": opts.output_format,
"sample_rate": opts.sample_rate,
"mp3_bitrate": 64,
"opus_bitrate": 64000,
"references": [],
# Fish Audio's wire field is `reference_id`; we expose it as `voice_id` on
# the plugin for consistency with other TTS plugins.
"reference_id": opts.voice_id if is_given(opts.voice_id) else None,
"normalize": True,
"latency": opts.latency_mode,
"prosody": prosody,
"top_p": 0.7,
"temperature": 0.7,
}
class ChunkedStream(tts.ChunkedStream):
"""Synthesize via the Fish Audio HTTP /v1/tts 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:
payload = _build_tts_request(self._opts, text=self._input_text)
try:
async with self._tts._ensure_session().post(
self._opts.get_http_url("/v1/tts"),
headers={
"Authorization": f"Bearer {self._opts.api_key}",
"Content-Type": "application/msgpack",
"model": self._opts.model,
},
data=msgpack.packb(payload, use_bin_type=True),
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=f"audio/{self._opts.output_format}",
)
async for data, _ in resp.content.iter_chunks():
output_emitter.push(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
class SynthesizeStream(tts.SynthesizeStream):
"""Streaming TTS via the Fish Audio /v1/tts/live WebSocket endpoint."""
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:
request_id = utils.shortuuid()
output_emitter.initialize(
request_id=request_id,
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
mime_type=f"audio/{self._opts.output_format}",
stream=True,
)
output_emitter.start_segment(segment_id=request_id)
try:
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
await self._run_ws(ws, output_emitter)
except asyncio.TimeoutError:
raise APITimeoutError() from None
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message, status_code=e.status, request_id=request_id, body=None
) from None
except APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
finally:
output_emitter.end_segment()
async def _run_ws(
self,
ws: aiohttp.ClientWebSocketResponse,
output_emitter: tts.AudioEmitter,
) -> None:
# Tokenize incoming text by sentence and flush after each sentence so
# Fish synthesizes immediately at sentence boundaries instead of waiting
# for `chunk_length` characters to accumulate. The result is much smoother
# audio: gaps line up with sentence breaks (where pauses are natural)
# rather than mid-clause.
sent_stream = self._tts._sentence_tokenizer.stream()
async def input_task() -> None:
try:
first_token = True
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
sent_stream.flush()
continue
if not data:
continue
if first_token:
self._mark_started()
first_token = False
sent_stream.push_text(data)
finally:
sent_stream.end_input()
async def send_task() -> None:
start_msg = {"event": "start", "request": _build_tts_request(self._opts)}
await ws.send_bytes(msgpack.packb(start_msg, use_bin_type=True))
async for ev in sent_stream:
sentence = ev.token
if not sentence:
continue
await ws.send_bytes(
msgpack.packb({"event": "text", "text": sentence + " "}, use_bin_type=True)
)
await ws.send_bytes(msgpack.packb({"event": "flush"}, use_bin_type=True))
await ws.send_bytes(msgpack.packb({"event": "stop"}, use_bin_type=True))
prebuffer_chunks = _PREBUFFER_CHUNKS
prebuffer = bytearray()
chunks_seen = 0
prebuffering = prebuffer_chunks > 1
def push_audio(audio: bytes) -> None:
nonlocal prebuffering, chunks_seen
if prebuffering:
prebuffer.extend(audio)
chunks_seen += 1
if chunks_seen < prebuffer_chunks:
return
output_emitter.push(bytes(prebuffer))
prebuffer.clear()
prebuffering = False
else:
output_emitter.push(audio)
def flush_prebuffer() -> None:
# Stream ended before we reached `prebuffer_chunks` (short utterance) —
# release whatever is held so nothing is dropped or left hanging.
nonlocal prebuffering
if prebuffering and prebuffer:
output_emitter.push(bytes(prebuffer))
prebuffer.clear()
prebuffering = False
async def recv_task() -> None:
# No per-receive timeout: Fish has natural inter-sentence gaps that
# can exceed `_conn_options.timeout` when the LLM is slow. Dead
# connections are detected by aiohttp's ws heartbeat (see ws_connect).
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
raise APIStatusError(
"Fish Audio websocket connection closed unexpectedly",
status_code=ws.close_code or -1,
request_id=None,
body=f"{msg.data=} {msg.extra=}",
)
if msg.type != aiohttp.WSMsgType.BINARY:
logger.debug("unexpected Fish Audio message type %s", msg.type)
continue
data = msgpack.unpackb(msg.data, raw=False)
event = data.get("event")
if event == "audio":
audio = data.get("audio")
if audio:
push_audio(audio)
elif event == "finish":
reason = data.get("reason")
if reason == "error":
raise APIStatusError(
"Fish Audio TTS reported an error",
status_code=-1,
request_id=None,
body=str(data),
)
flush_prebuffer()
break
else:
logger.debug("unknown Fish Audio event: %s", data)
tasks = [
asyncio.create_task(input_task()),
asyncio.create_task(send_task()),
asyncio.create_task(recv_task()),
]
try:
await asyncio.gather(*tasks)
finally:
await sent_stream.aclose()
await utils.aio.gracefully_cancel(*tasks)