# Lambbi speech API

> Haitian Creole (Kreyòl ayisyen) text to speech and speech to text over HTTP. Two routes, `POST /api/tts` (text in, WAV out) and `POST /api/asr` (audio in, JSON transcript out). Every call answers directly: no jobs to poll, no SDK needed.

Base URL: https://www.lambbi.xyz
OpenAPI: https://www.lambbi.xyz/openapi.json

## Quick facts

- Authentication: an API key in every request, `Authorization: Bearer lb_…`. Make keys in your Lambbi dashboard: https://www.lambbi.xyz/en/dashboard/api-keys (see Authentication below).
- Price: 1 credit per minute of audio (2 HTG), billed per second, after your 5 free minutes each month (see Pricing below).
- Rate limit: each IP address may also make 90 text-to-speech requests and 30 transcriptions every 10 minutes; past that you get 429 with a Retry-After header.
- Text to speech: Kreyòl text only, at most 1200 characters per request; returns `audio/wav`, 24000 Hz, 16-bit, mono.
- Speech to text: any common audio file (WAV, MP3, M4A, OGG, WebM), at most 25 MB; the first 120 seconds are heard; returns JSON.
- Errors are JSON: `{"error": "…"}` with a 4xx or 5xx status (401 and 402 add a `code`).
- Cold start: the first call after ~5 quiet minutes can wait up to ~30 seconds while the models load. Use a timeout of at least 60 seconds and retry once on 502/504.

## Authentication

Every request to `/api/tts` and `/api/asr` needs an API key:

1. Sign in to Lambbi and open your dashboard, under **API keys**: https://www.lambbi.xyz/en/dashboard/api-keys
2. Make a key and copy it. It starts with `lb_` and is shown only once; if you lose it, revoke it and make another. An account can have up to 10 keys, and a revoked key stops working at once.
3. Send it in the `Authorization` header of every request:

```
Authorization: Bearer lb_…
```

Keep the key secret, like a password: call the API from your server, not from a web page or app that others can open. The examples below read it from the `LAMBBI_API_KEY` environment variable.

Without a key you get `401` with `{"error": "…", "code": "no_key"}`; with a key that is not valid or was revoked, `401` with `"code": "bad_key"`. Both carry the header `WWW-Authenticate: Bearer`.

## Pricing

