Conversation
WalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 toVoice.sampling_param(typedOptional[SamplingParam]). Either parse intoSamplingParamintts.py(preferred) or widen the type toSamplingParam | 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 +## Prerequisiteslivekit-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 parityMany plugins expose both one-shot and streaming helpers. Re-exporting
SynthesizeStreamcan 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
TTSSampleRatesshould 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 headerHardcoding 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 chunksThe
/tts/bytesendpoint 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 namePassing the header name as
sourceis misleading. Use a stable source identifier and keepversiontied 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.
⛔ Files ignored due to path filters (1)
uv.lockis 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_KEYand follows the existing pattern used for other providers.
79-79: All Respeecher endpoints are already coveredA ripgrep search across
tests/andlivekit-plugins/found only references toapi.respeecher.com(inAPI_BASE_URLand in tests’proxy-upstreamsettings) and no other Respeecher domains or WebSocket endpoints. The existingextra_hostsentry 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 goodImporting
respeecheralongside the other providers matches the new plugin and enables the parametrized tests to instantiate it.
446-452: Stream test case added for Respeecher — OKStreaming 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 — OKPattern matches existing plugins (module metadata + logger, registered once). No issues spotted.
37-44: Doc control (pdoc) — OKSuppressing unexported internals is consistent with other packages.
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
Show resolved
Hide resolved
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
Show resolved
Hide resolved
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Show resolved
Hide resolved
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Show resolved
Hide resolved
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Show resolved
Hide resolved
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
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 hardcodingWe need to confirm whether the Respeecher TTS HTTP endpoint actually returns a WAV container (
audio/wav) or raw PCM (audio/pcm, etc.) when you requestencoding=pcm_s16leorpcm_f32le. Without that certainty, switching to derive the MIME fromresp.headerscould introduce inconsistencies if the server always returns WAV.Please verify via one of these approaches:
- Consult the official Respeecher API documentation for the
/tts/bytesendpoint and check the Content-Type they specify for PCM encodings.- Perform a quick request (e.g. with
curlor another HTTP client) against the/tts/bytesendpoint usingencoding=pcm_s16le/pcm_f32le, and inspect theContent-Typeresponse 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.
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Outdated
Show resolved
Hide resolved
6450e4e to
9af5141
Compare
There was a problem hiding this comment.
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 — LGTMThis 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 serializationIf 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 attributesIf 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 docsComputing 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.lockis 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 idempotentRegistering 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 — LGTMRespeecher added consistently with other providers.
446-452: Confirm WS domain/port alignmentIf 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.
| # 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", |
There was a problem hiding this comment.
@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.
- 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
- Hardcode a preferred voice - select one speaker (e.g., the one we think markets Respeecher best) for the
en-rtmodel 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 atinit/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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 secretsPutting 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 WAVYou 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 callsWrap 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 codeMinor, 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 strategyHardcoding "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)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
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 close499 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 logsEmbedding
api_keyin 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 flushWithout
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 URLsEnsure 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-formedUpdating 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 callsAlign 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 possibleIf the payload includes a code/status, raise
APIStatusErrorwith that status; otherwise include the raw body for diagnostics.I can add tolerant parsing that falls back to
APIErrorif no status is present.
68-69: Default voice strategy: consider dynamic default to reduce friction and churnGiven voices can change per model, either pick the first from
list_voices()at prewarm or add a backenddefault_voicefield; 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-decodingHTTP path sets
audio/wavwhile stream setsaudio/pcmbut both may usepcm_s16le. Ensure the emitter’s mime_type aligns with actual bytes (e.g., infer from response Content-Type or map fromencoding).Would you like a small helper that maps
TTSEncodingto a MIME (and falls back toresp.headers.get("Content-Type"))?Also applies to: 264-272
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/tts.py
Show resolved
Hide resolved
There was a problem hiding this comment.
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 URLsTrim 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 synthTotal 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 eAlso 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 eAlso 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 consistentlyMirror 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 defaultHardcoding “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)
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
Show resolved
Hide resolved
livekit-plugins/livekit-plugins-respeecher/livekit/plugins/respeecher/models.py
Outdated
Show resolved
Hide resolved
| "Programming Language :: Python :: 3", | ||
| "Programming Language :: Python :: 3.9", | ||
| "Programming Language :: Python :: 3.10", | ||
| "Programming Language :: Python :: 3 :: Only", |
There was a problem hiding this comment.
These classifiers seem random. Why 3.9 and 3.10 specifically? Is it just something that was in the LiveKit code?
There was a problem hiding this comment.
Yes, I (Claude) just copy pasted it from other plugins
There was a problem hiding this comment.
But I can check deeper
| pytest.param( | ||
| lambda: { | ||
| "tts": respeecher.TTS(), | ||
| "proxy-upstream": "api.respeecher.com:443", |
There was a problem hiding this comment.
api.respeecher.com is still coreweave, not cloudflare, right?
There was a problem hiding this comment.
Yes, I'll change it soon
There was a problem hiding this comment.
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 startexamples/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.
waveandosaren’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-rtyields 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 | |||
There was a problem hiding this comment.
💡 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.0Verify 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 5Length 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/jsonLength 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.
acb38fb to
a98d08f
Compare
d049eee to
e18821c
Compare
There was a problem hiding this comment.
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 status499 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 URLsEnsure 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 headersSecrets 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 docstringMake 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 mappingAlign 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)
e18821c to
d844b57
Compare
d844b57 to
2b09286
Compare
There was a problem hiding this comment.
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_pluginhard-fails off main thread. Guard it and avoid duplicates (e.g., during docs/tests or reloads).Add imports near other imports:
+import os +import threadingThen 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 blanketdir()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.lockis 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.
There was a problem hiding this comment.
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 closeProvide 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 slashesEnsure 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 poolAdd 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 defaultHardcoding "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.
📒 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].
| 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 |
There was a problem hiding this comment.
🛠️ 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.
| 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.
There was a problem hiding this comment.
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 issuesIf a caller passes a model without a leading slash (e.g.,
"public/tts/en-rt"), URL concatenation will break. Similarly, ifbase_urlhas 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 issuesSimilar 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 reconnectionBased on learnings
120-124: Security: Move API key from query string to WebSocket headersEmbedding the API key in the URL query string risks accidental leakage through logs, proxies, or analytics tools. Use the
headersparameter ofws_connectinstead.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_keyas 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 emitterAfter calling
flush(), you should callend_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 voicesBased on learnings
350-352: Use explicit status_code in APIStatusError for WebSocket closureWhile 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.
⛔ Files ignored due to path filters (1)
uv.lockis 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), |
There was a problem hiding this comment.
🛠️ 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.
| 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.
There was a problem hiding this comment.
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"), ifmodellacks a leading slash orbase_urlhas 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_connectaccepts aheadersparameter.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_keyas 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()andstream(). Without proper error mapping, timeouts and HTTP errors won't be caught asAPITimeoutErrororAPIStatusError.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 voicesBased on learnings
167-168: Normalize model path in update_options().If a user passes a
modelwithout 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=30timeout 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
APIStatusErrorconstructor expectsstatus_codeandbodyparameters for consistent error handling. Withoutstatus_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.
📒 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)
| asyncio.create_task(self._pool.aclose()) | ||
| self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse]( | ||
| connect_cb=self._connect_ws, | ||
| close_cb=self._close_ws, | ||
| ) |
There was a problem hiding this comment.
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.
| 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.
Summary by CodeRabbit
New Features
Documentation
Examples
Tests
Chores