Metadata-Version: 2.5
Name: pydantic-ai-tts
Version: 0.1.0
Summary: Speak pydantic-ai streamed output aloud, as it streams
Project-URL: Homepage, https://github.com/ggozad/pydantic-ai-tts
Project-URL: Repository, https://github.com/ggozad/pydantic-ai-tts
Project-URL: Issues, https://github.com/ggozad/pydantic-ai-tts/issues
Author-email: Yiorgis Gozadinos <ggozadinos@gmail.com>
License: MIT License
        
        Copyright (c) 2026 Yiorgis Gozadinos
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: agents,audio,glados,piper,pydantic-ai,speak,speech,text-to-speech,tts,voice
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: numpy>=1.26
Requires-Dist: piper-tts>=1.7.0
Requires-Dist: pydantic-ai-slim>=2.23.0
Requires-Dist: sounddevice>=0.5
Description-Content-Type: text/markdown

# pydantic-ai-tts

[![Tests](https://github.com/ggozad/pydantic-ai-tts/actions/workflows/test.yml/badge.svg)](https://github.com/ggozad/pydantic-ai-tts/actions/workflows/test.yml)
[![PyPI](https://img.shields.io/pypi/v/pydantic-ai-tts.svg)](https://pypi.org/project/pydantic-ai-tts/)
[![Python](https://img.shields.io/pypi/pyversions/pydantic-ai-tts.svg)](https://pypi.org/project/pydantic-ai-tts/)

Your [pydantic-ai](https://ai.pydantic.dev) agent has been producing text this entire
time. Silently. Into a terminal. Where no one could hear it.

We've fixed that.

This speaks an agent's output aloud, sentence by sentence, **while the model is still
streaming** — so it talks as it thinks, rather than delivering everything at the end like
some kind of coward.

```python
from pydantic_ai import Agent
from pydantic_ai_tts import Speaks

speaks = Speaks()
agent = Agent("openai:gpt-4o", capabilities=[speaks])

result = await agent.run("Why is the cake a lie?")
await speaks.wait()  # run() finishes long before the audio does
```

That is the entire API. Registering the capability switches `agent.run()` to streaming
internally, so nothing else about your code changes. You're welcome.

A `Speaks` runs two worker threads, started on the first sentence and kept for its
lifetime. In a script that's free — the process exits and takes them with it. In a
long-lived service that builds one per request it is a leak, so scope it instead:

```python
async with Speaks() as speaks:
    agent = Agent("openai:gpt-4o", capabilities=[speaks])
    await agent.run("Why is the cake a lie?")
    await speaks.wait()
```

Leaving the block finishes what's queued and then shuts the threads down. `await
speaks.aclose()` does the same thing by hand, as many times as you like.

## Installation

```sh
uv add pydantic-ai-tts
```

Synthesis is [piper-tts](https://github.com/OHF-Voice/piper1-gpl), which ships wheels for
macOS, Windows and Linux with espeak-ng already embedded. There is no system package to
install, no compiler to invoke, and no `brew` incantation to look up. Try to contain your
disappointment.

The default voice — GLaDOS, ~63MB, MIT — is fetched from
[Hugging Face](https://huggingface.co/rokeya71/VITS-Piper-GlaDOS-en-onnx) the first time
you use it, and cached in `$XDG_CACHE_HOME/pydantic-ai-tts` so we need never speak of
it again. Any other voice you name is cached beside it.

> **A note on licensing.** This package is MIT, but it depends on `piper-tts`, which is
> GPL-3.0, so installing it puts GPL code in your environment. No GPL code ships *in* this
> package, and `Speaks(engine=...)` lets you drive a different synthesizer entirely — see
> *Bringing your own engine* for a licence-clean option. You would still have piper
> installed, though, so if that is the problem, uninstall it and supply your own.

> **A note on deep install paths.** espeak-ng, embedded in `piper-tts`, stores its data
> directory in a fixed 160-character buffer. If `<site-packages>/piper/espeak-ng-data`
> exceeds that, synthesis kills the process with an error naming a path on piper's *build*
> machine. Measured: 146 characters works, 161 does not. Only deeply nested virtualenvs —
> some CI workspaces, sandbox directories — get near it.

> **A note on telemetry.** `piper-tts` pulls in `onnxruntime`, whose official builds
> collect trace events and send them to Microsoft. On macOS and Linux this uses the 1DS
> SDK over HTTPS and is **on by default**. Nothing here is otherwise networked once the
> voice is cached, so if you came for an offline synthesizer, set
> `ORT_DISABLE_TELEMETRY=1` before onnxruntime initializes. See onnxruntime's `Privacy.md`.

## Choosing a voice

GLaDOS is the default, and objectively the correct choice. Should you disagree, `voice`
accepts any of piper's **175 catalogue voices** (38 of them English) by name, or a path to
a `.onnx` file you already have:

```python
Speaks()  # GLaDOS
Speaks(voice="en_US-lessac-medium")  # any catalogue voice, fetched on first use
Speaks(voice="/voices/turret.onnx")  # a model you supply
```

Named voices are downloaded through piper itself and cached alongside GLaDOS. The full
catalogue is at [rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices);
names follow `<language>-<name>-<quality>`, and a name that doesn't will be rejected
before anything touches the network.

## How it works

```
model deltas  →  sentence splitter  →  synth thread  →  playback thread
```

The event handler sits on the agent's stream and back-pressures it, which means it is not
permitted to do anything slow. So it doesn't. It buffers text, finds sentence endings, and
drops finished sentences into a queue. Synthesis and playback each get their own thread,
so sentence *n+1* is being synthesized while *n* is still being said. The alternative was
a small pause between every sentence, which would have been noticeable, and irritating,
and therefore unacceptable.

Sentence boundaries are found by scanning the **accumulated buffer**, not by inspecting
each delta in isolation. This is not pedantry. Model deltas are arbitrary substrings:
`"world."` frequently arrives as a single token, and `3.14` must never be mistaken for the
end of a thought. Punctuation only counts as a boundary when whitespace follows it, which
solves both problems at once and cost eleven lines. Several well-known implementations get
this wrong. We won't name them.

## Declaring it in a spec

`Speaks` is spec-constructible, for those who prefer their agents in YAML:

```yaml
name: assistant
model: openai:gpt-4o
capabilities:
  - Speaks: {}                          # default GLaDOS voice
  - Speaks:
      voice: en_GB-alan-medium          # or any catalogue name, or a path
```

```python
agent = Agent.from_spec(spec, custom_capability_types=[Speaks])
```

A live engine object cannot be expressed in YAML, so `voice` is the only knob here.
Anything more elaborate requires `Speaks(engine=...)`, below, and a keyboard.

## Bringing your own engine

Voices are one thing; the whole synthesizer is another. `Speaks(engine=...)` accepts
anything satisfying:

```python
class SpeechEngine(Protocol):
    sample_rate: int

    def synthesize(self, text: str) -> NDArray[np.float32]: ...
```

[dnhkng/GLaDOS](https://github.com/dnhkng/GLaDOS) already fits, and deserves credit: it
replaces espeak-ng with an ONNX phonemizer, making it MIT with no GPL anywhere in the
dependency tree. It isn't on PyPI, so you'll need the repo importable:

```python
from glados.TTS import Synthesizer


class GladosSpeech:
    def __init__(self):
        self._tts = Synthesizer()
        self.sample_rate = self._tts.sample_rate

    def synthesize(self, text):
        return self._tts.generate_speech_audio(text).squeeze()


speaks = Speaks(engine=GladosSpeech())
```

Nothing prevents you from supplying some other voice here. Nothing except good judgement.

## Things you should know before testing begins

- **Speech outlives the run, deliberately.** `agent.run()` returns mid-sentence. `await
  speaks.wait()` blocks until the queue drains — optimistic by rather less than one device
  latency, which no one has ever noticed.
- **One `Speaks` shared across concurrent runs will interleave their sentences.** The
  result is confusing for everyone. Use two.
- **Tool calls and thinking parts are not spoken.** Text output only. The model's inner
  monologue is not for you.
- **`*asterisked asides*` are stripped; parentheses are not.** Models emit `*sighs*` as
  performance, but `(42)` as content, and silently swallowing the second to catch the
  first would be a poor trade.
- **Failures are logged, not fatal.** If synthesis or playback breaks, the pipeline keeps
  draining, so a machine with no audio device degrades to silence instead of hanging
  forever. Silence is, after all, the control condition.

## Development

```sh
uv sync
uv run pytest                   # unit tests
uv run pytest -m integration --no-cov   # needs the voice model and a speaker
uv run ruff check . && uv run ty check
```

The unit tests hold 100% coverage and require neither the model nor an audio device. The
integration tests require both, and will skip themselves rather than fail if the voice
hasn't been downloaded — a courtesy that was not strictly necessary.
