Metadata-Version: 2.5
Name: echospeaks
Version: 0.2.2
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. The tier
is enabled by passing an `api_key` in `cache_config`; without it the wrapped
service runs fully stock (no caching, no per-sentence contexts). 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
                remote.py - the echo-server client (the one audio store)
  pipecat_tts/  pipeline integration - mixin.py (read), capture.py (write),
                pending.py (read→write handoff); needs pipecat-ai
```

## How it works

Two pipeline pieces, wired around the host's existing TTS service:

- **Read - `EnableTTSCache`** (`echospeaks.pipecat_tts.mixin`): drop-in
  replacement for instantiating the provider class. Every sentence's
  `run_tts` asks echo-server (0.5s bounded) - a **hit** replays the cached
  PCM + word timestamps with zero provider traffic (barge-in aware); a
  **miss** synthesizes live and marks the sentence for capture.
- **Write - `TTSCacheCapture`** (`echospeaks.pipecat_tts.capture`): a
  pass-through processor placed right after the TTS service in the pipeline.
  It buffers each miss-marked sentence's audio + word timings and
  fire-and-forgets a POST to echo-server (audio base64-encoded inside the v1
  JSON envelope; decoded on fetch). Interruptions discard partial buffers,
  so truncated audio is never stored.

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

Provider specifics handled by the wrapper:

- One provider context **per sentence**, closed right after its text is sent
  (dialect-detected: ElevenLabs `_close_context`, Cartesia `flush_audio` with
  `continue: false`; close is graceful - audio flushes, then the final).
- A client-side gate caps simultaneous contexts (default 4 - ElevenLabs kills
  the socket at >5). Slots free on the context's final, interruption, or
  websocket reconnect.
- Cache hits take no context and no gate slot; the replayed context is
  completed explicitly so the sentence serializer never stalls.

## Install

```bash
pip install echospeaks              # core (no pipecat needed)
pip install "echospeaks[pipecat]"  # with the pipecat pipeline integration
```

## Usage

```python
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
from echospeaks.pipecat_tts import EnableTTSCache, TTSCacheCapture

# read side: instead of ElevenLabsTTSService(...). Works the same for any
# subclass of a supported provider service.
tts = EnableTTSCache(ElevenLabsTTSService,
    api_key=ELEVENLABS_API_KEY,                      # the TTS provider's key
    cache_config={
        "api_key": ECHO_SERVER_API_KEY,              # echo-server key: caching on
        "tag": AGENT_ID,                             # optional: label stores for filtered listings
    },
    settings=ElevenLabsTTSService.Settings(model="eleven_turbo_v2_5", voice=VOICE_ID),
)

# or Cartesia:
from pipecat.services.cartesia.tts import CartesiaTTSService

tts = EnableTTSCache(CartesiaTTSService,
    api_key=CARTESIA_API_KEY,
    cache_config={"api_key": ECHO_SERVER_API_KEY},
    settings=CartesiaTTSService.Settings(model="sonic-3", voice=VOICE_ID),
)

# write side: right after the TTS service in the pipeline.
pipeline = Pipeline([..., tts, TTSCacheCapture(), transport.output(), ...])
```

The wrapped service exposes `get_and_reset_turn_stats()` and
`log_cache_summary(call_sid=...)` for call logs. Skip both pieces and the
provider runs fully stock.

## 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. |
| `max_concurrent_context` | `4` | Gate cap on simultaneous provider contexts per service. ElevenLabs hard-kills the socket at >5 (per websocket); Cartesia's limit is account-wide per plan - size this to `plan_concurrency ÷ concurrent calls`. |
| `sentence_wait_timeout_s` | `3.0` | Longest a sentence waits at a full gate before stale slots are reclaimed (a context's final normally arrives 150–300 ms after close). |
| `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. |
| `pending_max_entries` | `512` | Bound on the read→write capture handoff registry (process-wide; entries for contexts that never finish age out). |
| `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. |

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