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

431 lines
16 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 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 base64
import json
import os
import weakref
from dataclasses import dataclass, replace
from typing import Any
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 .log import logger
from .models import (
TTSDefaultVoiceId,
TTSDefaultVoiceStyle,
TTSEncoding,
TTSLocales,
TTSModels,
TTSStyles,
)
API_AUTH_HEADER = "api-key"
@dataclass
class _TTSOptions:
api_key: str
locale: TTSLocales | str | None
model: TTSModels | str
voice: str
style: str | None
speed: int | None
pitch: int | None
sample_rate: int
encoding: TTSEncoding
base_url: str
min_buffer_size: int
max_buffer_delay_in_ms: int
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: str | None = None,
model: TTSModels | str = "FALCON",
locale: TTSLocales | str | None = None,
voice: str = TTSDefaultVoiceId,
style: TTSStyles | str | None = None,
speed: int | None = None,
pitch: int | None = None,
sample_rate: int = 24000,
encoding: TTSEncoding = "pcm",
base_url: str = "https://global.api.murf.ai",
http_session: aiohttp.ClientSession | None = None,
tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
text_pacing: tts.SentenceStreamPacer | bool = False,
min_buffer_size: int = 3,
max_buffer_delay_in_ms: int = 0,
streaming: bool = True,
) -> None:
"""
Create a new instance of Murf AI TTS.
See https://murf.ai/api/docs/api-reference/text-to-speech/stream-input for more details on the the Murf AI API.
Args:
api_key (str | None, optional): The Murf AI API key. If not provided, it will be read from the MURF_API_KEY environment variable.
model (TTSModels | str, optional): The Murf AI TTS model to use. Defaults to "FALCON".
locale (str | None, optional): The locale for synthesis (e.g., "en-US", "en-UK"). If not provided, will be inferred from voice.
voice (str, optional): The voice ID from Murf AI's voice library (e.g., "en-US-matthew"). Defaults to TTSDefaultVoiceId.
style (TTSStyles | str | None, optional): The voice style to apply (e.g., "Conversation"). Can be None for default style.
speed (int | None, optional): The speech speed control. Higher values = faster speech. None for default speed.
pitch (int | None, optional): The speech pitch control. Higher values = higher pitch. None for default pitch.
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 24000.
encoding (str, optional): The audio encoding format. Defaults to "pcm".
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
base_url (str, optional): The base URL for the Murf AI API. Defaults to "https://global.api.murf.ai".
tokenizer (tokenize.SentenceTokenizer, optional): The tokenizer to use. Defaults to tokenize.blingfire.SentenceTokenizer(min_sentence_len=min_buffer_size).
text_pacing (tts.SentenceStreamPacer | bool, optional): Stream pacer for the TTS. Set to True to use the default pacer, False to disable.
min_buffer_size (int, optional):Minimum characters to buffer before sending text to audio when no sentence boundary is detected. Higher values improve quality; lower values reduce TTFB. Defaults to 3.
max_buffer_delay_in_ms (int, optional): Maximum wait time before sending buffered text if min_buffer_size isnt reached. Defaults to 0.
streaming (bool, optional): If True, uses WebSocket streaming for real-time audio. If False, uses HTTP requests. Defaults to True.
""" # noqa: E501
self._streaming = streaming
super().__init__(
capabilities=tts.TTSCapabilities(streaming=streaming),
sample_rate=sample_rate,
num_channels=1,
)
murf_api_key = api_key or os.environ.get("MURF_API_KEY")
if not murf_api_key:
raise ValueError("MURF_API_KEY must be set")
self._opts = _TTSOptions(
api_key=murf_api_key,
model=model,
locale=locale,
voice=voice,
style=style or TTSDefaultVoiceStyle,
speed=speed,
pitch=pitch,
sample_rate=sample_rate,
encoding=encoding,
base_url=base_url,
min_buffer_size=min_buffer_size,
max_buffer_delay_in_ms=max_buffer_delay_in_ms,
)
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,
)
self._streams = weakref.WeakSet[SynthesizeStream]()
self._sentence_tokenizer = (
tokenizer
if is_given(tokenizer)
else tokenize.blingfire.SentenceTokenizer(min_sentence_len=min_buffer_size)
)
self._stream_pacer: tts.SentenceStreamPacer | None = None
if text_pacing is True:
self._stream_pacer = tts.SentenceStreamPacer()
elif isinstance(text_pacing, tts.SentenceStreamPacer):
self._stream_pacer = text_pacing
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "Murf"
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
url = self._opts.get_ws_url(
f"/v1/speech/stream-input?sample_rate={self._opts.sample_rate}&format={self._opts.encoding}&model={self._opts.model}"
)
headers = {API_AUTH_HEADER: self._opts.api_key}
return await asyncio.wait_for(session.ws_connect(url, headers=headers), timeout)
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def prewarm(self) -> None:
self._pool.prewarm()
def update_options(
self,
*,
locale: NotGivenOr[str] = NOT_GIVEN,
voice: NotGivenOr[str] = NOT_GIVEN,
style: NotGivenOr[str | None] = NOT_GIVEN,
speed: NotGivenOr[int | None] = NOT_GIVEN,
pitch: NotGivenOr[int | None] = NOT_GIVEN,
) -> None:
"""
Update the Text-to-Speech (TTS) configuration options.
This method allows updating the TTS settings, including model, locale, voice, style,
speed and pitch. If any parameter is not provided, the existing value will be retained.
Args:
locale (str, optional): The locale for synthesis (e.g., "en-US", "en-UK").
voice (str, optional): The voice ID from Murf AI's voice library. (e.g. "en-US-matthew")
style (str | None, optional): The voice style to apply (e.g., "Conversation", "Promo").
speed (int | None, optional): Controls the speech speed. Positive values increase speed, negative values decrease it. Valid range: -50 to 50.
pitch (int | None, optional): Controls the speech pitch. Positive values raise pitch, negative values lower it. Valid range: -50 to 50.
"""
if is_given(locale):
self._opts.locale = locale
if is_given(voice):
self._opts.voice = voice
if is_given(style):
self._opts.style = style
if is_given(speed):
self._opts.speed = speed
if is_given(pitch):
self._opts.pitch = pitch
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()
class ChunkedStream(tts.ChunkedStream):
"""Synthesize chunked text using the http streaming output 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:
request_id = utils.shortuuid()
try:
async with self._tts._ensure_session().post(
self._opts.get_http_url("/v1/speech/stream"),
headers={API_AUTH_HEADER: self._opts.api_key},
json={
"text": self._input_text,
"model": self._opts.model,
"multiNativeLocale": self._opts.locale,
"voice_id": self._opts.voice,
"style": self._opts.style,
"rate": self._opts.speed,
"pitch": self._opts.pitch,
"format": self._opts.encoding,
"sample_rate": self._opts.sample_rate,
},
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
) as resp:
resp.raise_for_status()
output_emitter.initialize(
request_id=request_id,
sample_rate=self._opts.sample_rate,
num_channels=1,
mime_type="audio/pcm",
)
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):
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions):
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=1,
mime_type="audio/pcm",
stream=True,
)
input_sent_event = asyncio.Event()
sent_tokenizer_stream = self._tts._sentence_tokenizer.stream()
if self._tts._stream_pacer:
sent_tokenizer_stream = self._tts._stream_pacer.wrap(
sent_stream=sent_tokenizer_stream,
audio_emitter=output_emitter,
)
async def _sentence_stream_task(ws: aiohttp.ClientWebSocketResponse) -> None:
context_id = utils.shortuuid()
base_pkt = _to_murf_websocket_pkt(self._opts)
async for ev in sent_tokenizer_stream:
token_pkt = base_pkt.copy()
token_pkt["context_id"] = context_id
token_pkt["text"] = ev.token + " "
self._mark_started()
await ws.send_str(json.dumps(token_pkt))
input_sent_event.set()
end_pkt = base_pkt.copy()
end_pkt["context_id"] = context_id
end_pkt["end"] = True
await ws.send_str(json.dumps(end_pkt))
input_sent_event.set()
async def _input_task() -> None:
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
sent_tokenizer_stream.flush()
continue
sent_tokenizer_stream.push_text(data)
sent_tokenizer_stream.end_input()
async def _recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
current_segment_id: str | None = None
await input_sent_event.wait()
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
raise APIStatusError(
"Murf AI connection closed unexpectedly", request_id=request_id
)
if msg.type != aiohttp.WSMsgType.TEXT:
logger.warning("unexpected Murf AI message type %s", msg.type)
continue
data = json.loads(msg.data)
segment_id = data.get("context_id")
if current_segment_id is None:
current_segment_id = segment_id
output_emitter.start_segment(segment_id=current_segment_id)
if data.get("audio"):
b64data = base64.b64decode(data["audio"])
output_emitter.push(b64data)
elif data.get("final"):
if sent_tokenizer_stream.closed:
# close only if the input stream is closed
output_emitter.end_input()
break
else:
logger.warning("unexpected message %s", data)
try:
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
tasks = [
asyncio.create_task(_input_task()),
asyncio.create_task(_sentence_stream_task(ws)),
asyncio.create_task(_recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
input_sent_event.set()
await sent_tokenizer_stream.aclose()
await utils.aio.gracefully_cancel(*tasks)
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 _to_murf_websocket_pkt(opts: _TTSOptions) -> dict[str, Any]:
voice_config: dict[str, Any] = {}
if opts.voice:
voice_config["voice_id"] = opts.voice
if opts.style:
voice_config["style"] = opts.style
if opts.speed:
voice_config["rate"] = opts.speed
if opts.pitch:
voice_config["pitch"] = opts.pitch
if opts.locale:
voice_config["multi_native_locale"] = opts.locale
return {
"voice_config": voice_config,
"min_buffer_size": opts.min_buffer_size,
"max_buffer_delay_in_ms": opts.max_buffer_delay_in_ms,
}