Skip to content

feat: add Lokutor TTS plugin - #7256

Open
danivs10 wants to merge 3 commits into
livekit:mainfrom
danivs10:add-lokutor-tts-plugin
Open

danivs10 wants to merge 3 commits into
livekit:mainfrom
danivs10:add-lokutor-tts-plugin

Conversation

@danivs10

Copy link
Copy Markdown

Summary

Adds livekit-plugins-lokutor, a TTS integration for Lokutor β€” a CPU-only voice-AI platform (no GPU) with 10 voices (F1–F5, M1–M5) and 32 languages.

The plugin connects to Lokutor's WebSocket TTS API and supports both streaming (stream()) and one-shot (synthesize()) synthesis, following the standard LiveKit plugin conventions:

  • utils.ConnectionPool for pooled/prewarmed WebSocket connections (prewarm() supported)
  • tts.AudioEmitter for streaming PCM frames
  • Typed error mapping (APIStatusError / APITimeoutError / APIConnectionError)
  • py.typed (PEP 561)

Usage

from livekit.plugins import lokutor

session = AgentSession(
    stt=deepgram.STT(),
    llm=openai.LLM(),
    tts=lokutor.TTS(voice="F1", language="en"),  # LOKUTOR_API_KEY from env
    vad=silero.VAD.load(),
)

Testing

  • 25 unit tests (tests/test_plugin_lokutor_tts.py) covering config, request building, and stream construction.
  • Gated live-integration tests (tests/test_integration.py) that run against the real API when LOKUTOR_API_KEY is set, exercising the full connect β†’ request β†’ binary audio β†’ EOS path.
  • ruff check and ruff format --check pass.

Notes

Supersedes #5925 (that PR was opened from main, had drifted into conflicts, and shipped an older revision). This one is a clean feature branch on top of current main and uses Lokutor's current canonical request vocabulary (voice / language / speed / steps), with all 32 languages. CLA is already signed.

@danivs10
danivs10 requested a review from a team as a code owner September 13, 2026 01:04
@CLAassistant

CLAassistant commented Sep 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@danivs10 danivs10 mentioned this pull request Sep 13, 2026
10 tasks
devin-ai-integration[bot]

This comment was marked as resolved.

@danivs10

Copy link
Copy Markdown
Author

Thanks for the review β€” addressed the flagged issues in the latest commit:

  • Custom sample rates corrupt audio β€” Lokutor's TTS output is a fixed 44.1 kHz, so the configurable sample_rate was removed and the rate is pinned to 44100 (the LiveKit pipeline resamples downstream if needed).
  • Provider failures lose typed details β€” _run now re-raises APIError/APIStatusError instead of collapsing them into a generic APIConnectionError.
  • API key in WebSocket URL β€” the key is now sent via an X-API-Key header instead of the URL query string.
  • Provider content in logs / exceptions β€” unexpected text frames and provider error messages are no longer interpolated into log lines or exception messages.

ruff check, ruff format, and the unit tests pass locally.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

3 flags not posted on this PR by your GitHub settings β€” view them in Devin Review. (Configure)

Devin Review

