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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ call answered by an all-Telnyx pipeline.

Adds a new `speechify` plugin exposing `speechify.TTS`, backed by Speechify's streaming API. It streams raw PCM audio, defaults to the `simba-3.2` model with the `geffen_32` voice, and reads `SPEECHIFY_API_KEY` from the environment. Install with `vision-agents[speechify]`.

### `audexum` plugin: Audexum TTS

Adds a new `audexum` plugin exposing `audexum.TTS`, backed by Audexum's OpenAI-compatible `/v1/audio/speech` endpoint. It returns a full clip per request, decoded to `PcmData`, defaults to the `Nova` voice, and reads `AUDEXUM_API_KEY` from the environment. Install with `vision-agents[audexum]`. (#1127)

### Agent metadata describing the configured providers (#618)

The agent user is now published with metadata describing its own pipeline, so dashboards and Stream Chat clients can tell what an agent is made of without out-of-band bookkeeping. `Agent.components_metadata` returns `{"llm" | "vlm" | "realtime": {...}, "stt": ..., "tts": ..., "turn_detection": ..., "avatar": ..., "processors": [...]}` where each entry is `{"provider": <plugin name>, "model": <str | None>}`, and `Agent.authenticate()` writes it (plus `is_agent: True`) to the edge user's custom data. `StreamEdge` now merges via a partial update instead of an upsert, so custom fields written by other clients survive. Adds the public `MetadataValue` type in `vision_agents.core.edge.types`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ agent = Agent(

