Skip to content

feat(maya): add Maya Research TTS plugin - #6899

Open
saicherry93479 wants to merge 1 commit into
livekit:mainfrom
MayaResearch:maya-tts
Open

feat(maya): add Maya Research TTS plugin#6899
saicherry93479 wants to merge 1 commit into
livekit:mainfrom
MayaResearch:maya-tts

Conversation

@saicherry93479

@saicherry93479 saicherry93479 commented Aug 19, 2026

Copy link
Copy Markdown

Summary

  • Adds livekit-plugins-maya (livekit-plugins/livekit-plugins-maya/), a WebSocket TTS provider covering ten Indian languages plus Indian English, with every voice speaking all eleven. Connections come from utils.ConnectionPool, so one socket carries a whole conversation and the handshake is paid once rather than per utterance.
  • Maya's v2 protocol needs a handshake per connection, so _connect_ws sends {"type": "start", "v2": true, ...} and waits for the metadata reply before returning the socket β€” turns sent before that are rejected rather than served in the older frame shape, so the pool must never hand out a socket that hasn't completed it. A rejected handshake raises APIError instead of yielding one that would fail every turn.
  • Each agent turn maps to one Maya context: SynthesizeStream mints a context_id, sends each tokenized sentence under it with continue: true, and closes with an empty continue: false frame, yielding exactly one end. Sentences are not gated on the previous one's audio, so a multi-sentence reply streams continuously.
  • A turn that ends without its terminator β€” the caller interrupted, or the task was cancelled β€” is dropped with a targeted cancel before the socket returns to the pool. Without it the server keeps generating and the next borrower of that connection receives audio from a dead turn.
  • Voice, language and model are passed through as given rather than checked against a fixed list; Maya validates them and answers with an error frame, so values it adds later work without a release here.
  • ChunkedStream runs over the same websocket rather than Maya's HTTP endpoint, so the plugin has one transport and one auth path.
  • Registers the workspace member, the maya optional dependency, and an entry in the cross-provider SYNTHESIZE_TTS suite. version.py tracks the livekit-agents version (1.6.10), matching the sibling plugins so the livekit-plugins-maya>=1.6.10 pin resolves.

Example usage

from livekit.plugins import maya

tts = maya.TTS(voice="Ananya", language="hi")

The API key is read from MAYA_API_KEY.

Test plan

  • uv run pytest tests/test_plugin_maya_tts.py β€” 25 tests against a local server speaking the v2 protocol, covering the handshake contents, the turn model, connection reuse across turns, cancel-on-interrupt, no cancel for a turn that finished, a rejected handshake, and error frames
  • uv run ruff check && uv run ruff format --check
  • uv run mypy livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/
  • Live against the production endpoint: one-shot synthesis, multi-sentence turns sharing one context, consecutive turns reusing the socket, and repeated barge-in followed by a clean turn with no leftover audio

Happy to adjust anything to match house style.

@saicherry93479
saicherry93479 requested a review from a team as a code owner August 19, 2026 08:18
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Streaming speech for ten Indian languages and Indian English, every voice
speaking all eleven.

One websocket carries a whole conversation, and each sentence is sent as the
LLM writes it, so the agent starts speaking before the reply is finished.
Interrupting it stops generation at the server rather than only muting
playback, which keeps a reused connection from carrying a turn nobody is
listening to into the next one.

Voice, language and model are passed through as given rather than checked
against a fixed list, so values Maya adds later work without a release here.
Leaving the language unset lets Maya detect it per sentence, which is what
code-switched Hinglish needs.

@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 3 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +408 to +411
async def _recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
nonlocal turn_closed
while True:
msg = await ws.receive(timeout=self._conn_options.timeout)

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.

πŸ”΄ Spoken replies fail when the assistant's first sentence takes more than ten seconds to arrive

The reply's audio-listening loop starts counting its timeout immediately (ws.receive(timeout=...) at livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py:411) instead of waiting until the first piece of text has actually been sent, so a slow-to-start reply is aborted as a timeout.
Impact: If the language model takes longer than the connect timeout (10s by default) to produce its first sentence, the whole spoken reply errors out and is retried instead of being spoken.

Missing input-sent gate before the receive loop

_recv_task is created together with _input_task/_sentence_stream_task at livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py:448-452 and immediately blocks in await ws.receive(timeout=self._conn_options.timeout). Nothing has been sent to Maya yet, so the server has nothing to answer with; if no sentence is emitted by the tokenizer within conn_options.timeout, asyncio.TimeoutError is raised and converted into APITimeoutError at livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py:461-462.

