Skip to content

Commit ec0038d

Browse files
elevenlabs-say: fix convert_realtime crash on default voice_settings (#341)
## Problem `echo hi | nix run .#elevenlabs-say` crashed on the streaming path: ``` File ".../elevenlabs/realtime_tts.py", line 115, in convert_realtime voice_settings=voice_settings.dict() if voice_settings else None, AttributeError: 'ellipsis' object has no attribute 'dict' ``` This is a bug in the `elevenlabs` SDK. `convert_realtime` defaults `voice_settings` to its `OMIT` sentinel, which is the Ellipsis `...`. The init-frame builder does `voice_settings.dict() if voice_settings else None`, and `...` is truthy, so the default value calls `.dict()` on the ellipsis and crashes. We did not pass `voice_settings`, so every `--stream` invocation hit it. ## Fix Pass `voice_settings=None` explicitly. `None` is falsy, so the SDK takes the `else None` branch, and `None` already means "use the voice's stored settings", so this is also the right value going forward, not just a workaround. ## Tests Added `test_stream_init_does_not_crash_on_omit_voice_settings`: it monkeypatches the SDK's `connect` with a fake socket, drives `stream_synthesize` far enough to build and send the init frame, and asserts the frame carries a null `voice_settings`. This reproduces the crash offline (it errors with the AttributeError if the explicit `None` is removed), so the streaming path now has a no-network guard against this class of SDK-integration break. ## Validation - ✅ `nix build .#elevenlabs-say` (runs `ty`) - ✅ `nix build .#elevenlabs-say.tests.streaming` and `.tests.printsHelp` - ✅ `nix run .#lint` - Not run against the live API: no `ELEVENLABS_API_KEY` here. This clears the AttributeError that aborted streaming before any frame was sent; confirm end to end with `echo hi | nix run .#elevenlabs-say`. Filing the upstream bug at elevenlabs/elevenlabs-python separately. --- Made with AI (Claude Opus 4.8).
1 parent 891143a commit ec0038d

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

packages/elevenlabs-say/src/elevenlabs_say/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,17 @@ def stream_synthesize(
274274
typed. It pins ``chunk_length_schedule=[50]`` for low latency, which is the
275275
intended trade for a pipe.
276276
"""
277+
# voice_settings must be passed explicitly as None. The SDK defaults it to its
278+
# OMIT sentinel (the Ellipsis ...), then builds the init frame with
279+
# `voice_settings.dict() if voice_settings else None`; Ellipsis is truthy, so
280+
# the default crashes with AttributeError. None is falsy and means "use the
281+
# voice's stored settings". https://github.com/elevenlabs/elevenlabs-python
277282
return stream_client(client).convert_realtime(
278283
voice_id=voice_id,
279284
text=text_source(args),
280285
model_id=args.model,
281286
output_format=args.output_format,
287+
voice_settings=None,
282288
)
283289

284290

packages/elevenlabs-say/tests/test_streaming.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
from __future__ import annotations
88

9+
import json
910
import os
1011
import sys
1112
import tempfile
1213
import threading
1314
import time
1415
from pathlib import Path
1516

17+
import elevenlabs.realtime_tts as realtime_module
18+
1619
import elevenlabs_say as say
1720

1821

@@ -121,10 +124,61 @@ def isatty(self) -> bool:
121124
sys.stdin = original
122125

123126

127+
def test_stream_init_does_not_crash_on_omit_voice_settings() -> None:
128+
"""Regression: building convert_realtime's init frame must not trip the SDK's
129+
OMIT-sentinel bug, where voice_settings defaults to Ellipsis (truthy, no
130+
.dict()) and crashes. stream_synthesize passes voice_settings=None; assert the
131+
init frame is sent and carries a null voice_settings.
132+
"""
133+
os.environ["ELEVENLABS_API_KEY"] = "test-key-not-used"
134+
client = say.make_client()
135+
136+
class _StopAfterInit(Exception):
137+
pass
138+
139+
class FakeSocket:
140+
def __init__(self) -> None:
141+
self.sent: list[str] = []
142+
143+
def send(self, message: str) -> None:
144+
self.sent.append(message)
145+
raise _StopAfterInit
146+
147+
def recv(self, *args: object) -> str:
148+
raise _StopAfterInit
149+
150+
socket = FakeSocket()
151+
152+
class FakeConnection:
153+
def __enter__(self) -> FakeSocket:
154+
return socket
155+
156+
def __exit__(self, *args: object) -> bool:
157+
return False
158+
159+
original_connect = realtime_module.connect
160+
realtime_module.connect = lambda *a, **k: FakeConnection() # type: ignore[assignment]
161+
try:
162+
chunks = say.stream_synthesize(
163+
client, say.parse_args(["hi", "--stream"]), "voice-id"
164+
)
165+
try:
166+
next(iter(chunks))
167+
except _StopAfterInit:
168+
pass
169+
finally:
170+
realtime_module.connect = original_connect # type: ignore[assignment]
171+
172+
assert socket.sent, "init frame was never sent"
173+
init = json.loads(socket.sent[0])
174+
assert init["voice_settings"] is None, init
175+
176+
124177
if __name__ == "__main__":
125178
test_stdin_yields_before_eof_and_rejoins_split_utf8()
126179
test_write_stream_writes_chunks_and_rejects_empty()
127180
test_play_stream_rejects_empty_without_spawning_ffplay()
128181
test_stream_client_narrows_to_realtime()
129182
test_should_stream_auto_and_overrides()
183+
test_stream_init_does_not_crash_on_omit_voice_settings()
130184
print("elevenlabs-say streaming tests passed")

0 commit comments

Comments
 (0)