Skip to content
Merged
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: 0 additions & 1 deletion agents-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ getstream = ["vision-agents-plugins-getstream"]
lemonslice = ["vision-agents-plugins-lemonslice"]
inworld = ["vision-agents-plugins-inworld"]
kokoro = ["vision-agents-plugins-kokoro"]
moonshine = ["vision-agents-plugins-moonshine"]
nvidia = ["vision-agents-plugins-nvidia"]
openai = ["vision-agents-plugins-openai"]
roboflow = ["vision-agents-plugins-roboflow"]
Expand Down
44 changes: 23 additions & 21 deletions plugins/kokoro/README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
# GetStream Kokoro Plugin
# Kokoro TTS Plugin

This package integrates the open-weight [Kokoro-82M TTS model](https://github.com/hexgrad/kokoro) with the GetStream audio/video SDK.
This package integrates the open-weight [Kokoro-82M TTS model](https://github.com/hexgrad/kokoro) with Vision Agents.

It provides a drop-in `KokoroTTS` class that implements the common `getstream_common.tts.TTS` interface, allowing you to stream PCM audio generated by Kokoro directly into a WebRTC
`AudioStreamTrack`.
Kokoro runs locally and produces 24 kHz mono PCM audio. Model initialization is handled by the Vision Agents warmup lifecycle.

```py
from getstream.plugins.kokoro import KokoroTTS
from getstream.video.rtc.audio_track import AudioStreamTrack
```python
from vision_agents.plugins import kokoro

track = AudioStreamTrack(framerate=24_000)
tts = kokoro.TTS(lang_code="a", voice="af_heart")

tts = KokoroTTS(lang_code="a", voice="af_heart")
tts.set_output_track(track)

async for chunk in tts.send_iter("Hello from Kokoro!"):
pass
try:
await tts.warmup()
audio_chunks = []
async for chunk in tts.send_iter("Hello from Kokoro!"):
if chunk.data:
audio_chunks.append(chunk.data)
finally:
await tts.close()
```

## Installation
Expand All @@ -26,18 +27,19 @@ uv add "vision-agents[kokoro]"
uv add vision-agents-plugins-kokoro
```

This will pull in the required `kokoro`, `numpy` and `getstream[webrtc]` dependencies. You also need `espeak-ng` **at runtime
** for pronunciation fallback. On macOS you can install it with Homebrew:
This installs the required `kokoro`, `misaki`, and `numpy` dependencies. You also need `espeak-ng` at runtime for phonemization. On macOS you can install it with Homebrew:

```bash
brew install espeak-ng
```

## Configuration options

| Parameter | Default | Description |
|---------------|--------------|------------------------------------------------------------------------------------------------------------------------------------|
| `lang_code` | `"a"` | Language group passed to `KPipeline` (`"a"` = American English, etc.) |
| `voice` | `"af_heart"` | Kokoro voice preset. See the [model card](https://huggingface.co/NeuML/kokoro-int8-onnx#speaker-reference) for available options. |
| `speed` | `1.0` | Playback speed multiplier. |
| `sample_rate` | `24000` | Output sample-rate (fixed by Kokoro). **The attached `AudioStreamTrack` must use the same value.** |
| Parameter | Default | Description |
|---------------|--------------|----------------------------------------------------------------------------------------------------------------------------------|
| `lang_code` | `"a"` | Language group passed to `KPipeline` (`"a"` is American English). |
| `voice` | `"af_heart"` | Kokoro voice preset. See the [Kokoro voices](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). |
| `speed` | `1.0` | Playback speed multiplier. |
| `sample_rate` | `24000` | Output sample rate. Kokoro produces 24 kHz audio. |
| `device` | `None` | Optional device passed to `KPipeline`, such as `"cpu"` or `"cuda"`. |
| `client` | `None` | Optional pre-initialized `KPipeline`. |
6 changes: 2 additions & 4 deletions plugins/kokoro/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The agent will:

1. Connect to the GetStream edge network
2. Initialize Kokoro TTS (downloads model on first run)
3. Join a call and greet participants when they join
3. Join a call and greet the user

## Customization

Expand All @@ -83,9 +83,7 @@ presets.
## Architecture

```
User Joins Call
Event Handler (CallSessionParticipantJoinedEvent)
Agent Joins Call
LLM generates greeting
Expand Down
4 changes: 3 additions & 1 deletion plugins/kokoro/example/kokoro_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from dotenv import load_dotenv
from vision_agents.core import Agent, Runner, User
from vision_agents.core.agents import AgentLauncher
from vision_agents.plugins import getstream, kokoro, openai
from vision_agents.plugins import getstream, kokoro, openai, deepgram

logger = logging.getLogger(__name__)

Expand All @@ -36,6 +36,7 @@ async def create_agent(**kwargs) -> Agent:
instructions="I'm a TTS bot that greets users when they join.",
llm=openai.LLM(model="gpt-4o-mini"),
tts=kokoro.TTS(),
stt=deepgram.STT(eager_turn_detection=True),
)

return agent
Expand All @@ -46,6 +47,7 @@ async def join_call(agent: Agent, call_type: str, call_id: str, **kwargs) -> Non
call = await agent.create_call(call_type, call_id)

async with agent.join(call):
await agent.simple_response("Greet the user briefly.")
await agent.finish()


Expand Down
42 changes: 35 additions & 7 deletions plugins/kokoro/tests/test_tts.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import numpy as np
import pytest
from vision_agents.plugins import kokoro

from conftest import skip_if_huggingface_model_unavailable


class FakePipeline:
def __call__(self, *args, **kwargs):
yield None, None, np.array([0.0, 0.25, -0.25], dtype=np.float32)


class TestKokoroTTS:
async def test_kokoro_tts_uses_injected_pipeline(self):
tts = kokoro.TTS(client=FakePipeline())
try:
out = [item async for item in tts.send_iter("Hello")]
finally:
await tts.close()

assert tts.provider_name == "kokoro"
assert out[0].data
assert out[-1].final


@pytest.mark.integration
class TestKokoroIntegration:
@pytest.fixture
async def tts(self): # returns kokoro TTS if available
async def tts(self):
tts_instance = kokoro.TTS()
try:
import kokoro # noqa: F401
except Exception:
pytest.skip("kokoro package not installed; skipping manual playback test.")
from vision_agents.plugins import kokoro as kokoro_plugin

return kokoro_plugin.TTS()
await tts_instance.warmup()
except Exception as exc:
await tts_instance.close()
skip_if_huggingface_model_unavailable(exc, "Kokoro model")
raise
try:
yield tts_instance
finally:
await tts_instance.close()

async def test_kokoro_tts_convert_text_to_audio(self, tts):
text = "Hello from Kokoro TTS."
Expand All @@ -21,3 +47,5 @@ async def test_kokoro_tts_convert_text_to_audio(self, tts):
out.append(item)

assert len(out) > 0
assert out[0].data
assert out[-1].final
70 changes: 53 additions & 17 deletions plugins/kokoro/vision_agents/plugins/kokoro/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import asyncio
import logging
from typing import AsyncIterator, Iterator, List, Optional
from concurrent.futures import ThreadPoolExecutor
from typing import AsyncIterator, Iterator, Optional

import numpy as np
from getstream.video.rtc.track_util import AudioFormat, PcmData
from vision_agents.core import tts
from vision_agents.core.warmup import Warmable

try:
from kokoro import KPipeline # type: ignore
Expand All @@ -17,7 +19,7 @@
logger = logging.getLogger(__name__)


class TTS(tts.TTS):
class TTS(tts.TTS, Warmable[KPipeline]):
"""Text-to-Speech plugin backed by the Kokoro-82M model."""

def __init__(
Expand All @@ -29,33 +31,61 @@ def __init__(
device: Optional[str] = None,
client: Optional[KPipeline] = None,
) -> None:
super().__init__()
super().__init__(provider_name="kokoro")

if KPipeline is None:
raise ImportError(
"The 'kokoro' package is not installed. ``pip install kokoro`` first."
)

self._pipeline = (
KPipeline(lang_code=lang_code)
if device is None
else KPipeline(lang_code=lang_code, device=device)
)
self.lang_code = lang_code
self.device = device
self.voice = voice
self.speed = speed
self.sample_rate = sample_rate
self.client = client if client is not None else self._pipeline
self._pipeline = client
self.client = client
self._executor = ThreadPoolExecutor(max_workers=1)

async def on_warmup(self) -> KPipeline:
if self._pipeline is not None:
return self._pipeline

loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self._executor,
lambda: (
KPipeline(lang_code=self.lang_code)
if self.device is None
else KPipeline(lang_code=self.lang_code, device=self.device)
),
)

def on_warmed_up(self, resource: KPipeline) -> None:
self._pipeline = resource
self.client = resource

async def _ensure_loaded(self) -> KPipeline:
if self._pipeline is None:
resource = await self.on_warmup()
self.on_warmed_up(resource)
return self._pipeline

async def stream_audio(
self, text: str, *_, **__
) -> PcmData | Iterator[PcmData] | AsyncIterator[PcmData]: # noqa: D401
loop = asyncio.get_event_loop()
chunks: List[bytes] = await loop.run_in_executor(
None, lambda: list(self._generate_chunks(text))
)
pipeline = await self._ensure_loaded()
loop = asyncio.get_running_loop()
done = object()

async def _aiter():
for chunk in chunks:
generator = self._generate_chunks(pipeline, text)
while True:
chunk = await loop.run_in_executor(
self._executor, next, generator, done
)
if chunk is done:
break
yield PcmData.from_bytes(
chunk,
sample_rate=self.sample_rate,
Expand All @@ -70,13 +100,19 @@ async def stop_audio(self) -> None:
Clears the queue and stops playing audio.

"""
logger.info("🎤 Kokoro TTS stop requested (no-op)")
logger.info("Kokoro TTS stop requested (no-op)")

def _generate_chunks(self, text: str):
for _gs, _ps, audio in self._pipeline(
def _generate_chunks(self, pipeline: KPipeline, text: str):
for _gs, _ps, audio in pipeline(
text, voice=self.voice, speed=self.speed, split_pattern=r"\n+"
):
if not isinstance(audio, np.ndarray):
audio = np.asarray(audio)
pcm16 = (np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2")
yield pcm16.tobytes()

async def close(self) -> None:
try:
await super().close()
finally:
self._executor.shutdown(wait=False)
2 changes: 1 addition & 1 deletion plugins/pocket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ tts = pocket.TTS(voice="path/to/your/voice.wav")

## Dependencies

- pocket-tts>=0.1.0
- pocket-tts>=1.1.1
- PyTorch 2.5+
3 changes: 1 addition & 2 deletions plugins/pocket/example/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
[project]
name = "pocket-tts-example"
version = "0.0.0"
requires-python = ">=3.11,<3.14"
requires-python = ">=3.10"

dependencies = [
"python-dotenv>=1.0",
"vision-agents-plugins-pocket",
"vision-agents-plugins-getstream",
"vision-agents-plugins-deepgram",
"vision-agents-plugins-smart-turn",
"vision-agents-plugins-gemini",
"vision-agents",
]
Expand Down
21 changes: 1 addition & 20 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.