Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions livekit-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ langchain = ["livekit-plugins-langchain>=1.8.0"]
lemonslice = ["livekit-plugins-lemonslice>=1.8.0"]
liveavatar = ["livekit-plugins-liveavatar>=1.8.0"]
lmnt = ["livekit-plugins-lmnt>=1.8.0"]
maya = ["livekit-plugins-maya>=1.8.0"]
minimax = ["livekit-plugins-minimax-ai>=1.8.0"]
mistralai = ["livekit-plugins-mistralai>=1.8.0"]
murf = ["livekit-plugins-murf>=1.8.0"]
Expand Down
100 changes: 100 additions & 0 deletions livekit-plugins/livekit-plugins-maya/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Maya Research voice models for LiveKit Agents

A native LiveKit TTS plugin for the [Maya Research API](https://www.mayaresearch.ai/llm.txt).
The provider and package names are model-independent.

The current documented model, checked on 8 September 2026, is **Maya Calyx**.
The default voice is **Aarav**. The public API documents Hindi, Telugu, Indian
English, Tamil, Bengali, Gujarati, Kannada, Malayalam, Marathi, Odia and Punjabi.
Model and voice strings are passed to the service, so a future model does not
need a renamed integration. Check model/voice compatibility in the current API
reference before changing them.

## Install this contribution

This contribution is not yet a published upstream package. From a checkout of
this branch, install the plugin into your agent environment:

```sh
uv pip install --no-sources ./livekit-plugins/livekit-plugins-maya
```

This resolves released LiveKit Agents rather than the repository's development
workspace. The [Maya Research Cookbook](https://github.com/MayaResearch/maya-cookbook)
provides a tested immutable pin, API reference, TTS quickstarts, and complete
LiveKit, Pipecat and from-scratch agent examples.

## Configure

Set `MAYA_API_KEY` in your server environment. Do not put it in browser code,
URLs, source control, logs or coding-agent prompts.

Custom `base_url` / `MAYA_BASE_URL` values must use HTTPS or WSS. Plain HTTP/WS,
including loopback URLs, is rejected before any key or text is transmitted.

```python
from livekit.agents import AgentSession
from livekit.plugins import maya

session = AgentSession(
# Supply your application's STT, LLM and turn-handling configuration.
tts=maya.TTS(model="Maya Calyx", voice="Aarav", language="hi"),
)
```

Omit `language` (or use `None`) for mixed-language input. To restore that mode
after choosing a language, call `update_options(language=None)` on your TTS
instance. Omitting the argument in `update_options` leaves the setting unchanged;
an active turn retains its existing settings. A Maya key provides TTS only, not
speech recognition, an LLM, or LiveKit room credentials.

## Streaming contract

- One persistent WebSocket can serve multiple completed turns.
- Each turn uses a new context ID. Sentences share that ID and one final closer.
- Follow current LiveKit semantics: create a new `stream()` per segment. Push
incremental text, then call `end_input()`. Do not push new text after `flush()`.
- No text is sent before validated startup metadata. Output is 24 kHz mono
signed 16-bit little-endian PCM; other formats fail instead of sounding wrong.
- Base64 is decoded strictly. Split sample bytes and the final partial frame
are preserved. Late frames from another context are ignored.
- Slow first text does not consume the server response timeout. After audio
progresses, a pause awaiting more LLM text does not abort the open turn.
New text re-arms the progress timeout; the final closer starts a bounded
audio/end wait. Text sends themselves are bounded too. Empty turns generate
neither speech nor a nonexistent turn-closer.
- Cancellation stops the turn and discards its connection; the next turn
cannot inherit abandoned audio. LiveKit handles clearing local playout.
- Updating options selects a correctly configured connection for the next turn,
without closing a currently active turn.
- Closing the provider prevents new acquisitions and retires handshakes that
finish during shutdown, including directly constructed public streams.
- Errors after audio receipt are not automatically retried, avoiding repeated
speech. Authentication and malformed protocol errors are not retried either.

The default LiveKit BlingFire tokenizer primarily splits western punctuation.
Pass an appropriate `tokenizer=` when early danda-delimited sentence emission
is required. This plugin does not rewrite, normalize, or translate input text.

The v2 protocol has no per-sentence completion acknowledgements. Once audio has
arrived while text input remains open, an idle provider cannot be distinguished
from one waiting for the LLM. The progress timeout therefore resumes on new text
or `end_input()`. Applications should also bound the LLM/overall turn and always
end or cancel an abandoned input stream. No timeout policy can prove that every
word was spoken; that needs end-to-end transcription or listening.

## Development and tests

From the LiveKit repository root:

```sh
uv sync --package livekit-plugins-maya --group dev --no-group typing --python 3.12 --locked
uv run --no-sync pytest tests/test_maya_tts.py --unit -q
uv run --no-sync ruff check .
uv run --no-sync ruff format --check .
uv run --no-sync mypy --follow-imports=silent livekit-plugins/livekit-plugins-maya/livekit/plugins/maya
```

The plugin tests use an in-memory protocol fixture, with no network or credentials.
Live API tests require explicit authorization and synthetic inputs. Successful
audio receipt is not a human listening score or a physical microphone/room test.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Maya Research voice models for LiveKit Agents.

See https://www.mayaresearch.ai/llm.txt for the current service contract and
https://github.com/MayaResearch/maya-cookbook for runnable examples.
"""

from .models import TTSLanguages, TTSModels
from .tts import TTS, ChunkedStream, SynthesizeStream
from .version import __version__

__all__ = ["TTS", "ChunkedStream", "SynthesizeStream", "TTSLanguages", "TTSModels", "__version__"]

from livekit.agents import Plugin

from .log import logger


class MayaPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)


Plugin.register_plugin(MayaPlugin())

__pdoc__ = {name: False for name in dir() if name not in __all__}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Copyright 2026 Maya Research
#
# 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 binascii
import contextlib
import json
from dataclasses import dataclass, field
from typing import Any

import aiohttp

from livekit.agents import APIConnectionError, APIError, APIStatusError, APITimeoutError, tts

SAMPLE_RATE = 24000
NUM_CHANNELS = 1


@dataclass
class Turn:
context_id: str
submitted: bool = False
input_closed: bool = False
terminal: bool = False
audio_bytes: int = 0
response_started_at: float | None = None
activity: asyncio.Event = field(default_factory=asyncio.Event, repr=False)


async def receive_json(
ws: aiohttp.ClientWebSocketResponse, timeout: float | None
) -> dict[str, Any]:
if timeout is not None and timeout <= 0:
raise APITimeoutError()
try:
message = await ws.receive(timeout=timeout)
except asyncio.TimeoutError:
raise APITimeoutError() from None
if message.type != aiohttp.WSMsgType.TEXT:
raise APIConnectionError("Maya websocket closed or returned a non-JSON message")
try:
data = json.loads(message.data)
except (ValueError, TypeError):
raise APIError("Maya returned malformed JSON", retryable=False) from None
if not isinstance(data, dict):
raise APIError("Maya returned a non-object JSON message", retryable=False)
return data


def validate_metadata(data: dict[str, Any]) -> None:
if data.get("type") != "metadata":
raise APIError("Maya rejected the connection settings", retryable=False)
if (
type(data.get("sample_rate")) is not int
or data["sample_rate"] != SAMPLE_RATE
or type(data.get("channels")) is not int
or data["channels"] != NUM_CHANNELS
or data.get("encoding") != "pcm_s16le"
):
raise APIError(
"Unsupported Maya audio format: expected 24000 Hz mono pcm_s16le", retryable=False
)


async def send_text(
ws: aiohttp.ClientWebSocketResponse, turn: Turn, text: str, *, more: bool, timeout: float
) -> None:
# Mark before yielding: a partially completed send may already reach the server.
turn.submitted = True
if not more:
turn.input_closed = True
# New text needs response progress, and the final closer needs a terminal.
# More input must not keep extending a wait that has received no audio.
if turn.response_started_at is None or not more:
turn.response_started_at = asyncio.get_running_loop().time()
turn.activity.set()
await asyncio.wait_for(
ws.send_json(
{"type": "text", "context_id": turn.context_id, "text": text, "continue": more}
),
timeout,
)


async def _receive_turn_json(
ws: aiohttp.ClientWebSocketResponse, turn: Turn, timeout: float
) -> dict[str, Any]:
# Keep one receive alive while the sender changes the deadline. Cancelling
# and recreating a receive on each input event could lose an incoming frame.
receive = asyncio.create_task(receive_json(ws, None))
try:
while True:
turn.activity.clear()
remaining = (
None
if turn.response_started_at is None
else turn.response_started_at + timeout - asyncio.get_running_loop().time()
)
if remaining is not None and remaining <= 0:
raise APITimeoutError()
changed = asyncio.create_task(turn.activity.wait())
try:
done, _ = await asyncio.wait(
(receive, changed), timeout=remaining, return_when=asyncio.FIRST_COMPLETED
)
finally:
changed.cancel()
await asyncio.gather(changed, return_exceptions=True)
if receive in done:
return receive.result()
if not done:
raise APITimeoutError()
finally:
receive.cancel()
await asyncio.gather(receive, return_exceptions=True)


async def receive_audio(
ws: aiohttp.ClientWebSocketResponse, turn: Turn, emitter: tts.AudioEmitter, timeout: float
) -> None:
carry = b""
loop = asyncio.get_running_loop()
while True:
data = await _receive_turn_json(ws, turn, timeout)
kind, context = data.get("type"), data.get("context_id")
if kind == "error" and context in (None, turn.context_id):
# Do not echo service bodies: they can contain submitted text or secrets.
raise APIError("Maya reported a synthesis error", retryable=False)
if context != turn.context_id:
continue # Includes unscoped audio and stale audio/terminators.
if kind == "audio":
try:
audio = base64.b64decode(data["audio"], validate=True)
except (KeyError, ValueError, TypeError, binascii.Error):
raise APIError("Maya returned invalid base64 audio", retryable=False) from None
if not audio:
continue
# v2 has no per-sentence completion ACK. Once audio has progressed,
# an open input may simply be waiting for the LLM. Re-arm on new
# text; after the closer, every audio gap/end wait stays bounded.
turn.response_started_at = loop.time() if turn.input_closed else None
turn.audio_bytes += len(audio)
carry += audio
complete = len(carry) - len(carry) % 2
if complete:
emitter.push(carry[:complete])
carry = carry[complete:]
elif kind == "end":
turn.terminal = True
if not turn.input_closed:
raise APIError("Maya ended the turn before text input closed", retryable=False)
if carry or not turn.audio_bytes:
raise APIError("Maya ended with truncated or empty PCM audio", retryable=False)
return
elif kind == "cancelled":
turn.terminal = True
# LiveKit treats 499 as a graceful cancellation, even with no audio.
raise APIStatusError("Maya turn cancelled", status_code=499, retryable=False)


async def cancel_unfinished(ws: aiohttp.ClientWebSocketResponse, turn: Turn) -> None:
if turn.submitted and not turn.terminal and not ws.closed:
with contextlib.suppress(Exception):
await asyncio.wait_for(
ws.send_json({"type": "cancel", "context_id": turn.context_id}), timeout=1.0
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import logging

logger = logging.getLogger("livekit.plugins.maya")
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from typing import Literal

TTSModels = Literal["Maya Calyx"]
"""Currently documented model. TTS also accepts future server-supported model strings."""

TTSLanguages = Literal["hi", "te", "en", "ta", "bn", "gu", "kn", "ml", "mr", "or", "pa"]
"""Documented language codes; en denotes Indian English. Omit for mixed-language input."""
Empty file.
Loading