livekit--agents
431 行
16 KiB
Python
431 行
16 KiB
Python
# 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 isn’t 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,
|
|
}
|