Metadata-Version: 2.5
Name: echospeaks
Version: 0.3.0
Summary: Sentence-level TTS audio cache for pipecat voice pipelines, backed by echo-server (EnableTTSCache + TTSCacheCapture)
Author-email: Futwork <akash@futwork.com>
License: Proprietary
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.9.0
Provides-Extra: pipecat
Requires-Dist: pipecat-ai>=1.0.0; extra == 'pipecat'
Description-Content-Type: text/markdown

# echospeaks

**[echospeaks.ai](https://echospeaks.ai/)**

Sentence-level TTS audio cache for [pipecat](https://github.com/pipecat-ai/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](https://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

```bash
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.

```python
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:

```python
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.

`TTSSpeakFrame`s (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](https://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

```bash
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).