**STT:** [Deepgram](https://visionagents.ai/integrations/deepgram) · [AssemblyAI](https://www.assemblyai.com/docs/streaming/universal-3-pro) · [Fast-Whisper](https://visionagents.ai/integrations/fast-whisper) · [Fish Audio](https://visionagents.ai/integrations/fish) · [Wizper](https://visionagents.ai/integrations/wizper) · [Mistral Voxtral](https://visionagents.ai/integrations/mistral) · [Telnyx](https://github.com/GetStream/Vision-Agents/tree/main/plugins/telnyx)

**TTS:** [ElevenLabs](https://visionagents.ai/integrations/elevenlabs) · [Cartesia](https://visionagents.ai/integrations/cartesia) · [Deepgram](https://visionagents.ai/integrations/deepgram) · [AWS Polly](https://visionagents.ai/integrations/aws-polly) · [Pocket](https://visionagents.ai/integrations/pocket) · [Kokoro](https://visionagents.ai/integrations/kokoro) · [Inworld](https://visionagents.ai/integrations/inworld) · [Fish Audio](https://visionagents.ai/integrations/fish) · [Telnyx](https://github.com/GetStream/Vision-Agents/tree/main/plugins/telnyx)
**TTS:** [ElevenLabs](https://visionagents.ai/integrations/elevenlabs) · [Cartesia](https://visionagents.ai/integrations/cartesia) · [Deepgram](https://visionagents.ai/integrations/deepgram) · [AWS Polly](https://visionagents.ai/integrations/aws-polly) · [Pocket](https://visionagents.ai/integrations/pocket) · [Kokoro](https://visionagents.ai/integrations/kokoro) · [Inworld](https://visionagents.ai/integrations/inworld) · [Fish Audio](https://visionagents.ai/integrations/fish) · [Telnyx](https://github.com/GetStream/Vision-Agents/tree/main/plugins/telnyx) · [Audexum](https://github.com/GetStream/Vision-Agents/tree/main/plugins/audexum)

**Vision:** [Ultralytics](https://visionagents.ai/integrations/ultralytics) · [Roboflow](https://visionagents.ai/integrations/roboflow) · [Moondream](https://visionagents.ai/integrations/moondream) · [TwelveLabs](https://github.com/GetStream/Vision-Agents/tree/main/plugins/twelvelabs) · [NVIDIA](https://visionagents.ai/integrations/nvidia) · [Decart](https://visionagents.ai/integrations/decart)

Expand Down
1 change: 1 addition & 0 deletions agents-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ tencent = ["vision-agents-plugins-tencent; sys_platform == 'linux'"]
minimax = ["vision-agents-plugins-minimax"]
twelvelabs = ["vision-agents-plugins-twelvelabs"]
speechify = ["vision-agents-plugins-speechify"]
audexum = ["vision-agents-plugins-audexum"]


[tool.hatch.metadata]
Expand Down
41 changes: 41 additions & 0 deletions plugins/audexum/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Audexum

[Audexum](https://audexum.com) is a text-to-speech and speech-to-text API. It has a free tier (2,000 credits to start, then 250 per day) and paid plans.

The Audexum plugin for the Stream Python AI SDK adds TTS to your project. It calls Audexum's OpenAI-compatible `/v1/audio/speech` endpoint and returns PCM audio.

## Installation

```bash
uv add "vision-agents[audexum]"
# or directly
uv add vision-agents-plugins-audexum
```

## Initialisation

```python
from vision_agents.plugins import audexum

tts = audexum.TTS()
```

Create an API key at [audexum.com](https://audexum.com) and export `AUDEXUM_API_KEY` in your shell, or put it in a `.env` file and load it (for example with `python-dotenv`'s `load_dotenv()`) before creating `audexum.TTS()`.

## Parameters

| Name | Type | Default | Description |
|------------|-----------------|----------|-------------------------------------------------------------------------------------------------|
| `api_key` | `str` or `None` | `None` | Your Audexum API key. Falls back to the `AUDEXUM_API_KEY` environment variable. |
| `voice` | `str` | `"Nova"` | Voice name, e.g. `Nova`, `Atlas`, `Reid`, `F1`, `M1`. List: `GET https://audexum.com/v1/voices`. |
| `speed` | `float` | `1.0` | Speaking-rate multiplier, 0.25 to 4.0. Applies to `F1`-`F5` / `M1`-`M5`; named voices ignore it. |
| `base_url` | `str` or `None` | `None` | Override the API base URL (default `https://audexum.com/v1`). |

## Functionality

The API returns a complete clip per request (no incremental streaming), so latency grows with text length. Send short sentences for conversational use.

```python
async for chunk in tts.send_iter("Demo text you want AI voice to say"):
pass
```
Empty file added plugins/audexum/py.typed
Empty file.
40 changes: 40 additions & 0 deletions plugins/audexum/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "vision-agents-plugins-audexum"
dynamic = ["version"]
description = "Audexum TTS integration for Vision Agents"
readme = "README.md"
keywords = ["audexum", "TTS", "text-to-speech", "AI", "voice agents", "agents"]
requires-python = ">=3.10"
license = "MIT"
dependencies = [
"vision-agents",
"aiohttp>=3.13.3,<4",
]

[project.urls]
Documentation = "https://visionagents.ai/"
Website = "https://visionagents.ai/"
Source = "https://github.com/GetStream/Vision-Agents"

[tool.hatch.version]
source = "vcs"
raw-options = { root = "..", search_parent_directories = true, fallback_version = "0.0.0" }

[tool.hatch.build.targets.wheel]
packages = ["vision_agents"]

[tool.hatch.build.targets.sdist]
include = ["/vision_agents"]

[tool.uv.sources]
vision-agents = { workspace = true }

[dependency-groups]
dev = [
"pytest>=8.4.1",
"pytest-asyncio>=1.0.0",
]
Empty file.
268 changes: 268 additions & 0 deletions plugins/audexum/tests/test_tts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import asyncio
import io
import os
import wave

import pytest
from aiohttp import web
from aiohttp.test_utils import TestServer
from vision_agents.plugins import audexum


def _wav(sample_rate: int = 24000, frames: int = 2400) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(b"\x01\x00" * frames)
return buf.getvalue()


class TestAudexumTTS:
def test_defaults(self) -> None:
tts = audexum.TTS(api_key="fake")
assert tts.voice == "Nova"
assert tts.speed == 1.0

def test_invalid_speed_raises(self) -> None:
with pytest.raises(ValueError):
audexum.TTS(api_key="fake", speed=5.0)

async def test_stop_audio_cancels_request(self) -> None:
release = asyncio.Event()

async def handler(request: web.Request) -> web.Response:
await release.wait()
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="fake", base_url=str(server.make_url("/v1")))
try:
pending = asyncio.ensure_future(tts.stream_audio("Hello"))
await asyncio.sleep(0.1)
await tts.stop_audio()
pcm = await pending
assert len(pcm.samples) == 0
finally:
release.set()
await tts.close()
await server.close()

async def test_stop_audio_cancels_every_concurrent_request(self) -> None:
release = asyncio.Event()
arrived = 0

async def handler(request: web.Request) -> web.Response:
nonlocal arrived
arrived += 1
await release.wait()
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="fake", base_url=str(server.make_url("/v1")))
try:
first = asyncio.ensure_future(tts.stream_audio("First"))
second = asyncio.ensure_future(tts.stream_audio("Second"))
for _ in range(50):
if arrived == 2:
break
await asyncio.sleep(0.02)
assert arrived == 2
await tts.stop_audio()
results = await asyncio.gather(first, second)
assert [len(pcm.samples) for pcm in results] == [0, 0]
assert not tts._pending
finally:
release.set()
await tts.close()
await server.close()

async def test_request_after_stop_still_returns_audio(self) -> None:
async def handler(request: web.Request) -> web.Response:
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="fake", base_url=str(server.make_url("/v1")))
try:
await tts.stop_audio() # nothing in flight: a no-op
pcm = await tts.stream_audio("Hello")
assert len(pcm.samples) == 2400
finally:
await tts.close()
await server.close()

async def test_external_cancellation_is_not_swallowed(self) -> None:
release = asyncio.Event()

async def handler(request: web.Request) -> web.Response:
await release.wait()
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="fake", base_url=str(server.make_url("/v1")))
try:
pending = asyncio.ensure_future(tts.stream_audio("Hello"))
await asyncio.sleep(0.1)
pending.cancel() # a caller cancelling, not stop_audio()
with pytest.raises(asyncio.CancelledError):
await pending
finally:
release.set()
await tts.close()
await server.close()

async def test_close_with_pending_requests_returns_empty_audio(self) -> None:
release = asyncio.Event()

async def handler(request: web.Request) -> web.Response:
await release.wait()
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="fake", base_url=str(server.make_url("/v1")))
try:
pending = [asyncio.ensure_future(tts.stream_audio(t)) for t in ("a", "b")]
await asyncio.sleep(0.1)
await tts.close()
results = await asyncio.gather(*pending)
assert [len(pcm.samples) for pcm in results] == [0, 0]
finally:
release.set()
await server.close()

async def test_caller_cancel_in_same_tick_as_stop_propagates(self) -> None:
release = asyncio.Event()

async def handler(request: web.Request) -> web.Response:
await release.wait()
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="fake", base_url=str(server.make_url("/v1")))
try:
pending = asyncio.ensure_future(tts.stream_audio("Hello"))
await asyncio.sleep(0.1)
await tts.stop_audio()
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
finally:
release.set()
await tts.close()
await server.close()

def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("AUDEXUM_API_KEY", raising=False)
with pytest.raises(ValueError):
audexum.TTS()

async def test_request_shape_and_pcm_decoding(self) -> None:
seen: dict = {}

async def handler(request: web.Request) -> web.Response:
seen["auth"] = request.headers["Authorization"]
seen["body"] = await request.json()
return web.Response(body=_wav(), content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="sk_live_test", base_url=str(server.make_url("/v1")))
try:
pcm = await tts.stream_audio("Hello")
assert seen["auth"] == "Bearer sk_live_test"
assert seen["body"]["input"] == "Hello"
assert seen["body"]["voice"] == "Nova"
assert seen["body"]["speed"] == 1.0
assert seen["body"]["response_format"] == "wav"
assert pcm.sample_rate == 24000
assert len(pcm.samples) == 2400
finally:
await tts.close()
await server.close()

@pytest.mark.parametrize("body", [b'{"error": "oops"}', _wav()[:20]])
async def test_invalid_wav_raises_with_context(self, body: bytes) -> None:
async def handler(request: web.Request) -> web.Response:
return web.Response(body=body, content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="k", base_url=str(server.make_url("/v1")))
try:
with pytest.raises(RuntimeError, match="Audexum TTS returned invalid WAV"):
await tts.stream_audio("Hello")
finally:
await tts.close()
await server.close()

async def test_truncated_wav_raises(self) -> None:
body = _wav()[:-4] # header promises more frames than the data holds

async def handler(request: web.Request) -> web.Response:
return web.Response(body=body, content_type="audio/wav")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="k", base_url=str(server.make_url("/v1")))
try:
with pytest.raises(RuntimeError, match="truncated WAV"):
await tts.stream_audio("Hello")
finally:
await tts.close()
await server.close()

async def test_http_error_raises(self) -> None:
async def handler(request: web.Request) -> web.Response:
return web.Response(status=402, text="out of credits")

app = web.Application()
app.router.add_post("/v1/audio/speech", handler)
server = TestServer(app)
await server.start_server()
tts = audexum.TTS(api_key="k", base_url=str(server.make_url("/v1")))
try:
with pytest.raises(RuntimeError, match="402"):
await tts.stream_audio("Hello")
finally:
await tts.close()
await server.close()


@pytest.mark.integration
class TestAudexumTTSIntegration:
async def test_audexum_convert_text_to_audio(self) -> None:
api_key = os.getenv("AUDEXUM_API_KEY")
if not api_key:
pytest.fail("Integration tests require AUDEXUM_API_KEY", pytrace=False)
tts = audexum.TTS(api_key=api_key)
try:
out = [item async for item in tts.send_iter("Hello from Audexum!")]
finally:
await tts.close()
assert len(out) > 0
assert out[-1].final
6 changes: 6 additions & 0 deletions plugins/audexum/vision_agents/plugins/audexum/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .tts import TTS

# Re-export under the new namespace for convenience
__path__ = __import__("pkgutil").extend_path(__path__, __name__)

__all__ = ["TTS"]
Loading
Loading