Skip to content

feat(telnyx): add Telnyx TTS plugin - #621

Open
a692570 wants to merge 3 commits into
GetStream:mainfrom
a692570:feat/telnyx-tts
Open

feat(telnyx): add Telnyx TTS plugin#621
a692570 wants to merge 3 commits into
GetStream:mainfrom
a692570:feat/telnyx-tts

Conversation

@a692570

@a692570 a692570 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Adds streaming Telnyx TTS to plugins/telnyx/, which today only contains the
media streaming transport from #594. The pattern precedent is plugins/sarvam/,
which keeps llm.py, stt.py, and tts.py in one vendor plugin. LLM and STT
are separate PRs so each lands independently.

What it adds

  • plugins/telnyx/vision_agents/plugins/telnyx/tts.py
  • TTS and TelnyxTTSError exports in the plugin __init__.py
  • aiohttp dependency in the plugin pyproject.toml
  • Unit and integration tests in plugins/telnyx/tests/test_telnyx_tts.py
  • README section

Two wire protocol details worth reviewing

Both were found by probing the live endpoint, and both differ from what the
sarvam TTS shape would suggest.

One connection per synthesis. Sarvam holds a single socket open across
stream_audio calls. Telnyx closes the socket once it has served the empty
text stop frame, and a second synthesis on the same socket gets a close frame
back, so the plugin reconnects each time. A synthesis is also rejected with
Invalid message unless a {"text": " "} init frame precedes the real text.

MP3 files are concatenated, not a single bitstream. A synthesis can span
several MP3 files, each introduced by its own ID3v2 tag at the head of a
WebSocket frame. Feeding the concatenated bytes straight to a decoder fails
part way through the utterance, which truncates the audio rather than raising
anything obvious at the call site. _Id3Stripper drops those tags, including
tags whose header or body spans WebSocket frames.

The decoded sample rate depends on the voice: Polly voices return 24 kHz and
Kokoro voices 22.05 kHz. The plugin takes the rate from the decoder instead of
exposing a sample_rate argument that could disagree with the audio.

Decoding uses av, which is already an agents-core dependency, so this adds
no new decode dependency.

Testing

Verified against the live Telnyx API, not from docs alone.

  • uv run ruff check . and ruff format --check . pass
  • uv run dev.py mypy and dev.py mypy-plugins pass
  • uv run dev.py validate-extras passes
  • uv run pytest -m "not integration" plugins/telnyx/tests/ passes, 42 tests,
    including 8 covering the ID3 stripper across frame boundaries
  • uv run pytest -m integration plugins/telnyx/tests/test_telnyx_tts.py passes
    with a real TELNYX_API_KEY, 3 tests. One asserts the decoded audio runs past
    three seconds and is non silent, which is what catches a regression in the
    ID3 handling, and one asserts a second synthesis reconnects successfully.

uv.lock changes by two lines, for the new dependency.

Open question

idle_timeout defaults to 10 seconds as a safety net for the case where the
server never sends isFinal. Happy to change the default if there is a house
value for this.

Relation to the other two Telnyx PRs

This is one of three independent PRs, each branched off main, not stacked:
#620 LLM, #621 TTS, #622 STT. Each touches the plugin __init__.py,
pyproject.toml, README.md, and two lines of uv.lock, so whichever lands
first leaves the other two with small conflicts in those four files. Happy to
rebase on request, or to fold all three into one PR if you would rather review
them together.

Streams text to speech over the Telnyx WebSocket endpoint and decodes the
returned MP3 into PcmData as it arrives. Telnyx closes the socket after
each stop frame, so the plugin connects per synthesis rather than holding
one socket open. Follows the plugins/sarvam precedent of one vendor plugin
covering LLM, STT, and TTS.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Tightens the Telnyx TTS provider’s WebSocket lifecycle with handshake timeouts, synchronized cancellation state, and improved close-error logging. Incoming messages now require dictionary payloads, invalid base64 is skipped, and undecodable MP3 payloads are dropped after logging. TTS and its exception are exported publicly. Documentation and runtime dependencies are updated, with unit coverage for ID3 handling, configuration, malformed payloads, decoding failures, streaming output, and reconnection.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
plugins/telnyx/vision_agents/plugins/telnyx/tts.py (1)

133-139: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

close() doesn't tear down the WebSocket directly.

close() closes self._session but never calls self._close_ws() or resets self._ws. If close() is called while a synthesis's finally block hasn't run yet (e.g. the consuming async generator was abandoned rather than exhausted), the stale self._ws reference outlives the session closing it implicitly rather than via its own graceful close path.

♻️ Proposed fix
 async def close(self) -> None:
     """Close the current WebSocket and release the aiohttp session."""
     await super().close()
+    await self._close_ws()
     if self._session is not None and not self._session.closed:
         await self._session.close()
     self._session = None
     self._on_disconnected()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d419cc24-2770-4040-a99a-2db31de5795e

📥 Commits

Reviewing files that changed from the base of the PR and between a9f5492 and bc45861.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • plugins/telnyx/README.md
  • plugins/telnyx/pyproject.toml
  • plugins/telnyx/tests/test_telnyx_tts.py
  • plugins/telnyx/vision_agents/plugins/telnyx/__init__.py
  • plugins/telnyx/vision_agents/plugins/telnyx/tts.py

Comment thread plugins/telnyx/tests/test_telnyx_tts.py
Comment thread plugins/telnyx/vision_agents/plugins/telnyx/tts.py
Comment thread plugins/telnyx/vision_agents/plugins/telnyx/tts.py
Comment thread plugins/telnyx/vision_agents/plugins/telnyx/tts.py

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
plugins/telnyx/vision_agents/plugins/telnyx/tts.py (1)

142-143: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unneeded Any annotations from the catch-all overrides.

tts.TTS.stream_audio uses *_, **__, not *_: Any, **__: Any; keep the Telnyx override matching that signature and drop the guideline-violating annotations.

Source: Path instructions


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ca362830-c00b-4149-a246-5843f3ffb18d

📥 Commits

Reviewing files that changed from the base of the PR and between bc45861 and 0a02318.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • plugins/telnyx/README.md
  • plugins/telnyx/pyproject.toml
  • plugins/telnyx/tests/test_telnyx_tts.py
  • plugins/telnyx/vision_agents/plugins/telnyx/__init__.py
  • plugins/telnyx/vision_agents/plugins/telnyx/tts.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • plugins/telnyx/pyproject.toml
  • plugins/telnyx/README.md

Comment thread plugins/telnyx/vision_agents/plugins/telnyx/tts.py

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
plugins/telnyx/tests/test_telnyx_tts.py (1)

94-108: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a typed pytest fixture for WebSocket setup.

fake_ws() is test setup encoded as a static helper, and the new async functions omit return annotations. Move setup into a typed @pytest.fixture factory and annotate receive, parse, and test coroutines.

As per coding guidelines: “Use pytest.fixture for test setup, not helper methods” and “Use type annotations everywhere.”

Also applies to: 110-131

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 67bd4608-6251-4d5f-b2fa-f269118ef306

📥 Commits

Reviewing files that changed from the base of the PR and between 0a02318 and 6511663.

📒 Files selected for processing (2)
  • plugins/telnyx/tests/test_telnyx_tts.py
  • plugins/telnyx/vision_agents/plugins/telnyx/tts.py

Comment thread plugins/telnyx/vision_agents/plugins/telnyx/tts.py
Comment thread plugins/telnyx/vision_agents/plugins/telnyx/tts.py

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
plugins/telnyx/tests/test_telnyx_tts.py (1)

94-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise barge-in through the real cancellation path.

DroppedWS/FakeSession mock transport and set _stop_event directly, so this never invokes stop_audio() or validates its close behavior. Use a typed pytest fixture with a local aiohttp WebSocket, call await tts.stop_audio(), and assert the stream ends cleanly.

Also applies to: 166-170

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 484c472c-01fe-47dd-bc61-7cfd2cad9f8f

📥 Commits

Reviewing files that changed from the base of the PR and between 6511663 and 1510363.

📒 Files selected for processing (2)
  • plugins/telnyx/tests/test_telnyx_tts.py
  • plugins/telnyx/vision_agents/plugins/telnyx/tts.py

Comment on lines +158 to +165
ws = await self._connect()
# Telnyx rejects a text frame that is not preceded by an
# init frame with "Invalid message".
await ws.send_str(json.dumps({"text": " "}))
await ws.send_str(json.dumps({"text": text}))
await ws.send_str(json.dumps({"text": ""}))
async for chunk in self._receive_audio(ws):
yield chunk

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Cancel pending connection attempts on barge-in.

stop_audio() can run while _connect() is awaiting the handshake. _ws is still None, so _close_ws() cannot interrupt it; once connected, this code still sends initialization and text frames. A hung handshake delays cancellation until idle_timeout. Race the connect task against _stop_event.wait(), cancel and await the connect task when stop wins, and skip sends.

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 160-160: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"text": " "})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 161-161: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"text": text})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 162-162: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"text": ""})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant