1360 lines
56 KiB
Python
1360 lines
56 KiB
Python
# 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
|
|
import time
|
|
import uuid
|
|
import weakref
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field, replace
|
|
from enum import Enum
|
|
from typing import Any, Literal, cast, get_args
|
|
from urllib.parse import urljoin
|
|
|
|
import aiohttp
|
|
|
|
from livekit.agents import tokenize, tts, utils
|
|
from livekit.agents._exceptions import (
|
|
APIConnectionError,
|
|
APIError,
|
|
APIStatusError,
|
|
APITimeoutError,
|
|
)
|
|
from livekit.agents.types import (
|
|
DEFAULT_API_CONNECT_OPTIONS,
|
|
NOT_GIVEN,
|
|
APIConnectOptions,
|
|
NotGivenOr,
|
|
)
|
|
from livekit.agents.utils import is_given
|
|
from livekit.agents.voice.io import TimedString
|
|
|
|
from .log import logger
|
|
from .version import __version__
|
|
|
|
USER_AGENT = f"livekit-agents-py/{__version__}"
|
|
|
|
DEFAULT_BIT_RATE = 64000
|
|
DEFAULT_ENCODING: Encoding = "PCM"
|
|
DEFAULT_MODEL = "inworld-tts-1.5-max"
|
|
DEFAULT_SAMPLE_RATE = 24000
|
|
DEFAULT_URL = "https://api.inworld.ai/"
|
|
DEFAULT_WS_URL = "wss://api.inworld.ai/"
|
|
DEFAULT_VOICE = "Ashley"
|
|
DEFAULT_TEMPERATURE = 1.0
|
|
DEFAULT_SPEAKING_RATE = 1.0
|
|
DEFAULT_BUFFER_CHAR_THRESHOLD = 120
|
|
DEFAULT_MAX_BUFFER_DELAY_MS = 3000
|
|
NUM_CHANNELS = 1
|
|
|
|
TTSModels = Literal["inworld-tts-2", "inworld-tts-1.5-max"]
|
|
Encoding = Literal["LINEAR16", "PCM", "MP3", "OGG_OPUS", "FLAC"]
|
|
TimestampType = Literal["TIMESTAMP_TYPE_UNSPECIFIED", "WORD", "CHARACTER"]
|
|
_TextNormalizationStr = Literal["APPLY_TEXT_NORMALIZATION_UNSPECIFIED", "ON", "OFF"]
|
|
TextNormalization = _TextNormalizationStr | bool
|
|
TimestampTransportStrategy = Literal["TIMESTAMP_TRANSPORT_STRATEGY_UNSPECIFIED", "SYNC", "ASYNC"]
|
|
DeliveryMode = Literal["DELIVERY_MODE_UNSPECIFIED", "STABLE", "BALANCED", "CREATIVE"]
|
|
|
|
DEFAULT_TIMESTAMP_TRANSPORT_STRATEGY: TimestampTransportStrategy = "ASYNC"
|
|
|
|
|
|
def _validate_str_param(value: object, name: str, literal_type: Any) -> None:
|
|
valid = get_args(literal_type)
|
|
if not isinstance(value, str) or value not in valid:
|
|
raise ValueError(f"Invalid {name}: {value!r}. Must be one of {sorted(valid)}")
|
|
|
|
|
|
def _resolve_text_normalization(value: Any) -> _TextNormalizationStr:
|
|
if isinstance(value, bool):
|
|
return "ON" if value else "OFF"
|
|
_validate_str_param(value, "text_normalization", _TextNormalizationStr)
|
|
return cast(_TextNormalizationStr, value)
|
|
|
|
|
|
@dataclass
|
|
class _TTSOptions:
|
|
model: TTSModels | str
|
|
encoding: Encoding
|
|
voice: str
|
|
sample_rate: int
|
|
bit_rate: int
|
|
speaking_rate: float
|
|
temperature: float
|
|
language: NotGivenOr[str] = NOT_GIVEN
|
|
timestamp_type: NotGivenOr[TimestampType] = NOT_GIVEN
|
|
text_normalization: NotGivenOr[TextNormalization] = NOT_GIVEN
|
|
delivery_mode: NotGivenOr[DeliveryMode] = NOT_GIVEN
|
|
timestamp_transport_strategy: TimestampTransportStrategy = DEFAULT_TIMESTAMP_TRANSPORT_STRATEGY
|
|
buffer_char_threshold: int = DEFAULT_BUFFER_CHAR_THRESHOLD
|
|
max_buffer_delay_ms: int = DEFAULT_MAX_BUFFER_DELAY_MS
|
|
|
|
@property
|
|
def mime_type(self) -> str:
|
|
if self.encoding == "PCM":
|
|
return "audio/pcm"
|
|
elif self.encoding == "MP3":
|
|
return "audio/mpeg"
|
|
elif self.encoding == "OGG_OPUS":
|
|
return "audio/ogg"
|
|
elif self.encoding == "FLAC":
|
|
return "audio/flac"
|
|
else:
|
|
return "audio/wav"
|
|
|
|
|
|
# Shared WebSocket connection infrastructure
|
|
class _ContextState(Enum):
|
|
ACTIVE = "active"
|
|
CREATING = "creating"
|
|
CLOSING = "closing"
|
|
|
|
|
|
@dataclass
|
|
class _ContextInfo:
|
|
context_id: str
|
|
state: _ContextState
|
|
emitter: tts.AudioEmitter | None = None
|
|
waiter: asyncio.Future[None] | None = None
|
|
segment_started: bool = False
|
|
flush_after_push: bool = False
|
|
created_at: float = field(default_factory=time.time)
|
|
close_started_at: float | None = None
|
|
# Cumulative timestamp tracking for monotonic timestamps across generations.
|
|
# When auto_mode is enabled or flush_context() is called, the server resets
|
|
# timestamps to 0 after each generation. We add cumulative_time to maintain
|
|
# monotonically increasing timestamps within an agent turn.
|
|
cumulative_time: float = 0.0
|
|
generation_end_time: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class _CreateContextMsg:
|
|
context_id: str
|
|
opts: _TTSOptions
|
|
|
|
|
|
@dataclass
|
|
class _SendTextMsg:
|
|
context_id: str
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class _FlushContextMsg:
|
|
context_id: str
|
|
|
|
|
|
@dataclass
|
|
class _CloseContextMsg:
|
|
context_id: str
|
|
|
|
|
|
_OutboundMessage = _CreateContextMsg | _SendTextMsg | _FlushContextMsg | _CloseContextMsg
|
|
|
|
|
|
class _InworldConnection:
|
|
"""Manages a single shared WebSocket connection with up to 5 concurrent contexts."""
|
|
|
|
MAX_CONTEXTS = 5
|
|
|
|
def __init__(
|
|
self,
|
|
session: aiohttp.ClientSession,
|
|
ws_url: str,
|
|
authorization: str,
|
|
on_capacity_available: Callable[[], None] | None = None,
|
|
) -> None:
|
|
self._session = session
|
|
self._ws_url = ws_url
|
|
self._authorization = authorization
|
|
self._on_capacity_available = on_capacity_available
|
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
|
|
# Context pool
|
|
self._contexts: dict[str, _ContextInfo] = {}
|
|
self._context_available = asyncio.Event()
|
|
self._pending_acquisitions: int = 0 # Reserved slots not yet in _contexts
|
|
|
|
# Message queue for outbound messages
|
|
self._outbound_queue: asyncio.Queue[_OutboundMessage] = asyncio.Queue()
|
|
|
|
# Connection state
|
|
self._closed = False
|
|
self._connect_lock = asyncio.Lock()
|
|
self._acquire_lock = asyncio.Lock() # Ensures atomic capacity check + context creation
|
|
self._last_activity: float = time.time()
|
|
|
|
# Background tasks
|
|
self._send_task: asyncio.Task[None] | None = None
|
|
self._recv_task: asyncio.Task[None] | None = None
|
|
self._cleanup_task: asyncio.Task[None] | None = None
|
|
|
|
@property
|
|
def context_count(self) -> int:
|
|
"""Number of contexts using server resources.
|
|
|
|
Includes all states (CREATING, ACTIVE, CLOSING) until contextClosed is received
|
|
from the server and the context is removed from _contexts.
|
|
"""
|
|
return len(self._contexts)
|
|
|
|
@property
|
|
def has_capacity(self) -> bool:
|
|
"""Whether this connection can accept more contexts.
|
|
|
|
Accounts for both active contexts and pending acquisitions (reserved slots
|
|
that haven't completed context creation yet).
|
|
"""
|
|
return (self.context_count + self._pending_acquisitions) < self.MAX_CONTEXTS
|
|
|
|
def reserve_capacity(self) -> None:
|
|
"""Reserve a slot for a pending context acquisition.
|
|
|
|
Call this before releasing the pool lock to prevent other callers from
|
|
over-subscribing this connection. The reservation is released when
|
|
acquire_context() completes (success or failure).
|
|
"""
|
|
self._pending_acquisitions += 1
|
|
|
|
def release_reservation(self) -> None:
|
|
"""Release a previously reserved slot.
|
|
|
|
Called automatically by acquire_context() on success, or manually on failure.
|
|
"""
|
|
if self._pending_acquisitions > 0:
|
|
self._pending_acquisitions -= 1
|
|
|
|
@property
|
|
def is_idle(self) -> bool:
|
|
"""Whether this connection has no active contexts and no pending acquisitions."""
|
|
return self.context_count == 0 and self._pending_acquisitions == 0
|
|
|
|
@property
|
|
def last_activity(self) -> float:
|
|
"""Timestamp of last context acquisition or release."""
|
|
return self._last_activity
|
|
|
|
async def connect(self) -> None:
|
|
"""Establish WebSocket connection and start background loops."""
|
|
async with self._connect_lock:
|
|
if self._ws is not None or self._closed:
|
|
return
|
|
|
|
url = urljoin(self._ws_url, "/tts/v1/voice:streamBidirectional")
|
|
request_id = str(uuid.uuid4())
|
|
self._ws = await self._session.ws_connect(
|
|
url,
|
|
headers={
|
|
"Authorization": self._authorization,
|
|
"X-User-Agent": USER_AGENT,
|
|
"X-Request-Id": request_id,
|
|
},
|
|
)
|
|
logger.debug(
|
|
"Established Inworld TTS WebSocket connection (shared)",
|
|
extra={"request_id": request_id},
|
|
)
|
|
|
|
self._send_task = asyncio.create_task(self._send_loop())
|
|
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
self._cleanup_task = asyncio.create_task(self._cleanup_stale_contexts())
|
|
|
|
async def acquire_context(
|
|
self,
|
|
emitter: tts.AudioEmitter,
|
|
opts: _TTSOptions,
|
|
timeout: float,
|
|
) -> tuple[str, asyncio.Future[None]]:
|
|
"""Acquire a new context for TTS synthesis.
|
|
|
|
Note: Caller should check has_capacity before calling this method when using
|
|
a connection pool. This method will still wait if at capacity, but the pool
|
|
should route to connections with available capacity first.
|
|
"""
|
|
await self.connect()
|
|
|
|
# Fail fast if connection is closed
|
|
if self._closed or self._ws is None:
|
|
raise APIConnectionError("Connection is closed")
|
|
|
|
start_time = time.time()
|
|
|
|
while True:
|
|
# Check closed state at start of each iteration
|
|
if self._closed or self._ws is None:
|
|
raise APIConnectionError("Connection is closed")
|
|
|
|
# Use lock to ensure atomic capacity check + context creation
|
|
async with self._acquire_lock:
|
|
# Check raw context_count, not has_capacity, because we ARE one of
|
|
# the pending acquisitions - has_capacity would always be false
|
|
if self.context_count < self.MAX_CONTEXTS:
|
|
self._last_activity = time.time()
|
|
ctx_id = utils.shortuuid()
|
|
waiter: asyncio.Future[None] = asyncio.get_event_loop().create_future()
|
|
|
|
ctx_info = _ContextInfo(
|
|
context_id=ctx_id,
|
|
state=_ContextState.CREATING,
|
|
emitter=emitter,
|
|
waiter=waiter,
|
|
flush_after_push=opts.encoding == "PCM",
|
|
)
|
|
self._contexts[ctx_id] = ctx_info
|
|
# Release reservation now that we have a real context
|
|
self.release_reservation()
|
|
|
|
await self._outbound_queue.put(_CreateContextMsg(context_id=ctx_id, opts=opts))
|
|
return ctx_id, waiter
|
|
|
|
# No capacity - wait outside the lock
|
|
elapsed = time.time() - start_time
|
|
remaining = timeout - elapsed
|
|
if remaining <= 0:
|
|
raise APITimeoutError()
|
|
|
|
try:
|
|
await asyncio.wait_for(self._context_available.wait(), timeout=remaining)
|
|
# Clear after wait returns to avoid lost-wakeup race
|
|
self._context_available.clear()
|
|
|
|
# Check closed state after waking
|
|
if self._closed or self._ws is None:
|
|
raise APIConnectionError("Connection is closed")
|
|
except asyncio.TimeoutError:
|
|
raise APITimeoutError() from None
|
|
|
|
def send_text(self, context_id: str, text: str) -> None:
|
|
"""Queue text to be sent to a context."""
|
|
try:
|
|
self._outbound_queue.put_nowait(_SendTextMsg(context_id=context_id, text=text))
|
|
except asyncio.QueueFull:
|
|
logger.warning("Outbound queue full, dropping text")
|
|
|
|
def flush_context(self, context_id: str) -> None:
|
|
"""Queue a flush message for a context."""
|
|
try:
|
|
self._outbound_queue.put_nowait(_FlushContextMsg(context_id=context_id))
|
|
except asyncio.QueueFull:
|
|
logger.warning("Outbound queue full, dropping flush")
|
|
|
|
def _release_context(self, context_id: str) -> None:
|
|
"""Remove a context and signal that capacity is available."""
|
|
self._contexts.pop(context_id, None)
|
|
self._last_activity = time.time()
|
|
self._context_available.set()
|
|
if self._on_capacity_available:
|
|
self._on_capacity_available()
|
|
|
|
def close_context(self, context_id: str) -> None:
|
|
"""Queue a close message for a context (removes from pool)."""
|
|
ctx = self._contexts.get(context_id)
|
|
if ctx:
|
|
ctx.state = _ContextState.CLOSING
|
|
ctx.close_started_at = time.time()
|
|
try:
|
|
self._outbound_queue.put_nowait(_CloseContextMsg(context_id=context_id))
|
|
except asyncio.QueueFull:
|
|
logger.warning("Outbound queue full, dropping close")
|
|
|
|
async def _send_loop(self) -> None:
|
|
"""Process outbound messages to WebSocket."""
|
|
try:
|
|
while not self._closed and self._ws:
|
|
try:
|
|
msg = await asyncio.wait_for(self._outbound_queue.get(), timeout=1.0)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
|
|
if self._closed or not self._ws:
|
|
break
|
|
|
|
if isinstance(msg, _CreateContextMsg):
|
|
opts = msg.opts
|
|
pkt: dict[str, Any] = {
|
|
"create": {
|
|
"voiceId": opts.voice,
|
|
"modelId": opts.model,
|
|
"audioConfig": {
|
|
"audioEncoding": opts.encoding,
|
|
"sampleRateHertz": opts.sample_rate,
|
|
"bitrate": opts.bit_rate,
|
|
"speakingRate": opts.speaking_rate,
|
|
},
|
|
"temperature": opts.temperature,
|
|
"bufferCharThreshold": opts.buffer_char_threshold,
|
|
"maxBufferDelayMs": opts.max_buffer_delay_ms,
|
|
"timestampTransportStrategy": opts.timestamp_transport_strategy,
|
|
},
|
|
"contextId": msg.context_id,
|
|
}
|
|
if is_given(opts.language):
|
|
pkt["create"]["language"] = opts.language
|
|
if is_given(opts.timestamp_type):
|
|
pkt["create"]["timestampType"] = opts.timestamp_type
|
|
if is_given(opts.text_normalization):
|
|
pkt["create"]["applyTextNormalization"] = opts.text_normalization
|
|
if is_given(opts.delivery_mode):
|
|
pkt["create"]["deliveryMode"] = opts.delivery_mode
|
|
# Always enable auto_mode since we always use SentenceTokenizer
|
|
pkt["create"]["autoMode"] = True
|
|
await self._ws.send_str(json.dumps(pkt))
|
|
|
|
elif isinstance(msg, _SendTextMsg):
|
|
pkt = {
|
|
"send_text": {"text": msg.text},
|
|
"contextId": msg.context_id,
|
|
}
|
|
await self._ws.send_str(json.dumps(pkt))
|
|
|
|
elif isinstance(msg, _FlushContextMsg):
|
|
pkt = {"flush_context": {}, "contextId": msg.context_id}
|
|
await self._ws.send_str(json.dumps(pkt))
|
|
|
|
elif isinstance(msg, _CloseContextMsg):
|
|
pkt = {"close_context": {}, "contextId": msg.context_id}
|
|
await self._ws.send_str(json.dumps(pkt))
|
|
|
|
except Exception as e:
|
|
logger.error("Inworld send loop error", exc_info=e)
|
|
finally:
|
|
if not self._closed:
|
|
await self._handle_connection_error(APIConnectionError())
|
|
|
|
async def _recv_loop(self) -> None:
|
|
"""Process inbound messages and route to correct context."""
|
|
try:
|
|
while not self._closed and self._ws:
|
|
try:
|
|
msg = await self._ws.receive(timeout=60.0)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
|
|
if msg.type in (
|
|
aiohttp.WSMsgType.CLOSED,
|
|
aiohttp.WSMsgType.CLOSE,
|
|
aiohttp.WSMsgType.CLOSING,
|
|
):
|
|
break
|
|
|
|
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
continue
|
|
|
|
data = json.loads(msg.data)
|
|
|
|
# Check for errors that are not associated with a context
|
|
if err_obj := data.get("error"):
|
|
logger.warning(
|
|
"Received error from Inworld",
|
|
extra={
|
|
"error_code": err_obj.get("code", 0),
|
|
"error_message": err_obj.get("message", "Unknown error"),
|
|
},
|
|
)
|
|
continue
|
|
|
|
result = data.get("result", {})
|
|
context_id = result.get("contextId")
|
|
|
|
ctx = self._contexts.get(context_id) if context_id else None
|
|
|
|
# Check for errors in status
|
|
status = result.get("status", {})
|
|
if status.get("code", 0) != 0:
|
|
error_msg = status.get("message", "Unknown error")
|
|
error = APIError(f"Inworld error: {error_msg}")
|
|
logger.warning(
|
|
"Received error from Inworld",
|
|
extra={
|
|
"context_id": context_id,
|
|
"error_code": status.get("code"),
|
|
"error_message": error_msg,
|
|
"context_state": ctx.state.value if ctx else "unknown",
|
|
"context_known": ctx is not None,
|
|
},
|
|
)
|
|
if ctx:
|
|
if ctx.waiter and not ctx.waiter.done():
|
|
ctx.waiter.set_exception(error)
|
|
self._release_context(context_id)
|
|
continue
|
|
|
|
if not ctx:
|
|
continue
|
|
|
|
if "contextCreated" in result:
|
|
ctx.state = _ContextState.ACTIVE
|
|
logger.info(
|
|
"Context created confirmed by server",
|
|
extra={"context_id": context_id},
|
|
)
|
|
continue
|
|
|
|
if audio_chunk := result.get("audioChunk"):
|
|
if ctx.emitter:
|
|
if not ctx.segment_started:
|
|
ctx.emitter.start_segment(segment_id=context_id)
|
|
ctx.segment_started = True
|
|
|
|
# Adjust timestamps for cumulative offset
|
|
if timestamp_info := audio_chunk.get("timestampInfo"):
|
|
if word_align := timestamp_info.get("wordAlignment"):
|
|
raw_words = word_align.get("words", [])
|
|
raw_starts = word_align.get("wordStartTimeSeconds", [])
|
|
raw_ends = word_align.get("wordEndTimeSeconds", [])
|
|
logger.debug(
|
|
"Raw timestamps from server",
|
|
extra={
|
|
"context_id": context_id,
|
|
"cumulative_offset": ctx.cumulative_time,
|
|
"raw_words": raw_words,
|
|
"raw_starts": raw_starts,
|
|
"raw_ends": raw_ends,
|
|
},
|
|
)
|
|
|
|
timed_strings = _parse_timestamp_info(
|
|
timestamp_info, cumulative_time=ctx.cumulative_time
|
|
)
|
|
# Track generation end time from last word for cumulative offset
|
|
if timed_strings:
|
|
last_ts = timed_strings[-1]
|
|
if utils.is_given(last_ts.end_time):
|
|
ctx.generation_end_time = last_ts.end_time
|
|
|
|
logger.debug(
|
|
"Adjusted timestamps (with cumulative offset)",
|
|
extra={
|
|
"context_id": context_id,
|
|
"words": [str(ts) for ts in timed_strings],
|
|
"adjusted_starts": [ts.start_time for ts in timed_strings],
|
|
"adjusted_ends": [ts.end_time for ts in timed_strings],
|
|
"generation_end_time": ctx.generation_end_time,
|
|
},
|
|
)
|
|
|
|
for ts in timed_strings:
|
|
ctx.emitter.push_timed_transcript(ts)
|
|
|
|
if audio_content := audio_chunk.get("audioContent"):
|
|
ctx.emitter.push(base64.b64decode(audio_content))
|
|
if ctx.flush_after_push:
|
|
ctx.emitter.flush()
|
|
continue
|
|
|
|
if "flushCompleted" in result:
|
|
# Signals the end of a generation, subsequent timestampes from the server
|
|
# will reset offset to 0. We need to update the cumulative time to the
|
|
# generation end time to maintain monotonically increasing timestamps
|
|
# within the agent turn.
|
|
if ctx.generation_end_time > 0:
|
|
logger.debug(
|
|
"flushCompleted - updating cumulative time",
|
|
extra={
|
|
"context_id": context_id,
|
|
"old_cumulative_time": ctx.cumulative_time,
|
|
"new_cumulative_time": ctx.generation_end_time,
|
|
},
|
|
)
|
|
ctx.cumulative_time = ctx.generation_end_time
|
|
continue
|
|
|
|
if "contextClosed" in result:
|
|
if ctx.waiter and not ctx.waiter.done():
|
|
ctx.waiter.set_result(None)
|
|
self._release_context(context_id)
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error("Inworld recv loop error", exc_info=e)
|
|
finally:
|
|
if not self._closed:
|
|
await self._handle_connection_error(APIConnectionError())
|
|
|
|
async def _cleanup_stale_contexts(self) -> None:
|
|
"""Periodically clean up orphaned contexts stuck in CLOSING state."""
|
|
while not self._closed:
|
|
await asyncio.sleep(60.0)
|
|
now = time.time()
|
|
for ctx in list(self._contexts.values()):
|
|
# Use close_started_at if available, otherwise fall back to created_at
|
|
close_time = ctx.close_started_at or ctx.created_at
|
|
if ctx.state == _ContextState.CLOSING and now - close_time > 120.0:
|
|
if ctx.waiter and not ctx.waiter.done():
|
|
ctx.waiter.set_result(None)
|
|
self._release_context(ctx.context_id)
|
|
|
|
async def _handle_connection_error(self, error: Exception) -> None:
|
|
"""Handle connection-level error by failing all active contexts."""
|
|
for ctx in list(self._contexts.values()):
|
|
if ctx.waiter and not ctx.waiter.done():
|
|
ctx.waiter.set_exception(error)
|
|
self._contexts.clear()
|
|
self._closed = True
|
|
|
|
# Wake local waiters so they can fail fast
|
|
self._context_available.set()
|
|
|
|
# Notify pool so callers blocked on capacity can create replacement connections
|
|
if self._on_capacity_available:
|
|
self._on_capacity_available()
|
|
|
|
async def aclose(self) -> None:
|
|
"""Close the connection and all contexts."""
|
|
self._closed = True
|
|
|
|
# Cancel background tasks
|
|
for task in [self._send_task, self._recv_task, self._cleanup_task]:
|
|
if task:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Close all contexts and fail their waiters
|
|
for ctx in list(self._contexts.values()):
|
|
if ctx.waiter and not ctx.waiter.done():
|
|
ctx.waiter.cancel()
|
|
|
|
self._contexts.clear()
|
|
|
|
if self._ws:
|
|
await self._ws.close()
|
|
self._ws = None
|
|
|
|
|
|
DEFAULT_MAX_CONNECTIONS = 20
|
|
DEFAULT_IDLE_CONNECTION_TIMEOUT = 300.0 # 5 minutes
|
|
|
|
|
|
class _ConnectionPool:
|
|
"""Manages a pool of _InworldConnection instances for high-concurrency scenarios.
|
|
|
|
Each connection supports up to 5 concurrent contexts. The pool automatically creates
|
|
new connections when all existing ones are at capacity, up to max_connections (default 20).
|
|
Idle connections are automatically cleaned up after idle_timeout seconds.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
session: aiohttp.ClientSession,
|
|
ws_url: str,
|
|
authorization: str,
|
|
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
idle_timeout: float = DEFAULT_IDLE_CONNECTION_TIMEOUT,
|
|
) -> None:
|
|
self._session = session
|
|
self._ws_url = ws_url
|
|
self._authorization = authorization
|
|
self._max_connections = max_connections
|
|
self._idle_timeout = idle_timeout
|
|
|
|
self._connections: list[_InworldConnection] = []
|
|
self._pool_lock = asyncio.Lock()
|
|
self._closed = False
|
|
|
|
# Event signaled when any connection has capacity available
|
|
self._capacity_available = asyncio.Event()
|
|
self._capacity_available.set() # Initially available since we can create connections
|
|
|
|
# Cleanup task
|
|
self._cleanup_task: asyncio.Task[None] | None = None
|
|
|
|
async def acquire_context(
|
|
self,
|
|
emitter: tts.AudioEmitter,
|
|
opts: _TTSOptions,
|
|
timeout: float,
|
|
) -> tuple[str, asyncio.Future[None], _InworldConnection]:
|
|
"""Acquire a context from the pool, creating new connections as needed.
|
|
|
|
Returns:
|
|
tuple of (context_id, waiter future, connection) - the connection is returned
|
|
so the caller can call send_text/flush_context/close_context on it.
|
|
"""
|
|
if self._closed:
|
|
raise APIConnectionError("Connection pool is closed")
|
|
|
|
start_time = time.time()
|
|
remaining_timeout = timeout
|
|
|
|
while True:
|
|
conn: _InworldConnection | None = None
|
|
created_new = False
|
|
|
|
async with self._pool_lock:
|
|
# Start cleanup task if not already running
|
|
if self._cleanup_task is None:
|
|
self._cleanup_task = asyncio.create_task(self._cleanup_idle_connections())
|
|
|
|
# Prune closed connections first
|
|
self._connections = [c for c in self._connections if not c._closed]
|
|
|
|
# First, try to find a connection with capacity
|
|
for existing in self._connections:
|
|
if not existing._closed and existing.has_capacity:
|
|
conn = existing
|
|
break
|
|
|
|
# No available capacity - can we create a new connection?
|
|
if conn is None and len(self._connections) < self._max_connections:
|
|
conn = _InworldConnection(
|
|
session=self._session,
|
|
ws_url=self._ws_url,
|
|
authorization=self._authorization,
|
|
on_capacity_available=self.notify_capacity_available,
|
|
)
|
|
created_new = True
|
|
# Add to pool IMMEDIATELY so other callers can see it
|
|
self._connections.append(conn)
|
|
logger.debug(
|
|
"Created new Inworld connection",
|
|
extra={"pool_size": len(self._connections)},
|
|
)
|
|
|
|
# Reserve capacity BEFORE releasing lock so other callers see it
|
|
if conn:
|
|
conn.reserve_capacity()
|
|
|
|
# Acquire context OUTSIDE the lock to avoid head-of-line blocking
|
|
if conn:
|
|
try:
|
|
ctx_id, waiter = await conn.acquire_context(emitter, opts, remaining_timeout)
|
|
except BaseException:
|
|
# Release reservation since we didn't get a context
|
|
conn.release_reservation()
|
|
# Remove failed new connection from pool
|
|
if created_new:
|
|
async with self._pool_lock:
|
|
if conn in self._connections:
|
|
self._connections.remove(conn)
|
|
await conn.aclose()
|
|
raise
|
|
|
|
return ctx_id, waiter, conn
|
|
|
|
# At max connections and all at capacity - wait for one to free up
|
|
elapsed = time.time() - start_time
|
|
remaining_timeout = timeout - elapsed
|
|
if remaining_timeout <= 0:
|
|
raise APITimeoutError("Timed out waiting for available connection capacity")
|
|
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._capacity_available.wait(),
|
|
timeout=remaining_timeout,
|
|
)
|
|
# Clear after wait returns to avoid lost-wakeup race
|
|
self._capacity_available.clear()
|
|
except asyncio.TimeoutError:
|
|
raise APITimeoutError(
|
|
"Timed out waiting for available connection capacity"
|
|
) from None
|
|
|
|
def notify_capacity_available(self) -> None:
|
|
"""Called when a context is released and capacity may be available."""
|
|
self._capacity_available.set()
|
|
|
|
async def _cleanup_idle_connections(self) -> None:
|
|
"""Periodically close idle connections that have been unused for a while."""
|
|
while not self._closed:
|
|
await asyncio.sleep(60.0) # Check every minute
|
|
|
|
async with self._pool_lock:
|
|
now = time.time()
|
|
# Keep at least one connection, close others if idle
|
|
connections_to_close: list[_InworldConnection] = []
|
|
|
|
for conn in self._connections:
|
|
if (
|
|
conn.is_idle
|
|
and now - conn.last_activity > self._idle_timeout
|
|
and len(self._connections) - len(connections_to_close) > 1
|
|
):
|
|
connections_to_close.append(conn)
|
|
|
|
for conn in connections_to_close:
|
|
self._connections.remove(conn)
|
|
logger.debug(
|
|
"Closing idle Inworld connection",
|
|
extra={"pool_size": len(self._connections)},
|
|
)
|
|
|
|
# Close connections outside the lock
|
|
for conn in connections_to_close:
|
|
await conn.aclose()
|
|
|
|
async def aclose(self) -> None:
|
|
"""Close all connections in the pool."""
|
|
self._closed = True
|
|
|
|
if self._cleanup_task:
|
|
self._cleanup_task.cancel()
|
|
try:
|
|
await self._cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
async with self._pool_lock:
|
|
connections = list(self._connections)
|
|
self._connections.clear()
|
|
|
|
for conn in connections:
|
|
await conn.aclose()
|
|
|
|
|
|
class TTS(tts.TTS):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
voice: NotGivenOr[str] = NOT_GIVEN,
|
|
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
|
|
encoding: NotGivenOr[Encoding] = NOT_GIVEN,
|
|
bit_rate: NotGivenOr[int] = NOT_GIVEN,
|
|
sample_rate: NotGivenOr[int] = NOT_GIVEN,
|
|
speaking_rate: NotGivenOr[float] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
language: NotGivenOr[str] = NOT_GIVEN,
|
|
timestamp_type: NotGivenOr[TimestampType] = NOT_GIVEN,
|
|
text_normalization: NotGivenOr[TextNormalization] = NOT_GIVEN,
|
|
delivery_mode: NotGivenOr[DeliveryMode] = NOT_GIVEN,
|
|
timestamp_transport_strategy: NotGivenOr[TimestampTransportStrategy] = NOT_GIVEN,
|
|
buffer_char_threshold: NotGivenOr[int] = NOT_GIVEN,
|
|
max_buffer_delay_ms: NotGivenOr[int] = NOT_GIVEN,
|
|
base_url: str = DEFAULT_URL,
|
|
ws_url: str = DEFAULT_WS_URL,
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
|
|
retain_format: NotGivenOr[bool] = NOT_GIVEN,
|
|
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
idle_connection_timeout: float = DEFAULT_IDLE_CONNECTION_TIMEOUT,
|
|
) -> None:
|
|
"""
|
|
Create a new instance of Inworld TTS.
|
|
|
|
Args:
|
|
api_key (str, optional): The Inworld API key.
|
|
If not provided, it will be read from the INWORLD_API_KEY environment variable.
|
|
voice (str, optional): The voice to use. Defaults to "Ashley".
|
|
model (str, optional): The Inworld model to use. Defaults to "inworld-tts-1.5-max".
|
|
encoding (str, optional): The encoding to use. Defaults to "PCM".
|
|
bit_rate (int, optional): Bits per second of the audio. Defaults to 64000.
|
|
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 24000.
|
|
speaking_rate (float, optional): The speed of the voice, in the range [0.5, 1.5].
|
|
Defaults to 1.0.
|
|
temperature (float, optional): Determines the degree of randomness when sampling audio
|
|
tokens to generate the response. Range (0, 2]. Defaults to 1.0.
|
|
language (str, optional): BCP-47 language tag (e.g., "en-US", "fr-FR", "ja-JP")
|
|
specifying the language that the given voice should speak the text in.
|
|
If not set, the model default applies.
|
|
timestamp_type (str, optional): Controls timestamp metadata returned with the audio.
|
|
Use "WORD" for word-level timestamps or "CHARACTER" for character-level.
|
|
Useful for karaoke-style captions, word highlighting, and lipsync.
|
|
text_normalization (str, optional): Controls text normalization. When "ON", numbers,
|
|
dates, and abbreviations are expanded (e.g., "Dr." -> "Doctor"). When "OFF",
|
|
text is read exactly as written. Defaults to automatic.
|
|
delivery_mode (str, optional): Controls output variation on ``inworld-tts-2`` only.
|
|
One of "DELIVERY_MODE_UNSPECIFIED", "STABLE", "BALANCED", or "CREATIVE".
|
|
The Inworld API ignores ``temperature`` on ``inworld-tts-2`` — use
|
|
``delivery_mode`` to steer output variation on that model instead.
|
|
Defaults to the server-side default ("BALANCED").
|
|
timestamp_transport_strategy (str, optional): Controls how timestamp info is
|
|
transported relative to audio data. "SYNC" returns timestamps in the same
|
|
message as audio data. "ASYNC" allows timestamps to return in trailing
|
|
messages after the audio data. Defaults to "ASYNC".
|
|
buffer_char_threshold (int, optional): For streaming, the minimum number of characters
|
|
in the buffer that automatically triggers audio generation. Defaults to 1000.
|
|
max_buffer_delay_ms (int, optional): For streaming, the maximum time in ms to buffer
|
|
before starting generation. Defaults to 3000.
|
|
base_url (str, optional): The base URL for the Inworld TTS API.
|
|
Defaults to "https://api.inworld.ai/".
|
|
ws_url (str, optional): The WebSocket URL for streaming TTS.
|
|
Defaults to "wss://api.inworld.ai/".
|
|
http_session (aiohttp.ClientSession, optional): The HTTP session to use.
|
|
tokenizer (tokenize.SentenceTokenizer, optional): The tokenizer to use for streaming.
|
|
Defaults to `livekit.agents.tokenize.blingfire.SentenceTokenizer`.
|
|
retain_format (bool, optional): Whether to retain the format of the text when tokenizing.
|
|
Defaults to True.
|
|
max_connections (int, optional): Maximum number of concurrent WebSocket connections.
|
|
Each connection supports up to 5 concurrent synthesis streams. Defaults to 20.
|
|
idle_connection_timeout (float, optional): Time in seconds after which idle connections
|
|
are closed. Defaults to 300 (5 minutes).
|
|
"""
|
|
if not is_given(sample_rate):
|
|
sample_rate = DEFAULT_SAMPLE_RATE
|
|
super().__init__(
|
|
capabilities=tts.TTSCapabilities(
|
|
streaming=True,
|
|
aligned_transcript=is_given(timestamp_type)
|
|
and timestamp_type != "TIMESTAMP_TYPE_UNSPECIFIED",
|
|
),
|
|
sample_rate=sample_rate,
|
|
num_channels=NUM_CHANNELS,
|
|
)
|
|
|
|
key = api_key if is_given(api_key) else os.getenv("INWORLD_API_KEY")
|
|
if not key:
|
|
raise ValueError(
|
|
"Inworld API key is required, either as argument or set"
|
|
" INWORLD_API_KEY environment variable"
|
|
)
|
|
|
|
self._authorization = f"Basic {key}"
|
|
self._base_url = base_url
|
|
self._ws_url = ws_url
|
|
self._session = http_session
|
|
|
|
if is_given(encoding):
|
|
_validate_str_param(encoding, "encoding", Encoding)
|
|
if is_given(timestamp_type):
|
|
_validate_str_param(timestamp_type, "timestamp_type", TimestampType)
|
|
if is_given(text_normalization):
|
|
text_normalization = _resolve_text_normalization(text_normalization)
|
|
if is_given(delivery_mode):
|
|
_validate_str_param(delivery_mode, "delivery_mode", DeliveryMode)
|
|
if is_given(timestamp_transport_strategy):
|
|
_validate_str_param(
|
|
timestamp_transport_strategy,
|
|
"timestamp_transport_strategy",
|
|
TimestampTransportStrategy,
|
|
)
|
|
|
|
self._opts = _TTSOptions(
|
|
voice=voice if is_given(voice) else DEFAULT_VOICE,
|
|
model=model if is_given(model) else DEFAULT_MODEL,
|
|
encoding=encoding if is_given(encoding) else DEFAULT_ENCODING,
|
|
bit_rate=bit_rate if is_given(bit_rate) else DEFAULT_BIT_RATE,
|
|
sample_rate=sample_rate if is_given(sample_rate) else DEFAULT_SAMPLE_RATE,
|
|
speaking_rate=speaking_rate if is_given(speaking_rate) else DEFAULT_SPEAKING_RATE,
|
|
temperature=temperature if is_given(temperature) else DEFAULT_TEMPERATURE,
|
|
language=language,
|
|
timestamp_type=timestamp_type,
|
|
text_normalization=text_normalization,
|
|
delivery_mode=delivery_mode,
|
|
timestamp_transport_strategy=timestamp_transport_strategy
|
|
if is_given(timestamp_transport_strategy)
|
|
else DEFAULT_TIMESTAMP_TRANSPORT_STRATEGY,
|
|
buffer_char_threshold=buffer_char_threshold
|
|
if is_given(buffer_char_threshold)
|
|
else DEFAULT_BUFFER_CHAR_THRESHOLD,
|
|
max_buffer_delay_ms=max_buffer_delay_ms
|
|
if is_given(max_buffer_delay_ms)
|
|
else DEFAULT_MAX_BUFFER_DELAY_MS,
|
|
)
|
|
|
|
self._max_connections = max_connections
|
|
self._idle_connection_timeout = idle_connection_timeout
|
|
self._pool: _ConnectionPool | None = None
|
|
self._pool_lock = asyncio.Lock()
|
|
self._streams = weakref.WeakSet[SynthesizeStream]()
|
|
self._sentence_tokenizer = (
|
|
tokenizer
|
|
if is_given(tokenizer)
|
|
else tokenize.blingfire.SentenceTokenizer(
|
|
retain_format=retain_format if is_given(retain_format) else True
|
|
)
|
|
)
|
|
|
|
class Markup(tts.TTS.Markup):
|
|
# markup delegation lives in the base class, keyed on _provider_key()
|
|
def _provider_key(self) -> str:
|
|
# only inworld-tts-2 understands the markup tags; older models get no
|
|
# markup so the tags aren't injected, converted, or stripped (matches
|
|
# the inference gateway's behavior)
|
|
return "inworld" if "tts-2" in self._tts.model else ""
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return self._opts.model
|
|
|
|
@property
|
|
def provider(self) -> str:
|
|
return "Inworld"
|
|
|
|
async def _get_pool(self) -> _ConnectionPool:
|
|
"""Get the connection pool, creating if needed."""
|
|
async with self._pool_lock:
|
|
if self._pool is None or self._pool._closed:
|
|
self._pool = _ConnectionPool(
|
|
session=self._ensure_session(),
|
|
ws_url=self._ws_url,
|
|
authorization=self._authorization,
|
|
max_connections=self._max_connections,
|
|
idle_timeout=self._idle_connection_timeout,
|
|
)
|
|
return self._pool
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
voice: NotGivenOr[str] = NOT_GIVEN,
|
|
model: NotGivenOr[TTSModels | str] = NOT_GIVEN,
|
|
encoding: NotGivenOr[Encoding] = NOT_GIVEN,
|
|
bit_rate: NotGivenOr[int] = NOT_GIVEN,
|
|
sample_rate: NotGivenOr[int] = NOT_GIVEN,
|
|
speaking_rate: NotGivenOr[float] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
language: NotGivenOr[str] = NOT_GIVEN,
|
|
timestamp_type: NotGivenOr[TimestampType] = NOT_GIVEN,
|
|
text_normalization: NotGivenOr[TextNormalization] = NOT_GIVEN,
|
|
delivery_mode: NotGivenOr[DeliveryMode] = NOT_GIVEN,
|
|
timestamp_transport_strategy: NotGivenOr[TimestampTransportStrategy] = NOT_GIVEN,
|
|
buffer_char_threshold: NotGivenOr[int] = NOT_GIVEN,
|
|
max_buffer_delay_ms: NotGivenOr[int] = NOT_GIVEN,
|
|
) -> None:
|
|
"""
|
|
Update the TTS configuration options.
|
|
|
|
Args:
|
|
voice (str, optional): The voice to use.
|
|
model (str, optional): The Inworld model to use.
|
|
encoding (str, optional): The encoding to use.
|
|
bit_rate (int, optional): Bits per second of the audio.
|
|
sample_rate (int, optional): The audio sample rate in Hz.
|
|
speaking_rate (float, optional): The speed of the voice.
|
|
temperature (float, optional): Determines the degree of randomness when sampling audio
|
|
tokens to generate the response.
|
|
language (str, optional): BCP-47 language tag (e.g., "en-US", "fr-FR").
|
|
timestamp_type (str, optional): Controls timestamp metadata ("WORD" or "CHARACTER").
|
|
text_normalization (str, optional): Controls text normalization ("ON" or "OFF").
|
|
delivery_mode (str, optional): Controls output variation on ``inworld-tts-2`` only
|
|
("DELIVERY_MODE_UNSPECIFIED", "STABLE", "BALANCED", or "CREATIVE"). The Inworld
|
|
API ignores ``temperature`` on TTS-2; use this instead.
|
|
timestamp_transport_strategy (str, optional): Controls timestamp transport strategy
|
|
("SYNC" or "ASYNC").
|
|
buffer_char_threshold (int, optional): For streaming, min characters before triggering.
|
|
max_buffer_delay_ms (int, optional): For streaming, max time to buffer.
|
|
"""
|
|
if is_given(voice):
|
|
self._opts.voice = voice
|
|
if is_given(model):
|
|
self._opts.model = model
|
|
if is_given(encoding):
|
|
_validate_str_param(encoding, "encoding", Encoding)
|
|
self._opts.encoding = encoding
|
|
if is_given(bit_rate):
|
|
self._opts.bit_rate = bit_rate
|
|
if is_given(sample_rate):
|
|
self._opts.sample_rate = sample_rate
|
|
if is_given(speaking_rate):
|
|
self._opts.speaking_rate = speaking_rate
|
|
if is_given(temperature):
|
|
self._opts.temperature = temperature
|
|
if is_given(language):
|
|
self._opts.language = language
|
|
if is_given(timestamp_type):
|
|
_validate_str_param(timestamp_type, "timestamp_type", TimestampType)
|
|
self._opts.timestamp_type = timestamp_type
|
|
if is_given(text_normalization):
|
|
self._opts.text_normalization = _resolve_text_normalization(text_normalization)
|
|
if is_given(delivery_mode):
|
|
_validate_str_param(delivery_mode, "delivery_mode", DeliveryMode)
|
|
self._opts.delivery_mode = delivery_mode
|
|
if is_given(timestamp_transport_strategy):
|
|
_validate_str_param(
|
|
timestamp_transport_strategy,
|
|
"timestamp_transport_strategy",
|
|
TimestampTransportStrategy,
|
|
)
|
|
self._opts.timestamp_transport_strategy = timestamp_transport_strategy
|
|
if is_given(buffer_char_threshold):
|
|
self._opts.buffer_char_threshold = buffer_char_threshold
|
|
if is_given(max_buffer_delay_ms):
|
|
self._opts.max_buffer_delay_ms = max_buffer_delay_ms
|
|
|
|
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
if not self._session:
|
|
self._session = utils.http_context.http_session()
|
|
|
|
return self._session
|
|
|
|
def prewarm(self) -> None:
|
|
asyncio.create_task(self._prewarm_impl())
|
|
|
|
async def _prewarm_impl(self) -> None:
|
|
# Just ensure the pool is created - first acquire will establish a connection
|
|
await self._get_pool()
|
|
|
|
def synthesize(
|
|
self,
|
|
text: str,
|
|
*,
|
|
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
) -> tts.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()
|
|
if self._pool:
|
|
await self._pool.aclose()
|
|
self._pool = None
|
|
|
|
async def list_voices(self, language: str | None = None) -> list[dict[str, Any]]:
|
|
"""
|
|
List all available voices in the workspace associated with the API key.
|
|
|
|
Args:
|
|
language (str, optional): ISO 639-1 language code to filter voices (e.g., 'en', 'es', 'fr').
|
|
"""
|
|
url = urljoin(self._base_url, "tts/v1/voices")
|
|
params = {}
|
|
if language:
|
|
params["filter"] = f"language={language}"
|
|
|
|
async with self._ensure_session().get(
|
|
url,
|
|
headers={
|
|
"Authorization": self._authorization,
|
|
"X-User-Agent": USER_AGENT,
|
|
"X-Request-Id": str(uuid.uuid4()),
|
|
},
|
|
params=params,
|
|
) as resp:
|
|
if not resp.ok:
|
|
error_body = await resp.json()
|
|
raise APIStatusError(
|
|
message=error_body.get("message"),
|
|
status_code=resp.status,
|
|
request_id=None,
|
|
body=None,
|
|
)
|
|
|
|
data = await resp.json()
|
|
return cast(list[dict[str, Any]], data.get("voices", []))
|
|
|
|
|
|
class ChunkedStream(tts.ChunkedStream):
|
|
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:
|
|
audio_config: dict[str, Any] = {
|
|
"audioEncoding": self._opts.encoding,
|
|
"bitrate": self._opts.bit_rate,
|
|
"sampleRateHertz": self._opts.sample_rate,
|
|
"temperature": self._opts.temperature,
|
|
"speakingRate": self._opts.speaking_rate,
|
|
}
|
|
|
|
body_params: dict[str, Any] = {
|
|
"text": self._input_text,
|
|
"voiceId": self._opts.voice,
|
|
"modelId": self._opts.model,
|
|
"audioConfig": audio_config,
|
|
}
|
|
if utils.is_given(self._opts.language):
|
|
body_params["language"] = self._opts.language
|
|
if utils.is_given(self._opts.timestamp_type):
|
|
body_params["timestampType"] = self._opts.timestamp_type
|
|
if utils.is_given(self._opts.text_normalization):
|
|
body_params["applyTextNormalization"] = self._opts.text_normalization
|
|
if utils.is_given(self._opts.delivery_mode):
|
|
body_params["deliveryMode"] = self._opts.delivery_mode
|
|
body_params["timestampTransportStrategy"] = self._opts.timestamp_transport_strategy
|
|
|
|
x_request_id = str(uuid.uuid4())
|
|
async with self._tts._ensure_session().post(
|
|
urljoin(self._tts._base_url, "/tts/v1/voice:stream"),
|
|
headers={
|
|
"Authorization": self._tts._authorization,
|
|
"X-User-Agent": USER_AGENT,
|
|
"X-Request-Id": x_request_id,
|
|
},
|
|
json=body_params,
|
|
timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
|
|
# large read_bufsize to avoid `ValueError: Chunk too big`
|
|
read_bufsize=10 * 1024 * 1024,
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
|
|
request_id = utils.shortuuid()
|
|
output_emitter.initialize(
|
|
request_id=request_id,
|
|
sample_rate=self._opts.sample_rate,
|
|
num_channels=NUM_CHANNELS,
|
|
mime_type=self._opts.mime_type,
|
|
)
|
|
|
|
async for raw_line in resp.content:
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
data = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
logger.warning("failed to parse Inworld response line: %s", line)
|
|
continue
|
|
|
|
if result := data.get("result"):
|
|
# Handle timestamp info if present
|
|
if timestamp_info := result.get("timestampInfo"):
|
|
timed_strings = _parse_timestamp_info(timestamp_info)
|
|
if timed_strings:
|
|
output_emitter.push_timed_transcript(timed_strings)
|
|
|
|
if audio_content := result.get("audioContent"):
|
|
output_emitter.push(base64.b64decode(audio_content))
|
|
if self._opts.encoding == "PCM":
|
|
output_emitter.flush()
|
|
elif error := data.get("error"):
|
|
raise APIStatusError(
|
|
message=error.get("message"),
|
|
status_code=error.get("code"),
|
|
request_id=x_request_id,
|
|
body=None,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
raise APITimeoutError() from None
|
|
except aiohttp.ClientResponseError as e:
|
|
raise APIStatusError(
|
|
message=e.message, status_code=e.status, request_id=x_request_id, 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()
|
|
sent_tokenizer_stream = self._tts._sentence_tokenizer.stream()
|
|
|
|
output_emitter.initialize(
|
|
request_id=request_id,
|
|
sample_rate=self._opts.sample_rate,
|
|
num_channels=NUM_CHANNELS,
|
|
mime_type=self._opts.mime_type,
|
|
stream=True,
|
|
)
|
|
|
|
pool = await self._tts._get_pool()
|
|
context_id, waiter, connection = await pool.acquire_context(
|
|
emitter=output_emitter,
|
|
opts=self._opts,
|
|
timeout=self._conn_options.timeout,
|
|
)
|
|
|
|
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 _send_task() -> None:
|
|
async for ev in sent_tokenizer_stream:
|
|
text = ev.token
|
|
# Chunk to stay within Inworld's 1000 char limit
|
|
for i in range(0, len(text), 1000):
|
|
connection.send_text(context_id, text[i : i + 1000])
|
|
self._mark_started()
|
|
connection.flush_context(context_id)
|
|
connection.close_context(context_id)
|
|
|
|
tasks = [
|
|
asyncio.create_task(_input_task()),
|
|
asyncio.create_task(_send_task()),
|
|
]
|
|
|
|
try:
|
|
await asyncio.wait_for(waiter, timeout=self._conn_options.timeout + 60)
|
|
except asyncio.TimeoutError:
|
|
connection.close_context(context_id)
|
|
raise APITimeoutError() from None
|
|
except asyncio.CancelledError:
|
|
connection.close_context(context_id)
|
|
raise
|
|
except APIError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Inworld stream error", extra={"context_id": context_id, "error": e})
|
|
connection.close_context(context_id)
|
|
raise APIConnectionError() from e
|
|
finally:
|
|
await utils.aio.gracefully_cancel(*tasks)
|
|
await sent_tokenizer_stream.aclose()
|
|
output_emitter.end_input()
|
|
|
|
|
|
def _parse_timestamp_info(
|
|
timestamp_info: dict[str, Any], cumulative_time: float = 0.0
|
|
) -> list[TimedString]:
|
|
"""Parse timestamp info from API response into TimedString objects.
|
|
|
|
Args:
|
|
timestamp_info: The timestamp info from the API response.
|
|
cumulative_time: Offset to add to all timestamps for monotonic ordering
|
|
across multiple generations within a single context.
|
|
"""
|
|
timed_strings: list[TimedString] = []
|
|
|
|
# Handle word-level alignment
|
|
if word_align := timestamp_info.get("wordAlignment"):
|
|
words = word_align.get("words", [])
|
|
starts = word_align.get("wordStartTimeSeconds", [])
|
|
ends = word_align.get("wordEndTimeSeconds", [])
|
|
|
|
for word, start, end in zip(words, starts, ends, strict=False):
|
|
text = f"{word}"
|
|
timed_strings.append(
|
|
TimedString(
|
|
text,
|
|
start_time=cumulative_time + start,
|
|
end_time=cumulative_time + end,
|
|
)
|
|
)
|
|
|
|
# Handle character-level alignment
|
|
if char_align := timestamp_info.get("characterAlignment"):
|
|
chars = char_align.get("characters", [])
|
|
starts = char_align.get("characterStartTimeSeconds", [])
|
|
ends = char_align.get("characterEndTimeSeconds", [])
|
|
|
|
for char, start, end in zip(chars, starts, ends, strict=False):
|
|
timed_strings.append(
|
|
TimedString(
|
|
char,
|
|
start_time=cumulative_time + start,
|
|
end_time=cumulative_time + end,
|
|
)
|
|
)
|
|
|
|
return timed_strings
|