Merge pull request #2840 from Rickaym/fix--excess-space-in-elevelabs-word-timestamp-joins

fix: handle ElevenLabs partial word concatenation across alignment chunks gracefully
This commit is contained in:
Mark Backman
2025-10-15 09:01:05 -04:00
committed by GitHub

View File

@@ -168,16 +168,24 @@ def build_elevenlabs_voice_settings(
def calculate_word_times( def calculate_word_times(
alignment_info: Mapping[str, Any], cumulative_time: float alignment_info: Mapping[str, Any],
) -> List[Tuple[str, float]]: cumulative_time: float,
partial_word: str = "",
partial_word_start_time: float = 0.0,
) -> tuple[List[Tuple[str, float]], str, float]:
"""Calculate word timestamps from character alignment information. """Calculate word timestamps from character alignment information.
Args: Args:
alignment_info: Character alignment data from ElevenLabs API. alignment_info: Character alignment data from ElevenLabs API.
cumulative_time: Base time offset for this chunk. cumulative_time: Base time offset for this chunk.
partial_word: Partial word carried over from previous chunk.
partial_word_start_time: Start time of the partial word.
Returns: Returns:
List of (word, timestamp) tuples. Tuple of (word_times, new_partial_word, new_partial_word_start_time):
- word_times: List of (word, timestamp) tuples for complete words
- new_partial_word: Incomplete word at end of chunk (empty if chunk ends with space)
- new_partial_word_start_time: Start time of the incomplete word
""" """
chars = alignment_info["chars"] chars = alignment_info["chars"]
char_start_times_ms = alignment_info["charStartTimesMs"] char_start_times_ms = alignment_info["charStartTimesMs"]
@@ -186,41 +194,37 @@ def calculate_word_times(
logger.error( logger.error(
f"calculate_word_times: length mismatch - chars={len(chars)}, times={len(char_start_times_ms)}" f"calculate_word_times: length mismatch - chars={len(chars)}, times={len(char_start_times_ms)}"
) )
return [] return ([], partial_word, partial_word_start_time)
# Build words and track their start positions # Build words and track their start positions
words = [] words = []
word_start_indices = [] word_start_times = []
current_word = "" current_word = partial_word # Start with any partial word from previous chunk
word_start_index = None word_start_time = partial_word_start_time if partial_word else None
for i, char in enumerate(chars): for i, char in enumerate(chars):
if char == " ": if char == " ":
# End of current word # End of current word
if current_word: # Only add non-empty words if current_word: # Only add non-empty words
words.append(current_word) words.append(current_word)
word_start_indices.append(word_start_index) word_start_times.append(word_start_time)
current_word = "" current_word = ""
word_start_index = None word_start_time = None
else: else:
# Building a word # Building a word
if word_start_index is None: # First character of new word if word_start_time is None: # First character of new word
word_start_index = i # Convert from milliseconds to seconds and add cumulative offset
word_start_time = cumulative_time + (char_start_times_ms[i] / 1000.0)
current_word += char current_word += char
# Handle the last word if there's no trailing space # Build result for complete words
if current_word and word_start_index is not None: word_times = list(zip(words, word_start_times))
words.append(current_word)
word_start_indices.append(word_start_index)
# Calculate timestamps for each word # Return any incomplete word at the end of this chunk
word_times = [] new_partial_word = current_word if current_word else ""
for word, start_idx in zip(words, word_start_indices): new_partial_word_start_time = word_start_time if word_start_time is not None else 0.0
# Convert from milliseconds to seconds and add cumulative offset
start_time_seconds = cumulative_time + (char_start_times_ms[start_idx] / 1000.0)
word_times.append((word, start_time_seconds))
return word_times return (word_times, new_partial_word, new_partial_word_start_time)
class ElevenLabsTTSService(AudioContextWordTTSService): class ElevenLabsTTSService(AudioContextWordTTSService):
@@ -332,6 +336,9 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
# there's an interruption or TTSStoppedFrame. # there's an interruption or TTSStoppedFrame.
self._started = False self._started = False
self._cumulative_time = 0 self._cumulative_time = 0
# Track partial words that span across alignment chunks
self._partial_word = ""
self._partial_word_start_time = 0.0
# Context management for v1 multi API # Context management for v1 multi API
self._context_id = None self._context_id = None
@@ -570,6 +577,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
logger.error(f"Error closing context on interruption: {e}") logger.error(f"Error closing context on interruption: {e}")
self._context_id = None self._context_id = None
self._started = False self._started = False
self._partial_word = ""
self._partial_word_start_time = 0.0
async def _receive_messages(self): async def _receive_messages(self):
"""Handle incoming WebSocket messages from ElevenLabs.""" """Handle incoming WebSocket messages from ElevenLabs."""
@@ -609,7 +618,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
if msg.get("alignment"): if msg.get("alignment"):
alignment = msg["alignment"] alignment = msg["alignment"]
word_times = calculate_word_times(alignment, self._cumulative_time) word_times, self._partial_word, self._partial_word_start_time = (
calculate_word_times(
alignment,
self._cumulative_time,
self._partial_word,
self._partial_word_start_time,
)
)
if word_times: if word_times:
await self.add_word_timestamps(word_times) await self.add_word_timestamps(word_times)
@@ -683,6 +699,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
yield TTSStartedFrame() yield TTSStartedFrame()
self._started = True self._started = True
self._cumulative_time = 0 self._cumulative_time = 0
self._partial_word = ""
self._partial_word_start_time = 0.0
# If a context ID does not exist, create a new one and # If a context ID does not exist, create a new one and
# register it. If an ID exists, that means the Pipeline is # register it. If an ID exists, that means the Pipeline is
# configured for allow_interruptions=False, so continue # configured for allow_interruptions=False, so continue
@@ -809,6 +827,10 @@ class ElevenLabsHttpTTSService(WordTTSService):
# Store previous text for context within a turn # Store previous text for context within a turn
self._previous_text = "" self._previous_text = ""
# Track partial words that span across alignment chunks
self._partial_word = ""
self._partial_word_start_time = 0.0
def language_to_service_language(self, language: Language) -> Optional[str]: def language_to_service_language(self, language: Language) -> Optional[str]:
"""Convert pipecat Language to ElevenLabs language code. """Convert pipecat Language to ElevenLabs language code.
@@ -836,6 +858,8 @@ class ElevenLabsHttpTTSService(WordTTSService):
self._cumulative_time = 0 self._cumulative_time = 0
self._started = False self._started = False
self._previous_text = "" self._previous_text = ""
self._partial_word = ""
self._partial_word_start_time = 0.0
logger.debug(f"{self}: Reset internal state") logger.debug(f"{self}: Reset internal state")
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
@@ -870,11 +894,13 @@ class ElevenLabsHttpTTSService(WordTTSService):
def calculate_word_times(self, alignment_info: Mapping[str, Any]) -> List[Tuple[str, float]]: def calculate_word_times(self, alignment_info: Mapping[str, Any]) -> List[Tuple[str, float]]:
"""Calculate word timing from character alignment data. """Calculate word timing from character alignment data.
This method handles partial words that may span across multiple alignment chunks.
Args: Args:
alignment_info: Character timing data from ElevenLabs. alignment_info: Character timing data from ElevenLabs.
Returns: Returns:
List of (word, timestamp) pairs. List of (word, timestamp) pairs for complete words in this chunk.
Example input data:: Example input data::
@@ -900,30 +926,28 @@ class ElevenLabsHttpTTSService(WordTTSService):
# Build the words and find their start times # Build the words and find their start times
words = [] words = []
word_start_times = [] word_start_times = []
current_word = "" # Start with any partial word from previous chunk
first_char_idx = -1 current_word = self._partial_word
word_start_time = self._partial_word_start_time if self._partial_word else None
for i, char in enumerate(chars): for i, char in enumerate(chars):
if char == " ": if char == " ":
if current_word: # Only add non-empty words if current_word: # Only add non-empty words
words.append(current_word) words.append(current_word)
# Use time of the first character of the word, offset by cumulative time word_start_times.append(word_start_time)
word_start_times.append(
self._cumulative_time + char_start_times[first_char_idx]
)
current_word = "" current_word = ""
first_char_idx = -1 word_start_time = None
else: else:
if not current_word: # This is the first character of a new word if word_start_time is None: # First character of a new word
first_char_idx = i # Use time of the first character of the word, offset by cumulative time
word_start_time = self._cumulative_time + char_start_times[i]
current_word += char current_word += char
# Don't forget the last word if there's no trailing space # Store any incomplete word at the end of this chunk
if current_word and first_char_idx >= 0: self._partial_word = current_word if current_word else ""
words.append(current_word) self._partial_word_start_time = word_start_time if word_start_time is not None else 0.0
word_start_times.append(self._cumulative_time + char_start_times[first_char_idx])
# Create word-time pairs # Create word-time pairs for complete words only
word_times = list(zip(words, word_start_times)) word_times = list(zip(words, word_start_times))
return word_times return word_times