From d1e93447168cfb3344582e4a07ba8bbc46cc3ffe Mon Sep 17 00:00:00 2001 From: Carlos Diosdado Date: Wed, 1 Jul 2026 16:52:42 -0600 Subject: [PATCH] feat(xai-tts): wire text_normalization parameter xAI TTS supports a text_normalization boolean that normalizes written-form text (numbers, abbreviations, symbols) into spoken-form before synthesis. This parameter was not being sent, leaving users with literal number/symbol pronunciation. - Add DEFAULT_XAI_TEXT_NORMALIZATION_DEFAULT = False - Read tts.xai.text_normalization from config (via _xai_bool_config) - Attach text_normalization: true to the POST payload when enabled - Omit the field entirely when unset or explicitly false (API default) - Add 3 tests: default omission, enabled passthrough, explicit false --- tests/tools/test_tts_xai_speech_tags.py | 74 +++++++++++++++++++++++++ tools/tts_tool.py | 13 +++++ 2 files changed, 87 insertions(+) diff --git a/tests/tools/test_tts_xai_speech_tags.py b/tests/tools/test_tts_xai_speech_tags.py index 98efe8eac9af..5559241bff62 100644 --- a/tests/tools/test_tts_xai_speech_tags.py +++ b/tests/tools/test_tts_xai_speech_tags.py @@ -580,3 +580,77 @@ def test_generate_xai_tts_provider_speed_overrides_global(tmp_path, monkeypatch) ) assert captured["json"]["speed"] == 0.7 + + +def test_generate_xai_tts_omits_text_normalization_by_default(tmp_path, monkeypatch): + """text_normalization is not sent when unset (API default is False).""" + captured = {} + + fake_response = Mock() + fake_response.content = b"mp3" + fake_response.raise_for_status.return_value = None + + def fake_post(url, headers, json, timeout): + captured["json"] = json + return fake_response + + monkeypatch.setenv("XAI_API_KEY", "test-xai-key") + monkeypatch.setattr("requests.post", fake_post) + + _generate_xai_tts( + "Hello world.", + str(tmp_path / "out.mp3"), + {"xai": {"voice_id": "ara", "language": "en"}}, + ) + + assert "text_normalization" not in captured["json"] + + +def test_generate_xai_tts_sends_text_normalization_when_enabled(tmp_path, monkeypatch): + """tts.xai.text_normalization: true flows into the POST body.""" + captured = {} + + fake_response = Mock() + fake_response.content = b"mp3" + fake_response.raise_for_status.return_value = None + + def fake_post(url, headers, json, timeout): + captured["json"] = json + return fake_response + + monkeypatch.setenv("XAI_API_KEY", "test-xai-key") + monkeypatch.setattr("requests.post", fake_post) + + _generate_xai_tts( + "Hello world.", + str(tmp_path / "out.mp3"), + {"xai": {"voice_id": "ara", "language": "en", "text_normalization": True}}, + ) + + assert captured["json"]["text_normalization"] is True + + +def test_generate_xai_tts_omits_text_normalization_when_explicit_false( + tmp_path, monkeypatch +): + """text_normalization: false is the API default; field is not sent.""" + captured = {} + + fake_response = Mock() + fake_response.content = b"mp3" + fake_response.raise_for_status.return_value = None + + def fake_post(url, headers, json, timeout): + captured["json"] = json + return fake_response + + monkeypatch.setenv("XAI_API_KEY", "test-xai-key") + monkeypatch.setattr("requests.post", fake_post) + + _generate_xai_tts( + "Hello world.", + str(tmp_path / "out.mp3"), + {"xai": {"voice_id": "ara", "language": "en", "text_normalization": False}}, + ) + + assert "text_normalization" not in captured["json"] diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 2167264fff53..6af7d9c1babe 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -235,6 +235,10 @@ DEFAULT_XAI_SPEED_DEFAULT = 1.0 # xAI TTS `optimize_streaming_latency` accepts 0, 1, or 2; 0 (best quality) is # the API default (omitted => default). Values >0 trade quality for time-to-first-audio. DEFAULT_XAI_OPTIMIZE_STREAMING_LATENCY_DEFAULT = 0 +# xAI TTS `text_normalization` is a boolean (default False). When enabled, +# the model normalizes written-form text (numbers, abbreviations, symbols) +# into spoken-form before generating audio. +DEFAULT_XAI_TEXT_NORMALIZATION_DEFAULT = False DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts" DEFAULT_GEMINI_TTS_VOICE = "Kore" DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" @@ -1594,6 +1598,12 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - optimize_streaming_latency = None if optimize_streaming_latency is not None: optimize_streaming_latency = max(0, min(2, optimize_streaming_latency)) + # ``tts.xai.text_normalization`` enables spoken-form normalization + # (numbers, abbreviations, symbols → words). Defaults to False. + text_normalization = _xai_bool_config( + xai_config.get("text_normalization"), + DEFAULT_XAI_TEXT_NORMALIZATION_DEFAULT, + ) if auto_speech_tags: text = _apply_xai_auto_speech_tags(text) if creds.get("provider") == "xai-oauth": @@ -1637,6 +1647,9 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - and optimize_streaming_latency != DEFAULT_XAI_OPTIMIZE_STREAMING_LATENCY_DEFAULT ): payload["optimize_streaming_latency"] = optimize_streaming_latency + # Only attach `text_normalization` when explicitly enabled (default is False). + if text_normalization: + payload["text_normalization"] = True response = requests.post( f"{base_url}/tts",