Metadata-Version: 2.4
Name: zeli-tts
Version: 0.3.1
Summary: ZeliSpeech Python SDK — self-hosted, low-latency streaming text-to-speech.
Author: Zeligate
License: MIT
Project-URL: Homepage, https://github.com/zelibot/zeli-tts-v2
Project-URL: Documentation, https://voice.zeligate.com
Project-URL: Source, https://github.com/zelibot/zeli-tts-v2
Keywords: tts,text-to-speech,streaming,speech,voice,zeli,zelispeech
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.28
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"

# ZeliSpeech — Python SDK

A small, dependency-light client for [ZeliSpeech](https://voice.zeligate.com) —
self-hosted, low-latency streaming text-to-speech. Point it at your running
server and get audio back in a couple of lines. The SDK talks the same
authenticated `/v1` API documented in the
[API reference](https://voice.zeligate.com/api-reference/text-to-speech/), so
the request/response shapes follow mainstream TTS-API conventions.

## Install

```bash
pip install zeli-tts
```

Runtime dep: `requests`. Live playback (`play`/`stream`) shells out to `ffplay`
(ffmpeg), `mpv`, or `afplay`.

## Quickstart

```python
from zeli_tts import ZeliSpeech, play, save, stream

client = ZeliSpeech(
    base_url="https://voice.zeligate.com",
    api_key="sk-zeli-...",                    # optional if the box runs open
)

# 1) Stream — first audio after the first sentence, not the whole passage
audio = client.text_to_speech.stream(
    voice_id="zeli-voice-1",
    text="Hey there, this is Zeli.",
    output_format="mp3_44100_128",
)
for chunk in audio:
    ...                # audio bytes as they generate

# 2) Stream + play live (needs ffplay or mpv)
stream(client.text_to_speech.stream(voice_id="zeli-voice-1", text="Playing as I generate."))

# 3) One-shot — a finished clip
audio = client.text_to_speech.convert(voice_id="zeli-voice-1", text="Hello world.")
save(audio, "hello.mp3")
```

## Client

```python
ZeliSpeech(base_url, *, api_key=None, timeout=60.0)
```

- `base_url` — `http://host:8000` or `https://voice.zeligate.com`; do **not**
  include a trailing `/v1`.
- `api_key` — sent as `Authorization: Bearer …` on every request. Required only
  when the box sets `ZELI_API_KEY`; a loopback/embedded box can run open.
- `client.capabilities()` / `client.health()` / `client.is_ready()` — engine
  info and readiness (handy while a box is warming up).

## Synthesis — `client.text_to_speech`

```python
stream(voice_id, text, *, model_id="zeli-turbo", voice_settings=None,
       output_format="mp3_44100_128", timeout=None) -> AudioStream

convert(voice_id, text, *, ...same...) -> bytes
```

- `stream(...)` returns an **`AudioStream`** — iterate it for audio `bytes` as
  they generate; `.read()` drains it to one blob. It exposes `.output_format`,
  `.sample_rate`, `.voice` (the resolved voice id), and `.request_id`.
- `convert(...)` returns the finished clip in the requested `output_format`.
- `output_format` accepts the full menu — `mp3_*`, `wav_*`, raw `pcm_*`,
  `ulaw_8000`, `alaw_8000`, `opus_48000_*` — see the
  [output formats reference](https://voice.zeligate.com/api-reference/output-formats/).
- `voice_settings` (`stability`/`style`/`speed`/…) maps onto Turbo delivery —
  see [voice settings](https://voice.zeligate.com/capabilities/voice-settings/).

## Voices — `client.voices`

```python
client.voices.list()                 # -> [Voice(id, label, description, custom), ...]
client.voices.get("zeli-voice-1")    # -> Voice (404s on unknown ids)
client.voices.add(name="My voice", file="me.wav", description="")  # -> Voice
client.voices.delete("custom-abc123")
```

`add` clones a voice **zero-shot** from a short reference clip (~10–20 s of
clean single-speaker audio; `file` may be a path, a file object, or `bytes`).
Cloning and removal apply to the Turbo engine's custom voices, and require a
**full** API key — ephemeral tokens get `401 insufficient_scope` here.

## Ephemeral tokens — `client.tokens`

Never ship a real API key to a browser or mobile app: anyone can read it. The
intended flow is that your **backend** (which holds a full `api_key`) mints a
short-lived, synthesis-only token and hands it to the client, which then talks
to the API directly with that token.

```python
tok = client.tokens.create_ephemeral(ttl_seconds=300)
# -> {"token": "zsk_temp_…", "expires_at": 1770000000, "scope": "tts"}
```

- `ttl_seconds` is clamped to **30–600** (default 300). `expires_at` is the
  epoch second the token dies — enforced server-side on every request, so a
  leaked token is usable for minutes at most.
- The returned token has scope `"tts"`: it can call synthesis and read
  endpoints (`text_to_speech.stream/convert`, `voices.list/get`) but **not**
  `voices.add/delete` or `tokens.create_ephemeral` — those return
  `401 insufficient_scope`.
- Minting requires a full key, and `501 not_supported` is returned on a server
  without the portal keys table configured.

Backend endpoint example (FastAPI):

```python
from fastapi import FastAPI
from zeli_tts import ZeliSpeech

app = FastAPI()
zeli = ZeliSpeech(base_url="https://voice.zeligate.com", api_key=os.environ["ZELISPEECH_API_KEY"])

@app.post("/api/tts-token")
def tts_token():
    return zeli.tokens.create_ephemeral(ttl_seconds=300)  # browser uses this directly
```

## More examples

```python
# Delivery control (stability / style / speed map onto Turbo knobs)
clip = client.text_to_speech.convert(
    "zeli-voice-1", "Same words, very different delivery!",
    voice_settings={"stability": 0.2, "style": 0.9, "speed": 1.1},
)

# Raw PCM for your own audio pipeline (24 kHz mono int16, no container)
pcm = client.text_to_speech.convert("zeli-voice-1", "Raw samples.", output_format="pcm_24000")

# Telephony formats
ulaw = client.text_to_speech.convert("zeli-voice-1", "For the phone line.", output_format="ulaw_8000")

# Stream straight to a file as it generates
with open("out.mp3", "wb") as f:
    for chunk in client.text_to_speech.stream("zeli-voice-1", "Written as it speaks."):
        f.write(chunk)

# Clone a voice, use it, remove it
voice = client.voices.add(name="My narrator", file="reference.wav")
client.text_to_speech.convert(voice.id, "Cloned voice speaking.")
client.voices.delete(voice.id)

# Play inline in a Jupyter notebook
from IPython.display import Audio
Audio(data=bytes(client.text_to_speech.convert("zeli-voice-1", "Hello, notebook!")))

# Robust error handling
from zeli_tts import APIError, ConnectionError
try:
    client.text_to_speech.convert("zeli-voice-1", "…")
except APIError as e:
    if e.status == "invalid_api_key":
        ...  # rotate/refresh the key
    elif e.status_code == 503:
        ...  # model still loading — retry shortly
except ConnectionError:
    ...      # box unreachable / stopped
```

## Helpers

```python
from zeli_tts import play, save, stream, pcm_to_wav

play(audio)                    # play a finished clip (any output format)
stream(audio_stream)           # play chunks live; returns the collected bytes
save(audio, "out.mp3")         # write bytes to a file (pcm_* wrapped when .wav)
pcm_to_wav(pcm, sample_rate=24000)
```

## Errors

All derive from `ZeliSpeechError`:

| Exception | When |
|---|---|
| `ConfigurationError` | bad `base_url`, or a missing dep/player |
| `ConnectionError` | server unreachable (refused / DNS / timeout) |
| `APIError` | non-2xx from an HTTP endpoint (`.status_code`, `.status`, `.body`) |
| `GenerationError` | the stream broke mid-synthesis |

Auth failures are `APIError`s whose `.status` tells you why:

| `.status_code` | `.status` | When |
|---|---|---|
| 401 | `missing_api_key` | auth required but no key sent |
| 401 | `invalid_api_key` | unknown, revoked, or **expired** key/token |
| 401 | `insufficient_scope` | an ephemeral token hit a management route |
| 501 | `not_supported` | minting on a server without the keys table |

## Migrating from 0.1.x

0.2.0 moves the SDK onto the authenticated `/v1` API and adopts the ZeliSpeech
naming. `ZeliTTS` / `ZeliTTSError` remain as aliases, but the synthesis calls
changed: `stream(text, voice=...)` → `stream(voice_id, text)`, audio now
arrives in `output_format` (mp3 by default) instead of raw PCM, and the
engine-knob kwargs (`exaggeration`, `cfg_weight`, …) are replaced by
`voice_settings`.
