Blackbird ASR
Speech recognition for 27 Indian languages. Send a file and get a transcript, or stream audio over a WebSocket and get words as they are spoken. Output comes in the speaker's own script, transliterated into Latin, or code-mixed the way people actually talk.
27 languages
English plus the 22 constitutionally recognised languages, two Hindi dialects, and two low-resource languages.
Code-mixed natively
Hinglish and other mixed speech is transcribed as spoken, not forced into one language.
Realtime or batch
A streaming socket with interim results, and a batch endpoint that handles hours of audio.
Built-in language ID
Leave the language out and it is identified from the audio before transcription.
Quickstart
Every request needs an API key in the Authorization header. Replace
$BLACKBIRD_KEY below with yours.
# a Hindi file, native script
curl -X POST https://your-host/v1/transcribe \
-H "Authorization: Bearer $BLACKBIRD_KEY" \
-F "file=@call.wav" \
-F "language=hi"
# response
{
"id": "req_936519b3205b4330bdfcc155",
"model": "blackbird-indic-v1",
"text": "मैंने कल पाँच बजे तीन फ़ाइलें अपलोड कीं",
"language": "hi",
"language_name": "Hindi",
"language_source": "explicit",
"mode": "native",
"duration_ms": 3408,
"segments": [{"start_ms": 0, "end_ms": 3408, "text": "मैंने कल…"}],
"processing_ms": 116,
"queue_ms": 0
}
Authentication
Keys look like bb_live_… (production) or bb_test_…. Pass one
as a bearer token:
Authorization: Bearer bb_live_t31F8uwonghgx8dWn8dB…
The header X-API-Key: <key> is accepted as an alternative. For
WebSockets — where browsers cannot set headers — pass the key as the
api_key field of the config frame, or as ?api_key= in the URL.
Keys are shown once. The plaintext appears only in the response that creates it; only a hash is stored. If it is lost, rotate the key rather than trying to recover it.
Keys are scoped to a project, and carry their own concurrency limit, rate limit and optional monthly quota. A key can also be restricted to specific languages or output modes — useful when you hand one to a single product surface.
Languages
Pass a code as language. Omit it to have the language identified from the
audio. GET /v1/models returns this table at runtime.
| Group | Codes |
|---|---|
| Indian-accented English | en |
| 22 constitutionally recognised | as bn
brx doi gu hi kn
ks kok mai ml mni
mr ne or pa sa
sat sd ta te ur |
| Hindi dialects | hne (Chhattisgarhi) bgc (Haryanvi) |
| Low-resource | bhb (Bhili) bho (Bhojpuri) |
Output modes
The same utterance, three renderings. Set mode to pick one.
| Mode | Output | Use it for |
|---|---|---|
native | मैंने कल पाँच बजे तीन फ़ाइलें अपलोड कीं | Everything in the language's own script. The default, and what most production systems want. |
mixed | मैंने कल 5 बजे 3 files upload कीं | Native words in native script, English and numerals in Latin. Spoken numbers become digits. |
romanized | maine kal 5 baje 3 files upload kin | All Latin. Search indexing, keyword spotting, Latin-only interfaces. |
Language detection
Omit language and the model identifies it first, then transcribes — one
extra decoder step, not a second pass over the audio. The response always reports which
happened:
| Field | Meaning |
|---|---|
language_source | explicit if you supplied it, detected if the model chose. |
language_candidates | Ranked alternatives with confidences, when detection ran. |
low_confidence_language | true when the detected language is one the model confuses often. |
Pass the language when you know it. Detection is strong
for most languages but weak for hi, bho, mai and
ur, which absorb their neighbours — and English speech is sometimes read as
Hindi and written in Devanagari. A wrong language gives you the wrong script rather than
an obvious error, so low_confidence_language is worth checking.
Audio
| Property | Accepted |
|---|---|
| Formats | wav, mp3, flac, m4a/mp4, ogg, opus, webm, amr — anything ffmpeg reads |
| Sample rate | Any; resampled to 16 kHz internally |
| Channels | Any; downmixed to mono |
| Upload size | 512 MB |
| Duration | Up to 4 hours per file |
The model is trained on single-speaker audio. Overlapping speakers degrade accuracy; it does not separate or label them, and there is no diarization.
Transcribe a file synchronous
POST/v1/transcribe
Transcribes and waits. Long audio is split at natural pauses internally and returned as timestamped segments. For anything over a few minutes prefer async jobs, so you are not holding a connection open.
| Parameter | Type | Notes |
|---|---|---|
file | multipart | The audio. Alternatively send raw bytes as the request body. |
language | string | Optional. Omit to auto-detect. |
mode | string | native (default), mixed, romanized. |
Response
| Field | Description |
|---|---|
text | The full transcript. |
segments[] | start_ms, end_ms, text per segment. |
duration_ms | Length of the audio. |
processing_ms | Time spent decoding. |
queue_ms | Time spent waiting for a GPU slot. |
Timestamps are per segment, not per word. Segments break at the pauses the audio actually contains, so they line up with sentences in practice. The model does not emit word-level alignments, and none are invented.
Async jobs long audio
POST/v1/transcriptions
Accepts the same fields as /v1/transcribe, returns 202 with a
job id immediately, and processes in the background.
# submit
curl -X POST https://your-host/v1/transcriptions \
-H "Authorization: Bearer $BLACKBIRD_KEY" \
-F "file=@hour-long-call.mp3" -F "language=hi"
# -> {"id": "job_7c82399e…", "status": "queued", …}
# poll
curl https://your-host/v1/transcriptions/job_7c82399e… \
-H "Authorization: Bearer $BLACKBIRD_KEY"
GET/v1/transcriptions/{id} —
status is queued, processing, completed or
failed. When completed, result holds the same object
/v1/transcribe returns.
GET/v1/transcriptions — list recent jobs. DELETE/v1/transcriptions/{id} — delete a job and its stored audio.
Jobs and their uploaded audio are deleted automatically 72 hours after creation. Fetch results before then, or delete them yourself as soon as you have the transcript.
Streaming over WebSocket realtime
WS/v1/stream
Connect, send one JSON config frame, then send raw PCM as binary frames. Interim text
arrives as partial events and is replaced as more audio arrives; each
sentence settles into a final event at the pause that ends it.
1 — Config frame
{
"api_key": "bb_live_…",
"language": "hi", // omit or null to auto-detect
"mode": "native",
"sample_rate": 16000, // 8000–96000; resampled if not 16k
"encoding": "pcm_s16le", // or "pcm_f32le"
"interim_results": true
}
2 — Audio frames
Send binary frames of raw mono PCM — no container, no header. Frames of roughly 100 ms are a good default. There is no minimum; the server buffers.
3 — Finish
Send {"type":"finalize"}. The server flushes whatever is still open,
emits a last final, then a done summary, then closes.
Full example
import asyncio, json, websockets
async def transcribe(pcm_frames):
url = "wss://your-host/v1/stream"
async with websockets.connect(url) as ws:
await ws.send(json.dumps({
"api_key": "bb_live_…",
"language": "hi",
"mode": "native",
"sample_rate": 16000,
"encoding": "pcm_s16le",
}))
async def receive():
async for msg in ws:
ev = json.loads(msg)
if ev["type"] == "partial":
print("~", ev["text"], end="\r")
elif ev["type"] == "final":
print("#", ev["text"])
elif ev["type"] == "done":
return ev["text"]
task = asyncio.create_task(receive())
for frame in pcm_frames: # ~100 ms of int16 PCM each
await ws.send(frame)
await ws.send(json.dumps({"type": "finalize"}))
return await task
Event reference
| Event | When | Fields |
|---|---|---|
ready | After the config frame is accepted. | session_id, model, echoed config |
language | Once, if the language was auto-detected. | language, confidence, low_confidence, candidates[] |
partial | While a sentence is still being spoken. | text, start_ms, end_ms |
final | When a sentence settles. | segment_id, text, start_ms, end_ms, language |
done | After finalize. |
text, duration_ms, segments[] |
error | On any failure. | code, message |
pong | Reply to {"type":"ping"}. | — |
Treat partial as disposable. Replace the last
interim line each time one arrives, and append only on final. Partial text
is a best guess at an unfinished sentence and routinely changes — including the last word
— as more audio arrives.
Latency and behaviour
The model transcribes a whole clip at once rather than token by token, so streaming works by re-decoding the open sentence on a timer and closing it at a pause. Three consequences are worth designing around:
- Finals land shortly after a pause. A sentence is closed once about 250 ms of silence follows it, and the final text arrives a few hundred milliseconds later.
- Interim updates are paced by cost, and dropped under load. Refresh interval adapts to how long the last decode took, and interim work is skipped entirely when the GPU is busy with finals. Final results are never delayed by interim ones.
- Very long sentences are cut. A speaker who never pauses is force-segmented at 25 seconds, at the quietest point available.
Silence is never transcribed: audio below the speech threshold is discarded rather than decoded, so background noise does not produce invented words.
Limits and concurrency
Three independent limits apply, checked in this order:
| Limit | Scope | Exceeded |
|---|---|---|
| Monthly audio quota | Per key, optional | 402 quota_exceeded |
| Rate limit | Per key, requests/minute | 429 rate_limit_exceeded + Retry-After |
| Concurrency | Per key, simultaneous requests | 429 concurrency_limit_exceeded |
| Server capacity | Service-wide | 503 server_overloaded + Retry-After |
Requests that exceed service-wide capacity queue briefly rather than failing outright;
only a saturated queue or a timed-out wait returns 503. Retry on
429 and 503 with exponential backoff, honouring
Retry-After where present.
Usage and quotas
GET/v1/usage/me — what the calling key has spent this month and what remains.
GET/v1/usage — usage across
the project, bucketed by grain=hour|day|month, with optional
since and until.
Usage is metered in seconds of audio submitted, for both batch and streaming. Failed requests are not billed.
Errors
Every error uses one shape, with a stable code to branch on and a
request_id to quote in a support request.
{
"error": {
"code": "invalid_language",
"message": "'zz' is not supported. See GET /v1/models.",
"request_id": "req_044045f1af0241b58beb"
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_audio | The audio could not be decoded. |
| 400 | invalid_language / invalid_mode | Unsupported value. |
| 400 | no_audio / audio_too_short | Nothing usable was sent. |
| 401 | missing_api_key / invalid_api_key | No key, or not a real one. |
| 403 | api_key_revoked / api_key_expired | Key is no longer usable. |
| 403 | language_not_allowed / mode_not_allowed | Restricted by key policy. |
| 402 | quota_exceeded | Monthly audio quota spent. |
| 413 | file_too_large / audio_too_long | Over the size or duration cap. |
| 422 | invalid_request | Malformed parameters; see fields[]. |
| 429 | rate_limit_exceeded / concurrency_limit_exceeded | Slow down. |
| 503 | server_overloaded | Retry with backoff. |
Key administration admin token
These routes need the deployment's admin token, not a customer key. A leaked customer key cannot mint more keys.
curl -X POST https://your-host/v1/keys \
-H "Authorization: Bearer $BLACKBIRD_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_name": "Acme Support Desk",
"name": "production",
"max_concurrency": 4,
"rate_limit_rpm": 240,
"monthly_seconds_quota": 360000,
"allowed_languages": ["hi", "en", "mr"],
"allowed_modes": ["native", "mixed"]
}'
| Route | Does |
|---|---|
POST/v1/keys | Mint a key. Returns the plaintext once. |
GET/v1/keys | List keys, masked. |
PATCH/v1/keys/{id} | Change limits, quotas, restrictions, or disable. |
POST/v1/keys/{id}/rotate | New secret, same id and usage history. |
DELETE/v1/keys/{id} | Revoke. The row survives so usage history does too. |
GET/v1/admin/usage | Usage for any project or key. |
Health and metrics
| Route | Auth | Purpose |
|---|---|---|
GET /healthz | none | Liveness. Returns 200 as soon as the process is up. |
GET /readyz | none | Readiness. 503 until the model is loaded and warm — point your load balancer here. |
GET /metrics | none | Prometheus metrics: queue depth, decode latency, GPU memory, rejections by reason. |
GET /v1/status | admin | Detailed engine and scheduler state. |