Merge pull request #4424 from pipecat-ai/mb/revert-elevenlabs-tts-alignment
fix(elevenlabs): only use normalizedAlignment when pronunciation dict is set
This commit is contained in:
1
changelog/4424.fixed.md
Normal file
1
changelog/4424.fixed.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` writing romanized/normalized text to the LLM context. With non-Latin input (e.g., Chinese), the assistant transcript was getting populated with pinyin (`Ni Hao !` instead of `你好!`), which then degraded subsequent LLM turns. The services now consume `alignment` by default and only switch to `normalizedAlignment` / `normalized_alignment` when `pronunciation_dictionary_locators` is configured (where `alignment` has overlapping restarts that produce duplicated/garbled words, per #4316). Both fields are read with preferred-with-fallback semantics since each is nullable per the API schema.
|
||||||
@@ -248,17 +248,56 @@ class ElevenLabsHttpTTSSettings(TTSSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_alignment(
|
||||||
|
msg: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
normalized_key: str,
|
||||||
|
alignment_key: str,
|
||||||
|
prefer_normalized: bool,
|
||||||
|
) -> Mapping[str, Any] | None:
|
||||||
|
"""Pick the alignment field to use from a TTS message, with fallback.
|
||||||
|
|
||||||
|
ElevenLabs returns two alignment fields per chunk:
|
||||||
|
|
||||||
|
- ``normalized_key`` (``normalizedAlignment`` for WebSocket,
|
||||||
|
``normalized_alignment`` for HTTP): the post-normalized form of what was
|
||||||
|
spoken - pronunciation-dictionary substitutions, text normalization, or
|
||||||
|
romanization of non-Latin scripts (e.g., Chinese rendered as pinyin).
|
||||||
|
- ``alignment_key`` (``alignment``): the original input characters.
|
||||||
|
|
||||||
|
Prefer ``normalized`` only when a pronunciation dictionary is configured -
|
||||||
|
that's the case where ``alignment`` has overlapping restarts that produce
|
||||||
|
duplicated/garbled words (issue #4316). Otherwise prefer ``alignment`` so
|
||||||
|
the LLM context preserves the original input rather than the normalized
|
||||||
|
form. Fall back to the other field if the preferred one is missing or
|
||||||
|
null - the API schema marks both as nullable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: TTS response message from ElevenLabs.
|
||||||
|
normalized_key: Key for the normalized-alignment field on this transport.
|
||||||
|
alignment_key: Key for the original-alignment field on this transport.
|
||||||
|
prefer_normalized: True iff the caller is using pronunciation dictionaries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The chosen alignment dict, or ``None`` if both fields are absent/null.
|
||||||
|
"""
|
||||||
|
if prefer_normalized:
|
||||||
|
return msg.get(normalized_key) or msg.get(alignment_key)
|
||||||
|
return msg.get(alignment_key) or msg.get(normalized_key)
|
||||||
|
|
||||||
|
|
||||||
def _strip_utterance_leading_spaces(
|
def _strip_utterance_leading_spaces(
|
||||||
alignment: Mapping[str, Any], keys: tuple[str, str, str], should_strip: bool
|
alignment: Mapping[str, Any], keys: tuple[str, str, str], should_strip: bool
|
||||||
) -> Mapping[str, Any]:
|
) -> Mapping[str, Any]:
|
||||||
"""Return alignment with utterance-leading space chars removed, if requested.
|
"""Return alignment with utterance-leading space chars removed, if requested.
|
||||||
|
|
||||||
Normalized alignment chunks from ElevenLabs often begin with a space. On the
|
ElevenLabs Flash normalized alignment chunks can begin with a leading space
|
||||||
first chunk of an utterance, that space is leading whitespace and should not
|
at the start of an utterance. Strip only utterance-leading spaces so bot
|
||||||
become a text token. On subsequent chunks, however, a leading space can be a
|
turn text does not start with whitespace. On subsequent chunks, however, a
|
||||||
real inter-word separator (Flash models commonly split sentences this way),
|
leading space can be a real inter-word separator (Flash models commonly
|
||||||
so it must be preserved for ``calculate_word_times`` to flush any partial
|
split sentences this way), so it must be preserved for
|
||||||
word carried over from the previous chunk.
|
``calculate_word_times`` to flush any partial word carried over from the
|
||||||
|
previous chunk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
alignment: Alignment dict from the API.
|
alignment: Alignment dict from the API.
|
||||||
@@ -829,13 +868,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("normalizedAlignment"):
|
raw_alignment = _select_alignment(
|
||||||
# Use normalizedAlignment (what was actually spoken) rather than
|
msg,
|
||||||
# alignment (the input text), so word timestamps stay accurate
|
normalized_key="normalizedAlignment",
|
||||||
# when a pronunciation dictionary or text normalization rewrites
|
alignment_key="alignment",
|
||||||
# the input.
|
prefer_normalized=bool(self._pronunciation_dictionary_locators),
|
||||||
|
)
|
||||||
|
if raw_alignment:
|
||||||
alignment = _strip_utterance_leading_spaces(
|
alignment = _strip_utterance_leading_spaces(
|
||||||
msg["normalizedAlignment"],
|
raw_alignment,
|
||||||
("chars", "charStartTimesMs", "charDurationsMs"),
|
("chars", "charStartTimesMs", "charDurationsMs"),
|
||||||
received_ctx_id not in self._alignment_started_context_ids,
|
received_ctx_id not in self._alignment_started_context_ids,
|
||||||
)
|
)
|
||||||
@@ -1353,13 +1394,15 @@ 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. Use normalized_alignment
|
raw_alignment = data and _select_alignment(
|
||||||
# (what was actually spoken) so word timestamps stay
|
data,
|
||||||
# accurate when a pronunciation dictionary or text
|
normalized_key="normalized_alignment",
|
||||||
# normalization rewrites the input.
|
alignment_key="alignment",
|
||||||
if data and data.get("normalized_alignment"):
|
prefer_normalized=bool(self._pronunciation_dictionary_locators),
|
||||||
|
)
|
||||||
|
if raw_alignment:
|
||||||
alignment = _strip_utterance_leading_spaces(
|
alignment = _strip_utterance_leading_spaces(
|
||||||
data["normalized_alignment"],
|
raw_alignment,
|
||||||
(
|
(
|
||||||
"characters",
|
"characters",
|
||||||
"character_start_times_seconds",
|
"character_start_times_seconds",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pipecat.services.elevenlabs.tts import (
|
from pipecat.services.elevenlabs.tts import (
|
||||||
|
_select_alignment,
|
||||||
_strip_utterance_leading_spaces,
|
_strip_utterance_leading_spaces,
|
||||||
calculate_word_times,
|
calculate_word_times,
|
||||||
)
|
)
|
||||||
@@ -90,3 +91,112 @@ def test_elevenlabs_alignment_strips_only_utterance_leading_spaces():
|
|||||||
|
|
||||||
assert first["chars"] == list("Hello")
|
assert first["chars"] == list("Hello")
|
||||||
assert subsequent["chars"] == list(" world")
|
assert subsequent["chars"] == list(" world")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_alignment_default_prefers_alignment():
|
||||||
|
msg = {
|
||||||
|
"alignment": _chunk("Hello"),
|
||||||
|
"normalizedAlignment": _chunk(" Hello"),
|
||||||
|
}
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg,
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=False,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["chars"] == list("Hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_alignment_dictionary_mode_prefers_normalized():
|
||||||
|
msg = {
|
||||||
|
"alignment": _chunk("Hello"),
|
||||||
|
"normalizedAlignment": _chunk(" Hello"),
|
||||||
|
}
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg,
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=True,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["chars"] == list(" Hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_alignment_falls_back_when_preferred_missing():
|
||||||
|
msg_default = {"normalizedAlignment": _chunk(" Hello")}
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg_default,
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=False,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["chars"] == list(" Hello")
|
||||||
|
|
||||||
|
msg_dict = {"alignment": _chunk("Hello")}
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg_dict,
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=True,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["chars"] == list("Hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_alignment_falls_back_when_preferred_null():
|
||||||
|
msg = {"alignment": None, "normalizedAlignment": _chunk(" Hello")}
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg,
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=False,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["chars"] == list(" Hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_alignment_returns_none_when_both_missing():
|
||||||
|
assert (
|
||||||
|
_select_alignment(
|
||||||
|
{},
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=False,
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_select_alignment(
|
||||||
|
{"alignment": None, "normalizedAlignment": None},
|
||||||
|
normalized_key="normalizedAlignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=True,
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_alignment_works_with_http_field_names():
|
||||||
|
msg = {
|
||||||
|
"alignment": {"characters": list("Hi")},
|
||||||
|
"normalized_alignment": {"characters": list(" Hi")},
|
||||||
|
}
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg,
|
||||||
|
normalized_key="normalized_alignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=False,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["characters"] == list("Hi")
|
||||||
|
|
||||||
|
selected = _select_alignment(
|
||||||
|
msg,
|
||||||
|
normalized_key="normalized_alignment",
|
||||||
|
alignment_key="alignment",
|
||||||
|
prefer_normalized=True,
|
||||||
|
)
|
||||||
|
assert selected is not None
|
||||||
|
assert selected["characters"] == list(" Hi")
|
||||||
|
|||||||
Reference in New Issue
Block a user