Fixing ElevenLabs TTS word timestamp interleaving across sentences.

This commit is contained in:
filipi87
2026-02-12 12:54:47 -03:00
parent d99a256715
commit 6d95a2425c

View File

@@ -13,6 +13,7 @@ with support for streaming audio, word timestamps, and voice customization.
import asyncio import asyncio
import base64 import base64
import json import json
import uuid
from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional, Tuple, Union
import aiohttp import aiohttp
@@ -680,6 +681,20 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
msg = {"text": text, "context_id": self._context_id} msg = {"text": text, "context_id": self._context_id}
await self._websocket.send(json.dumps(msg)) await self._websocket.send(json.dumps(msg))
def create_context_id(self) -> str:
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
Returns:
A unique string identifier for the TTS context.
"""
# If a context ID does not exist, create a new one.
# If an ID exists, continue using the current ID.
# When interruptions happens, user speech results in
# an interruption, which resets the context ID.
if not self._context_id:
return str(uuid.uuid4())
return self._context_id
@traced_tts @traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text using ElevenLabs' streaming WebSocket API. """Generate speech from text using ElevenLabs' streaming WebSocket API.
@@ -698,31 +713,28 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
await self._connect() await self._connect()
try: try:
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
self._cumulative_time = 0
self._partial_word = ""
self._partial_word_start_time = 0.0
# If a context ID does not exist, use the provided one.
# If an ID exists, that means the Pipeline doesn't allow
# user interruptions, so continue using the current ID.
# When interruptions are allowed, user speech results in
# an interruption, which resets the context ID.
if not self._context_id: if not self._context_id:
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
self._context_id = context_id self._context_id = context_id
if not self.audio_context_available(self._context_id): self._cumulative_time = 0
await self.create_audio_context(self._context_id) self._partial_word = ""
self._partial_word_start_time = 0.0
# Initialize context with voice settings and pronunciation dictionaries if not self.audio_context_available(self._context_id):
msg = {"text": " ", "context_id": self._context_id} await self.create_audio_context(self._context_id)
if self._voice_settings:
msg["voice_settings"] = self._voice_settings # Initialize context with voice settings and pronunciation dictionaries
if self._pronunciation_dictionary_locators: msg = {"text": " ", "context_id": self._context_id}
msg["pronunciation_dictionary_locators"] = [ if self._voice_settings:
locator.model_dump() for locator in self._pronunciation_dictionary_locators msg["voice_settings"] = self._voice_settings
] if self._pronunciation_dictionary_locators:
await self._websocket.send(json.dumps(msg)) msg["pronunciation_dictionary_locators"] = [
logger.trace(f"Created new context {self._context_id}") locator.model_dump()
for locator in self._pronunciation_dictionary_locators
]
await self._websocket.send(json.dumps(msg))
logger.trace(f"Created new context {self._context_id}")
await self._send_text(text) await self._send_text(text)
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)