Comment on lines +137 to +140
session.ws_connect(
self._opts.get_ws_url(),
max_msg_size=0,
headers={"X-API-Key": self._opts.api_key},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

πŸ”΄ Header authentication blocks all synthesis

Every TTS request sends X-API-Key, but Lokutor's WebSocket handshake accepts only the api_key query parameter. The upgrade fails before either synthesis path can emit audio.

Learn more

Lokutor authenticates /ws/tts during the WebSocket handshake. Its authentication and streaming API documentation specifies ?api_key=... for WebSockets; X-API-Key is documented for REST endpoints. The new request reaches the correct endpoint without the only supported WebSocket credential, so the server rejects it before any JSON synthesis request is sent.

Example: With api_key="sk_test", the plugin connects to wss://api.lokutor.com/ws/tts with an X-API-Key header. Lokutor expects wss://api.lokutor.com/ws/tts?api_key=sk_test, so both stream() and synthesize() fail during connection setup instead of returning audio.

Recommended fix: Confirm and adopt a Lokutor-supported WebSocket authentication mechanism that keeps credentials out of observable URLs. If header authentication is unavailable, coordinate provider support or add explicit sanitization around every URL-bearing exception and telemetry path before retaining query authentication.

Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Streaming + one-shot TTS integration for Lokutor (CPU-only voice AI, 10 voices,
32 languages) over Lokutor's WebSocket API. Follows the LiveKit plugin
conventions (ConnectionPool, AudioEmitter, prewarm, typed error mapping),
ships py.typed, sends the API key via header, pins the fixed 44.1 kHz output,
and includes unit tests plus gated live-integration tests. Registered in the
root workspace sources + uv.lock.
@danivs10
danivs10 force-pushed the add-lokutor-tts-plugin branch from 0327952 to acaf2d1 Compare September 13, 2026 07:40
@danivs10

Copy link
Copy Markdown
Author

Re: the "Header authentication blocks all synthesis" flag β€” this is a false positive. Lokutor's WebSocket handshake does accept the X-API-Key header; the public docs previously only mentioned the ?api_key= query parameter (now updated). The server reads X-API-Key first and falls back to the query param.

Verified against production wss://api.lokutor.com/ws/tts:

  • Invalid key via X-API-Key header β†’ 401 {"code":"auth.invalid_key"} (the header was read and the key evaluated)
  • No auth β†’ 401 {"code":"auth.missing_key"}

If the header were ignored, the header case would return auth.missing_key; it returns auth.invalid_key, confirming the header is honored. Synthesis is unaffected.

The remaining red ruff / type-check checks are unrelated to this plugin β€” they fail on a pre-existing Duplicate keyword argument "secondary_languages" syntax error in livekit-plugins-elevenlabs on the base branch. This plugin's own ruff, type-check, and unit tests pass.

@danivs10

Copy link
Copy Markdown
Author

This is ready for a maintainer review whenever someone has a moment πŸ™

Quick status recap:

  • CLA: signed βœ…
  • Plugin ruff, type-check, and unit-tests pass βœ…
  • The failing top-level ruff / type-check runs are not from this plugin β€” they fail on a pre-existing Duplicate keyword argument "secondary_languages" syntax error in livekit-plugins-elevenlabs on main, which breaks those jobs for every open PR right now. Happy to rebase the moment that's fixed on main.

Lokutor is a CPU-only TTS provider (10 voices, 32 languages); the plugin follows the standard conventions (ConnectionPool, AudioEmitter, prewarm, typed errors, py.typed) with unit + gated live-integration tests. Thanks for taking a look!

@danivs10

Copy link
Copy Markdown
Author

@tinalenguyen would you mind taking a look at this one when you have a moment? I believe it's ready.

CI is fully green as of today. The checks were red until a few hours ago, which I want to flag so it doesn't look like a problem with this PR: it was an unrelated duplicate secondary_languages parameter in the elevenlabs plugin that had landed on main and broke ruff plus both type-check jobs repo-wide. That was already fixed upstream β€” I merged main in and everything passes now.

Scope: 15 files, all new, entirely inside livekit-plugins/livekit-plugins-lokutor/. No changes to any existing plugin. One line added to the root pyproject.toml for workspace registration, plus the uv.lock entry. No CHANGELOG.md edits, per CONTRIBUTING.

Includes unit + integration tests and two examples, following the structure of the existing TTS plugins. Happy to make any changes you'd like.

@danivs10

Copy link
Copy Markdown
Author

Went through the Devin flags. Three of the four were already addressed in earlier revisions of this branch, so recording the current state for whoever reviews:

  • Custom sample rates corrupt audio β€” resolved; the public sample_rate option was removed and the emitter is fixed at 44.1 kHz.
  • Provider failures lose typed details β€” resolved; except APIError: raise now sits before the broad handler in both ChunkedStream._run and SynthesizeStream._run, so APIStatusError/APIError keep their status, request ID and body.
  • Provider content enters log bodies β€” resolved; both logger.debug calls are static strings with no interpolation.
  • API key travels in WebSocket URLs β€” resolved; the key is sent as an X-API-Key header, not a query parameter.

The remaining one, provider errors escape through exceptions, was raise APIConnectionError() from e. Just pushed 7118c31 changing both sites to raise APIConnectionError(type(e).__name__) from None, since an aiohttp transport error can carry the request URL, headers or a response body and from e keeps all of it on __cause__ for anything that walks the chain. Keeping the exception type preserves diagnosability, which is the "log only safe metadata such as its type" allowance in REVIEW.md, and matches the existing pattern in the cartesia plugin.

Worth noting from e is the more common pattern across the existing plugins (elevenlabs, deepgram, rime), so this is the stricter reading of REVIEW.md rather than a fix to something those got wrong β€” happy to align either way if you'd prefer consistency with the majority.

REVIEW.md treats tracebacks and __cause__ / __context__ chains as possible
sensitive data, and an aiohttp transport error can carry the request URL,
headers or a response body. `raise APIConnectionError() from e` preserved all
of that on __cause__ for any telemetry that walks the chain.

Keeps the exception type as the message so the failure is still diagnosable,
which is the "log only safe metadata such as its type" escape hatch REVIEW.md
allows, and matches the existing pattern in the cartesia plugin.
@danivs10
danivs10 force-pushed the add-lokutor-tts-plugin branch from 7118c31 to 91e62dc Compare September 14, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants