Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .log import logger

SUPPORTED_SAMPLE_RATE = 48000
DEFAULT_VOICE_ID = "4SZHfMpw-p46Ywgs"


@dataclass
Expand All @@ -57,7 +58,7 @@ def __init__(
model_endpoint: str | None = None,
model_name: str = "default",
voice: str | None = None,
voice_id: str | None = "4SZHfMpw-p46Ywgs",
voice_id: str | None = None,
pronunciation_id: str | None = None,
json_config: dict[str, Any] | None = None,
http_session: aiohttp.ClientSession | None = None,
Expand All @@ -70,8 +71,9 @@ def __init__(
api_key (str): Gradium API key, or `GRADIUM_API_KEY` env var.
model_endpoint (str): Gradium model endpoint, or `GRADIUM_MODEL_ENDPOINT` env var.
model_name (str): Model name.
voice (str): Speaker voice.
voice_id (str): Speaker voice ID.
voice (str): Speaker voice name. Mutually exclusive with `voice_id`.
voice_id (str): Speaker voice ID. Mutually exclusive with `voice`; used by
default (`DEFAULT_VOICE_ID`) when neither is given.
pronunciation_id (str): Optional pronunciation ID for controlling TTS pronunciation.
word_tokenizer (tokenize.WordTokenizer): Tokenizer for processing text. Defaults to basic WordTokenizer.
"""
Expand Down Expand Up @@ -100,6 +102,13 @@ def __init__(
self._model_endpoint = model_endpoint
self._model_name = model_name

# The server resolves `voice_id` to an embedding and then ignores `voice`, so
# sending both would silently discard the voice name.
if voice is not None and voice_id is not None:
raise ValueError("`voice` and `voice_id` are mutually exclusive")
if voice is None and voice_id is None:
voice_id = DEFAULT_VOICE_ID
Comment on lines +109 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Explicit null selects the catalog voice

With voice_id=None and no voice, TTS substitutes Harper's ID instead of omitting both selectors. The provider uses Harper rather than its model-default voice.

Learn more

A Gradium setup message includes voice_id only when its stored value is not None in ChunkedStream._run and SynthesizeStream._run_ws. Previously, passing voice_id=None with no name left both selectors absent, allowing the provider to choose its model voice. The new fallback treats an explicit None exactly like an omitted argument and inserts the catalog ID.

Example: An agent constructs TTS(api_key="...", voice_id=None) to use its deployed model voice. The setup now sends voice_id="4SZHfMpw-p46Ywgs" and selects Harper instead.

Recommended fix: Distinguish an omitted voice_id from explicit None using a sentinel default, and apply DEFAULT_VOICE_ID only for the omitted case when voice is absent. Keep explicit None as an absent selector in both setup messages.

Devin Review


Was this helpful? React with 馃憤 or 馃憥 to provide feedback.


if not word_tokenizer:
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
self._opts = _TTSOptions(
Expand Down Expand Up @@ -144,10 +153,14 @@ def update_options(
voice_id: NotGivenOr[str] = NOT_GIVEN,
json_config: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
) -> None:
if is_given(voice) and is_given(voice_id):
raise ValueError("`voice` and `voice_id` are mutually exclusive")
if is_given(voice):
self._opts.voice = voice
self._opts.voice_id = None
if is_given(voice_id):
self._opts.voice_id = voice_id
self._opts.voice = None
if is_given(json_config):
self._opts.json_config = json_config

Expand Down
65 changes: 65 additions & 0 deletions tests/test_plugin_gradium_tts_voice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Gradium TTS voice selection: `voice` and `voice_id` are alternatives, never both."""

from __future__ import annotations

import pytest

pytestmark = pytest.mark.unit


def test_default_voice_id_when_neither_is_given():
from livekit.plugins.gradium import TTS
from livekit.plugins.gradium.tts import DEFAULT_VOICE_ID

tts = TTS(api_key="test-key")
assert tts._opts.voice_id == DEFAULT_VOICE_ID
assert tts._opts.voice is None


def test_voice_name_is_not_overridden_by_a_default_voice_id():
from livekit.plugins.gradium import TTS

tts = TTS(api_key="test-key", voice="narrator")
assert tts._opts.voice == "narrator"
assert tts._opts.voice_id is None


def test_voice_id_alone_is_kept():
from livekit.plugins.gradium import TTS

tts = TTS(api_key="test-key", voice_id="abc")
assert tts._opts.voice_id == "abc"
assert tts._opts.voice is None


def test_voice_and_voice_id_are_exclusive():
from livekit.plugins.gradium import TTS

with pytest.raises(ValueError, match="mutually exclusive"):
TTS(api_key="test-key", voice="narrator", voice_id="abc")


def test_update_options_voice_clears_voice_id():
from livekit.plugins.gradium import TTS

tts = TTS(api_key="test-key")
tts.update_options(voice="narrator")
assert tts._opts.voice == "narrator"
assert tts._opts.voice_id is None


def test_update_options_voice_id_clears_voice():
from livekit.plugins.gradium import TTS

tts = TTS(api_key="test-key", voice="narrator")
tts.update_options(voice_id="abc")
assert tts._opts.voice_id == "abc"
assert tts._opts.voice is None


def test_update_options_rejects_both():
from livekit.plugins.gradium import TTS

tts = TTS(api_key="test-key")
with pytest.raises(ValueError, match="mutually exclusive"):
tts.update_options(voice="narrator", voice_id="abc")
Loading