- 1 credit = 1 minute of audio = 2 HTG. Add credit with MonCash (HTG) or a card (USD, through Stripe) in your dashboard.
- Billed per second of audio: text to speech counts the seconds of audio it made; speech to text the seconds it heard (at most 120 per request).
- Every account gets 5 free minutes each month (months in Haiti's time), used first. They are shared with the Studio.
- A request that fails (4xx or 5xx) is not charged.
- When your free minutes and credit have run out you get `402` with `{"error": "…", "code": "no_credit"}`. Requests made at the same moment can take your credit slightly below zero; the next one then gets 402.

Every charged response carries these headers:

| Header | Meaning |
|---|---|
| `X-Charged-Seconds` | Seconds of audio this request was billed for: the audio made (text to speech) or heard (speech to text). |
| `X-Credits-Used` | Credits this request took from your balance; 0 while it fits in your free minutes. |
| `X-Credits-Left` | Credits left on your account after this request. |
| `X-Free-Seconds-Left` | Free seconds left on your account this month. |

`/api/asr` also returns the same numbers in its JSON, as `charged`: `{"seconds", "credits", "creditsLeft", "freeSecondsLeft"}`.

## POST /api/tts — text to speech

Request: `Content-Type: application/json`.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `text` | string | yes | | Kreyòl text, 1 to 1200 characters. Numbers, dates, times, prices, percentages, phone numbers and web addresses are spelled out as they are said. |
| `voice` | string | no | `female1` | One of the voice ids below. |
| `model` | string | no | `v1` | `v1`, `v2` or `v3` (below). |
| `continues` | boolean | no | false | The text carries on a sentence cut at a comma, so it is not read as a new sentence. |

Reading settings (all optional, numbers; out-of-range values are brought to the nearest end, and the `X-Settings` response header says what was used):

| Setting | Type | Range | Default | Effect |
|---|---|---|---|---|
| `temperature` | number | 0.1 to 1 | 0.7 for v1 and v2, 0.3 for v3 | How freely the voice reads: lower is more even, higher more varied. |
| `lsd_steps` | integer | 1 to 8 | 1 | Generation steps per sound frame. More is slower and rarely clearer. |
| `eos_threshold` | number | -6 to 2 | -4 | When the voice decides the text is over. Lower ends sooner and can cut the last word; higher waits longer. |
| `cfg` | number | 1 to 3 | 1 | Guidance toward the text. Above 1 the voice follows the text more closely, in about twice the time; 1 is off. |
| `pause_ms` | integer | 0 to 2000 | 300 | Longest silence kept, in milliseconds: longer pauses are shortened to this. 0 keeps every pause as made (otherwise 100 to 2000). |

Voices (`voice`):

| id | Name | Gender | Character |
|---|---|---|---|
| `female1` | Fanm 1 | female | Clear, even |
| `female2` | Fanm 2 | female | Lively |
| `female3` | Fanm 3 | female | Soft |
| `female4` | Fanm 4 | female | Low, very steady |
| `female5` | Fanm 5 | female | Clear, bright |
| `female6` | Fanm 6 | female | Calm |
| `man1` | Gason 1 | male | Bright |
| `man2` | Gason 2 | male | Deep |
| `man3` | Gason 3 | male | Warm, steady |
| `nouvo1` | Gason 4 | male | Deep, calm (new voice, no real person) |
| `nouvo2` | Gason 5 | male | Clear, natural (new voice, no real person) |
| `nouvo3` | Gason 6 | male | Light (new voice, no real person) |
| `nouvo4` | Gason 7 | male | Firm (new voice, no real person) |

The nine Fatiman voices belong to Haitian speakers who consented to their use. `nouvo1` to `nouvo4` (Gason 4 to 7) were made from the consenting men's voices and belong to no real person. Do not present any voice as a real person.

Models (`model`):

| id | Default temperature | About |
|---|---|---|
| `v1` | 0.7 | Fatiman-TTS-v1, the first model (open weights). The default. |
| `v2` | 0.7 | Fatiman v2, trained on real human voices only; as clear as v1. |
| `v3` | 0.3 | Fatiman V3, the newest: the most natural and the closest to each voice in our tests. |

Response: `200` with the WAV file as the body, and these headers (plus the billing headers under Pricing):

| Header | Meaning |
|---|---|
| `X-Audio-Seconds` | Length of the audio, in seconds. |
| `X-Made-Ms` | Time the voice service took to make it, in milliseconds. |
| `X-Model` | The model that read it: v1, v2 or v3. |
| `X-Settings` | JSON of the reading settings actually used, after bringing out-of-range values to the nearest end, e.g. {"temperature":0.3,"lsd_steps":1,"eos_threshold":-4.0,"cfg":1.0,"pause_ms":300}. |

Long text: split it into sentences (each at most 1200 characters), send them in order, starting with a short one, and play each as it arrives; generation runs about twice as fast as playback, so playback does not stop. Set `continues: true` on a piece that starts in the middle of a sentence.

### Examples

```bash
curl -X POST https://www.lambbi.xyz/api/tts \
  -H "Authorization: Bearer $LAMBBI_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"text": "Bonjou! Ki jan ou ye jodi a?", "voice": "female1", "model": "v3"}' \
  -o bonjou.wav
```

```javascript
const r = await fetch("https://www.lambbi.xyz/api/tts", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.LAMBBI_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ text: "Bonjou! Ki jan ou ye jodi a?", voice: "female1", model: "v3" }),
});
if (!r.ok) throw new Error((await r.json()).error);
const wav = await r.blob(); // audio/wav, 24000 Hz mono
```

```python
import os
import requests

r = requests.post(
    "https://www.lambbi.xyz/api/tts",
    headers={"Authorization": f"Bearer {os.environ['LAMBBI_API_KEY']}"},
    json={"text": "Bonjou! Ki jan ou ye jodi a?", "voice": "female1", "model": "v3"},
    timeout=90,
)
r.raise_for_status()  # on error the body is {"error": "..."}
open("bonjou.wav", "wb").write(r.content)
print(r.headers["X-Audio-Seconds"], r.headers["X-Credits-Left"])
```

## POST /api/asr — speech to text

Request: the audio file as the raw body with its `Content-Type` (e.g. `audio/wav`, `audio/mpeg`, `audio/mp4`, `audio/ogg`, `audio/webm`), or a `multipart/form-data` upload with the file in a field named `file`. At most 25 MB; the first 120 seconds are heard. Recognizer: Oswald-ASR-0.6-V3 (Kreyòl only; it writes numbers as digits).

Optional query parameters:

| Parameter | Effect |
|---|---|
| `punctuate` | `0` returns the text exactly as heard (no punctuation), about a second sooner. Default: punctuation and capitals are added, without changing any word. |
| `correct` | `1` also corrects words the recognizer misheard, the spelling and words said twice by accident (Lambbi AI). `heard` keeps the raw text. A correction that rewrites more than 40% of the words is not used; you get the punctuated text instead. |
| `translate` | `en`, `fr` or `es`: also translate the (punctuated or corrected) Kreyòl text into English, French or Spanish, returned in `translation`. |

Response: `200`, JSON:

| Field | Type | Meaning |
|---|---|---|
| `text` | string | The transcript in Kreyòl spelling, punctuated (and corrected with correct=1). Empty when nothing was heard. |
| `heard` | string | What the recognizer (Oswald-ASR-0.6-V3) wrote, lowercase, without punctuation. |
| `punctuated` | boolean | Whether punctuation was added. |
| `corrected` | boolean | Whether the auto-correction was applied. |
| `translation` | string | null | With translate=…: the translation; null if it failed. Absent without translate. |
| `translatedTo` | string | With translate=…: the language code of translation. |
| `seconds` | number | How many seconds of audio were heard. |
| `ms` | number | Server time in milliseconds, punctuation, correction and translation included. |
| `maxSeconds` | number | The most audio heard per request (120). |
| `charged` | object | What this request cost: `seconds` billed, `credits` used, `creditsLeft` and `freeSecondsLeft` on your account after it (the same as the X-Charged-Seconds, X-Credits-Used, X-Credits-Left and X-Free-Seconds-Left headers). |

### Examples

```bash
curl -X POST 'https://www.lambbi.xyz/api/asr?correct=1&translate=en' \
  -H "Authorization: Bearer $LAMBBI_API_KEY" \
  -H 'Content-Type: audio/mp4' \
  --data-binary @recording.m4a

# or as a form upload
curl -X POST https://www.lambbi.xyz/api/asr -H "Authorization: Bearer $LAMBBI_API_KEY" -F file=@recording.m4a
```

```json
{"text":"Doktè, ou ka esplike m ankò diferans ant timè benin ak timè malfezan?","heard":"doktè ou ka esplike m ankò diferans ant timè benin ak timè malfezan","punctuated":true,"corrected":true,"translation":"Doctor, can you explain to me again the difference between a benign tumor and a malignant tumor?","translatedTo":"en","seconds":5.2,"ms":3371,"maxSeconds":120,"charged":{"seconds":5.2,"credits":0,"creditsLeft":25,"freeSecondsLeft":287}}
```

```javascript
// on your server (Node 18+); blob: any Blob, e.g. an uploaded recording
const r = await fetch("https://www.lambbi.xyz/api/asr?translate=fr", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.LAMBBI_API_KEY}`, "Content-Type": blob.type },
  body: blob,
});
if (!r.ok) throw new Error((await r.json()).error);
const { text, translation } = await r.json();
```

```python
import os
import requests

