Metadata-Version: 2.4
Name: elvenreader
Version: 0.3.0
Summary: CLI and library for the ElevenReader TTS API
Project-URL: Homepage, https://github.com/bmf/elvenreader
Project-URL: Repository, https://github.com/bmf/elvenreader
Author: Brandon Fryslie
License-Expression: MIT
License-File: LICENSE
Keywords: audiobook,elevenlabs,elvenreader,text-to-speech,tts
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Requires-Python: >=3.12
Requires-Dist: click>=8
Requires-Dist: httpx>=0.27
Requires-Dist: keyring>=25
Requires-Dist: truststore>=0.10
Requires-Dist: websockets>=13
Description-Content-Type: text/markdown

# elvenreader

Python library and CLI for the ElevenReader TTS API. Designed for on-demand,
throwaway TTS — give it text, get back audio bytes and word-level timings.

Built to drive voice acting for LLM-based tools where every line is dynamically
generated and disposable. Auto-deletes library entries after streaming so you
don't accumulate junk.

## Install

```bash
pip install elvenreader
```

Requires macOS (uses the Keychain for credentials, `afplay` for CLI playback,
and Chrome for the one-time App Check token capture). The `compose_scene`
multi-voice primitive additionally requires `ffmpeg` on PATH.

## Quick start

```bash
elvenreader login      # Stores email + password in Keychain
elvenreader app-check  # Launches Chrome, captures App Check token (~7 day lifetime)
```

Then, from Python:

```python
import asyncio
from elvenreader import ElvenReader

async def main():
    async with ElvenReader.from_keychain() as reader:
        result = await reader.read("A warm wind blows through the tavern.", voice="brian")

        # result.audio: bytes (MP3)
        # result.alignment.total_ms: int
        # result.alignment.words: list[WordTiming]
        # result.alignment.chars: list[CharTiming]

        with open("line.mp3", "wb") as f:
            f.write(result.audio)

asyncio.run(main())
```

The read is auto-deleted from your ElevenReader library after streaming. Pass
`keep=True` to retain it.

## Python API

### Reading text

```python
async with ElvenReader.from_keychain() as reader:
    # One-shot: collect everything into memory
    result = await reader.read(text, voice="brian")
    result.save_audio("out.mp3")
    result.save_alignment_srt("out.srt")

    # Streaming: process chunks as they arrive (lower latency)
    async for chunk in reader.read_stream(text, voice="brian"):
        speaker.feed(chunk.audio)
        if chunk.alignment:
            update_karaoke(chunk.alignment)

    # Convenience wrapper
    await reader.read_to_file(text, "out.mp3",
                              align_srt_path="out.srt",
                              align_json_path="out.json")
```

### Timings

Every read comes with per-character and per-word timing data extracted from the
streaming WebSocket:

```python
result = await reader.read("Hello world.")

for word in result.alignment.words:
    print(f"{word.word:10s} {word.start_ms:>5}ms - {word.end_ms:>5}ms")
# Hello          0ms -   325ms
# world.       372ms -   708ms

for c in result.alignment.chars:
    print(c.char, c.start_ms, c.duration_ms, c.end_ms)
```

Use these for karaoke-style highlighting, phoneme-synced animation, subtitle
generation, or lining up audio with visual events.

### Voices

```python
voices = await reader.list_voices()
voice_id = await reader.resolve_voice("brian")  # fuzzy match by name

# Design a new voice from a text description
previews = await reader.create_voice_previews("warm British male, authoritative")
voice = await reader.save_voice_from_preview(
    generated_voice_id=previews[0]["generated_voice_id"],
    voice_name="Storyteller",
    description="warm British male, authoritative",
)
```

### Multi-voice scenes

For dialogue-heavy content (game scenes, audiobooks with character voices),
use `compose_scene` to generate a single combined MP3 from an ordered list of
segments. Each segment has its own voice.

```python
from elvenreader import SceneSegment, PauseConfig

async with ElvenReader.from_keychain() as reader:
    result = await reader.compose_scene(
        [
            SceneSegment(text="She approached the desk.", voice_id=narrator),
            SceneSegment(text="Show me your papers.",     voice_id=clerk),
            SceneSegment(text="He hesitated.",            voice_id=narrator),
        ],
        narrator_voice=narrator,
        pause=PauseConfig(
            entering_overlay_ms=400,  # narrator → character
            leaving_overlay_ms=200,   # character → narrator
            overlay_to_overlay_ms=300,
            same_voice_ms=0,
        ),
        loudnorm=True,  # normalize volume across all pieces
    )

    result.audio                 # bytes (MP3, single file)
    result.total_duration_ms     # int
    result.segments              # list[RenderedSegment] — per-segment start_ms/end_ms
    result.narrator_alignment    # CompletedAlignment from the continuous narrator read
```

**Why this exists.** Generating many short clips individually makes each one a
cold start for the voice model — short narrator lines sound inconsistent and
lose prosody continuity. `compose_scene` sends the entire scene through ONE
narrator read, then slices out the narrator-voiced segments using the per-char
alignment data. Character-voiced segments still get their own calls (you want
those voices distinct). All pieces are concatenated with configurable silence
at speaker transitions and optional loudnorm volume normalization.

**Requires `ffmpeg` on PATH.**

### Library hygiene

Every `reader.read()` creates a library entry server-side and auto-deletes it
when streaming completes. If you end up with accumulated entries (e.g. from
crashes):

```python
await reader.clean_reads(max_words=50)     # delete short user imports
await reader.clean_reads(delete_all=True)  # delete every user import
```

### Auth helpers

```python
from elvenreader import login, logout, refresh_app_check_token

await login("me@example.com", "password")  # persists to Keychain
await refresh_app_check_token()            # launches Chrome, runs CDP automation
logout()                                   # wipes Keychain entries
```

## CLI

The CLI is a thin wrapper over the Python API — every command has a Python
equivalent. Useful for ad-hoc testing.

```bash
elvenreader login                               # Store credentials
elvenreader app-check                           # Refresh App Check token
elvenreader voices                              # List voices

# Read text
elvenreader read "Hello" --play                 # Stream playback
elvenreader read "Hello" --output out.mp3       # Save audio
elvenreader read "Hello" --align-srt out.srt    # Save subtitles
elvenreader read --file script.txt -p -o out.mp3 --align-json out.json --voice george

# Library
elvenreader list-reads
elvenreader clean-reads --dry-run
elvenreader clean-reads                         # delete short imports
elvenreader clean-reads --all                   # delete every user import

# Voice design
elvenreader create-voice "deep, raspy noir narrator" --name "Detective"

elvenreader logout
```

Reads auto-delete after streaming unless `--keep` is passed.

## How it works

The streaming API requires two auth artifacts:

1. **Firebase ID token** — obtained from email/password via the standard
   Firebase auth endpoint. Refreshed automatically.
2. **App Check token** — issued by Firebase App Check to verify requests come
   from the ElevenReader web app. Not exposed via any public API. We obtain it
   by automating Chrome via CDP: launching a headless-ish browser, signing in,
   clicking play on a dummy read, and intercepting the token from the
   WebSocket subprotocol list.

Tokens live in the macOS Keychain. The App Check token is valid for roughly 7
days; re-run `elvenreader app-check` to refresh it. The HTTP client shares a
connection pool across the whole process and automatically retries on 429
responses with exponential backoff.

## Design notes

- **Dataflow-first**: the same operations run for every read. Variability lives
  in values (voice, text, keep flag), not in which branches execute.
- **Single HTTP client**: one shared `httpx.AsyncClient` per process with a
  retry transport. Every module routes through `http.shared_client()`.
- **Single enforcer**: auth is encoded into the WebSocket subprotocol list in
  exactly one place; rate-limit handling is in the transport layer only.

## License

MIT
