Metadata-Version: 2.5
Name: oddyai
Version: 0.0.2
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.2).** 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.pipecat import cached

store = MemoryStore()

tts = cached(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
)
```

Everything else in your pipeline stays the same.

---

## Two ways to demo it

### A. No setup — let the cache fill itself

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 is empty again.

### B. Preloaded — a HIT on the very first line

A demo clip ships inside the package:

```python
store.seed_demo(tts)     # bot must then speak oddyai.DEMO_TEXT
```

```python
>>> import oddyai
>>> oddyai.DEMO_TEXT
'hi, i am callingyou from futwork'
```

**Seed only after the pipeline is running.** The cache key includes the sample
rate, and that is `0` until Pipecat sends its StartFrame — a key built before
then is one the bot will never ask for. `seed()` raises rather than let that
happen silently:

```python
@transport.event_handler("on_client_connected")
async def _(transport, client):
    store.seed_demo(tts)
```

### Regenerating the demo clip

The bundled clip is raw PCM — 16-bit signed little-endian, mono, 16 kHz, no
header. To rebuild it with your own voice:

```bash
curl -X POST \
  "https://api.elevenlabs.io/v1/text-to-speech/$ELEVENLABS_VOICE_ID?output_format=pcm_16000" \
  -H "xi-api-key: $ELEVENLABS_API_KEY" -H "content-type: application/json" \
  -d '{"text":"hi, i am callingyou from futwork","model_id":"eleven_turbo_v2_5"}' \
  --output src/oddyai/assets/demo_16000.pcm
```

Then rebuild the wheel. Changing `DEMO_TEXT` invalidates any clip generated
against the old text, because the key is built from the text.

---

## 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()
# {'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() / seed_demo()
├── assets/
│   ├── __init__.py  loads the bundled clip from inside the package
│   └── demo_*.pcm   the clip itself
├── utils/
│   └── pcm.py       chunk_pcm() — split raw audio into frames
└── pipecat/
    ├── __init__.py  cached() — 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_demo_asset.py` | the bundled clip and the seed guard |