with open("recording.m4a", "rb") as f:
    r = requests.post(
        "https://www.lambbi.xyz/api/asr",
        headers={"Authorization": f"Bearer {os.environ['LAMBBI_API_KEY']}"},
        params={"correct": "1"},
        files={"file": f},
        timeout=180,
    )
r.raise_for_status()
print(r.json()["text"])
```

## Errors

| Status | Meaning |
|---|---|
| 400 | The request is not what the route expects (missing text, unknown voice/model/translate, a setting that is not a number, no audio). `error` says which. |
| 401 | No API key (`code: "no_key"`) or a key that is not valid or was revoked (`code: "bad_key"`). Send `Authorization: Bearer lb_…` with a key from your dashboard. |
| 402 | Your free minutes and credit have run out (`code: "no_credit"`). Add credit in your dashboard. |
| 405 | Wrong method: both routes take POST only. |
| 413 | Too long: text over 1200 characters, or audio over 25 MB. |
| 429 | Too many requests from your address. Wait the number of seconds in the Retry-After header. |
| 502 | The speech service failed. Retry. |
| 504 | The speech service took too long, usually a cold start: the first call after ~5 quiet minutes waits up to ~30 s while a machine loads the models. Retry. |

All error bodies are JSON: `{"error": "human-readable message"}`. 401 and 402 also have a `code`: `no_key`, `bad_key` or `no_credit`.
