pydantic-ai-tts
Your pydantic-ai 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.
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:
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
uv add pydantic-ai-tts
Synthesis is piper-tts, 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 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, andSpeaks(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-dataexceeds 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-ttspulls inonnxruntime, 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, setORT_DISABLE_TELEMETRY=1before onnxruntime initializes. See onnxruntime'sPrivacy.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:
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;
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:
name: assistant
model: openai:gpt-4o
capabilities:
- Speaks: {} # default GLaDOS voice
- Speaks:
voice: en_GB-alan-medium # or any catalogue name, or a path
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:
class SpeechEngine(Protocol):
sample_rate: int
def synthesize(self, text: str) -> NDArray[np.float32]: ...
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:
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
Speaksshared 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
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.
Metadata
Release files for pydantic-ai-tts 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pydantic_ai_tts-0.1.0.tar.gz | 119.3 kB | With provenance | Details for pydantic_ai_tts-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pydantic_ai_tts-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pydantic_ai_tts-0.1.0-py3-none-any.whl |
Total release size: 131.9 kB
Release files / pydantic_ai_tts-0.1.0.tar.gz
| Download URL | pydantic_ai_tts-0.1.0.tar.gz |
|---|---|
| Size | 119.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8a65dd87cb9d673178d950bb03314ca013c686ff4a96204ab7f5f7e964893e6a
|
|
BLAKE2b-256 checksum How to use checksums |
c0fc8168cd16db70508e0ec14f6dbfee941f299c603ec1fb2c05a373e6989007
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency logRelease files / pydantic_ai_tts-0.1.0-py3-none-any.whl
| Download URL | pydantic_ai_tts-0.1.0-py3-none-any.whl |
|---|---|
| Size | 12.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c1ade89c97a20121ca7baca06b7e802393ff19dff0bb5ae8bc9e8489a2f75b4
|
|
BLAKE2b-256 checksum How to use checksums |
3b9ae686b8aadce3fc00449d65a777afd8c7011cbec0dc6d42c21d277fd3b31c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency logSecurity for pydantic-ai-tts 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pydantic_ai_tts-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.