diff --git a/pyproject.toml b/pyproject.toml index 53568336..1e99453f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", "pipecat-ai>=1.0.0", - "elevenlabs>=1.0.0", + "elevenlabs>=2.53.0", "openai>=2.36.0", "anthropic>=0.83.0", "litellm==1.85.0", diff --git a/src/eva/assistant/elevenlabs_audio_interface.py b/src/eva/assistant/elevenlabs_audio_interface.py index b0e7f87e..fffc6efc 100644 --- a/src/eva/assistant/elevenlabs_audio_interface.py +++ b/src/eva/assistant/elevenlabs_audio_interface.py @@ -7,9 +7,8 @@ from elevenlabs.conversational_ai.conversation import AsyncAudioInterface -# ElevenLabs recommends 4000 samples (250ms) per input_callback call. -# At 16 kHz PCM16 that is 4000 * 2 = 8000 bytes. -INPUT_CHUNK_BYTES = 8000 +# The agent accepts µ-law 8 kHz; forward ~250ms chunks (2000 bytes) per +# input_callback call. INPUT_CHUNK_DURATION = 0.25 # seconds @@ -78,15 +77,17 @@ async def get_output_audio(self, timeout: float = 1.0) -> bytes | None: async def _feed_input(self) -> None: """Buffer small Twilio chunks into 250 ms frames for ElevenLabs. - ElevenLabs expects 16 kHz PCM16 in ~4000-sample (8000-byte) chunks. - Twilio media messages are ~640 bytes each after conversion, so we - accumulate until we have a full chunk or the interval elapses. + The agent is configured to accept µ-law 8 kHz audio. Twilio media + messages are small (~160 bytes each), so we accumulate until we have a + full 250 ms chunk or the interval elapses, then forward the user audio. """ + # 8 kHz µ-law, 1 byte per sample → 2000 bytes per 250 ms chunk + mulaw_chunk_bytes = int(8000 * INPUT_CHUNK_DURATION) + buf = bytearray() while self._running: try: - # Collect audio until we fill a chunk or time out - remaining = max(0.01, INPUT_CHUNK_DURATION - len(buf) / (16000 * 2)) + remaining = max(0.01, INPUT_CHUNK_DURATION - len(buf) / 8000) chunk = await asyncio.wait_for(self._input_queue.get(), timeout=remaining) buf.extend(chunk) except TimeoutError: @@ -94,14 +95,15 @@ async def _feed_input(self) -> None: except asyncio.CancelledError: break - # Send when we have enough data, or on timeout if there's anything - if len(buf) >= INPUT_CHUNK_BYTES: - while len(buf) >= INPUT_CHUNK_BYTES and self._input_callback: - await self._input_callback(bytes(buf[:INPUT_CHUNK_BYTES])) - del buf[:INPUT_CHUNK_BYTES] + if len(buf) >= mulaw_chunk_bytes: + # Send full chunks of real audio + while len(buf) >= mulaw_chunk_bytes and self._input_callback: + await self._input_callback(bytes(buf[:mulaw_chunk_bytes])) + del buf[:mulaw_chunk_bytes] elif buf: - # Partial buffer on timeout — send what we have so we don't - # add latency waiting for the next Twilio packet + # Partial audio on timeout — send as-is without silence padding. + # Mixing silence into speech chunks causes the VAD to trigger + # end-of-speech mid-utterance. if self._input_callback: await self._input_callback(bytes(buf)) buf.clear() diff --git a/src/eva/assistant/elevenlabs_server.py b/src/eva/assistant/elevenlabs_server.py index 708b59aa..bb5c30bb 100644 --- a/src/eva/assistant/elevenlabs_server.py +++ b/src/eva/assistant/elevenlabs_server.py @@ -39,7 +39,6 @@ create_twilio_media_message, mulaw_8k_to_pcm16_16k, parse_twilio_media_message, - sync_buffer_to_position, ) from eva.assistant.base_server import AbstractAssistantServer from eva.assistant.elevenlabs_audio_interface import TwilioAudioBridge @@ -57,6 +56,10 @@ MULAW_CHUNK_SIZE = 160 # bytes per chunk (20ms at 8kHz, 1 byte per sample) MULAW_CHUNK_DURATION_S = 0.02 # 20ms per chunk +# 20ms of recording-rate (16kHz) 16-bit mono PCM that corresponds to one +# MULAW_CHUNK_SIZE mulaw chunk: 160 mulaw samples @8kHz -> 320 PCM @16kHz -> 640 bytes. +PCM_CHUNK_SIZE = MULAW_CHUNK_SIZE * 4 # 640 bytes + # --------------------------------------------------------------------------- # Audio conversion helper @@ -69,6 +72,19 @@ def _pcm16_16k_to_mulaw_8k(pcm_16k: bytes) -> bytes: return audioop.lin2ulaw(pcm_8k, 2) +def _pad_buffer_to_walltime(buffer: bytearray, elapsed_s: float, sample_rate: int) -> None: + """Pad *buffer* with silence so its length reflects *elapsed_s* of real time. + + Both recording channels are anchored to a single session start, so the + mixed/stereo output preserves real inter-turn timing instead of collapsing + silence gaps (which previously made the recording shorter than reality and + overlapped turns). 16-bit mono PCM => 2 bytes per sample. + """ + target_bytes = int(elapsed_s * sample_rate) * 2 + if len(buffer) < target_bytes: + buffer.extend(b"\x00" * (target_bytes - len(buffer))) + + # --------------------------------------------------------------------------- # Tool conversion helper # --------------------------------------------------------------------------- @@ -227,10 +243,21 @@ async def _handle_session(self, websocket: WebSocket) -> None: # noqa: C901 _user_speaking = False _user_speech_start_ts: str | None = None _user_speech_stop_ts: str | None = None + # ElevenLabs' own end-of-user-turn signal (wall-clock ms), stamped when the + # user transcript arrives. ElevenLabs runs its own server-side VAD and + # responds off that, often before the user sim's local end-of-speech + # detection (and its user_speech_stop event) fires. Using this as the + # model-response-latency reference avoids the race that drops most turns. + _user_turn_end_ts: str | None = None _assistant_turn_start_ts: str | None = None + # Shared recording anchor: set on the first recorded audio of either + # channel. Both tracks pad silence relative to this so the mixed output + # keeps real wall-clock timing (set lazily; see _pad_buffer_to_walltime). + _record_t0: float | None = None - # Queue for outbound mulaw chunks; the pacer drains at real-time rate - audio_output_queue: asyncio.Queue[bytes] = asyncio.Queue() + # Queue of (mulaw_chunk, pcm16k_chunk, turn_start) items; the pacer drains + # at real-time rate, sends the mulaw and records the PCM at playback time. + audio_output_queue: asyncio.Queue[tuple[bytes, bytes, bool]] = asyncio.Queue() # Signalled when ElevenLabs ends the session session_ended = asyncio.Event() @@ -242,20 +269,22 @@ async def _handle_session(self, websocket: WebSocket) -> None: # noqa: C901 # -- ElevenLabs callbacks ------------------------------------------ async def _on_agent_response(text: str) -> None: - nonlocal _assistant_turn_start_ts, _in_model_turn, _is_first_turn + nonlocal _assistant_turn_start_ts, _is_first_turn logger.info(f"Agent response: {text}") self.audit_log.append_assistant_output(text, timestamp_ms=_assistant_turn_start_ts) self._fw_log.llm_response(text) self._fw_log.turn_end(was_interrupted=False) if _is_first_turn: - # Need to track first turn to set _assistant_turn_start_ts correctly _is_first_turn = False - else: - _in_model_turn = False + # NOTE: do not reset _in_model_turn here. The agent_response event + # carries the full text but audio is still streaming; resetting the + # flag mid-stream would make the recorder treat the rest of the same + # utterance as a new turn (re-anchoring to wall-clock and injecting + # silence). _in_model_turn resets at the next user_speech_start. _assistant_turn_start_ts = None async def _on_agent_response_correction(original: str, corrected: str) -> None: - nonlocal _assistant_turn_start_ts, _in_model_turn + nonlocal _assistant_turn_start_ts logger.info(f"Agent response corrected: {original!r} -> {corrected!r}") if corrected: self.audit_log.append_assistant_output( @@ -264,13 +293,16 @@ async def _on_agent_response_correction(original: str, corrected: str) -> None: ) self._fw_log.s2s_transcript(corrected) self._fw_log.turn_end(was_interrupted=True) - _in_model_turn = False + # Same as _on_agent_response: do not reset _in_model_turn here. _assistant_turn_start_ts = None async def _on_user_transcript(text: str) -> None: - nonlocal _user_speech_start_ts, _user_speaking + nonlocal _user_speech_start_ts, _user_speaking, _user_turn_end_ts logger.info(f"User transcript: {text}") _user_speaking = False + # ElevenLabs has finished hearing the user — this is its end-of-turn, + # the reference for model-response latency to the first agent audio. + _user_turn_end_ts = str(int(round(time.time() * 1000))) self.audit_log.append_user_input(text, timestamp_ms=_user_speech_start_ts) _user_speech_start_ts = None @@ -331,7 +363,7 @@ async def _forward_user_audio() -> None: """Read Twilio WS messages, convert audio, send to ElevenLabs.""" nonlocal stream_sid, twilio_connected nonlocal _user_speech_start_ts, _user_speech_stop_ts - nonlocal _user_speaking, _in_model_turn + nonlocal _user_speaking, _in_model_turn, _record_t0 try: while twilio_connected and self._running: try: @@ -365,17 +397,23 @@ async def _forward_user_audio() -> None: if mulaw_bytes is None: continue - # Record user audio as 16 kHz PCM for the WAV file + # Record user audio as 16 kHz PCM for the WAV file, + # anchored to real time so gaps are preserved. pcm_16k = mulaw_8k_to_pcm16_16k(mulaw_bytes) - if not _in_model_turn: - sync_buffer_to_position( - self.assistant_audio_buffer, - len(self.user_audio_buffer), - ) + now = time.monotonic() + if _record_t0 is None: + _record_t0 = now + _pad_buffer_to_walltime( + self.user_audio_buffer, + now - _record_t0, + self._audio_sample_rate, + ) self.user_audio_buffer.extend(pcm_16k) # Feed raw 8 kHz mulaw to ElevenLabs — the agent - # is configured to accept mulaw input directly + # is configured to accept mulaw input directly. The + # bridge keeps ElevenLabs' VAD fed with silence during + # gaps, so no separate silence task is needed here. await audio_bridge.feed_user_audio(mulaw_bytes) except WebSocketDisconnect: @@ -389,9 +427,9 @@ async def _forward_user_audio() -> None: twilio_connected = False async def _forward_assistant_audio() -> None: - """Pull audio from bridge, record, convert, enqueue for pacer.""" + """Pull audio from bridge, convert, and enqueue for the pacer.""" nonlocal _in_model_turn, _assistant_turn_start_ts - nonlocal _user_speech_stop_ts, _user_speaking + nonlocal _user_speech_stop_ts, _user_turn_end_ts try: while self._running: pcm_16k = await audio_bridge.get_output_audio(timeout=1.0) @@ -401,15 +439,21 @@ async def _forward_assistant_audio() -> None: continue # First audio chunk of a new model turn + new_turn = False if not _in_model_turn: _in_model_turn = True + new_turn = True _assistant_turn_start_ts = str(int(round(time.time() * 1000))) self._fw_log.turn_start() - # Model response latency: user speech end -> first - # audio. Absent on the initial greeting turn. - if _user_speech_stop_ts and self._metrics_log: - latency_ms = int(_assistant_turn_start_ts) - int(_user_speech_stop_ts) + # Model response latency: user turn end -> first audio. + # Prefer ElevenLabs' own end-of-turn (user transcript), + # which is what triggered the response and is available + # before the agent audio; fall back to the user sim's + # user_speech_stop event. Absent on the greeting turn. + _user_end_ref = _user_turn_end_ts or _user_speech_stop_ts + if _user_end_ref and self._metrics_log: + latency_ms = int(_assistant_turn_start_ts) - int(_user_end_ref) if 0 < latency_ms < 30_000: self._metrics_log.write_latency( "model_response", @@ -417,16 +461,17 @@ async def _forward_assistant_audio() -> None: self._model, ) _user_speech_stop_ts = None - - # Populate recording buffer - if not _user_speaking: - sync_buffer_to_position( - self.user_audio_buffer, - len(self.assistant_audio_buffer), - ) - self.assistant_audio_buffer.extend(pcm_16k) - - # Convert to mulaw and enqueue for pacer + _user_turn_end_ts = None + + # Convert to mulaw and enqueue for pacer. Recording is + # deferred to the pacer so the assistant track is placed + # at playback (real) time rather than this bursty receive + # time — each mulaw chunk is paired with its 16kHz PCM so + # the pacer can record without a lossy re-conversion. Only + # the first chunk of a turn carries turn_start=True; the + # pacer pads to wall-clock just at the turn boundary and + # then records contiguously, so ElevenLabs' bursty intra-turn + # delivery does not inject silence mid-utterance. if twilio_connected: try: mulaw = _pcm16_16k_to_mulaw_8k(pcm_16k) @@ -434,11 +479,15 @@ async def _forward_assistant_audio() -> None: logger.warning(f"Audio conversion error ({len(pcm_16k)} bytes): {conv_err}") continue - offset = 0 - while offset < len(mulaw): - chunk = mulaw[offset : offset + MULAW_CHUNK_SIZE] - offset += MULAW_CHUNK_SIZE - await audio_output_queue.put(chunk) + m_off = 0 + p_off = 0 + while m_off < len(mulaw): + m_chunk = mulaw[m_off : m_off + MULAW_CHUNK_SIZE] + p_chunk = pcm_16k[p_off : p_off + PCM_CHUNK_SIZE] + m_off += MULAW_CHUNK_SIZE + p_off += PCM_CHUNK_SIZE + turn_start = new_turn and m_off == MULAW_CHUNK_SIZE + await audio_output_queue.put((m_chunk, p_chunk, turn_start)) except asyncio.CancelledError: pass @@ -447,23 +496,40 @@ async def _forward_assistant_audio() -> None: async def _pace_audio_output() -> None: """Drain audio_output_queue and send to Twilio at real-time rate.""" - nonlocal twilio_connected + nonlocal twilio_connected, _record_t0 next_send_time = time.monotonic() try: while self._running: try: - chunk = await asyncio.wait_for(audio_output_queue.get(), timeout=1.0) + mulaw_chunk, pcm_chunk, turn_start = await asyncio.wait_for( + audio_output_queue.get(), timeout=1.0 + ) except TimeoutError: continue - twilio_msg = create_twilio_media_message(stream_sid, chunk) + # Stamp before send so WAV position matches when the chunk + # starts transmission (when the user sim can first receive it). + now = time.monotonic() + + # Pad assistant track to wall-clock only at turn boundaries; + # append contiguously within a turn to avoid mid-utterance gaps. + if _record_t0 is None: + _record_t0 = now + if turn_start: + _pad_buffer_to_walltime( + self.assistant_audio_buffer, + now - _record_t0, + self._audio_sample_rate, + ) + self.assistant_audio_buffer.extend(pcm_chunk) + + twilio_msg = create_twilio_media_message(stream_sid, mulaw_chunk) try: await websocket.send_text(twilio_msg) except Exception: twilio_connected = False return - now = time.monotonic() if next_send_time <= now: next_send_time = now next_send_time += MULAW_CHUNK_DURATION_S diff --git a/src/eva/user_simulator/audio_bridge.py b/src/eva/user_simulator/audio_bridge.py index 0b9725f5..d1e75415 100644 --- a/src/eva/user_simulator/audio_bridge.py +++ b/src/eva/user_simulator/audio_bridge.py @@ -113,6 +113,15 @@ def __init__( self._assistant_audio_active = False # assistant speaking self._user_audio_ended_time = None # Track when user audio ended for silence sending self._assistant_audio_ended_time = None # Track when assistant audio ended for silence sending + # Loop time of the last real user-audio chunk sent — used to stamp the + # user audio_end at the actual end (detection lags it by ~600ms). + self._last_user_audio_send_time: float | None = None + # Set when ElevenLabs signals the user agent has finished its utterance + # (callback_agent_response). Used as the authoritative end-of-turn cue so + # we still mark end-of-utterance when the audio drains frame-aligned with + # no leftover partial chunk (otherwise the partial-chunk detector below + # never fires, no trailing silence is sent, and S2S assistant VADs stall). + self._user_turn_complete = False # Shutdown state self._stopping = False @@ -429,12 +438,29 @@ async def _on_user_audio_start(self) -> None: except Exception as e: logger.warning(f"Error sending user_speech_start event: {e}") + def notify_user_utterance_complete(self) -> None: + """Arm end-of-turn: the ElevenLabs user agent finished its utterance. + + Called from the client's agent-response callback. The send loop fires + ``_on_user_audio_end`` once the audio buffer drains, even when no partial + chunk remains — so trailing silence is always sent for the assistant VAD. + """ + self._user_turn_complete = True + async def _on_user_audio_end(self, current_time: float) -> None: """Handle user audio ending.""" self._user_audio_ended_time = current_time self._user_audio_active = False + # Consume the end-of-turn cue so it doesn't carry into the next turn. + self._user_turn_complete = False if self.event_logger: - self.event_logger.log_audio_end("simulated_user") + # Detection lags the real end by ~600ms; stamp at the last real chunk. + real_end_unix = ( + time.time() - (current_time - self._last_user_audio_send_time) + if self._last_user_audio_send_time is not None + else time.time() + ) + self.event_logger.log_audio_end("simulated_user", real_end_unix) logger.info("🎤 User audio END") # Send user_speech_stop event so assistant servers can compute model response latency. @@ -474,9 +500,19 @@ def _on_assistant_audio_start(self) -> None: async def _on_assistant_audio_end(self) -> None: """Handle assistant audio ending (silence detected).""" self._assistant_audio_active = False - self._assistant_audio_ended_time = asyncio.get_event_loop().time() + # On entry, _assistant_audio_ended_time still holds the last received-chunk + # loop time (the real end). Detection lags it by SILENCE_DETECTION_THRESHOLD_S, + # so stamp the event at the real end (in unix time) rather than now. + loop_now = asyncio.get_event_loop().time() + real_end_unix = ( + time.time() - (loop_now - self._assistant_audio_ended_time) + if self._assistant_audio_ended_time is not None + else time.time() + ) if self.event_logger: - self.event_logger.log_audio_end("assistant") + self.event_logger.log_audio_end("assistant", real_end_unix) + # Now mark detection time for the silence-sending state machine. + self._assistant_audio_ended_time = loop_now logger.info("🔊 Assistant audio END (silence detected)") # Send catch-up silence to cover the detection delay for ElevenLabs if ASSISTANT_CATCHUP_SILENCE_CHUNKS > 0 and not self._should_send_ambient_noise(): @@ -689,6 +725,7 @@ async def _send_to_assistant(self) -> None: mulaw_audio = self._convert_pcm_to_mulaw(chunk) if mulaw_audio and await self._send_audio_frame(mulaw_audio): audio_chunks_sent += 1 + self._last_user_audio_send_time = current_time # Calculate next send time based on absolute target (prevents drift) next_send_time = stream_start_time + (audio_chunks_sent * send_interval) if audio_chunks_sent % LOG_INTERVAL_AUDIO_SEND == 0: @@ -721,6 +758,23 @@ async def _send_to_assistant(self) -> None: stream_start_time = None next_send_time = current_time + send_interval + elif ( + self._user_audio_active + and self._user_turn_complete + and not pending_audio + and self.send_queue.empty() + ): + # Exact-boundary end of utterance: ElevenLabs signaled the user + # agent finished and the audio drained with no leftover partial + # chunk, so the branch above never fires. Mark end after the + # detection delay so trailing silence is sent and the assistant + # VAD can close the turn (otherwise the conversation stalls). + time_since_last_send = current_time - next_send_time + send_interval + if time_since_last_send >= send_interval * USER_END_DETECTION_DELAY_INTERVALS: + await self._on_user_audio_end(current_time) + stream_start_time = None + next_send_time = current_time + send_interval + # Send user silence/ambient noise while user is not speaking. # Ambient noise streams continuously (including during assistant speech). # Regular silence only sends when waiting for user to respond after assistant spoke. diff --git a/src/eva/user_simulator/elevenlabs.py b/src/eva/user_simulator/elevenlabs.py index 084923eb..f412961a 100644 --- a/src/eva/user_simulator/elevenlabs.py +++ b/src/eva/user_simulator/elevenlabs.py @@ -386,6 +386,12 @@ def _on_user_speaks(self, response: str) -> None: self._reset_keepalive_counter() logger.info(f"🎭 User (ElevenLabs): {response}") + # Authoritative end-of-turn cue: the user agent has finished its utterance. + # Lets the audio interface mark end-of-utterance once the audio drains even + # when no partial chunk remains, so trailing silence reaches the assistant VAD. + if self._audio_interface: + self._audio_interface.notify_user_utterance_complete() + self.event_logger.log_event( "user_speech", { diff --git a/src/eva/user_simulator/event_logger.py b/src/eva/user_simulator/event_logger.py index 9816c3ee..13b7ff9c 100644 --- a/src/eva/user_simulator/event_logger.py +++ b/src/eva/user_simulator/event_logger.py @@ -126,14 +126,18 @@ def log_audio_start(self, role: str, timestamp: float | None = None) -> None: self._events.append(event) logger.debug(f"Audio start logged: {role}") - def log_audio_end(self, role: str) -> None: + def log_audio_end(self, role: str, timestamp: float | None = None) -> None: """Log when audio ends for a given role. Args: role: Speaker role (e.g. "simulated_user", "assistant"). + timestamp: Unix timestamp (seconds) of the *actual* audio end. End-of- + audio is detected after a silence threshold, so callers pass the + real end time; otherwise the stamp would lag by that threshold and + shrink the following pause/latency in the timeline. """ # Use Unix timestamp in seconds (as float) - audio_timestamp = time.time() + audio_timestamp = timestamp if timestamp is not None else time.time() # Note: For audio events, we need to store event_type and user at top level # not nested in data self._sequence += 1 diff --git a/tests/unit/user_simulator/test_audio_bridge.py b/tests/unit/user_simulator/test_audio_bridge.py index d58fd25a..d416ced9 100644 --- a/tests/unit/user_simulator/test_audio_bridge.py +++ b/tests/unit/user_simulator/test_audio_bridge.py @@ -149,7 +149,9 @@ async def test_user_end_records_timestamp(self): assert iface._user_audio_active is False assert iface._user_audio_ended_time == 150.0 - event_logger.log_audio_end.assert_called_once_with("simulated_user") + args, _ = event_logger.log_audio_end.call_args + assert args[0] == "simulated_user" + assert isinstance(args[1], float) @pytest.mark.asyncio async def test_assistant_end_records_timestamp(self): diff --git a/uv.lock b/uv.lock index 8d7e2839..8054e3a7 100644 --- a/uv.lock +++ b/uv.lock @@ -703,7 +703,7 @@ wheels = [ [[package]] name = "elevenlabs" -version = "2.45.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -713,9 +713,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/a3/1696fd5b32e94703ff64b009de49bc5f4909397b5400900e3c5ca10a6499/elevenlabs-2.45.0.tar.gz", hash = "sha256:237eb96508e973393eb273728c562ae1f029764a544ab282acfd5d6d1cb8a043", size = 554966, upload-time = "2026-04-27T09:57:21.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/5f1afc8d3649b303e6a0f1c317f37062694be4dd95daec99d73581ceee54/elevenlabs-2.53.0.tar.gz", hash = "sha256:4681561f12a72baffc451f5d9d14b915dffe7e3d9b007917a1b077859bc63866", size = 646150, upload-time = "2026-06-15T09:33:10.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/2f/e9136e7b049898fcd8ddf1836d687cac0acc5ed76329740942498fe602e0/elevenlabs-2.45.0-py3-none-any.whl", hash = "sha256:19edf75de4da22b340bd96c833c3589a9871015addc1d487b034b4541c71b0ef", size = 1518097, upload-time = "2026-04-27T09:57:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/56c99ee7701ab4c2c587637a32b2f9b0afbfe3373069c02ecf524f752e16/elevenlabs-2.53.0-py3-none-any.whl", hash = "sha256:c1cb3bce2134ddc42375de77d88a8154c92bef497593ece2634b23e616ef057e", size = 1752582, upload-time = "2026-06-15T09:33:08.258Z" }, ] [[package]] @@ -786,7 +786,7 @@ requires-dist = [ { name = "azure-cognitiveservices-speech", specifier = ">=1.31.0" }, { name = "cartesia", specifier = ">=1.0.0" }, { name = "deepgram-sdk", specifier = ">=7" }, - { name = "elevenlabs", specifier = ">=1.0.0" }, + { name = "elevenlabs", specifier = ">=2.53.0" }, { name = "fastapi", specifier = ">=0.100.0" }, { name = "google-cloud-speech", specifier = ">=2.0.0" }, { name = "google-cloud-texttospeech", specifier = ">=2.0.0" },