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
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