Both sibling websocket plugins guard against exactly this: cartesia waits on input_sent_event before entering the loop (livekit-plugins/livekit-plugins-cartesia/livekit/plugins/cartesia/tts.py:462-464) and rime does the same (livekit-plugins/livekit-plugins-rime/livekit/plugins/rime/tts.py:560-563), setting the event right after the first frame is sent.

Prompt for agents
In livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py, SynthesizeStream._recv_task enters `ws.receive(timeout=self._conn_options.timeout)` as soon as the three tasks are started, before any text frame has been sent to Maya. The receive timeout therefore measures the time until the LLM/tokenizer emits its first sentence rather than the server's response latency, and a slow first sentence aborts the turn with APITimeoutError. Follow the pattern used by the cartesia and rime plugins: introduce an asyncio.Event that _sentence_stream_task sets right after it sends its first frame, have _recv_task await that event before the receive loop, and make sure the event is also set in the finally block so the receive task can never be left waiting forever.
Open in Devin Review

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

Comment on lines +393 to +397
async for ev in sent_tokenizer_stream:
self._mark_started()
await ws.send_str(_text_frame(context_id, ev.token, cont=True))

await ws.send_str(_text_frame(context_id, "", cont=False))

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.

🟑 A turn with no text stalls for the full timeout and then reports a failure

When no sentence is produced for a turn, an empty closing frame is still sent (_text_frame(context_id, "", cont=False) at livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py:397) for a turn the server never started, so nothing ever comes back and the turn hangs until it times out.
Impact: An empty reply blocks the speech pipeline for the timeout period and then surfaces an error, repeated once per retry attempt.

No early-exit for zero sentences

Maya only opens a context when it receives text; the closing empty continue: false frame for a context that was never opened produces no end (the test double models exactly this at tests/test_plugin_maya_tts.py:131-147, where open_turns is only populated when text is non-empty). _recv_task therefore keeps waiting and eventually raises asyncio.TimeoutError, converted to APITimeoutError at livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py:461-462, and the base class retries up to max_retry (3) times.

The rime plugin handles this explicitly: if no sentence was emitted it sets an empty_input flag, calls output_emitter.end_input() and returns without sending a flush, and the receive task exits immediately (livekit-plugins/livekit-plugins-rime/livekit/plugins/rime/tts.py:546-563).

Prompt for agents
In livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py, SynthesizeStream._sentence_stream_task always sends the empty `continue: false` closing frame, even when the tokenizer produced zero sentences (e.g. the stream was ended without any text, or only whitespace was pushed). Maya only opens a context once it receives text, so the closing frame for an unopened context yields no `end` message and _recv_task blocks until the receive timeout, failing the turn and retrying. Mirror the rime plugin's approach: count the sentences actually sent; if none were sent, skip the closing frame, signal the receive task to finish (end the emitter input and return) so the run completes cleanly instead of timing out.
Open in Devin Review

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

# closes the turn and makes Maya emit its single `end`.
async for ev in sent_tokenizer_stream:
self._mark_started()
await ws.send_str(_text_frame(context_id, ev.token, cont=True))

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.

🟑 Consecutive sentences are sent glued together with no separator

Each sentence is sent with its surrounding whitespace stripped (_text_frame(context_id, ev.token, cont=True) at livekit-plugins/livekit-plugins-maya/livekit/plugins/maya/tts.py:395), so the server sees the end of one sentence run straight into the start of the next.
Impact: Multi-sentence replies can be pronounced with words merged across sentence boundaries.

Tokenizer strips whitespace; siblings re-add a space

The default tokenize.blingfire.SentenceTokenizer emits stripped tokens (retain_format=False strips each span, see livekit-agents/livekit/agents/tokenize/blingfire.py:28-31), so consecutive frames concatenate as ...forest.He walked... on the server side. Cartesia (livekit-plugins/livekit-plugins-cartesia/livekit/plugins/cartesia/tts.py:438), rime (livekit-plugins/livekit-plugins-rime/livekit/plugins/rime/tts.py:549), murf and asyncai all append a trailing space to ev.token for this reason.

Suggested change
await ws.send_str(_text_frame(context_id, ev.token, cont=True))
await ws.send_str(_text_frame(context_id, ev.token + " ", cont=True))
Open in Devin Review

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

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