Metadata-Version: 2.5
Name: oddyai
Version: 0.0.3
Summary: TTS audio caching for voice agents — serve repeated lines from cache instead of paying a TTS vendor twice.
Project-URL: Homepage, https://bitbucket.org/futwork/oddy-ai-py
License-Expression: MIT
License-File: LICENSE
Keywords: cache,elevenlabs,pipecat,tts,voice-agent
Requires-Python: >=3.11
Requires-Dist: loguru>=0.7.0
Requires-Dist: pipecat-ai<2.0.0,>=1.0.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# oddyai

TTS audio caching for Pipecat voice agents.

**Test build (0.0.3).** One sentence at a time. It proves the HIT / MISS logic
and captures live audio into an in-memory store. Sentence ordering across a
multi-sentence reply is not in this build — see *Known limits*.

---

## What you will see

```
[oddy] HIT    hi, i am callingyou from futwork
[oddy] MISS   your appointment is confirmed for thursday
[oddy] STORED 57600 bytes  your appointment is confirmed for thursday
```

A HIT costs nothing and never reaches ElevenLabs. A MISS is spoken live exactly
as today, then saved so the next time is free.

**These are INFO logs.** If your bot filters INFO you will see nothing and
conclude it is broken.

---

## Install

```bash
pip install oddyai
```

Python 3.11+. Pipecat comes with it — no extras to remember. Built and tested
against pipecat-ai 1.7.0.

---

## Use it

Two changes to your bot: wrap the TTS class, pass a store.

```python
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
from oddyai import MemoryStore
from oddyai.tts.pipecat import EnableTTSCache

store = MemoryStore()

tts = EnableTTSCache(ElevenLabsTTSService)(      # <- wrap
    api_key=os.environ["ELEVENLABS_API_KEY"],
    settings=ElevenLabsTTSService.Settings(
        voice=os.environ["ELEVENLABS_VOICE_ID"],
        model=os.environ["ELEVENLABS_MODEL"],
    ),
    sample_rate=16000,
    store=store,                             # <- and pass the store
    provider_name="elevenlabs",             # <- label, no behaviour yet
)
```

Everything else in your pipeline stays the same.

---

## Demo it

Make the bot say the same line **twice in one call**:

1. first time → `MISS`, then `STORED n bytes`
2. second time → `HIT`

The store lives in memory, so both must happen in the **same bot process**.
Restart the bot and it starts empty again.

### Preloading by hand (optional)

To get a HIT on the very first line of a call, put audio in yourself with
`store.seed(tts, text, pcm_bytes)` — raw 16-bit mono PCM at the bot's sample
rate. **Seed only after the pipeline is running**: the cache key includes the
sample rate, which is `0` until Pipecat starts, and `seed()` raises rather
than build a key the bot will never ask for:

```python
@transport.event_handler("on_client_connected")
async def _(transport, client):
    store.seed(tts, "hi, i am callingyou from futwork", pcm_bytes)
```

---

## Known limits in this build

Read these before reporting a bug.

**1. One sentence per reply.** Pipecat gives every sentence in a reply the same
context id. A reply mixing cached and live sentences can play **out of order**:
cached audio is instant, live audio takes ~250 ms, so a later cached sentence
can overtake an earlier live one.

**2. A reply with two live sentences stores nothing.** Both land in one bucket
and their audio mixes. We discard rather than save a wrong recording.

**3. The store is written when a sentence FINISHES, not when it is requested.**
The same line twice back-to-back *in one reply* misses both times. A turn later
it hits. Expected.

**4. Memory only.** Restart the bot and the store is empty.

**5. Interrupted sentences are not stored.** A half sentence saved once would
play cut off on every future call, forever. Not caching it costs one extra
synthesis.

**6. Sample rate must match.** 8 kHz audio is not served into a 16 kHz pipeline
— it is treated as a miss, so you never hear a chipmunk.

---

## Stats

```python
tts.get_stats()
# {'provider_name': 'elevenlabs', 'hits': 3, 'misses': 1, 'stored': 1,
#  'hit_rate': 0.75, 'store_size': 4}
```

A summary line is logged when the call ends.

---

## Layout

```
src/oddyai/
├── __init__.py      what you import
├── cache_key.py     sentence -> "v1.elevenlabs.9f3a2c…"
├── store.py         MemoryStore — the dict, plus seed()
├── utils/
│   └── pcm.py       chunk_pcm() — split raw audio into frames
└── tts/
    └── pipecat/
        ├── __init__.py  EnableTTSCache() — the one-line wrapper
        └── mixin.py     the three hooks
```

**The three hooks in `mixin.py`:**

| Hook | Job |
|---|---|
| `run_tts` | is this cached? HIT or MISS |
| `push_frame` | collect the live audio, save it when the sentence ends |
| `_handle_interruption` | throw away half-finished audio |

`push_frame` is where capture happens because ElevenLabs over a websocket
replies "ok" instantly and sends **no** audio — it arrives ~250 ms later on a
separate path, long after `run_tts` has returned. `push_frame` is the one place
every audio frame passes through, for websocket and HTTP services alike.

---

## Tests

```bash
uv sync --extra dev          # or: pip install -e ".[dev]"
uv run pytest tests/ -q
```

65 tests. No ElevenLabs key, no network, no server.

| File | Covers |
|---|---|
| `test_cache_key.py` | key stability — the contract that makes seeding work |
| `test_pcm.py` | chunk alignment |
| `test_cache_flow.py` | HIT / MISS / STORE through a **real Pipecat pipeline** |
| `test_seed_guard.py` | the seed guard and the compatibility names |
