Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
43ae34b
slng: rewrite plugin around the Unmute Bridge (v2, breaking)
metehan-slng Jul 15, 2026
b501ca3
slng: preserve words and detected language when normalizing Deepgram …
metehan-slng Jul 15, 2026
82e99aa
slng: create TTS segments channel per run so base-class retries produ…
metehan-slng Jul 15, 2026
f2d8c06
slng: mark plugin test modules with the unit test category
metehan-slng Jul 15, 2026
3d82d49
slng: keep letterless phrase buffers at flush boundaries so numbers a…
metehan-slng Jul 15, 2026
c7dc384
slng: disarm the final-transcript watchdog on options-change reconnects
metehan-slng Jul 15, 2026
01dc936
slng: add world_part_override for broad geographic zone routing on ST…
metehan-slng Jul 15, 2026
e65c376
slng: close the speech bracket and reset transcript state on options-…
metehan-slng Jul 15, 2026
085165d
slng: forward world_part_override to fallback TTS candidates
metehan-slng Jul 15, 2026
f61b91e
slng: fail over immediately on first-audio timeout, wrap single-candi…
metehan-slng Jul 15, 2026
1ff942f
slng: add attach_to_session helper and document end-of-turn finalization
metehan-slng Jul 16, 2026
6a1dee4
slng: remove plugin test files per review
metehan-slng Jul 17, 2026
e084790
slng: catch asyncio.TimeoutError on py3.10, guard non-JSON frames, de…
metehan-slng Jul 17, 2026
8d09c26
slng: fix prewarm recursion with fallback chains, propagate voice/lan…
metehan-slng Jul 20, 2026
42d4547
slng: catch ChanClosed (not RuntimeError) when enqueuing finalize dur…
metehan-slng Jul 20, 2026
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
55 changes: 55 additions & 0 deletions livekit-plugins/livekit-plugins-slng/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,46 @@ pip install livekit-plugins-slng

You'll need an API key from SLNG. It can be set as an environment variable: `SLNG_API_KEY`

## Usage

Pass an SLNG model identifier; the plugin connects through SLNG's Unmute Bridge and builds the endpoint itself.

```python
from livekit.plugins import slng

stt = slng.STT(
model="deepgram/nova:3",
language="en",
)

tts = slng.TTS(
model="deepgram/aura:2",
voice="aura-2-thalia-en", # provider voice ID, required
language="en",
)
```

Additional keyword arguments are forwarded to the gateway and applied according to the selected model's contract. Failover across multiple models or endpoints is available via `connections=[...]`; see [docs.slng.ai](https://docs.slng.ai/) for details.

## End of turn finalization

For the lowest STT turn latency, let the plugin know when the user stops speaking. The plugin then sends a finalize signal so the provider returns the final transcript immediately instead of waiting for its own endpointing:

```python
session = AgentSession(stt=stt, ...)
stt.attach_to_session(session)
```

Or wire it manually:

```python
@session.on("user_state_changed")
def _on_user_state_changed(ev):
stt.notify_user_state(ev.new_state)
```

Without this hook the plugin still works, but end of turn detection relies entirely on the provider's endpointing, which typically adds a few hundred milliseconds per turn.

## Region override

The plugin supports gateway region routing via the `region_override` option on both `STT` and `TTS`.
Expand All @@ -40,3 +80,18 @@ tts = slng.TTS(
region_override=["eu-west-1", "us-east-1"],
)
```

To constrain routing to a broad geographic zone instead of a specific region,
use `world_part_override` (for example `"eu"`), which maps to the gateway's
`X-World-Part-Override` header. `region_override` takes precedence when both
are set.

## Migrating from 1.x

Version 2.0 is a breaking change:

- All traffic goes through the Unmute Bridge. `model_endpoint` and `model_endpoints` were removed; pass a model identifier or `connections=[...]` instead.
- TTS `voice` is required and passed verbatim as the provider's voice identifier.
- Language codes are no longer normalized client-side; send the value the model expects (for example BCP-47 `hi-IN` for Sarvam, not `hi`).
- STT `recognize()` (HTTP batch) is no longer supported; use `stream()`. Only `pcm_s16le` input audio is supported.
- `api_token` still works on STT but is deprecated; use `api_key`.
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@
See https://docs.slng.ai/ for more information.
"""

from .connection import PluginEvent, STTConnectionConfig, TTSConnectionConfig
from .log import logger
from .stt import STT, SpeechStream
from .tts import TTS
from .version import __version__

__all__ = [
"STT",
"SpeechStream",
"TTS",
"PluginEvent",
"STTConnectionConfig",
"SpeechStream",
"TTSConnectionConfig",
"__version__",
]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

import time
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Literal
from urllib.parse import urlparse

from .gateway_adapter import validate_model_identifier


@dataclass(frozen=True)
class STTConnectionConfig:
endpoint: str
model: str | None = None
headers: Mapping[str, str] = field(default_factory=dict)
init: Mapping[str, Any] | None = None


@dataclass(frozen=True)
class TTSConnectionConfig:
endpoint: str
model: str | None = None
voice: str | None = None
headers: Mapping[str, str] = field(default_factory=dict)
init: Mapping[str, Any] | None = None
control_profile: str | None = None


@dataclass(frozen=True)
class PluginEvent:
name: str
component: Literal["stt", "tts"]
level: Literal["info", "warning", "error"] = "info"
data: Mapping[str, Any] = field(default_factory=dict)


class CandidateState:
def __init__(self, count: int, recovery_cooldown_s: float) -> None:
self._count = count
self._cooldown = max(0.0, recovery_cooldown_s)
self._active = 0
self._primary_failed_at: float | None = None

def start(self) -> int:
if (
self._active != 0
and self._primary_failed_at is not None
and time.monotonic() - self._primary_failed_at >= self._cooldown
):
self._active = 0
self._primary_failed_at = None
return self._active

def advance(self, index: int) -> int | None:
if index == 0:
self._primary_failed_at = time.monotonic()
next_index = index + 1
if next_index >= self._count:
return None
self._active = next_index
return next_index

def select(self, index: int) -> None:
self._active = index


def bridge_endpoint(base_url: str, service: Literal["stt", "tts"], model: str) -> str:
validate_model_identifier(model)
host = base_url.removeprefix("https://").removeprefix("http://").rstrip("/")
protocol = "ws" if host.split(":", 1)[0] in {"localhost", "127.0.0.1"} else "wss"
return f"{protocol}://{host}/v1/bridges/unmute/{service}/{model}"


def bridge_model(endpoint: str, service: Literal["stt", "tts"]) -> str:
parsed = urlparse(endpoint)
marker = f"/v1/bridges/unmute/{service}/"
if (
parsed.scheme not in {"ws", "wss"}
or not parsed.netloc
or not parsed.path.startswith(marker)
):
raise ValueError(f"{service.upper()} endpoint must target the Unmute Bridge path {marker}")
model = parsed.path.split(marker, 1)[1].rstrip("/")
return validate_model_identifier(model)
Loading