Merge pull request #4344 from pipecat-ai/mb/11labs-normalized-alignment

This commit is contained in:
Mark Backman
2026-04-21 13:41:04 -04:00
committed by GitHub
2 changed files with 62 additions and 16 deletions

1
changelog/4344.fixed.md Normal file
View File

@@ -0,0 +1 @@
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` emitting word timestamps and `TTSTextFrame` content that matched the input text instead of the spoken audio when a pronunciation dictionary (`pronunciation_dictionary_locators`) or text normalization rewrote the input. Both services now consume ElevenLabs' normalized alignment, so downstream consumers (captions, transcripts, context aggregation) reflect what the listener actually hears.

View File

@@ -245,6 +245,35 @@ class ElevenLabsHttpTTSSettings(TTSSettings):
) )
def _strip_leading_space(
alignment: Mapping[str, Any], keys: tuple[str, str, str]
) -> Mapping[str, Any]:
"""Return alignment with a prepended space char removed, if present.
Normalized alignment chunks from ElevenLabs begin with a leading space that
marks the prosody/chunk boundary. Left in place, it would prematurely
terminate a partial word carried over from the previous chunk. Stripping it
is lossless for timing: the dropped space's duration is still reflected in
the next char's `charStartTimesMs`, and the chunk's last-element values
(used to advance cumulative time) are untouched.
Args:
alignment: Alignment dict from the API.
keys: Tuple of (chars_key, start_times_key, durations_or_end_times_key)
naming the three parallel arrays — these differ between the
WebSocket and HTTP response schemas.
"""
chars_key, starts_key, tail_key = keys
chars = alignment.get(chars_key) or []
if chars and chars[0] == " ":
return {
chars_key: chars[1:],
starts_key: alignment.get(starts_key, [])[1:],
tail_key: alignment.get(tail_key, [])[1:],
}
return alignment
def calculate_word_times( def calculate_word_times(
alignment_info: Mapping[str, Any], alignment_info: Mapping[str, Any],
cumulative_time: float, cumulative_time: float,
@@ -790,8 +819,15 @@ class ElevenLabsTTSService(WebsocketTTSService):
frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id) frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id)
await self.append_to_audio_context(received_ctx_id, frame) await self.append_to_audio_context(received_ctx_id, frame)
if msg.get("alignment"): if msg.get("normalizedAlignment"):
alignment = msg["alignment"] # Use normalizedAlignment (what was actually spoken) rather than
# alignment (the input text), so word timestamps stay accurate
# when a pronunciation dictionary or text normalization rewrites
# the input.
alignment = _strip_leading_space(
msg["normalizedAlignment"],
("chars", "charStartTimesMs", "charDurationsMs"),
)
word_times, self._partial_word, self._partial_word_start_time = ( word_times, self._partial_word, self._partial_word_start_time = (
calculate_word_times( calculate_word_times(
alignment, alignment,
@@ -1296,10 +1332,19 @@ class ElevenLabsHttpTTSService(TTSService):
audio, self.sample_rate, 1, context_id=context_id audio, self.sample_rate, 1, context_id=context_id
) )
# Process alignment if present # Process alignment if present. Use normalized_alignment
if data and "alignment" in data: # (what was actually spoken) so word timestamps stay
alignment = data["alignment"] # accurate when a pronunciation dictionary or text
if alignment: # Ensure alignment is not None # normalization rewrites the input.
if data and data.get("normalized_alignment"):
alignment = _strip_leading_space(
data["normalized_alignment"],
(
"characters",
"character_start_times_seconds",
"character_end_times_seconds",
),
)
# Get end time of the last character in this chunk # Get end time of the last character in this chunk
char_end_times = alignment.get("character_end_times_seconds", []) char_end_times = alignment.get("character_end_times_seconds", [])
if char_end_times: if char_end_times: