Skip to content

Add respeecher tts plugin#1

Open
mitrushchienkova wants to merge 17 commits intomainfrom
add-respeecher-TTS-plugin
Open

Add respeecher tts plugin#1
mitrushchienkova wants to merge 17 commits intomainfrom
add-respeecher-TTS-plugin

Conversation

@mitrushchienkova
Copy link
Copy Markdown
Collaborator

@mitrushchienkova mitrushchienkova commented Aug 22, 2025

Summary by CodeRabbit

  • New Features

    • Adds Respeecher TTS plugin: one‑shot synthesis, real‑time streaming (HTTP & WebSocket), voice listing, runtime-configurable model/voice/settings, automatic plugin registration, RESPEECHER_API_KEY support.
  • Documentation

    • New Respeecher README with install, prerequisites, example run and links; updated TTS examples README with environment guidance.
  • Examples

    • New Respeecher TTS example demonstrating streaming into LiveKit.
  • Tests

    • Tests extended to cover Respeecher (synthesis, streaming, error propagation); test env var and host mapping added.
  • Chores

    • Packaging configuration and initial version added for distribution; basic plugin logging included.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Aug 22, 2025

Walkthrough

Adds a new Respeecher LiveKit plugin package with plugin bootstrap/registration, logger and version, typed voice models, HTTP one-shot and WebSocket streaming TTS implementations, packaging and README, example script, tests, and docker-compose test networking/env updates.

Changes

Cohort / File(s) Summary of Changes
Plugin core
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py, livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py, livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py, livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py, livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py
New Respeecher plugin package: plugin class with auto-registration, re-exports (TTS, ChunkedStream, __version__), centralized logger, __version__ = "0.1.0", typed Voice/VoiceSettings, TTS implementation with HTTP one-shot (chunked) and WebSocket streaming (ChunkedStream, SynthesizeStream), API constants and mapped error handling.
Packaging & docs
livekit-plugins/livekit-plugins-respeecher/pyproject.toml, livekit-plugins/livekit-plugins-respeecher/README.md
New package pyproject using hatchling with dynamic version from version.py, metadata and dependencies (livekit-agents, aiohttp), README documenting installation (pip install livekit-plugins-respeecher), RESPEECHER_API_KEY prerequisite, example run command, and link to Respeecher docs.
Examples
examples/other/text-to-speech/respeecher_tts.py, examples/other/text-to-speech/requirements.txt, examples/other/text-to-speech/README.md
New example script streaming Respeecher TTS into a LiveKit track; example dependency livekit-plugins-respeecher>=0.0.1 added; TTS examples README updated with environment variable guidance and usage notes.
Tests & infra
tests/test_tts.py, tests/docker-compose.yml
Tests extended to include Respeecher provider in one-shot, streaming, and error-propagation cases; docker-compose adds RESPEECHER_API_KEY env var and api.respeecher.com extra_hosts mapping for test networking.
Workspace config
pyproject.toml
Adds workspace source member livekit-plugins-respeecher = { workspace = true } under [tool.uv.sources].

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant App as App (Agent)
  participant Plugin as Respeecher.TTS
  participant HTTP as Respeecher API (HTTP)
  participant WS as Respeecher API (WebSocket)

  rect rgba(235,245,255,0.9)
    note over App,Plugin: One-shot synthesis (HTTP chunked)
    App->>Plugin: synthesize(text)
    Plugin->>HTTP: POST /tts/bytes (auth, voice, output_format[, sampling_params])
    HTTP-->>Plugin: 200 OK (chunked audio)
    loop for each chunk
      Plugin-->>App: audio_chunk
    end
    alt error
      HTTP--x Plugin: timeout/non-2xx
      Plugin-->>App: raise mapped API error
    end
  end

  rect rgba(245,235,255,0.9)
    note over App,Plugin: Streaming synthesis (WebSocket)
    App->>Plugin: stream() / send_segment(text)
    Plugin->>WS: CONNECT /tts/websocket (auth headers)
    Plugin->>WS: send segment (context_id, transcript, voice[, sampling_params])
    loop messages
      WS-->>Plugin: chunk | done | error
      alt chunk
        Plugin-->>App: audio_chunk
      else done
        Plugin-->>App: end_segment / end_input
      else error
        Plugin-->>App: propagate error
      end
    end
    App->>Plugin: end_input()
    Plugin->>WS: send end
    Plugin-->>App: close stream
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I thump my paws—new voices near,
Bytes like carrots, crisp and clear.
Streams that hop and chunks that sing,
Keys and tests make circuits spring.
Hop—Respeecher brings the cheer! 🐇🎶

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.05% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title accurately summarizes the primary change—adding the Respeecher TTS plugin—and directly corresponds to the modifications introduced in the pull request, making it clear and concise for reviewers scanning the project history.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch add-respeecher-TTS-plugin

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1)

47-56: Voice.sampling_param type vs. JSON shape

list_voices() currently assigns a raw dict to Voice.sampling_param (typed Optional[SamplingParam]). Either parse into SamplingParam in tts.py (preferred) or widen the type to SamplingParam | dict. See the tts.py comment with a suggested fix to parse into the dataclass.

🧹 Nitpick comments (11)
livekit-plugins/livekit-plugins-respeecher/README.md (2)

20-20: Use a Markdown link to avoid MD034 (bare URL).

Switch the bare URL to a proper Markdown link.

-https://space.respeecher.com/docs/quickstart
+[Respeecher Quickstart](https://space.respeecher.com/docs/quickstart)

13-13: Spelling/style: “Prerequisites” over “Pre-requisites”.

Minor wording tweak for consistency with the rest of the docs.

-## Pre-requisites
+## Prerequisites
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py (1)

1-3: Attach a NullHandler to the library logger.

Prevents “No handler could be found” warnings in consumer apps that haven’t configured logging yet; standard library best practice for reusable packages.

 import logging
 
 logger = logging.getLogger("livekit.plugins.respeecher")
+logger.addHandler(logging.NullHandler())
livekit-plugins/livekit-plugins-respeecher/pyproject.toml (3)

26-30: Point Documentation to the plugin-specific page and update Source if appropriate.

Improves discoverability and avoids landing users on a generic docs page.

 [project.urls]
-Documentation = "https://docs.livekit.io"
+Documentation = "https://docs.livekit.io/agents/integrations/tts/respeecher/"
 Website = "https://livekit.io/"
-Source = "https://github.com/livekit/agents"
+Source = "https://github.com/livekit/agents"
+# If this package is maintained in a different repository (e.g., a fork),
+# consider pointing Source to that canonical repo instead.

13-14: Add “respeecher” to keywords.

Helps package discovery on PyPI.

-keywords = ["realtime", "audio", "livekit", "tts"]
+keywords = ["realtime", "audio", "livekit", "tts", "respeecher"]

24-24: Consider bounding aiohttp to a tested major range.

Optional, but pinning a lower bound (and optionally an upper bound before next major) can prevent unexpected breakages from aiohttp 4.x when it lands.

-dependencies = ["livekit-agents>=1.2.6", "aiohttp"]
+dependencies = ["livekit-agents>=1.2.6", "aiohttp>=3.9"]
+# Optionally until validated:
+# dependencies = ["livekit-agents>=1.2.6", "aiohttp>=3.9,<4"]
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1)

20-24: Consider re-exporting SynthesizeStream for parity

Many plugins expose both one-shot and streaming helpers. Re-exporting SynthesizeStream can improve DX without requiring deep imports.

-from .tts import TTS, ChunkedStream
+from .tts import TTS, ChunkedStream, SynthesizeStream
 from .version import __version__
 
-__all__ = ["TTS", "ChunkedStream", "__version__"]
+__all__ = ["TTS", "ChunkedStream", "SynthesizeStream", "__version__"]
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1)

16-23: Prefer immutable container for sample rates

TTSSampleRates should be immutable to avoid accidental mutation at runtime.

-TTSSampleRates = [
+TTSSampleRates = (
     8000,
     11025,
     16000,
     22050,
     44100,
     48000,
-]
+)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (3)

42-46: Use package version for API version header

Hardcoding a date string is fragile. Use the plugin’s __version__ instead so header/reporting stays in sync with releases.

+from .version import __version__
 ...
-API_VERSION = "2025-08-20"
+API_VERSION = __version__
 API_BASE_URL = "https://api.respeecher.com"

225-236: HTTP response handling: ensure correct MIME and skip empty chunks

The /tts/bytes endpoint likely returns raw PCM (not WAV). Also, defensive skip of empty chunks avoids emitting zero-length frames.

                 output_emitter.initialize(
                     request_id=utils.shortuuid(),
                     sample_rate=self._tts._opts.sample_rate,
                     num_channels=1,
-                    mime_type="audio/wav",
+                    mime_type="audio/pcm",
                 )
 
-                async for data, _ in resp.content.iter_chunks():
-                    output_emitter.push(data)
+                async for data, _ in resp.content.iter_chunks():
+                    if not data:
+                        continue
+                    output_emitter.push(data)

265-267: WS URL query: “source” should be an identifier, not a header name

Passing the header name as source is misleading. Use a stable source identifier and keep version tied to your plugin version.

-        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket?api_key={self._tts._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
+        full_ws_url = (
+            f"{ws_url}{self._tts._opts.model}/tts/websocket"
+            f"?api_key={self._tts._opts.api_key}&source=livekit-plugins-respeecher&version={API_VERSION}"
+        )
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 05b36d6 and de752ab.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • livekit-plugins/livekit-plugins-respeecher/README.md (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/pyproject.toml (1 hunks)
  • tests/docker-compose.yml (2 hunks)
  • tests/test_tts.py (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/test_tts.py (2)
livekit-agents/livekit/agents/tts/tts.py (1)
  • TTS (65-126)
livekit-plugins/livekit-plugins-resemble/livekit/plugins/resemble/tts.py (1)
  • TTS (52-160)
🪛 markdownlint-cli2 (0.17.2)
livekit-plugins/livekit-plugins-respeecher/README.md

20-20: Bare URL used

(MD034, no-bare-urls)

🔇 Additional comments (9)
tests/docker-compose.yml (2)

57-57: Pass-through env var for Respeecher API key looks good.

This matches the plugin’s expected RESPEECHER_API_KEY and follows the existing pattern used for other providers.


79-79: All Respeecher endpoints are already covered

A ripgrep search across tests/ and livekit-plugins/ found only references to api.respeecher.com (in API_BASE_URL and in tests’ proxy-upstream settings) and no other Respeecher domains or WebSocket endpoints. The existing extra_hosts entry for "api.respeecher.com:172.30.0.10" therefore suffices—no additional mappings are required.

livekit-plugins/livekit-plugins-respeecher/pyproject.toml (1)

31-33: Version sourcing via hatch looks correct.

Dynamic version path matches the new version module.

livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py (1)

1-16: License header and version constant look good.

__version__ = "0.1.0" aligns with pyproject dynamic version config.

tests/test_tts.py (3)

36-40: Add respeecher to plugin import list — looks good

Importing respeecher alongside the other providers matches the new plugin and enables the parametrized tests to instantiate it.


446-452: Stream test case added for Respeecher — OK

Streaming path uses the same upstream host; consistent with the WebSocket URL construction in the plugin. No changes requested.


226-232: Synthesize test case added for Respeecher — env & host mapping verified

  • tests/docker-compose.yml includes both
    • the RESPEECHER_API_KEY environment variable on line 57
    • the "api.respeecher.com:172.30.0.10" Toxiproxy host mapping on line 79
  • The plugin’s README and livekit/plugins/respeecher/tts.py code correctly reference RESPEECHER_API_KEY
    No further changes needed—CI’s existing docker-compose setup already wires the API key and routes the upstream host for the new Respeecher test.
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (2)

30-36: Plugin registration at import-time — OK

Pattern matches existing plugins (module metadata + logger, registered once). No issues spotted.


37-44: Doc control (pdoc) — OK

Suppressing unexported internals is consistent with other packages.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (4)

336-341: WebSocket close: use 499 to reflect client/transport closure and mark non-retryable.

Using 499 (client closed request) better conveys transport closure semantics and will set retryable=False per APIStatusError logic.

Apply this diff:

-                            raise APIStatusError(
-                                message="Respeecher websocket closed unexpectedly",
-                                status_code=500,
-                                request_id=request_id,
-                                body=None,
-                            )
+                            raise APIStatusError(
+                                message="Respeecher websocket closed unexpectedly",
+                                status_code=499,
+                                request_id=request_id,
+                                body=None,
+                            )

100-108: Normalize model and base_url once to prevent malformed URLs.

If a caller passes model without a leading slash or base_url with a trailing slash, you may end up with broken paths (e.g., https://api...//v1 or https://api...v1). Normalize both values in the constructor before storing.

Apply this diff:

         respeecher_api_key = api_key if is_given(api_key) else os.environ.get("RESPEECHER_API_KEY")
         if not respeecher_api_key:
             raise ValueError("RESPEECHER_API_KEY must be set")

+        # Normalize user-provided URL/model once
+        normalized_model = model if str(model).startswith("/") else f"/{model}"
+        normalized_base_url = base_url.rstrip("/")
+
         self._opts = _TTSOptions(
-            model=model,
+            model=normalized_model,
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=normalized_base_url,
         )

117-147: list_voices: add timeout/error mapping consistent with synthesize/stream.

Current implementation lacks timeout handling and consistent exception mapping. Mirror the pattern used in ChunkedStream/SynthesizeStream. Also, accept conn_options for parity.

Apply this diff:

-    async def list_voices(self) -> list[Voice]:
-        """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
-            voices = []
-            for voice_data in data:
-                voices.append(
-                    Voice(
-                        id=voice_data["id"],
-                        gender=voice_data.get("gender"),
-                        accent=voice_data.get("accent"),
-                        age=voice_data.get("age"),
-                        sampling_params=(
-                            SamplingParams(**voice_data["sampling_params"])
-                            if isinstance(voice_data.get("sampling_params"), dict)
-                            else None
-                        ),
-                    )
-                )
-
-            if len(voices) == 0:
-                raise APIError("No voices are available")
-
-            return voices
+    async def list_voices(
+        self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
+    ) -> list[Voice]:
+        """List available voices from Respeecher API"""
+        async def _http_operation() -> list[Voice]:
+            async with self._ensure_session().get(
+                f"{self._opts.base_url}{self._opts.model}/voices",
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+                voices: list[Voice] = []
+                for voice_data in data:
+                    voices.append(
+                        Voice(
+                            id=voice_data["id"],
+                            gender=voice_data.get("gender"),
+                            accent=voice_data.get("accent"),
+                            age=voice_data.get("age"),
+                            sampling_params=(
+                                SamplingParams(**voice_data["sampling_params"])
+                                if isinstance(voice_data.get("sampling_params"), dict)
+                                else None
+                            ),
+                        )
+                    )
+                if not voices:
+                    raise APIError("No voices are available")
+                return voices
+
+        try:
+            return await asyncio.wait_for(_http_operation(), timeout=conn_options.timeout)
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(
+                message=e.message, status_code=e.status, request_id=None, body=None
+            ) from None
+        except Exception as e:
+            raise APIConnectionError() from e

157-162: Sanitize model in update_options to avoid broken URLs.

If model is provided without a leading slash, URL concatenation will break. Normalize here as well.

Apply this diff:

-        if is_given(model):
-            self._opts.model = model
+        if is_given(model):
+            self._opts.model = model if str(model).startswith("/") else f"/{model}"
🧹 Nitpick comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1)

326-369: Minor: enrich unexpected/errored WS message exceptions with details.

Including the server-provided payload helps operators debug. Consider passing body=data or including type fields in the error text for both "error" and default cases.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between de752ab and 6450e4edb48994ed71700e3bf973423204e6a00b.

📒 Files selected for processing (3)
  • livekit-plugins/livekit-plugins-respeecher/README.md (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • livekit-plugins/livekit-plugins-respeecher/README.md
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (5)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • SamplingParams (27-37)
  • Voice (48-55)
  • VoiceSettings (41-44)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
🔇 Additional comments (2)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1)

4-7: Models look solid; previous issues addressed (leading slash, temperature).

  • Leading slash on TTSModels is correct now.
  • SamplingParams uses temperature (spelling fixed). Good.
  • Voice/VoiceSettings types align with usage in tts.py.

No further changes needed here from my side.

Also applies to: 26-37, 47-55

livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1)

218-236: Verify Respeecher /tts/bytes MIME type before hardcoding

We need to confirm whether the Respeecher TTS HTTP endpoint actually returns a WAV container (audio/wav) or raw PCM (audio/pcm, etc.) when you request encoding=pcm_s16le or pcm_f32le. Without that certainty, switching to derive the MIME from resp.headers could introduce inconsistencies if the server always returns WAV.

Please verify via one of these approaches:

  • Consult the official Respeecher API documentation for the /tts/bytes endpoint and check the Content-Type they specify for PCM encodings.
  • Perform a quick request (e.g. with curl or another HTTP client) against the /tts/bytes endpoint using encoding=pcm_s16le/pcm_f32le, and inspect the Content-Type response header.

Once confirmed:

  • If it always returns WAV, you can safely keep mime_type="audio/wav".
  • Otherwise, derive it from resp.headers.get("Content-Type", "<fallback>") as shown in the suggested diff.

@mitrushchienkova mitrushchienkova force-pushed the add-respeecher-TTS-plugin branch from 6450e4e to 9af5141 Compare September 1, 2025 07:27
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1)

4-7: Leading slash fix on model path — LGTM

This aligns with base_url + model concatenation and prevents malformed URLs (e.g., https://...comv1).

🧹 Nitpick comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)

1-2: Prep for clean JSON payloads: import asdict for selective serialization

If you plan to omit None fields when sending params, import asdict here to support a to_dict() helper on dataclasses.

-from dataclasses import dataclass
+from dataclasses import dataclass, asdict

26-37: Avoid sending nulls to the API (filter None fields)

dataclasses.asdict will keep None values; many APIs prefer omitted keys. Add a helper to serialize SamplingParams without None.

 @dataclass
 class SamplingParams:
     """Check https://space.respeecher.com/docs/api/tts/sampling-params-guide for details"""
 
     seed: Optional[int] = None
     temperature: Optional[float] = None
     top_k: Optional[int] = None
     top_p: Optional[float] = None
     min_p: Optional[float] = None
     presence_penalty: Optional[float] = None
     repetition_penalty: Optional[float] = None
     frequency_penalty: Optional[float] = None
+
+    def to_dict(self) -> dict:
+        d = asdict(self)
+        return {k: v for k, v in d.items() if v is not None}

47-55: Consider stronger typing for voice attributes

If the API constrains gender/accent/age, consider Literals/Enums to catch typos at type-check time; otherwise, minimally document allowed values.

livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1)

37-44: Reduce risk of over-hiding in docs

Computing NOT_IN_ALL from dir() may hide unexpected names. Prefer explicitly listing internal modules in pdoc (e.g., {"log": False, "models": False, "tts": False, "version": False}).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6450e4edb48994ed71700e3bf973423204e6a00b and 9af51410864edbfd2d9be96a417b988dc61b4342.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • livekit-plugins/livekit-plugins-respeecher/README.md (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/pyproject.toml (1 hunks)
  • tests/docker-compose.yml (2 hunks)
  • tests/test_tts.py (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • livekit-plugins/livekit-plugins-respeecher/pyproject.toml
  • tests/docker-compose.yml
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py
  • livekit-plugins/livekit-plugins-respeecher/README.md
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py
🔇 Additional comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1)

30-35: Confirm plugin registration is idempotent

Registering at import time can duplicate entries under hot-reload/tests if not deduped by name. Confirm Plugin.register_plugin() is idempotent or guarded upstream.

tests/test_tts.py (3)

36-36: Provider import — LGTM

Respeecher added consistently with other providers.


446-452: Confirm WS domain/port alignment

If streaming uses WebSocket, confirm the WS endpoint is also behind api.respeecher.com:443 through the proxy (no separate WS host).


226-232: No action required: RESPEECHER_API_KEY and api.respeecher.com host mapping are already configured in tests/docker-compose.yml.

Comment on lines +1 to +67
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import asyncio
import base64
import dataclasses
import json
import os
import weakref
from dataclasses import dataclass

import aiohttp

from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIError,
APIStatusError,
APITimeoutError,
tts,
utils,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given

from .log import logger
from .models import SamplingParams, TTSEncoding, TTSModels, Voice, VoiceSettings
from .version import __version__

API_VERSION = __version__
API_AUTH_HEADER = "X-API-Key"
API_VERSION_HEADER = "LiveKit-Plugin-Respeecher-Version"
API_BASE_URL = "https://api.respeecher.com"


@dataclass
class _TTSOptions:
model: TTSModels | str
encoding: TTSEncoding
sample_rate: int
voice_id: str
voice_settings: NotGivenOr[VoiceSettings]
api_key: str
base_url: str


class TTS(tts.TTS):
def __init__(
self,
*,
api_key: NotGivenOr[str] = NOT_GIVEN,
model: TTSModels | str = "/v1/public/tts/en-rt",
encoding: TTSEncoding = "pcm_s16le",
voice_id: str = "samantha",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@greaber I’d like to discuss the logic for setting the default voice in the integration.
@Kharacternyk — I assume Pipecat integration faces the same question. How are you handling it?

I see three possible approaches:

  • No default voice - require the user to choose explicitly.
    • Downside: this disrupts the onboarding experience compared to other integrations. With other providers, users just set an API key and can test immediately. Here, users would need to inspect the code and realize they must call list_voices() first. That feels like unnecessary friction.
  • Hardcode a preferred voice - select one speaker (e.g., the one we think markets Respeecher best) for the en-rt model and set it as the default.
    • Other integrations do this.
    • Concern: our available voices may vary across models as we add more in the future. In addition, voice actors may withdraw consent, forcing us to remove a voice quickly.
  • Dynamic default via list_voices - call it at init/prewarm.
    • Option A: simply take the first voice in the list (currently amara).
    • Option B: extend the backend API to include a field like default_voice: "amara", which integrations can rely on.

The option B looks like the most robust approach, though I’m unsure if the added complexity is justified. Wdyt?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I assume Pipecat integration faces the same question. How are you handling it?

TTS services in Pipecat require voice as a mandatory parameter without a default. In code samples, I just used samantha.

Option A: simply take the first voice in the list (currently amara).
Option B: extend the backend API to include a field like default_voice: "amara", which integrations can rely on.

Option A is what our CLI client does. It's a fine solution from my point of view, however hardcoding samantha isn't much worse. I agree that option B is probably the best, and it's not that difficult to implement

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (2)

66-74: Normalize base_url and model once; mirror in update_options()

Prevent double/missing slashes and Enum/string ambiguities by sanitizing in ctor and when updating.

@@
-        model: TTSModels | str = "/v1/public/tts/en-rt",
+        model: TTSModels | str = "/v1/public/tts/en-rt",
@@
-        self._opts = _TTSOptions(
-            model=model,
+        # normalize base_url + model
+        base_url = base_url.rstrip("/")
+        _model_str = str(model)
+        _model_str = "/" + _model_str.lstrip("/")
+
+        self._opts = _TTSOptions(
+            model=_model_str,
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=base_url,
         )
@@
-        if is_given(model):
-            self._opts.model = model
+        if is_given(model):
+            self._opts.model = "/" + str(model).lstrip("/")

Also applies to: 102-111, 154-168


274-282: Do not embed api_key in the WS URL; also avoid logging secrets

Putting secrets in URLs leaks via logs/proxies; you also log the full URL. Pass auth/version as headers and log a sanitized URL.

-        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
-        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket?api_key={self._tts._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
+        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
+        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket"
@@
-            async with self._tts._ensure_session().ws_connect(full_ws_url) as ws:
-                logger.debug(f"WebSocket connected {full_ws_url}")
+            async with self._tts._ensure_session().ws_connect(
+                full_ws_url,
+                headers={
+                    API_AUTH_HEADER: self._tts._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                # optional: keepalive for intermediates
+                heartbeat=30,
+            ) as ws:
+                logger.debug("WebSocket connected %s", full_ws_url)
🧹 Nitpick comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (4)

234-239: Confirm MIME type: raw PCM vs WAV

You request encoding "pcm_s16le" but set mime_type="audio/wav" (not treated as raw by AudioEmitter). If the endpoint returns raw PCM, use audio/pcm; if WAV container, align output_format accordingly.

-                    mime_type="audio/wav",
+                    mime_type="audio/pcm",

122-153: Align list_voices() error/timeout handling with other calls

Wrap the request to map timeouts/statuses consistently and set a per-call timeout.

-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
+        try:
+            async with self._ensure_session().get(
+                f"{self._opts.base_url}{self._opts.model}/voices",
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(total=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(message=e.message, status_code=e.status, request_id=None, body=None) from None
+        except Exception as e:
+            raise APIConnectionError() from e

353-358: WS close status: prefer 499 (client/transport) or include close code

Minor, but 500 suggests server fault; 499 or the actual close code (if available on msg.data) improves diagnostics.

-                                status_code=500,
+                                status_code=499,

68-71: Default voice strategy

Hardcoding "samantha" risks 4xx if unavailable for the selected model. Consider a sentinel ("auto") that fetches the first available voice once (or rely on a backend-provided default), caching the choice.

Also applies to: 122-153

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9af51410864edbfd2d9be96a417b988dc61b4342 and f5568933b28c0faa144eb8cbbbdea9d129a46be9.

📒 Files selected for processing (2)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/init.py
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (6)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • SamplingParams (27-37)
  • Voice (48-55)
  • VoiceSettings (41-44)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (4)
  • num_channels (96-97)
  • AudioEmitter (553-947)
  • start_segment (640-647)
  • end_segment (659-666)

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (6)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (6)

353-358: Use a sentinel “client closed” status for unexpected WS close

499 better reflects transport/client closure and keeps retry semantics correct.

-                            raise APIStatusError(
-                                message="Respeecher websocket closed unexpectedly",
-                                status_code=500,
-                                request_id=request_id,
-                                body=None,
-                            )
+                            raise APIStatusError(
+                                message="Respeecher websocket closed unexpectedly",
+                                status_code=499,
+                                request_id=request_id,
+                                body=None,
+                            )

274-282: Remove API key from WebSocket URL; pass via headers and avoid leaking secrets in logs

Embedding api_key in the URL risks leakage via logs/proxies and is inconsistent with your HTTP calls. Pass it in headers and log a sanitized URL.

-        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
-        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket?api_key={self._tts._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
+        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
+        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket"
@@
-            async with self._tts._ensure_session().ws_connect(full_ws_url) as ws:
-                logger.debug(f"WebSocket connected {full_ws_url}")
+            async with self._tts._ensure_session().ws_connect(
+                full_ws_url,
+                headers={
+                    API_AUTH_HEADER: self._tts._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
+            ) as ws:
+                logger.debug("WebSocket connected %s", full_ws_url)

244-245: Finalize non-streaming emitter by closing input after flush

Without end_input(), the emitter may keep resources open.

-                output_emitter.flush()
+                output_emitter.flush()
+                output_emitter.end_input()

102-110: Normalize base_url and model once to prevent malformed URLs

Ensure single leading slash on model and strip trailing slash from base_url.

-        self._opts = _TTSOptions(
-            model=model,
+        normalized_model = (model.value if hasattr(model, "value") else str(model))
+        normalized_model = "/" + normalized_model.lstrip("/")
+        normalized_base_url = base_url.rstrip("/")
+        self._opts = _TTSOptions(
+            model=normalized_model,
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=normalized_base_url,
         )

161-167: Sanitize model in update_options to keep URL well-formed

Updating with a model lacking a leading “/” currently breaks requests.

-        if is_given(model):
-            self._opts.model = model
+        if is_given(model):
+            m = (model.value if hasattr(model, "value") else str(model))
+            self._opts.model = "/" + m.lstrip("/")

122-153: list_voices(): add timeout/error mapping consistency with other calls

Align with synthesize/stream: set a connect-timeout and translate exceptions.

-    async def list_voices(self) -> list[Voice]:
-        """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
-            voices = []
-            for voice_data in data:
-                voices.append(
-                    Voice(
-                        id=voice_data["id"],
-                        gender=voice_data.get("gender"),
-                        accent=voice_data.get("accent"),
-                        age=voice_data.get("age"),
-                        sampling_params=(
-                            SamplingParams(**voice_data["sampling_params"])
-                            if isinstance(voice_data.get("sampling_params"), dict)
-                            else None
-                        ),
-                    )
-                )
-
-            if len(voices) == 0:
-                raise APIError("No voices are available")
-
-            return voices
+    async def list_voices(self) -> list[Voice]:
+        """List available voices from Respeecher API"""
+        try:
+            async with self._ensure_session().get(
+                f"{self._opts.base_url}{self._opts.model}/voices",
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+                voices: list[Voice] = []
+                for voice_data in data:
+                    voices.append(
+                        Voice(
+                            id=voice_data["id"],
+                            gender=voice_data.get("gender"),
+                            accent=voice_data.get("accent"),
+                            age=voice_data.get("age"),
+                            sampling_params=(
+                                SamplingParams(**voice_data["sampling_params"])
+                                if isinstance(voice_data.get("sampling_params"), dict)
+                                else None
+                            ),
+                        )
+                    )
+                if not voices:
+                    raise APIError("No voices are available")
+                return voices
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(message=e.message, status_code=e.status, request_id=None, body=None) from None
+        except Exception as e:
+            raise APIConnectionError() from e
🧹 Nitpick comments (2)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (2)

366-368: Map WS “error” frames to APIStatusError when possible

If the payload includes a code/status, raise APIStatusError with that status; otherwise include the raw body for diagnostics.

I can add tolerant parsing that falls back to APIError if no status is present.


68-69: Default voice strategy: consider dynamic default to reduce friction and churn

Given voices can change per model, either pick the first from list_voices() at prewarm or add a backend default_voice field; avoids hardcoding “samantha.”

I can wire a prewarm resolver that caches a per-model default voice with a short TTL.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f5568933b28c0faa144eb8cbbbdea9d129a46be9 and 9c72d4c706933004518385cefc3307f7d756e708.

📒 Files selected for processing (2)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (7)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • SamplingParams (18-28)
  • Voice (39-46)
  • VoiceSettings (32-35)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (5)
  • TTSCapabilities (46-50)
  • num_channels (96-97)
  • AudioEmitter (553-947)
  • start_segment (640-647)
  • end_segment (659-666)
livekit-agents/livekit/agents/utils/log.py (1)
  • log_exceptions (9-41)
🔇 Additional comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1)

234-239: Verify MIME type matches encoding (WAV vs raw PCM) to avoid mis-decoding

HTTP path sets audio/wav while stream sets audio/pcm but both may use pcm_s16le. Ensure the emitter’s mime_type aligns with actual bytes (e.g., infer from response Content-Type or map from encoding).

Would you like a small helper that maps TTSEncoding to a MIME (and falls back to resp.headers.get("Content-Type"))?

Also applies to: 264-272

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (5)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (5)

102-110: Normalize base_url and model to prevent malformed URLs

Trim trailing slash on base_url and ensure model has a single leading slash.

         respeecher_api_key = api_key if is_given(api_key) else os.environ.get("RESPEECHER_API_KEY")
         if not respeecher_api_key:
             raise ValueError("RESPEECHER_API_KEY must be set")

+        # Normalize URL parts once
+        normalized_base_url = base_url.rstrip("/")
+        normalized_model = str(model)
+        if not normalized_model.startswith("/"):
+            normalized_model = f"/{normalized_model}"
+
         self._opts = _TTSOptions(
-            model=model,
+            model=normalized_model,
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=normalized_base_url,
         )

162-167: Sanitize model in update_options()

Ensure caller-provided model gains a leading slash to avoid broken joins.

-        if is_given(model):
-            self._opts.model = model
+        if is_given(model):
+            m = str(model)
+            self._opts.model = m if m.startswith("/") else f"/{m}"

244-246: Non-streaming path never closes emitter; call end_input()

Without end_input(), the emitter may keep its channel open, leaking resources or stalling consumers.

                 output_emitter.flush()
+                output_emitter.end_input()

223-231: Use connect-timeout only; remove total-timeout around long-running HTTP synth

Total timeout will abort large texts mid-stream. Let the request run; only bound the connect phase.

             async with self._tts._ensure_session().post(
                 http_url,
                 headers={
                     API_AUTH_HEADER: self._tts._opts.api_key,
                     API_VERSION_HEADER: API_VERSION,
                     "Content-Type": "application/json",
                 },
                 json=json_data,
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
             ) as resp:
@@
-        try:
-            await asyncio.wait_for(_http_operation(), timeout=self._conn_options.timeout)
+        try:
+            await _http_operation()
         except asyncio.TimeoutError:
             raise APITimeoutError() from None
         except aiohttp.ClientResponseError as e:
             raise APIStatusError(
                 message=e.message, status_code=e.status, request_id=None, body=None
             ) from None
         except Exception as e:
             raise APIConnectionError() from e

Also applies to: 246-256


274-281: Do not put API key in WS URL; pass via headers. Also, remove total-timeout and use connect-timeout.

Secrets in URLs leak via logs/proxies; aiohttp supports headers. Total wait_for can kill long streams.

-        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
-        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket?api_key={self._tts._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
+        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
+        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket"
@@
-        async def _ws_operation():
-            async with self._tts._ensure_session().ws_connect(full_ws_url) as ws:
-                logger.debug(f"WebSocket connected {full_ws_url}")
+        async def _ws_operation():
+            async with self._tts._ensure_session().ws_connect(
+                full_ws_url,
+                headers={
+                    API_AUTH_HEADER: self._tts._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
+            ) as ws:
+                logger.debug("WebSocket connected %s", full_ws_url)
@@
-        try:
-            await asyncio.wait_for(_ws_operation(), timeout=self._conn_options.timeout)
+        try:
+            await _ws_operation()
         except asyncio.TimeoutError:
             raise APITimeoutError() from None
         except aiohttp.ClientResponseError as e:
             raise APIStatusError(
                 message=e.message, status_code=e.status, request_id=request_id, body=None
             ) from None
         except Exception as e:
             raise APIConnectionError() from e

Also applies to: 402-409

🧹 Nitpick comments (3)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (3)

122-153: list_voices: add connect-timeout and map errors consistently

Mirror synthesize/stream by adding a connect-timeout and mapping aiohttp errors to API* errors.

-    async def list_voices(self) -> list[Voice]:
+    async def list_voices(self) -> list[Voice]:
         """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
+        try:
+            async with self._ensure_session().get(
+                f"{self._opts.base_url}{self._opts.model}/voices",
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(message=e.message, status_code=e.status, request_id=None, body=None) from None
+        except Exception as e:
+            raise APIConnectionError() from e

234-239: Derive MIME type from response header (avoid hardcoding WAV)

Prefer server-declared Content-Type; fallback to audio/wav if absent.

-                output_emitter.initialize(
+                mime = resp.headers.get("Content-Type", "audio/wav")
+                output_emitter.initialize(
                     request_id=utils.shortuuid(),
                     sample_rate=self._tts._opts.sample_rate,
                     num_channels=1,
-                    mime_type="audio/wav",
+                    mime_type=mime,
                 )

66-71: Default voice strategy: consider making voice_id optional with a discoverable default

Hardcoding “samantha” can break if availability changes. Option: make voice_id optional and, on first use, pick the first voice from list_voices() (or a backend-provided default) and cache it. Document the behavior.

Also applies to: 82-87

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9c72d4c706933004518385cefc3307f7d756e708 and fba658ecce04063ffa902e32d8555863075b15f4.

📒 Files selected for processing (2)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (6)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • SamplingParams (18-28)
  • Voice (39-46)
  • VoiceSettings (32-35)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (6)
  • TTSCapabilities (46-50)
  • num_channels (96-97)
  • input_text (156-157)
  • AudioEmitter (553-947)
  • start_segment (640-647)
  • end_segment (659-666)

"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3 :: Only",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These classifiers seem random. Why 3.9 and 3.10 specifically? Is it just something that was in the LiveKit code?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I (Claude) just copy pasted it from other plugins

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

But I can check deeper

pytest.param(
lambda: {
"tts": respeecher.TTS(),
"proxy-upstream": "api.respeecher.com:443",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

api.respeecher.com is still coreweave, not cloudflare, right?

Copy link
Copy Markdown
Collaborator Author

@mitrushchienkova mitrushchienkova Sep 3, 2025

Choose a reason for hiding this comment

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

Yes, I'll change it soon

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
livekit-plugins/livekit-plugins-respeecher/README.md (1)

13-16: Fix heading and add copyable env-var setup examples.

“Pre-requisites” → “Prerequisites”; include platform-specific commands so users don’t guess.

Apply:

-## Pre-requisites
+## Prerequisites
@@
-You'll need an API key from Respeecher. It can be set as an environment variable: `RESPEECHER_API_KEY` or passed to the `respeecher.TTS()` constructor.
+You'll need an API key from Respeecher. Set it via `RESPEECHER_API_KEY` or pass it to `respeecher.TTS()`.
+
+macOS/Linux:
+```bash
+export RESPEECHER_API_KEY="your_api_key_here"
+```
+
+Windows (PowerShell):
+```powershell
+$env:RESPEECHER_API_KEY="your_api_key_here"
+```
🧹 Nitpick comments (9)
livekit-plugins/livekit-plugins-respeecher/README.md (1)

23-25: Use consistent uv invocation (drop “python”).

Other examples call the script directly with uv; keep consistency.

-uv run python examples/other/text-to-speech/respeecher_tts.py start
+uv run examples/other/text-to-speech/respeecher_tts.py start
examples/other/text-to-speech/README.md (2)

7-9: List the exact env var names per plugin (at least RESPEECHER_API_KEY).

Reduces confusion when users switch plugins.

 ### Plugin API Keys
-Set the API key for your chosen plugin.
+Set the API key for your chosen plugin.
+
+- Respeecher: `RESPEECHER_API_KEY`
+- ElevenLabs: `ELEVEN_API_KEY`
+- Cartesia: `CARTESIA_API_KEY`
+- Speechify: `SPEECHIFY_API_KEY`

28-30: Offer a concrete local-testing tip.

Add one line noting users can print or count frames, or write WAV, to validate output without a room.

 To test TTS output locally without a LiveKit room, you would need to modify the example file to save the generated audio frames to a WAV file instead of publishing them to a track. The saved WAV file can then be played using any audio player on your system.
+Tip: as a quick smoke test, log the number of frames received or append raw bytes to a file; for a proper WAV, set channels/sample width (s16le=2 bytes) and sample rate to match the TTS options.
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1)

15-16: Replace stray triple-quoted string with a comment.

This string isn’t a module docstring (not at top) and has no effect.

-"""Check https://space.respeecher.com/docs/api/tts/sampling-params-guide for details"""
-SamplingParams = Dict[str, Any]
+# Check https://space.respeecher.com/docs/api/tts/sampling-params-guide for details
+SamplingParams = Dict[str, Any]
examples/other/text-to-speech/respeecher_tts.py (5)

3-4: Remove unused imports.

wave and os aren’t used.

-import wave
-import os

30-30: Avoid indefinite wait for subscribers.

Add a short timeout so the example proceeds without a remote participant.

-await publication.wait_for_subscription()
+try:
+    await asyncio.wait_for(publication.wait_for_subscription(), timeout=5)
+except asyncio.TimeoutError:
+    logger.info("no subscribers yet; proceeding without remote subscription")

33-37: Remove unused counter.

Simplify the playback task.

-        async def _playback_task():
-            count = 0
-            async for audio in stream:
-                count += 1
-                await source.capture_frame(audio.frame)
+        async def _playback_task():
+            async for audio in stream:
+                await source.capture_frame(audio.frame)

32-55: Close the TTS client session after streaming.

Prevents lingering aiohttp sessions in longer-lived workers.

     async with tts.stream() as stream:
         ...
-        await asyncio.gather(task)
+        await asyncio.gather(task)
 
+    await tts.aclose()

14-16: Ensure logs are visible when run standalone.

Add a default stream handler if none is configured.

 logger = logging.getLogger("respeecher-tts-demo")
 logger.setLevel(logging.INFO)
+if not logger.handlers:
+    logger.addHandler(logging.StreamHandler())
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between fba658ecce04063ffa902e32d8555863075b15f4 and 1420404d3bbd2dfd5a2b318b174440af6e4f20e0.

📒 Files selected for processing (6)
  • examples/other/text-to-speech/README.md (1 hunks)
  • examples/other/text-to-speech/requirements.txt (1 hunks)
  • examples/other/text-to-speech/respeecher_tts.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/README.md (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
🧰 Additional context used
🧬 Code graph analysis (1)
examples/other/text-to-speech/respeecher_tts.py (3)
livekit-agents/livekit/agents/job.py (2)
  • AutoSubscribe (60-64)
  • JobContext (90-435)
livekit-agents/livekit/agents/worker.py (1)
  • WorkerOptions (150-237)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (2)
  • TTS (60-184)
  • stream (169-174)
🔇 Additional comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1)

4-7: API_BASE_URL includes /v1; concatenation with /public/tts/en-rt yields valid URLs.

@@ -2,5 +2,6 @@ livekit-agents>=0.12.18
livekit-plugins-openai>=0.12.2
livekit-plugins-cartesia>=0.4.11
livekit-plugins-elevenlabs>=0.8.1
livekit-plugins-respeecher>=0.0.1
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Align example requirement with package version and add a safe upper bound.

The plugin's version module is 0.1.0, but the example depends on >=0.0.1. Consider aligning and capping to avoid future breaking changes.

Apply:

-livekit-plugins-respeecher>=0.0.1
+livekit-plugins-respeecher>=0.1.0,<1.0.0

Verify the package/version exists on PyPI:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
curl -s https://pypi.org/pypi/livekit-plugins-respeecher/json | jq -r '.info.version,.releases | keys[]' | sort -V | tail -n 5

Length of output: 49


🏁 Script executed:

#!/bin/bash
set -euo pipefail
curl -s -o /dev/null -w "%{http_code}\n" https://pypi.org/pypi/livekit-plugins-respeecher/json

Length of output: 3


Correct or publish the referenced package before pinning versions.

The livekit-plugins-respeecher package is not found on PyPI (HTTP 404); please confirm the correct package name or publish this plugin before updating the example’s requirements.

🤖 Prompt for AI Agents
In examples/other/text-to-speech/requirements.txt around line 5, the pinned
dependency "livekit-plugins-respeecher>=0.0.1" is not available on PyPI; either
correct the package name to the published PyPI identifier or remove/replace this
line until the plugin is published. Update the requirements to reference a valid
package/version (or point to a VCS URL or wheel if you must use an unpublished
artifact), and ensure the file's README or a comment documents the dependency
source and that CI installs will succeed.

@mitrushchienkova mitrushchienkova force-pushed the add-respeecher-TTS-plugin branch from acb38fb to a98d08f Compare September 8, 2025 10:09
@mitrushchienkova mitrushchienkova force-pushed the add-respeecher-TTS-plugin branch from d049eee to e18821c Compare September 8, 2025 11:43
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (4)

344-349: WebSocket closed unexpectedly: use a sentinel 499 status

499 better indicates client/transport closure while preserving request_id.

-                            raise APIStatusError(
-                                message="Respeecher websocket closed unexpectedly",
-                                status_code=500,
-                                request_id=request_id,
-                                body=None,
-                            )
+                            raise APIStatusError(
+                                message="Respeecher websocket closed unexpectedly",
+                                status_code=499,
+                                request_id=request_id,
+                                body=None,
+                            )

101-109: Normalize base_url and model once to prevent broken URLs

Ensure base_url has no trailing slash and model has exactly one leading slash in both constructor and update_options.

         self._opts = _TTSOptions(
-            model=model,
+            model=model if str(model).startswith("/") else f"/{model}",
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=base_url.rstrip("/"),
         )
         if is_given(model):
-            self._opts.model = model
+            self._opts.model = model if str(model).startswith("/") else f"/{model}"

Also applies to: 141-154


218-219: Avoid total timeout; finalize emitter properly

  • total=30 can abort long syntheses; use connect-timeout only.
  • Call end_input() after flush() to close the non-streaming emitter.
-                timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
-                output_emitter.flush()
+                output_emitter.flush()
+                output_emitter.end_input()

Also applies to: 232-233


259-269: Don’t put API keys in WebSocket URLs; pass via headers

Secrets in URLs leak via logs/proxies. Also prefer connect-timeout param over wait_for.

-        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
-        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket?api_key={self._tts._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
+        ws_url = self._tts._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
+        full_ws_url = f"{ws_url}{self._tts._opts.model}/tts/websocket"
@@
-            try:
-                ws = await asyncio.wait_for(
-                    self._tts._ensure_session().ws_connect(full_ws_url),
-                    timeout=self._conn_options.timeout
-                )
+            try:
+                ws = await self._tts._ensure_session().ws_connect(
+                    full_ws_url,
+                    headers={
+                        API_AUTH_HEADER: self._tts._opts.api_key,
+                        API_VERSION_HEADER: API_VERSION,
+                    },
+                    timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
+                )
🧹 Nitpick comments (3)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (2)

1-2: Promote the sampling-params note to a real module docstring

Make it the first statement in the file.

+"""Check https://space.respeecher.com/docs/api/tts/sampling-params-guide for details"""
 from dataclasses import dataclass
 from typing import Any, Literal, Optional

15-16: Remove stray triple-quoted string (not a docstring here)

It’s currently a no-op literal. Either keep the new top-level docstring or turn this into a comment.

-"""Check https://space.respeecher.com/docs/api/tts/sampling-params-guide for details"""
-SamplingParams = dict[str, Any]
+# See https://space.respeecher.com/docs/api/tts/sampling-params-guide for details
+SamplingParams = dict[str, Any]
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1)

121-139: list_voices(): add connect-timeout and consistent exception mapping

Align with synthesize()/stream patterns and avoid hanging calls.

     async def list_voices(self) -> list[Voice]:
         """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
-            voices = []
-            for voice_data in data:
-                voices.append(Voice(voice_data))
-
-            if len(voices) == 0:
-                raise APIError("No voices are available")
-
-            return voices
+        try:
+            async with self._ensure_session().get(
+                f"{self._opts.base_url}{self._opts.model}/voices",
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+                voices = [Voice(v) for v in data]
+                if not voices:
+                    raise APIError("No voices are available")
+                return voices
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(message=e.message, status_code=e.status, request_id=None, body=None) from None
+        except Exception as e:
+            raise APIConnectionError() from e
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a98d08f607029b3cd5fd5415b2451528213645f3 and d049eee8cf0fa12afccb5b2bd7d9149592b7352e.

📒 Files selected for processing (2)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (7)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • Voice (24-38)
  • VoiceSettings (19-21)
  • sampling_params (37-38)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (3)
  • AudioEmitter (553-947)
  • start_segment (640-647)
  • end_segment (659-666)
livekit-agents/livekit/agents/utils/log.py (1)
  • log_exceptions (9-41)

@mitrushchienkova mitrushchienkova force-pushed the add-respeecher-TTS-plugin branch from e18821c to d844b57 Compare September 8, 2025 11:44
@mitrushchienkova mitrushchienkova force-pushed the add-respeecher-TTS-plugin branch from d844b57 to 2b09286 Compare September 8, 2025 11:50
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (4)

23-23: Optionally export the plugin class for explicit imports.

Exposing the class can help tests/examples import it directly without reaching into internals.

-__all__ = ["TTS", "ChunkedStream", "__version__"]
+__all__ = ["TTS", "ChunkedStream", "RespeecherPlugin", "__version__"]

31-33: Use a human-friendly plugin title and guard package fallback.

Title currently uses the module name; prefer a readable title. Also, __package__ can be None in edge cases; add a fallback.

-class RespeecherPlugin(Plugin):
-    def __init__(self) -> None:
-        super().__init__(__name__, __version__, __package__, logger)
+class RespeecherPlugin(Plugin):
+    def __init__(self) -> None:
+        super().__init__("Respeecher TTS", __version__, __package__ or __name__, logger)

35-35: Make auto-registration safe for non-main-thread imports and idempotent.

Plugin.register_plugin hard-fails off main thread. Guard it and avoid duplicates (e.g., during docs/tests or reloads).

Add imports near other imports:

+import os
+import threading

Then update registration:

-Plugin.register_plugin(RespeecherPlugin())
+if threading.current_thread() is threading.main_thread() and not os.getenv("LIVEKIT_DISABLE_AUTO_PLUGIN_REGISTRATION"):
+    already = any(p.package == (__package__ or __name__) for p in Plugin.registered_plugins)
+    if not already:
+        Plugin.register_plugin(RespeecherPlugin())
+else:
+    logger.debug("Skipping auto-registration for RespeecherPlugin (non-main thread or disabled).")

Please confirm your test/docs import path runs on the main thread; if not, the guard prevents runtime errors.


37-44: Tighten pdoc control; avoid blanket dir() suppression.

Mass-disabling everything not in __all__ can hide useful symbols and includes many dunders. Keep it explicit.

-# Cleanup docs of unexported modules
-_module = dir()
-NOT_IN_ALL = [m for m in _module if m not in __all__]
-
-__pdoc__ = {}
-
-for n in NOT_IN_ALL:
-    __pdoc__[n] = False
+# Cleanup docs of selected internals (pdoc)
+__pdoc__ = {
+    "RespeecherPlugin": False,
+    "Plugin": False,
+    "logger": False,
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d049eee8cf0fa12afccb5b2bd7d9149592b7352e and 2b09286.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • examples/other/text-to-speech/README.md (1 hunks)
  • examples/other/text-to-speech/requirements.txt (1 hunks)
  • examples/other/text-to-speech/respeecher_tts.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/README.md (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/pyproject.toml (1 hunks)
  • tests/docker-compose.yml (2 hunks)
  • tests/test_tts.py (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (11)
  • examples/other/text-to-speech/requirements.txt
  • livekit-plugins/livekit-plugins-respeecher/pyproject.toml
  • tests/docker-compose.yml
  • examples/other/text-to-speech/README.md
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/log.py
  • tests/test_tts.py
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/version.py
  • livekit-plugins/livekit-plugins-respeecher/README.md
  • examples/other/text-to-speech/respeecher_tts.py
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (2)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (2)
  • TTS (60-176)
  • ChunkedStream (179-238)
livekit-agents/livekit/agents/plugin.py (2)
  • Plugin (13-56)
  • register_plugin (31-36)
🔇 Additional comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/__init__.py (1)

1-18: LGTM on module structure and licensing.

Header, docstring, and high-level layout look clean.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (5)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (5)

350-352: Include status_code and body when raising on WS close

Provide structured context; 499 is a reasonable sentinel for transport closure.

-                    raise APIStatusError(
-                        "Respeecher connection closed unexpectedly", request_id=context_id
-                    )
+                    raise APIStatusError(
+                        message="Respeecher websocket closed unexpectedly",
+                        status_code=499,
+                        request_id=context_id,
+                        body=None,
+                    )

97-109: Normalize base_url and model once to avoid broken joins and double slashes

Ensure base_url has no trailing slash and model has exactly one leading slash.

-        self._opts = _TTSOptions(
-            model=model,
+        normalized_base_url = base_url.rstrip("/")
+        normalized_model = str(model)
+        if not normalized_model.startswith("/"):
+            normalized_model = f"/{normalized_model}"
+
+        self._opts = _TTSOptions(
+            model=normalized_model,
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=normalized_base_url,
         )

154-170: Sanitize model updates and safely reset the WS pool

Add the same normalization when updating model; current code can produce malformed URLs if callers omit the leading slash.

-        if is_given(model) and model != self._opts.model:
-            self._opts.model = model
+        if is_given(model) and model != self._opts.model:
+            new_model = str(model)
+            if not new_model.startswith("/"):
+                new_model = f"/{new_model}"
+            self._opts.model = new_model
             # Clear the connection pool when model changes to force reconnection
             asyncio.create_task(self._pool.aclose())
             self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
                 connect_cb=self._connect_ws,
                 close_cb=self._close_ws,
             )

231-256: HTTP synthesis: avoid total timeout on long texts and close emitter input

  • total=30 will abort long-running streams; use connect-timeout only.
  • Call end_input() after flush() to close the channel.
-                timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
             ) as resp:
                 resp.raise_for_status()
@@
                 async for data, _ in resp.content.iter_chunks():
                     output_emitter.push(data)
 
                 output_emitter.flush()
+                output_emitter.end_input()

120-125: Do not put API keys in WS URLs; use headers and connect-timeout (security + correctness)

Move auth to headers, drop query params, and rely on aiohttp’s connect-timeout rather than asyncio.wait_for.

-    async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
-        session = self._ensure_session()
-        ws_url = self._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
-        full_ws_url = f"{ws_url}{self._opts.model}/tts/websocket?api_key={self._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
-        return await asyncio.wait_for(session.ws_connect(full_ws_url), timeout)
+    async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
+        session = self._ensure_session()
+        ws_url = self._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
+        full_ws_url = f"{ws_url}{self._opts.model}/tts/websocket"
+        return await session.ws_connect(
+            full_ws_url,
+            headers={
+                API_AUTH_HEADER: self._opts.api_key,
+                API_VERSION_HEADER: API_VERSION,
+            },
+            timeout=aiohttp.ClientTimeout(sock_connect=timeout),
+        )
🧹 Nitpick comments (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1)

65-86: Default voice choice: consider dynamic default or no default

Hardcoding "samantha" may 404 on models where it’s unavailable. Options:

  • Require explicit voice_id; or
  • On first use/prewarm, call list_voices() and pick the first (or backend-provided default) and cache it.

Would you like a follow-up patch to implement “first-voice” fallback with a small cache and a toggle to disable it?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2b09286 and cc93c16.

📒 Files selected for processing (2)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
  • pyproject.toml (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (7)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • Voice (26-40)
  • VoiceSettings (20-23)
  • sampling_params (39-40)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (4)
  • num_channels (96-97)
  • AudioEmitter (553-947)
  • start_segment (640-647)
  • end_segment (659-666)
livekit-agents/livekit/agents/utils/connection_pool.py (2)
  • ConnectionPool (13-179)
  • connection (72-85)
🔇 Additional comments (1)
pyproject.toml (1)

33-33: Approve workspace source addition for Respeecher plugin. All validations passed: the plugin directory exists, its pyproject.toml name matches the uv source key, the Python namespace package is present, and the workspace source entry appears exactly once and in alphabetical order.
Optional: add a pre-commit check to enforce alphabetical ordering of [tool.uv.sources].

Comment on lines +134 to +152
async def list_voices(self) -> list[Voice]:
"""List available voices from Respeecher API"""
async with self._ensure_session().get(
f"{self._opts.base_url}{self._opts.model}/voices",
headers={
API_AUTH_HEADER: self._opts.api_key,
API_VERSION_HEADER: API_VERSION,
},
) as resp:
resp.raise_for_status()
data = await resp.json()
voices = []
for voice_data in data:
voices.append(Voice(voice_data))

if len(voices) == 0:
raise APIError("No voices are available")

return voices
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

list_voices(): add connect-timeout + consistent error mapping

Mirror synthesize/stream behavior: set a connect-timeout and map errors to APITimeoutError/APIStatusError.

-    async def list_voices(self) -> list[Voice]:
+    async def list_voices(self) -> list[Voice]:
         """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
-            voices = []
-            for voice_data in data:
-                voices.append(Voice(voice_data))
-
-            if len(voices) == 0:
-                raise APIError("No voices are available")
-
-            return voices
+        url = f"{self._opts.base_url}{self._opts.model}/voices"
+        try:
+            async with self._ensure_session().get(
+                url,
+                headers={API_AUTH_HEADER: self._opts.api_key, API_VERSION_HEADER: API_VERSION},
+                timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(message=e.message, status_code=e.status, request_id=None, body=None) from None
+        except Exception as e:
+            raise APIConnectionError() from e
+
+        voices: list[Voice] = [Voice(v) for v in data]
+        if not voices:
+            raise APIError("No voices are available")
+        return voices
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def list_voices(self) -> list[Voice]:
"""List available voices from Respeecher API"""
async with self._ensure_session().get(
f"{self._opts.base_url}{self._opts.model}/voices",
headers={
API_AUTH_HEADER: self._opts.api_key,
API_VERSION_HEADER: API_VERSION,
},
) as resp:
resp.raise_for_status()
data = await resp.json()
voices = []
for voice_data in data:
voices.append(Voice(voice_data))
if len(voices) == 0:
raise APIError("No voices are available")
return voices
async def list_voices(self) -> list[Voice]:
"""List available voices from Respeecher API"""
url = f"{self._opts.base_url}{self._opts.model}/voices"
try:
async with self._ensure_session().get(
url,
headers={
API_AUTH_HEADER: self._opts.api_key,
API_VERSION_HEADER: API_VERSION,
},
timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
) as resp:
resp.raise_for_status()
data = await resp.json()
except asyncio.TimeoutError:
raise APITimeoutError() from None
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=None,
body=None,
) from None
except Exception as e:
raise APIConnectionError() from e
voices: list[Voice] = [Voice(v) for v in data]
if not voices:
raise APIError("No voices are available")
return voices
🤖 Prompt for AI Agents
In livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
around lines 134-152, list_voices currently performs a plain GET and raises
generic exceptions; update it to mirror synthesize/stream by adding a
connect-timeout to the request (use aiohttp.ClientTimeout(connect=... from
self._opts or a sensible default) passed into session.get) and wrap the
request/response handling in try/except to map timeout/connect errors to
APITimeoutError and non-2xx HTTP responses to APIStatusError (include status and
response body in the APIStatusError). Ensure you still parse JSON and construct
Voice objects, but replace resp.raise_for_status() with explicit status-check
and raise APIStatusError on failure; convert underlying asyncio.TimeoutError /
aiohttp.ClientConnectorError to APITimeoutError.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (4)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (4)

101-109: Normalize model and base_url in constructor to prevent URL concatenation issues

If a caller passes a model without a leading slash (e.g., "public/tts/en-rt"), URL concatenation will break. Similarly, if base_url has a trailing slash, you may end up with double slashes.

Apply this diff to normalize both values:

         self._opts = _TTSOptions(
-            model=model,
+            model=model if str(model).startswith("/") else f"/{model}",
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=base_url.rstrip("/"),
         )

Apply the same normalization in update_options() (see separate comment below).

Based on learnings


162-163: Normalize model in update_options() to prevent URL issues

Similar to the constructor, if a caller passes a model without a leading slash, URL concatenation will fail.

Apply this diff:

         if is_given(model) and model != self._opts.model:
-            self._opts.model = model
+            self._opts.model = model if str(model).startswith("/") else f"/{model}"
             # Clear the connection pool when model changes to force reconnection

Based on learnings


120-124: Security: Move API key from query string to WebSocket headers

Embedding the API key in the URL query string risks accidental leakage through logs, proxies, or analytics tools. Use the headers parameter of ws_connect instead.

Apply this diff:

     async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
         session = self._ensure_session()
         ws_url = self._opts.base_url.replace("http", "ws")
-        full_ws_url = f"{ws_url}{self._opts.model}/tts/websocket?api_key={self._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
-        return await asyncio.wait_for(session.ws_connect(full_ws_url), timeout)
+        full_ws_url = f"{ws_url}{self._opts.model}/tts/websocket?source={API_VERSION_HEADER}&version={API_VERSION}"
+        return await asyncio.wait_for(
+            session.ws_connect(
+                full_ws_url,
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+            ),
+            timeout,
+        )

Note: If the Respeecher WebSocket API strictly requires api_key as a query parameter, document this constraint and ensure URLs are never logged without redaction.

Based on learnings


254-254: Call end_input() to properly finalize the non-streaming emitter

After calling flush(), you should call end_input() to properly close the emitter's channel and release resources.

Apply this diff:

                 output_emitter.flush()
+                output_emitter.end_input()
         except asyncio.TimeoutError:

Based on learnings

🧹 Nitpick comments (2)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (2)

134-153: Add connect-timeout and standardize error handling in list_voices()

The list_voices() method lacks a connect timeout and doesn't map errors to the standard API exception types used elsewhere (APITimeoutError, APIStatusError, APIConnectionError). This creates inconsistent error handling across the plugin.

Apply this diff:

     async def list_voices(self) -> list[Voice]:
         """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
-            voices = []
-            for voice_data in data:
-                voices.append(Voice(voice_data))
-
-            if len(voices) == 0:
-                raise APIError("No voices are available")
-
-            return voices
+        url = f"{self._opts.base_url}{self._opts.model}/voices"
+        try:
+            async with self._ensure_session().get(
+                url,
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(
+                message=e.message, status_code=e.status, request_id=None, body=None
+            ) from None
+        except Exception as e:
+            raise APIConnectionError() from e
+
+        voices = [Voice(v) for v in data]
+        if not voices:
+            raise APIError("No voices are available")
+        return voices

Based on learnings


350-352: Use explicit status_code in APIStatusError for WebSocket closure

While the current code works (due to default values), explicitly providing a status code improves clarity and debugging. Use a sentinel value like 499 to indicate client-side/transport closure.

Apply this diff:

                     raise APIStatusError(
-                        "Respeecher connection closed unexpectedly", request_id=context_id
+                        message="Respeecher connection closed unexpectedly",
+                        status_code=499,  # client-side/transport closure
+                        request_id=context_id,
+                        body=None,
                     )

Based on learnings

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between cc93c16 and 3fd1646.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
  • pyproject.toml (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (7)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • Voice (25-39)
  • VoiceSettings (19-22)
  • sampling_params (38-39)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (4)
  • num_channels (121-122)
  • AudioEmitter (580-1013)
  • start_segment (667-674)
  • _mark_started (435-438)
livekit-agents/livekit/agents/utils/connection_pool.py (2)
  • ConnectionPool (13-179)
  • connection (72-85)

"Content-Type": "application/json",
},
json=json_data,
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove total timeout from ChunkedStream POST request

The total=30 timeout will abort long-running synthesis operations. Use only sock_connect to enforce connection timeout while allowing the synthesis to complete.

Apply this diff:

                 json=json_data,
-                timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
             ) as resp:

Based on learnings

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
json=json_data,
timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
) as resp:
🤖 Prompt for AI Agents
In livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
around line 240, the aiohttp.ClientTimeout is currently created with total=30
which will abort long-running synthesis; remove the total timeout and only
enforce connection timeout by constructing the timeout as
aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout) (or explicitly
set total=None and sock_connect=...), so the POST request can run until
synthesis completes while still protecting connection establishment.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (7)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (7)

101-109: Normalize model and base_url to prevent URL concatenation errors.

When constructing URLs (e.g., line 234: f"{base_url}{model}/tts/bytes"), if model lacks a leading slash or base_url has a trailing slash, the resulting URL will be malformed.

Apply this diff to normalize in the constructor:

         self._opts = _TTSOptions(
-            model=model,
+            model=model if str(model).startswith("/") else f"/{model}",
             encoding=encoding,
             sample_rate=sample_rate,
             voice_id=voice_id,
             voice_settings=voice_settings,
             api_key=respeecher_api_key,
-            base_url=base_url,
+            base_url=base_url.rstrip("/"),
         )

Apply the same normalization in update_options() (lines 167-168).

Based on learnings


120-129: Security: Remove API key from WebSocket URL and use headers.

Embedding API keys in URLs risks leakage through logs, proxies, and analytics. The comment on line 122 stating "WebSocket protocol does not support custom headers" is incorrect—aiohttp.ClientSession.ws_connect accepts a headers parameter.

Apply this diff:

     async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
         session = self._ensure_session()
-        # WebSocket protocol does not support custom headers, using query parameter
         ws_url = self._opts.base_url.replace("https://", "wss://").replace("http://", "ws://")
         if not ws_url.startswith("wss://"):
             logger.error("Insecure WebSocket connection detected, wss:// required")
             raise APIConnectionError("Secure WebSocket connection (wss://) required")
 
-        full_ws_url = f"{ws_url}{self._opts.model}/tts/websocket?api_key={self._opts.api_key}&source={API_VERSION_HEADER}&version={API_VERSION}"
-        return await asyncio.wait_for(session.ws_connect(full_ws_url), timeout)
+        full_ws_url = f"{ws_url}{self._opts.model}/tts/websocket?source={API_VERSION_HEADER}&version={API_VERSION}"
+        return await session.ws_connect(
+            full_ws_url,
+            headers={
+                API_AUTH_HEADER: self._opts.api_key,
+                API_VERSION_HEADER: API_VERSION,
+            },
+            timeout=aiohttp.ClientTimeout(sock_connect=timeout),
+        )

Note: If the Respeecher API strictly requires api_key as a query parameter, document this limitation and ensure URLs are redacted from all logging.

Based on learnings


139-157: Add connect-timeout and consistent error mapping to list_voices().

This method lacks the timeout and error-handling patterns used in synthesize() and stream(). Without proper error mapping, timeouts and HTTP errors won't be caught as APITimeoutError or APIStatusError.

Apply this diff:

     async def list_voices(self) -> list[Voice]:
         """List available voices from Respeecher API"""
-        async with self._ensure_session().get(
-            f"{self._opts.base_url}{self._opts.model}/voices",
-            headers={
-                API_AUTH_HEADER: self._opts.api_key,
-                API_VERSION_HEADER: API_VERSION,
-            },
-        ) as resp:
-            resp.raise_for_status()
-            data = await resp.json()
-            voices = []
-            for voice_data in data:
-                voices.append(Voice(voice_data))
-
-            if len(voices) == 0:
-                raise APIError("No voices are available")
-
-            return voices
+        url = f"{self._opts.base_url}{self._opts.model}/voices"
+        try:
+            async with self._ensure_session().get(
+                url,
+                headers={
+                    API_AUTH_HEADER: self._opts.api_key,
+                    API_VERSION_HEADER: API_VERSION,
+                },
+                timeout=aiohttp.ClientTimeout(sock_connect=DEFAULT_API_CONNECT_OPTIONS.timeout),
+            ) as resp:
+                resp.raise_for_status()
+                data = await resp.json()
+        except asyncio.TimeoutError:
+            raise APITimeoutError() from None
+        except aiohttp.ClientResponseError as e:
+            raise APIStatusError(
+                message=e.message, status_code=e.status, request_id=None, body=None
+            ) from None
+        except Exception as e:
+            raise APIConnectionError() from e
+
+        voices: list[Voice] = [Voice(v) for v in data]
+        if not voices:
+            raise APIError("No voices are available")
+        return voices

Based on learnings


167-168: Normalize model path in update_options().

If a user passes a model without a leading slash, URL construction will break (same issue as in __init__).

Apply this diff:

         if is_given(model) and model != self._opts.model:
-            self._opts.model = model
+            self._opts.model = model if str(model).startswith("/") else f"/{model}"

Based on learnings


245-245: Remove total timeout to allow long-running synthesis.

The total=30 timeout will abort synthesis of long texts after 30 seconds. Only connection establishment should be time-bounded, not the full request/response cycle.

Apply this diff:

                 json=json_data,
-                timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
+                timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
             ) as resp:

Based on learnings


256-259: Call end_input() to finalize the AudioEmitter.

Without end_input(), the emitter's internal task/channel may remain open, preventing proper cleanup.

Apply this diff:

                 async for data, _ in resp.content.iter_chunks():
                     output_emitter.push(data)
 
                 output_emitter.flush()
+                output_emitter.end_input()

Based on learnings


355-357: Complete APIStatusError construction with status_code and body.

The APIStatusError constructor expects status_code and body parameters for consistent error handling. Without status_code, the retryability logic (4xx vs 5xx) won't work correctly.

Apply this diff:

                 ):
                     raise APIStatusError(
-                        "Respeecher connection closed unexpectedly", request_id=context_id
+                        message="Respeecher websocket closed unexpectedly",
+                        status_code=499,  # client-side/transport closure
+                        request_id=context_id,
+                        body=None,
                     )

Based on learnings

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3fd1646 and b344a0e.

📒 Files selected for processing (2)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (1 hunks)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
🧰 Additional context used
🧬 Code graph analysis (1)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py (7)
livekit-agents/livekit/agents/_exceptions.py (4)
  • APIConnectionError (84-88)
  • APIError (14-42)
  • APIStatusError (45-81)
  • APITimeoutError (91-95)
livekit-agents/livekit/agents/types.py (1)
  • APIConnectOptions (50-84)
livekit-agents/livekit/agents/utils/misc.py (2)
  • is_given (23-24)
  • shortuuid (19-20)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py (3)
  • Voice (23-37)
  • VoiceSettings (17-20)
  • sampling_params (36-37)
livekit-agents/livekit/agents/utils/http_context.py (1)
  • http_session (40-51)
livekit-agents/livekit/agents/tts/tts.py (4)
  • num_channels (121-122)
  • AudioEmitter (580-1013)
  • start_segment (667-674)
  • end_segment (686-693)
livekit-agents/livekit/agents/utils/connection_pool.py (2)
  • ConnectionPool (13-179)
  • connection (72-85)

Comment on lines +170 to +174
asyncio.create_task(self._pool.aclose())
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid fire-and-forget pool closure; invalidate synchronously instead.

Creating an unawaited task to close the pool (line 170) can cause resource leaks or race conditions. The new pool is used immediately while the old one closes in the background.

Apply this diff to synchronously invalidate the pool without creating a background task:

         if is_given(model) and model != self._opts.model:
             self._opts.model = model if str(model).startswith("/") else f"/{model}"
-            # Clear the connection pool when model changes to force reconnection
-            asyncio.create_task(self._pool.aclose())
+            # Invalidate the pool when model changes to force reconnection on next use
+            self._pool.invalidate()
-            self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
-                connect_cb=self._connect_ws,
-                close_cb=self._close_ws,
-            )

The invalidate() method marks all connections for closure without blocking. The next call to prewarm() or stream() will establish fresh connections using the updated model.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
asyncio.create_task(self._pool.aclose())
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
)
if is_given(model) and model != self._opts.model:
self._opts.model = model if str(model).startswith("/") else f"/{model}"
- # Clear the connection pool when model changes to force reconnection
- asyncio.create_task(self._pool.aclose())
- self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
- connect_cb=self._connect_ws,
- close_cb=self._close_ws,
# Invalidate the pool when model changes to force reconnection on next use
self._pool.invalidate()
🤖 Prompt for AI Agents
In livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
around lines 170 to 174, avoid fire-and-forget closing of the old connection
pool; instead synchronously invalidate it so connections are marked for closure
before replacing the pool. Replace the asyncio.create_task(self._pool.aclose())
call with a synchronous self._pool.invalidate() (or equivalent invalidate
method) to mark existing connections for closure, then create the new
utils.ConnectionPool as shown so subsequent prewarm()/stream() calls open fresh
connections; do not spawn a background task to close the old pool.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants