Skip to main content

pydantic-ai-tts

Tests PyPI Python

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, 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:

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 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

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

pydantic_ai_tts-0.1.0.tar.gz (119.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pydantic_ai_tts-0.1.0-py3-none-any.whl (12.7 kB view details)

Uploaded Python 3

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

Hashes for pydantic_ai_tts-0.1.0.tar.gz
Algorithm Hash digest
SHA256 8a65dd87cb9d673178d950bb03314ca013c686ff4a96204ab7f5f7e964893e6a
MD5 37c066fd3b357d8c3dce9e0656dc4c69
BLAKE2b-256 c0fc8168cd16db70508e0ec14f6dbfee941f299c603ec1fb2c05a373e6989007

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydantic_ai_tts-0.1.0.tar.gz:

Publisher: publish.yml on ggozad/pydantic-ai-tts

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydantic_ai_tts-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for pydantic_ai_tts-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8c1ade89c97a20121ca7baca06b7e802393ff19dff0bb5ae8bc9e8489a2f75b4
MD5 836558e7a46713d8b661065ec6b2adec
BLAKE2b-256 3b9ae686b8aadce3fc00449d65a777afd8c7011cbec0dc6d42c21d277fd3b31c

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page