From e3f524e5a7c3e5cf8c09b584fc2819798d7f4f46 Mon Sep 17 00:00:00 2001 From: pepikir <90158120+pepikir@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:54:41 +0300 Subject: [PATCH 1/5] feat(audexum): add Audexum TTS plugin Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 + README.md | 2 +- agents-core/pyproject.toml | 1 + plugins/audexum/README.md | 41 +++++++ plugins/audexum/py.typed | 0 plugins/audexum/pyproject.toml | 40 +++++++ plugins/audexum/tests/__init__.py | 0 plugins/audexum/tests/test_tts.py | 88 +++++++++++++++ .../vision_agents/plugins/audexum/__init__.py | 6 + .../vision_agents/plugins/audexum/tts.py | 104 ++++++++++++++++++ pyproject.toml | 2 + uv.lock | 33 +++++- 12 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 plugins/audexum/README.md create mode 100644 plugins/audexum/py.typed create mode 100644 plugins/audexum/pyproject.toml create mode 100644 plugins/audexum/tests/__init__.py create mode 100644 plugins/audexum/tests/test_tts.py create mode 100644 plugins/audexum/vision_agents/plugins/audexum/__init__.py create mode 100644 plugins/audexum/vision_agents/plugins/audexum/tts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ac7a268f..a9fe4e7ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]`. + ### 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": , "model": }`, 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`. diff --git a/README.md b/README.md index 25cad852ac..6fc11b725b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/agents-core/pyproject.toml b/agents-core/pyproject.toml index 708c73683b..82802486a9 100644 --- a/agents-core/pyproject.toml +++ b/agents-core/pyproject.toml @@ -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] diff --git a/plugins/audexum/README.md b/plugins/audexum/README.md new file mode 100644 index 0000000000..5066749eb9 --- /dev/null +++ b/plugins/audexum/README.md @@ -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 make `AUDEXUM_API_KEY` available as an environment variable (in a `.env` file or exported in your terminal). + +## 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. | +| `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 +``` diff --git a/plugins/audexum/py.typed b/plugins/audexum/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/audexum/pyproject.toml b/plugins/audexum/pyproject.toml new file mode 100644 index 0000000000..e001b46475 --- /dev/null +++ b/plugins/audexum/pyproject.toml @@ -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", +] diff --git a/plugins/audexum/tests/__init__.py b/plugins/audexum/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/audexum/tests/test_tts.py b/plugins/audexum/tests/test_tts.py new file mode 100644 index 0000000000..b64cc980a5 --- /dev/null +++ b/plugins/audexum/tests/test_tts.py @@ -0,0 +1,88 @@ +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_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", voice="Atlas", 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"] == "Atlas" + assert seen["body"]["response_format"] == "wav" + assert pcm.sample_rate == 24000 + assert len(pcm.samples) == 2400 + 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 diff --git a/plugins/audexum/vision_agents/plugins/audexum/__init__.py b/plugins/audexum/vision_agents/plugins/audexum/__init__.py new file mode 100644 index 0000000000..4aaee0ff1a --- /dev/null +++ b/plugins/audexum/vision_agents/plugins/audexum/__init__.py @@ -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"] diff --git a/plugins/audexum/vision_agents/plugins/audexum/tts.py b/plugins/audexum/vision_agents/plugins/audexum/tts.py new file mode 100644 index 0000000000..19152b2951 --- /dev/null +++ b/plugins/audexum/vision_agents/plugins/audexum/tts.py @@ -0,0 +1,104 @@ +import io +import logging +import os +import wave +from typing import AsyncIterator, Iterator, Optional + +import aiohttp +import numpy as np +from getstream.video.rtc.track_util import AudioFormat, PcmData +from vision_agents.core import tts + +logger = logging.getLogger(__name__) + +BASE_URL = "https://audexum.com/v1" + + +class TTS(tts.TTS): + """Text-to-Speech plugin backed by the Audexum API (https://audexum.com). + + Uses Audexum's OpenAI-compatible ``/v1/audio/speech`` endpoint and decodes + the returned WAV into PCM. + """ + + def __init__( + self, + api_key: Optional[str] = None, + voice: str = "Nova", + speed: float = 1.0, + base_url: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + ) -> None: + """Create a new Audexum TTS instance. + + Args: + api_key: Audexum API key. Falls back to the ``AUDEXUM_API_KEY`` env var. + voice: Voice name, e.g. ``Nova``, ``Atlas``, ``F1`` or ``M1`` + (list: ``GET https://audexum.com/v1/voices``). + speed: Speaking-rate multiplier, 0.25 to 4.0. + base_url: Override the API base URL. + session: Optional pre-existing ``aiohttp.ClientSession``. + """ + super().__init__(provider_name="audexum") + + self.api_key = api_key or os.getenv("AUDEXUM_API_KEY") + if not self.api_key: + raise ValueError("AUDEXUM_API_KEY env var or api_key parameter required") + + self.voice = voice + self.speed = speed + self._base_url = (base_url or BASE_URL).rstrip("/") + self._session = session + self._own_session = session is None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + self._own_session = True + return self._session + + async def stream_audio( + self, text: str, *_, **__ + ) -> PcmData | Iterator[PcmData] | AsyncIterator[PcmData]: + """Synthesize ``text`` and return it as a single PcmData buffer.""" + session = await self._get_session() + async with session.post( + f"{self._base_url}/audio/speech", + json={ + "model": "audexum", + "input": text, + "voice": self.voice, + "speed": self.speed, + "response_format": "wav", + }, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=aiohttp.ClientTimeout(total=60), + ) as resp: + if resp.status != 200: + raise RuntimeError( + f"Audexum TTS API error {resp.status}: {await resp.text()}" + ) + audio = await resp.read() + + with wave.open(io.BytesIO(audio), "rb") as wf: + if wf.getsampwidth() != 2: + raise RuntimeError("Audexum TTS returned non 16-bit WAV audio") + samples = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16) + return PcmData.from_numpy( + samples, + sample_rate=wf.getframerate(), + channels=wf.getnchannels(), + format=AudioFormat.S16, + ) + + async def stop_audio(self) -> None: + """No-op: each request is a single one-shot HTTP call.""" + logger.debug("Audexum TTS stop requested (no-op)") + + async def close(self) -> None: + """Close the HTTP session if this instance created it.""" + try: + if self._own_session and self._session and not self._session.closed: + await self._session.close() + finally: + await super().close() diff --git a/pyproject.toml b/pyproject.toml index 58fc59cc89..a2816172f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ vision-agents-plugins-liveavatar = { workspace = true } vision-agents-plugins-tencent = { workspace = true } vision-agents-plugins-twelvelabs = { workspace = true } vision-agents-plugins-speechify = { workspace = true } +vision-agents-plugins-audexum = { workspace = true } [tool.uv] # Workspace-level override to resolve numpy version conflicts @@ -102,6 +103,7 @@ members = [ "plugins/tencent", "plugins/twelvelabs", "plugins/speechify", + "plugins/audexum", ] exclude = [ "**/__pycache__", diff --git a/uv.lock b/uv.lock index 96e124e5f6..bdaf945c8a 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ members = [ "vision-agents-plugins-anam", "vision-agents-plugins-anthropic", "vision-agents-plugins-assemblyai", + "vision-agents-plugins-audexum", "vision-agents-plugins-aws", "vision-agents-plugins-cartesia", "vision-agents-plugins-decart", @@ -6463,6 +6464,9 @@ anthropic = [ assemblyai = [ { name = "vision-agents-plugins-assemblyai" }, ] +audexum = [ + { name = "vision-agents-plugins-audexum" }, +] aws = [ { name = "vision-agents-plugins-aws" }, ] @@ -6608,6 +6612,7 @@ requires-dist = [ { name = "vision-agents-plugins-anam", marker = "extra == 'anam'", editable = "plugins/anam" }, { name = "vision-agents-plugins-anthropic", marker = "extra == 'anthropic'", editable = "plugins/anthropic" }, { name = "vision-agents-plugins-assemblyai", marker = "extra == 'assemblyai'", editable = "plugins/assemblyai" }, + { name = "vision-agents-plugins-audexum", marker = "extra == 'audexum'", editable = "plugins/audexum" }, { name = "vision-agents-plugins-aws", marker = "extra == 'aws'", editable = "plugins/aws" }, { name = "vision-agents-plugins-cartesia", marker = "extra == 'cartesia'", editable = "plugins/cartesia" }, { name = "vision-agents-plugins-decart", marker = "extra == 'decart'", editable = "plugins/decart" }, @@ -6646,7 +6651,7 @@ requires-dist = [ { name = "vision-agents-plugins-wizper", marker = "extra == 'wizper'", editable = "plugins/wizper" }, { name = "vision-agents-plugins-xai", marker = "extra == 'xai'", editable = "plugins/xai" }, ] -provides-extras = ["anam", "anthropic", "assemblyai", "aws", "cartesia", "decart", "deepgram", "dev", "elevenlabs", "fast-whisper", "fish", "gemini", "getstream", "huggingface", "inworld", "kokoro", "lemonslice", "liveavatar", "local", "minimax", "mistral", "moondream", "nvidia", "openai", "openrouter", "pocket", "qdrant", "qwen", "redis", "roboflow", "sarvam", "smart-turn", "speechify", "telnyx", "tencent", "turbopuffer", "twelvelabs", "twilio", "ultralytics", "vogent", "wizper", "xai"] +provides-extras = ["anam", "anthropic", "assemblyai", "audexum", "aws", "cartesia", "decart", "deepgram", "dev", "elevenlabs", "fast-whisper", "fish", "gemini", "getstream", "huggingface", "inworld", "kokoro", "lemonslice", "liveavatar", "local", "minimax", "mistral", "moondream", "nvidia", "openai", "openrouter", "pocket", "qdrant", "qwen", "redis", "roboflow", "sarvam", "smart-turn", "speechify", "telnyx", "tencent", "turbopuffer", "twelvelabs", "twilio", "ultralytics", "vogent", "wizper", "xai"] [[package]] name = "vision-agents-plugins-anam" @@ -6726,6 +6731,32 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.0.0" }, ] +[[package]] +name = "vision-agents-plugins-audexum" +source = { editable = "plugins/audexum" } +dependencies = [ + { name = "aiohttp" }, + { name = "vision-agents" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.13.3,<4" }, + { name = "vision-agents", editable = "agents-core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, +] + [[package]] name = "vision-agents-plugins-aws" source = { editable = "plugins/aws" } From e185a4a841480186d2fae55ab6302163510e8cf3 Mon Sep 17 00:00:00 2001 From: pepikir <90158120+pepikir@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:44:11 +0300 Subject: [PATCH 2/5] fix(audexum): cancel in-flight request in stop_audio, validate speed, clarify .env loading Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- plugins/audexum/README.md | 2 +- plugins/audexum/tests/test_tts.py | 35 ++++++++++++++++-- .../vision_agents/plugins/audexum/tts.py | 37 +++++++++++++++++-- 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fe4e7ba4..813aec0550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ Adds a new `speechify` plugin exposing `speechify.TTS`, backed by Speechify's st ### `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]`. +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) diff --git a/plugins/audexum/README.md b/plugins/audexum/README.md index 5066749eb9..0b1b9e31e3 100644 --- a/plugins/audexum/README.md +++ b/plugins/audexum/README.md @@ -20,7 +20,7 @@ from vision_agents.plugins import audexum tts = audexum.TTS() ``` -Create an API key at [audexum.com](https://audexum.com) and make `AUDEXUM_API_KEY` available as an environment variable (in a `.env` file or exported in your terminal). +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 diff --git a/plugins/audexum/tests/test_tts.py b/plugins/audexum/tests/test_tts.py index b64cc980a5..3f4fae0bfa 100644 --- a/plugins/audexum/tests/test_tts.py +++ b/plugins/audexum/tests/test_tts.py @@ -1,3 +1,4 @@ +import asyncio import io import os import wave @@ -24,6 +25,33 @@ def test_defaults(self) -> None: 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() + def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("AUDEXUM_API_KEY", raising=False) with pytest.raises(ValueError): @@ -41,14 +69,13 @@ async def handler(request: web.Request) -> web.Response: app.router.add_post("/v1/audio/speech", handler) server = TestServer(app) await server.start_server() - tts = audexum.TTS( - api_key="sk_live_test", voice="Atlas", base_url=str(server.make_url("/v1")) - ) + 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"] == "Atlas" + 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 diff --git a/plugins/audexum/vision_agents/plugins/audexum/tts.py b/plugins/audexum/vision_agents/plugins/audexum/tts.py index 19152b2951..833c40c0ec 100644 --- a/plugins/audexum/vision_agents/plugins/audexum/tts.py +++ b/plugins/audexum/vision_agents/plugins/audexum/tts.py @@ -1,3 +1,4 @@ +import asyncio import io import logging import os @@ -45,11 +46,16 @@ def __init__( if not self.api_key: raise ValueError("AUDEXUM_API_KEY env var or api_key parameter required") + if not 0.25 <= speed <= 4.0: + raise ValueError(f"speed must be between 0.25 and 4.0, got {speed}") + self.voice = voice self.speed = speed self._base_url = (base_url or BASE_URL).rstrip("/") self._session = session self._own_session = session is None + self._current_task: Optional[asyncio.Task] = None + self._stopped: Optional[asyncio.Task] = None async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -60,7 +66,28 @@ async def _get_session(self) -> aiohttp.ClientSession: async def stream_audio( self, text: str, *_, **__ ) -> PcmData | Iterator[PcmData] | AsyncIterator[PcmData]: - """Synthesize ``text`` and return it as a single PcmData buffer.""" + """Synthesize ``text`` and return it as a single PcmData buffer. + + Returns an empty buffer if ``stop_audio()`` cancels the request. + """ + task = asyncio.ensure_future(self._synthesize(text)) + self._current_task = task + try: + return await task + except asyncio.CancelledError: + if self._stopped is not task: + raise + return PcmData.from_numpy( + np.zeros(0, dtype=np.int16), + sample_rate=24000, + channels=1, + format=AudioFormat.S16, + ) + finally: + if self._current_task is task: + self._current_task = None + + async def _synthesize(self, text: str) -> PcmData: session = await self._get_session() async with session.post( f"{self._base_url}/audio/speech", @@ -92,8 +119,12 @@ async def stream_audio( ) async def stop_audio(self) -> None: - """No-op: each request is a single one-shot HTTP call.""" - logger.debug("Audexum TTS stop requested (no-op)") + """Cancel the in-flight synthesis request, if any; its output is discarded.""" + task = self._current_task + if task and not task.done(): + self._stopped = task + task.cancel() + logger.debug("Audexum TTS stop requested") async def close(self) -> None: """Close the HTTP session if this instance created it.""" From e2f2386a2ff2d66ce09d9441aabfddc4ca3bcd21 Mon Sep 17 00:00:00 2001 From: pepikir <90158120+pepikir@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:45:33 +0200 Subject: [PATCH 3/5] fix(audexum): stop_audio() cancels every concurrent synthesis Review of #1127: stream_audio() stored only the latest task, so with two concurrent calls a manual stop_audio() cancelled the second request while the first could still return speech after the stop. All in-flight tasks are now tracked in a set and stop_audio() cancels each one. Only a cancel requested by stop_audio() is turned into empty audio; a caller that is itself cancelled (also in the same tick as a stop, via Task.cancelling() on 3.11+) still gets CancelledError. close() stops pending requests before closing the session, so their callers receive empty audio instead of a connection error. Tests: concurrent requests all return empty audio after a stop (fails on the previous code), a stop with nothing in flight does not affect the next request, external and same-tick cancellation propagate, close() with pending requests. Docs note that `speed` applies to the F1-F5 / M1-M5 voices. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01No5BjeaLzSDP4LhEthEGit --- plugins/audexum/README.md | 2 +- plugins/audexum/tests/test_tts.py | 121 ++++++++++++++++++ .../vision_agents/plugins/audexum/tts.py | 35 +++-- 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/plugins/audexum/README.md b/plugins/audexum/README.md index 0b1b9e31e3..530a420c8b 100644 --- a/plugins/audexum/README.md +++ b/plugins/audexum/README.md @@ -28,7 +28,7 @@ Create an API key at [audexum.com](https://audexum.com) and export `AUDEXUM_API_ |------------|-----------------|----------|-------------------------------------------------------------------------------------------------| | `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. | +| `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 diff --git a/plugins/audexum/tests/test_tts.py b/plugins/audexum/tests/test_tts.py index 3f4fae0bfa..c6f8495d5a 100644 --- a/plugins/audexum/tests/test_tts.py +++ b/plugins/audexum/tests/test_tts.py @@ -52,6 +52,127 @@ async def handler(request: web.Request) -> web.Response: 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 and not tts._stopped + 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() + + @pytest.mark.skipif( + not hasattr(asyncio.Task, "cancelling"), reason="Task.cancelling() is 3.11+" + ) + 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): diff --git a/plugins/audexum/vision_agents/plugins/audexum/tts.py b/plugins/audexum/vision_agents/plugins/audexum/tts.py index 833c40c0ec..3f661db3cb 100644 --- a/plugins/audexum/vision_agents/plugins/audexum/tts.py +++ b/plugins/audexum/vision_agents/plugins/audexum/tts.py @@ -36,7 +36,9 @@ def __init__( api_key: Audexum API key. Falls back to the ``AUDEXUM_API_KEY`` env var. voice: Voice name, e.g. ``Nova``, ``Atlas``, ``F1`` or ``M1`` (list: ``GET https://audexum.com/v1/voices``). - speed: Speaking-rate multiplier, 0.25 to 4.0. + speed: Speaking-rate multiplier, 0.25 to 4.0. Applied to the + ``F1``-``F5`` / ``M1``-``M5`` voices; named voices keep their + natural pace. base_url: Override the API base URL. session: Optional pre-existing ``aiohttp.ClientSession``. """ @@ -54,8 +56,9 @@ def __init__( self._base_url = (base_url or BASE_URL).rstrip("/") self._session = session self._own_session = session is None - self._current_task: Optional[asyncio.Task] = None - self._stopped: Optional[asyncio.Task] = None + # Every in-flight synthesis, so stop_audio() can cancel concurrent calls. + self._pending: set[asyncio.Task] = set() + self._stopped: set[asyncio.Task] = set() async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -71,11 +74,14 @@ async def stream_audio( Returns an empty buffer if ``stop_audio()`` cancels the request. """ task = asyncio.ensure_future(self._synthesize(text)) - self._current_task = task + self._pending.add(task) try: return await task except asyncio.CancelledError: - if self._stopped is not task: + # Swallow only a cancel that stop_audio() requested; the caller being + # cancelled itself must still propagate (Task.cancelling: 3.11+). + cancelling = getattr(asyncio.current_task(), "cancelling", None) + if task not in self._stopped or (cancelling is not None and cancelling()): raise return PcmData.from_numpy( np.zeros(0, dtype=np.int16), @@ -84,8 +90,8 @@ async def stream_audio( format=AudioFormat.S16, ) finally: - if self._current_task is task: - self._current_task = None + self._pending.discard(task) + self._stopped.discard(task) async def _synthesize(self, text: str) -> PcmData: session = await self._get_session() @@ -119,16 +125,19 @@ async def _synthesize(self, text: str) -> PcmData: ) async def stop_audio(self) -> None: - """Cancel the in-flight synthesis request, if any; its output is discarded.""" - task = self._current_task - if task and not task.done(): - self._stopped = task - task.cancel() + """Cancel every in-flight synthesis request; their output is discarded.""" + for task in list(self._pending): + if not task.done(): + self._stopped.add(task) + task.cancel() logger.debug("Audexum TTS stop requested") async def close(self) -> None: - """Close the HTTP session if this instance created it.""" + """Stop pending requests, then close the HTTP session if this instance created it.""" try: + # Cancel in-flight requests first so their callers get empty audio + # instead of a connection error from the session teardown. + await self.stop_audio() if self._own_session and self._session and not self._session.closed: await self._session.close() finally: From 3c0ebef6782c842e52961c96d5a6c91b892777fd Mon Sep 17 00:00:00 2001 From: pepikir <90158120+pepikir@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:05:43 +0200 Subject: [PATCH 4/5] fix(audexum): keep caller cancellation on 3.10, name Audexum in WAV decode errors stream_audio() now waits with asyncio.wait(), which raises only when the caller is cancelled and leaves the request task alone, so a stop_audio() and a caller cancel in the same tick both behave on every Python version without Task.cancelling(). Truncated or non-WAV 200 responses raise a RuntimeError naming Audexum instead of a bare wave.Error/EOFError. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01No5BjeaLzSDP4LhEthEGit --- plugins/audexum/tests/test_tts.py | 22 +++++++-- .../vision_agents/plugins/audexum/tts.py | 48 +++++++++++-------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/plugins/audexum/tests/test_tts.py b/plugins/audexum/tests/test_tts.py index c6f8495d5a..15f05c24a5 100644 --- a/plugins/audexum/tests/test_tts.py +++ b/plugins/audexum/tests/test_tts.py @@ -78,7 +78,7 @@ async def handler(request: web.Request) -> web.Response: await tts.stop_audio() results = await asyncio.gather(first, second) assert [len(pcm.samples) for pcm in results] == [0, 0] - assert not tts._pending and not tts._stopped + assert not tts._pending finally: release.set() await tts.close() @@ -146,9 +146,6 @@ async def handler(request: web.Request) -> web.Response: release.set() await server.close() - @pytest.mark.skipif( - not hasattr(asyncio.Task, "cancelling"), reason="Task.cancelling() is 3.11+" - ) async def test_caller_cancel_in_same_tick_as_stop_propagates(self) -> None: release = asyncio.Event() @@ -204,6 +201,23 @@ async def handler(request: web.Request) -> web.Response: 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_http_error_raises(self) -> None: async def handler(request: web.Request) -> web.Response: return web.Response(status=402, text="out of credits") diff --git a/plugins/audexum/vision_agents/plugins/audexum/tts.py b/plugins/audexum/vision_agents/plugins/audexum/tts.py index 3f661db3cb..27d1f41a55 100644 --- a/plugins/audexum/vision_agents/plugins/audexum/tts.py +++ b/plugins/audexum/vision_agents/plugins/audexum/tts.py @@ -58,7 +58,6 @@ def __init__( self._own_session = session is None # Every in-flight synthesis, so stop_audio() can cancel concurrent calls. self._pending: set[asyncio.Task] = set() - self._stopped: set[asyncio.Task] = set() async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -76,22 +75,23 @@ async def stream_audio( task = asyncio.ensure_future(self._synthesize(text)) self._pending.add(task) try: - return await task + # asyncio.wait() raises only when *this* caller is cancelled and + # never cancels the inner task itself, so the two cases stay + # distinct on every Python version (no Task.cancelling(), 3.11+). + await asyncio.wait({task}) except asyncio.CancelledError: - # Swallow only a cancel that stop_audio() requested; the caller being - # cancelled itself must still propagate (Task.cancelling: 3.11+). - cancelling = getattr(asyncio.current_task(), "cancelling", None) - if task not in self._stopped or (cancelling is not None and cancelling()): - raise + task.cancel() + raise + finally: + self._pending.discard(task) + if task.cancelled(): # only stop_audio() cancels it return PcmData.from_numpy( np.zeros(0, dtype=np.int16), sample_rate=24000, channels=1, format=AudioFormat.S16, ) - finally: - self._pending.discard(task) - self._stopped.discard(task) + return task.result() async def _synthesize(self, text: str) -> PcmData: session = await self._get_session() @@ -113,22 +113,28 @@ async def _synthesize(self, text: str) -> PcmData: ) audio = await resp.read() - with wave.open(io.BytesIO(audio), "rb") as wf: - if wf.getsampwidth() != 2: - raise RuntimeError("Audexum TTS returned non 16-bit WAV audio") - samples = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16) - return PcmData.from_numpy( - samples, - sample_rate=wf.getframerate(), - channels=wf.getnchannels(), - format=AudioFormat.S16, - ) + try: + with wave.open(io.BytesIO(audio), "rb") as wf: + width = wf.getsampwidth() + frames = wf.readframes(wf.getnframes()) + rate, channels = wf.getframerate(), wf.getnchannels() + except (wave.Error, EOFError) as e: + raise RuntimeError( + f"Audexum TTS returned invalid WAV audio ({len(audio)} bytes): {e}" + ) from e + if width != 2: + raise RuntimeError("Audexum TTS returned non 16-bit WAV audio") + return PcmData.from_numpy( + np.frombuffer(frames, dtype=np.int16), + sample_rate=rate, + channels=channels, + format=AudioFormat.S16, + ) async def stop_audio(self) -> None: """Cancel every in-flight synthesis request; their output is discarded.""" for task in list(self._pending): if not task.done(): - self._stopped.add(task) task.cancel() logger.debug("Audexum TTS stop requested") From 2f95738387c8adef0e09f8f7070424c68cbfb600 Mon Sep 17 00:00:00 2001 From: pepikir <90158120+pepikir@users.noreply.github.com> Date: Sat, 14 Nov 2026 18:17:13 +0200 Subject: [PATCH 5/5] fix(audexum): reject truncated WAV audio instead of returning it as final A WAV whose data chunk is shorter than its header advertises was decoded and returned as complete speech. Compare the decoded byte count with the advertised frame count and raise, as suggested in review. --- plugins/audexum/tests/test_tts.py | 18 ++++++++++++++++++ .../vision_agents/plugins/audexum/tts.py | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/audexum/tests/test_tts.py b/plugins/audexum/tests/test_tts.py index 15f05c24a5..b9e3623c27 100644 --- a/plugins/audexum/tests/test_tts.py +++ b/plugins/audexum/tests/test_tts.py @@ -218,6 +218,24 @@ async def handler(request: web.Request) -> web.Response: 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") diff --git a/plugins/audexum/vision_agents/plugins/audexum/tts.py b/plugins/audexum/vision_agents/plugins/audexum/tts.py index 27d1f41a55..11e4cadd20 100644 --- a/plugins/audexum/vision_agents/plugins/audexum/tts.py +++ b/plugins/audexum/vision_agents/plugins/audexum/tts.py @@ -116,7 +116,8 @@ async def _synthesize(self, text: str) -> PcmData: try: with wave.open(io.BytesIO(audio), "rb") as wf: width = wf.getsampwidth() - frames = wf.readframes(wf.getnframes()) + frame_count = wf.getnframes() + frames = wf.readframes(frame_count) rate, channels = wf.getframerate(), wf.getnchannels() except (wave.Error, EOFError) as e: raise RuntimeError( @@ -124,6 +125,8 @@ async def _synthesize(self, text: str) -> PcmData: ) from e if width != 2: raise RuntimeError("Audexum TTS returned non 16-bit WAV audio") + if len(frames) != frame_count * width * channels: + raise RuntimeError("Audexum TTS returned truncated WAV audio") return PcmData.from_numpy( np.frombuffer(frames, dtype=np.int16), sample_rate=rate,