# Maya 2 Native API Text-to-speech for eleven Indian languages, including Indian English. Two voices, both speaking every language, streaming 24 kHz audio over a single HTTP POST. Audio starts arriving before the full clip is generated and keeps streaming, so you can play it as it lands instead of waiting for the whole thing. - Base URL: https://tts.mayaresearch.ai - Endpoint: POST /v1/tts - Auth: Bearer API key - Output: raw PCM, 16-bit little-endian, mono, 24000 Hz - Contact for a key: charan@mayaresearch.ai, bharath@mayaresearch.ai, dheemanth@mayaresearch.ai ## Quickstart curl -sS --fail-with-body -X POST https://tts.mayaresearch.ai/v1/tts \ -H "Authorization: Bearer $MAYA_API_KEY" \ -H "content-type: application/json" \ -d '{"voice":"Ananya","text":"नमस्ते! आपका ऑर्डर कल पहुँच जाएगा।","language":"hi"}' \ --output out.pcm Keep `--fail-with-body`. Without it, curl writes the JSON error body *into* `out.pcm` on a 4xx/5xx, and the next command converts that JSON into a silent 0-second file — so a rate-limit or bad-key error looks like "the audio is empty" instead of an error. # the response has NO file header -- wrap it to play in a normal player ffmpeg -f s16le -ar 24000 -ac 1 -i out.pcm out.wav Play without converting: ffplay -f s16le -ar 24000 -ch_layout mono out.pcm ## Step by step Follow these in order. Do not skip step 5. **1. Get a key.** Email charan@mayaresearch.ai, bharath@mayaresearch.ai or dheemanth@mayaresearch.ai. Put it in an environment variable; never paste it into source code or anything a browser downloads. export MAYA_API_KEY="maya_sk_live_..." **2. Choose exactly one voice.** The only valid values are `Ananya` (female) and `Arjun` (male). Anything else is rejected. Do not invent voice names. **3. Choose a language code** from the Languages table: `hi`, `bn`, `gu`, `kn`, `ml`, `mr`, `or`, `pa`, `ta`, `te`, `en`. `language` is optional. `en` is Indian English: English words in an Indian accent. It is not British or American English, and there is no code for those. When set, the code must match the script the text is actually written in — Hindi text with `"language":"ta"` is accepted and pronounced wrongly, with no error. Leave it out if your text switches languages mid-sentence (see below). **4. Build the request.** `voice` and `text` are required; `language` and `region` are optional: {"voice": "Ananya", "text": "", "language": "hi"} Send the complete text in one request. Do not send SSML, HTML or markdown; tags are read aloud literally. **5. POST it, then check the status code BEFORE reading the body.** POST https://tts.mayaresearch.ai/v1/tts Authorization: Bearer $MAYA_API_KEY content-type: application/json user-agent: On any non-200 the body is a JSON error, not audio. If you write it to disk without checking, you will produce a file that looks like broken audio and you will debug the wrong problem. In curl, use `--fail-with-body`. In code, check `r.status_code` or `res.ok` first. **6. Read the body as raw binary.** It is audio, not text — never decode it as UTF-8. It streams, so read it in pieces and forward each piece if you are playing it live. **7. If you are saving a file, add a WAV header.** The response has no header. Either convert: ffmpeg -f s16le -ar 24000 -ac 1 -i out.pcm out.wav or write the WAV in code with exactly these parameters — 1 channel, 2 bytes per sample, 24000 Hz (see the Python example). Getting any of the three wrong produces noise or chipmunk audio, not an error. **8. Verify it worked.** Expected bytes ≈ `seconds × 48000`. A 3-second clip is about 144,000 bytes. If you got a few hundred bytes, you saved an error body — go back to step 5 and print it as text. ### If something goes wrong | Symptom | Do this | |---|---| | 401 | Key is missing or wrong. Do not retry — fix the key. | | 400 | Read the JSON message; usually a misspelt voice or empty text. Do not retry unchanged. | | 429 | Wait, then retry with backoff. Do not retry in a tight loop. | | 503 or 5xx | Retry with backoff. | | 403 with a non-JSON body | Set a `user-agent` header and retry. | | File plays as noise | Wrong sample rate or channel count. Must be 24000 Hz, mono, 16-bit. | | File is silent / 0 seconds | You saved an error body as audio. Check the status code. | ## Request { "voice": "Ananya", "text": "नमस्ते! आपका ऑर्डर कल पहुँच जाएगा।", "language": "hi", "region": "IN" } | Field | Required | Default | Notes | |----------------------|----------|---------|-------| | `voice` | yes | — | `Ananya` (female) or `Arjun` (male). Case-insensitive; any other value is rejected. | | `text` | yes | — | Plain text. Write numbers, currency and dates naturally — see Common mistakes. | | `language` | no | — | Code from the table below. Omit it if the text mixes languages mid-sentence — see below. | | `region` | no | `IN` | `US` or `IN`. Routes the request to the nearer inference region to reduce latency. | Models are deployed in two regions: `US` (Iowa) and `IN` (Mumbai). Pick whichever is closer to where your traffic originates. Send plain text, not SSML or markup — angle brackets and tags are read aloud as literal text, not interpreted. ## Languages | Code | Language | |------|----------------| | `hi` | Hindi | | `te` | Telugu | | `bn` | Bengali | | `gu` | Gujarati | | `kn` | Kannada | | `ml` | Malayalam | | `mr` | Marathi | | `or` | Odia | | `pa` | Punjabi | | `ta` | Tamil | | `en` | Indian English | `en` is Indian English: English words, Indian accent and rhythm. It is the code to use for Latin-script English text. There is no British or American variant, and no generic `en-US`/`en-GB` - `en` is the only English code and it is always Indian. Both voices support all eleven. `language` selects how the text is pronounced — passing the wrong code produces intelligible but mispronounced audio rather than an error, so it is worth getting right. **Mixing languages in one sentence?** Don't set `language`. If a sentence code-switches — e.g. two or more of the eleven languages above written together in the same sentence (say, Hindi and Tamil words side by side) — leave the field out and each part is pronounced with its own script's rules. Setting `language` explicitly forces every word to that language's rules, which mispronounces the parts that are actually in a different script. ## Response Status 200 with a streaming body: content-type: audio/L16; rate=24000; channels=1 **Raw PCM, no file header.** 16-bit signed little-endian, mono, 24000 Hz. This is deliberate: voice pipelines consume PCM frames directly, and a WAV header would only be stripped again before playback. The trade-off is that saving the body straight to a file gives you something most desktop players will not open until you wrap it (see Quickstart). Response headers: | Header | Meaning | |----------------------------|---------| | `x-request-id` | Quote this when reporting a problem. | | `x-ratelimit-limit` | Requests allowed in the current window. | | `x-ratelimit-remaining` | Requests left in the current window. | | `x-ratelimit-reset` | Seconds until the window resets (sent on 429). | ## Latency and how to keep it low Audio begins streaming back well before the full clip is generated, and the time to that first byte is **flat with input length** — a long paragraph starts speaking as fast as a short sentence. Measure it from your own region; network distance is the largest variable. Four things on your side actually move it: **1. Reuse the connection.** A fresh TCP + TLS handshake is paid on every request. Use a client that keeps connections alive — `requests.Session`, an `undici` agent, a Go `http.Client` you hold onto. Creating a new client per request throws this away. **2. Stream the response; do not buffer it.** If you read the body to completion before playing, your effective latency becomes the full generation time for the clip rather than the time to its first byte. Iterate over the body and forward each piece. **3. Send the whole text in one request.** Splitting it into sentences yourself adds a full round-trip per sentence and gives worse prosody, since each fragment is generated without the surrounding context. **4. Keep concurrency under your cap.** Requests over the limit come back as errors straight away rather than waiting in line, so a burst that exceeds it fails fast instead of slowing everything down. **Best practice: call from a server near the region, not from a laptop.** Network distance dominates the numbers below — calling from a laptop over home/office wifi adds latency and jitter that a colocated server does not have. | metric | Laptop | Bangalore server box | difference | |---------------------|------------|-----------------------|-----------------------| | N=1 seq — p50 | ~161 ms | 75.5 ms | −85 ms (2.1× faster) | | N=1 — p90 | ~224 ms | 83.5 ms | −140 ms | | N=1 — p99 | ~291 ms | 118.7 ms | −172 ms | | N=1 — floor (min) | 148 ms | 69 ms | −79 ms | | N=1 — sd (consistency) | ~30–60 ms | 7.3 ms | ~5–8× tighter | | N=10 — p50 | 190 ms | 95.3 ms | −95 ms | | N=10 — p90 | 262 ms | 104 ms | −158 ms | | N=10 — p99 | 327 ms | 108 ms | −219 ms | Beyond lower averages, the server box is far more consistent (tighter standard deviation) — fewer outlier-slow requests, which matters more for real-time playback than the median. ## Long text There is no practical length limit. Send a full paragraph in one request and you receive one continuous audio stream — do not split it into sentences yourself. ## Rate limits and concurrency Default external tier: | Limit | Value | |-----------------------------|-------| | Requests per minute | 120 | | Concurrent requests | 16 | Spending the whole allowance in a burst leaves you rejected until it returns, so pace requests rather than firing them all at once. `x-ratelimit-remaining` tells you what is left and `x-ratelimit-reset` tells you when it comes back — read them instead of guessing. Concurrency is counted as requests in flight simultaneously. Exceeding it returns 429 `concurrency_limit` with `retry-after: 1` — retry after a short backoff. {"error":"concurrency_limit","request_id":"…","active":16,"limit":16} **Abandoning a response early can keep it counted.** If you stop reading the body part-way — a barge-in where the user interrupts playback, a timeout, a crashed process — that request may keep occupying concurrency for a while after you have moved on. An agent that interrupts often can therefore drift into `concurrency_limit` while looking idle. Read each response to completion even when you intend to discard the audio, or cancel it cleanly so the connection closes (`AbortController` in fetch/undici, `r.close()` after consuming, context cancellation in Go). If `active` already equals `limit` with nothing running, pause briefly and let it clear rather than retrying in a loop. Higher limits are available; ask. ## Errors | Status | `error` | Cause | |--------|----------------------------|-------| | 400 | validation message | Missing/empty `text`, or an unknown `voice`. | | 401 | `missing_or_malformed_key` | No `Authorization: Bearer …` header. | | 401 | `invalid_key` | Key is wrong, revoked, or disabled. | | 429 | `rate_limited` | Over requests/minute. Wait for `x-ratelimit-reset`. | | 429 | `concurrency_limit` | Too many in flight. Honour `retry-after`. | | 503 | `model_unavailable` | Temporarily unavailable. Retry with backoff. | | 5xx | `origin_error` | Transient server-side failure. Safe to retry. | Every error body carries `request_id`. Include it in support requests. {"error":"invalid_key","request_id":"ff14b2d0-60fc-46c2-bc62-45478100869a"} ## Common mistakes **Treating the response as a WAV file.** The body is headerless PCM. Writing it to `out.wav` and double-clicking will fail or play static. Convert it, or wrap it with a 44-byte WAV header in code (see Python example). **Saving the response without checking the status.** On any error the body is JSON, not audio — and `curl --output`, `wget -O` or a naive `open(f,'wb').write(r.content)` will happily write that JSON into your `.pcm` file. Converting it yields a silent 0-second clip, so a 429 or 401 shows up as "the audio came back empty" and sends you debugging the wrong thing. Use `curl --fail-with-body`, or check `r.status_code` / `res.ok` before writing anything. **Playing at the wrong sample rate.** It is 24000 Hz mono 16-bit LE. Playing it as 44100 makes it fast and high-pitched; playing it as stereo makes it noise. Almost every "the audio sounds broken" report is one of these two. **Setting the wrong `language`.** Hindi text sent with `"language":"ta"` will be pronounced with Tamil rules and sound wrong — no error is raised. If unsure, or if the sentence mixes languages, omit the field instead of guessing. **Misspelling the voice.** Only `Ananya` and `Arjun` exist. `"Ananaya"` returns 400 rather than silently substituting, so you are not billed for audio in a voice you did not ask for. **Buffering the whole response before playing.** Discards the streaming advantage entirely. See "Latency" above. **Opening a new connection per request.** Pays the TLS handshake every time. **Putting the key in browser JavaScript.** Anything shipped to a browser is public. Call from your server; proxy if a browser needs audio. **Retrying a 400 or 401.** These are deterministic — the same request will fail identically forever. Only 429, 503 and 5xx are worth retrying. **Getting an unexpected 403** (rare). A 403 whose body is *not* JSON did not come from the API — it is a request filter reacting to your HTTP client's default User-Agent, not an authentication problem, though it reads like one and sends people hunting a bad key. Our own errors are always JSON with a `request_id`. Setting any User-Agent clears it: headers["user-agent"] = "my-app/1.0" This affects a few libraries' default agents, notably Python's stdlib `urllib`. `requests`, `curl`, `node-fetch`, `axios`, `okhttp` and Go's `net/http` are unaffected. ## Python import json, requests, wave session = requests.Session() # reuse the connection r = session.post( "https://tts.mayaresearch.ai/v1/tts", headers={"Authorization": f"Bearer {API_KEY}", "content-type": "application/json", "user-agent": "my-app/1.0"}, data=json.dumps({"voice": "Ananya", "text": "नमस्ते! आपका ऑर्डर कल पहुँच जाएगा।", "language": "hi"}), stream=True, timeout=120, ) r.raise_for_status() pcm = bytearray() for chunk in r.iter_content(4096): # arrives progressively -- play it here pcm += chunk with wave.open("out.wav", "wb") as w: # add the header PCM does not have w.setnchannels(1); w.setsampwidth(2); w.setframerate(24000) w.writeframes(pcm) ## Node import { writeFile } from "node:fs/promises"; const res = await fetch("https://tts.mayaresearch.ai/v1/tts", { method: "POST", headers: { authorization: `Bearer ${API_KEY}`, "content-type": "application/json" }, body: JSON.stringify({ voice: "Arjun", text: "வணக்கம்!", language: "ta" }), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const parts = []; for await (const chunk of res.body) parts.push(chunk); // stream, don't buffer-then-wait await writeFile("out.pcm", Buffer.concat(parts)); ## Support charan@mayaresearch.ai, bharath@mayaresearch.ai, dheemanth@mayaresearch.ai — include the `x-request-id` or `request_id` of a failing call.