echospeaks
Sentence-level TTS audio cache for pipecat
voice pipelines. Audio lives in echo-server (the pod-shared cache service;
it keeps its own L1/L2 internally) - there is no client-side store. Caching is
enabled by passing an api_key in cache_config; without it everything
passes through untouched and the pipeline behaves fully stock. To get a key,
visit echospeaks.ai and contact us.
Layout
echospeaks/
defaults.py every SDK-wide tunable default, in one place
common/ shared dataclasses (CachedAudio, AudioMeta) + logger shim
cache/ keying (blake2b-128 cache keys + text normalization) and
echo_server.py - the echo-server client (the one audio store)
pipecat_tts/ pipeline integration - read.py (EchoTTSCacheRead), write.py
(EchoTTSCacheWrite), plan.py (shared turn plan + pairing);
needs pipecat-ai
How it works
Two pipeline processors around the host's completely stock TTS service:
- Read -
EchoTTSCacheRead(echospeaks.pipecat_tts.read): placed before the tts. Aggregates LLM tokens into sentences (the TTS receives pre-aggregated sentences, so its own aggregator never engages) and asks echo-server per sentence (0.5s bounded) - a hit is swallowed (the TTS never sees it); a miss flows through and is registered in the shared turn plan for capture. - Write -
EchoTTSCacheWrite(echospeaks.pipecat_tts.write): placed right after the tts. Slices each miss sentence's audio out of the turn's shared provider context by word timestamps and fire-and-forgets a POST to echo-server (audio base64-encoded inside the v1 JSON envelope; decoded on fetch), and injects cached hit audio at the exact sentence boundary so sentences always play in LLM order. Interruptions discard partial buffers, so truncated audio is never stored.
Standalone utterances (TTSSpeakFrame) are cached too - welcome
messages, silence prompts, closing lines: any text the host queues directly
instead of generating via the LLM. Each one is a single cache entry over the
FULL utterance text (the stock TTS synthesizes a TTSSpeakFrame as one
context, so capture and replay are whole-utterance). Condition ⇒ action:
miss ⇒ the original frame is forwarded and the utterance is captured and
stored; hit ⇒ the frame is swallowed, the writer plays the cached audio and
closes the utterance itself (assistant-context push honoring
append_to_context, then TTSStoppedFrame), so bot started/stopped timing
matches a live utterance exactly. Since these texts repeat on every call,
they are usually the highest-value entries in the cache.
echo-server behavior is strictly fail-open: the lookup is bounded by a 0.5s timeout and every error (down, slow, 401, 5xx) degrades to a plain miss
- a dead cache server costs money, never quality. The 4xx
invalid_*family is logged as an error instead of swallowed (it means this library sent something malformed).
Cache keys (echospeaks.cache.keying) are blake2b-128 hex (32 chars - the
audio_key format echo-server validates) over normalized text + provider +
voice + model + sample rate, so whitespace/casing never causes a false miss
and two voices can never serve each other's audio. Normalization is for key
matching only - synthesis always uses the real text.
Install
pip install echospeaks # core (no pipecat needed)
pip install "echospeaks[pipecat]" # with the pipecat pipeline integration
Usage
The TTS service stays completely stock: the cache is two pipeline processors around it. The read side aggregates LLM tokens into sentences (so the TTS receives pre-aggregated sentences and its own aggregator never engages), swallows cache hits, and forwards misses; the paired writer captures each miss sentence's audio for storing and injects cached hit audio at the exact sentence boundary, so sentences always play in LLM order for any hit/miss pattern.
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
from echospeaks.pipecat_tts import EchoTTSCacheRead, EchoTTSCacheWrite
tts = ElevenLabsTTSService( # untouched, fully stock
api_key=ELEVENLABS_API_KEY,
settings=ElevenLabsTTSService.Settings(model="eleven_turbo_v2_5", voice=VOICE_ID),
)
EchoTTSCacheRead.configure({ # task-local: safe across
"api_key": ECHO_SERVER_API_KEY, # concurrent calls in one
"provider": "elevenlabs", # process. Keying inputs —
"voice_id": VOICE_ID, # the processor cannot
"model_id": "eleven_turbo_v2_5", # introspect the service,
"tag": AGENT_ID, # so the host states them.
})
pipeline = Pipeline([
...,
llm,
EchoTTSCacheRead(), # picks up the configure()d config
tts,
EchoTTSCacheWrite(), # pairs itself with the read upstream at StartFrame
transport.output(),
...,
])
EchoTTSCacheRead(cache_config={...}) also works for passing the config
explicitly. EchoTTSCacheRead.current() returns this call's instance, whose
stats property exposes the raw counters for the host's own logs/metrics:
s = EchoTTSCacheRead.current().stats
# {"hits": 6, "misses": 3, "hit_chars": 214, "miss_chars": 101,
# "total_chars": 315, "total_sentences": 9, "hit_rate": 0.679}
log.info(f"cache hits={s['hits']} misses={s['misses']} hit_rate={s['hit_rate']:.0%}")
hit_rate is character-weighted (characters are what synthesis costs), 0..1.
Without an api_key (or the keying inputs) everything passes through
untouched. Segmentation and in-order injection use word timestamps;
ElevenLabs and Cartesia both provide them. Without word timestamps the writer
degrades gracefully: single-sentence turns still store, mid-turn hits inject
at turn end.
TTSSpeakFrames (welcome / silence / closing texts) need no extra wiring -
they pass through the same read processor and are cached whole. One trade-off
to know: a speak utterance now waits for its lookup before synthesis starts,
so its time-to-first-audio shifts by the lookup round trip (~60-250ms,
bounded at 0.5s fail-open) on a miss - and drops the synthesis entirely on a
hit.
cache_config reference
One dict holds every host tunable. Missing or None keys fall back to the
defaults in echospeaks/defaults.py; unknown keys are warned about and
ignored (never a crash).
| Key | Default | What it does |
|---|---|---|
api_key |
(unset) | echo-server API key (Authorization: Bearer). Setting it enables the cache; unset = caching disabled, service runs fully stock. Process-wide. Visit echospeaks.ai to contact us and get a key. |
provider / voice_id / model_id |
(unset) | Identify the downstream TTS for cache keying — the processor cannot introspect the service, so the host states them. Missing any of them disables caching. |
cache_settings |
(unset) | Optional dict of audio-affecting synthesis settings folded into the cache key (speed, stability, ...). |
replay_chunk_bytes |
960 |
Hit-replay slice size (~30 ms @ 16 kHz mono s16le). Smaller = finer barge-in stop granularity, more frame overhead. Sample-aligned and clamped automatically. |
tag |
(unset) | Optional grouping label (an agent id, a campaign, a tenant of your own) attached to every store. echo-server records it per entry, so listings can be filtered with GET /stats/{l1|l2}?tag=. It is NOT part of the cache key: the same sentence stays one shared entry no matter which tag stored it (the latest store's tag wins). Normalized to lowercase [a-z0-9-], max 32 chars; an unusable tag is dropped with a warning, never an error. |
debug |
False |
Enables the verbose per-frame DEBUG logs on the write side: word attribution (word 'x' -> sentence n/m), hold transitions (hold ON/hold OFF), cut boundaries (cut at byte=), and the per-turn finalize plan= summary. INFO-level HIT/MISS/stored/injected lines are always on. |
How the dict is sourced is the host's business - env vars, a config file, a per-tenant document; the SDK only sees the final dict.
Tests
uv sync && uv run pytest
Includes an HTTP round-trip test against an in-process fake echo-server (auth, miss, store, hit, delete, fail-open).
Metadata
Release files for echospeaks 0.3.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 |
|---|---|---|---|---|
| echospeaks-0.3.0.tar.gz | 29.8 kB | Without provenance | Details for echospeaks-0.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| echospeaks-0.3.0-py3-none-any.whl | Python 3 | none | any | Details for echospeaks-0.3.0-py3-none-any.whl |
Total release size: 57.4 kB
Release files / echospeaks-0.3.0.tar.gz
| Download URL | echospeaks-0.3.0.tar.gz |
|---|---|
| Size | 29.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cd08a229c2f4f541df52958ac718bcc2644de1d391c5efd769e76deaf7c30be9
|
|
BLAKE2b-256 checksum How to use checksums |
97612b4323feb49b9237015aa65d2ab89634d72f906689acd0af85ac74ee61bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.5
|
Release files / echospeaks-0.3.0-py3-none-any.whl
| Download URL | echospeaks-0.3.0-py3-none-any.whl |
|---|---|
| Size | 27.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5ea2955488b3d15b8da829d986246d0ffc687ab25b40f8771f77521b400268e5
|
|
BLAKE2b-256 checksum How to use checksums |
1e313ad3baa97987e5131fa064a95e80f3f0c634c66a383d7927fbbc350410db
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.9.5
|
Security for echospeaks 0.3.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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A 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.