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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pydantic_ai_tts-0.1.0.tar.gz.
File metadata
- Download URL: pydantic_ai_tts-0.1.0.tar.gz
- Upload date:
- Size: 119.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8a65dd87cb9d673178d950bb03314ca013c686ff4a96204ab7f5f7e964893e6a
|
|
| MD5 |
37c066fd3b357d8c3dce9e0656dc4c69
|
|
| BLAKE2b-256 |
c0fc8168cd16db70508e0ec14f6dbfee941f299c603ec1fb2c05a373e6989007
|
Provenance
The following attestation bundles were made for pydantic_ai_tts-0.1.0.tar.gz:
Publisher:
publish.yml on ggozad/pydantic-ai-tts
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pydantic_ai_tts-0.1.0.tar.gz -
Subject digest:
8a65dd87cb9d673178d950bb03314ca013c686ff4a96204ab7f5f7e964893e6a - Sigstore transparency entry: 2677362965
- Sigstore integration time:
-
Permalink:
ggozad/pydantic-ai-tts@54d6cd390013d27cbcaf672084fc0ea599a2eb5c -
Branch / Tag:
refs/tags/0.1.0 - Owner: https://github.com/ggozad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@54d6cd390013d27cbcaf672084fc0ea599a2eb5c -
Trigger Event:
release
-
Statement type:
File details
Details for the file pydantic_ai_tts-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pydantic_ai_tts-0.1.0-py3-none-any.whl
- Upload date:
- Size: 12.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c1ade89c97a20121ca7baca06b7e802393ff19dff0bb5ae8bc9e8489a2f75b4
|
|
| MD5 |
836558e7a46713d8b661065ec6b2adec
|
|
| BLAKE2b-256 |
3b9ae686b8aadce3fc00449d65a777afd8c7011cbec0dc6d42c21d277fd3b31c
|
Provenance
The following attestation bundles were made for pydantic_ai_tts-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on ggozad/pydantic-ai-tts
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pydantic_ai_tts-0.1.0-py3-none-any.whl -
Subject digest:
8c1ade89c97a20121ca7baca06b7e802393ff19dff0bb5ae8bc9e8489a2f75b4 - Sigstore transparency entry: 2677362982
- Sigstore integration time:
-
Permalink:
ggozad/pydantic-ai-tts@54d6cd390013d27cbcaf672084fc0ea599a2eb5c -
Branch / Tag:
refs/tags/0.1.0 - Owner: https://github.com/ggozad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@54d6cd390013d27cbcaf672084fc0ea599a2eb5c -
Trigger Event:
release
-
Statement type: