Devnagri
Blackbird ASR
API documentation

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.

GroupCodes
Indian-accented Englishen
22 constitutionally recognisedas bn brx doi gu hi kn ks kok mai ml mni mr ne or pa sa sat sd ta te ur
Hindi dialectshne (Chhattisgarhi) bgc (Haryanvi)
Low-resourcebhb (Bhili) bho (Bhojpuri)

Output modes

The same utterance, three renderings. Set mode to pick one.

ModeOutputUse 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.
romanizedmaine 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:

FieldMeaning
language_sourceexplicit if you supplied it, detected if the model chose.
language_candidatesRanked alternatives with confidences, when detection ran.
low_confidence_languagetrue 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

PropertyAccepted
Formatswav, mp3, flac, m4a/mp4, ogg, opus, webm, amr — anything ffmpeg reads
Sample rateAny; resampled to 16 kHz internally
ChannelsAny; downmixed to mono
Upload size512 MB
DurationUp 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.

ParameterTypeNotes
filemultipartThe audio. Alternatively send raw bytes as the request body.
languagestringOptional. Omit to auto-detect.
modestringnative (default), mixed, romanized.

Response

FieldDescription
textThe full transcript.
segments[]start_ms, end_ms, text per segment.
duration_msLength of the audio.
processing_msTime spent decoding.
queue_msTime 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

EventWhenFields
readyAfter the config frame is accepted. session_id, model, echoed config
languageOnce, if the language was auto-detected. language, confidence, low_confidence, candidates[]
partialWhile a sentence is still being spoken. text, start_ms, end_ms
finalWhen a sentence settles. segment_id, text, start_ms, end_ms, language
doneAfter finalize. text, duration_ms, segments[]
errorOn any failure.code, message
pongReply 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:

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:

LimitScopeExceeded
Monthly audio quotaPer key, optional402 quota_exceeded
Rate limitPer key, requests/minute429 rate_limit_exceeded + Retry-After
ConcurrencyPer key, simultaneous requests429 concurrency_limit_exceeded
Server capacityService-wide503 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"
  }
}
StatusCodeMeaning
400invalid_audioThe audio could not be decoded.
400invalid_language / invalid_modeUnsupported value.
400no_audio / audio_too_shortNothing usable was sent.
401missing_api_key / invalid_api_keyNo key, or not a real one.
403api_key_revoked / api_key_expiredKey is no longer usable.
403language_not_allowed / mode_not_allowedRestricted by key policy.
402quota_exceededMonthly audio quota spent.
413file_too_large / audio_too_longOver the size or duration cap.
422invalid_requestMalformed parameters; see fields[].
429rate_limit_exceeded / concurrency_limit_exceededSlow down.
503server_overloadedRetry 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"]
  }'
RouteDoes
POST/v1/keysMint a key. Returns the plaintext once.
GET/v1/keysList keys, masked.
PATCH/v1/keys/{id}Change limits, quotas, restrictions, or disable.
POST/v1/keys/{id}/rotateNew secret, same id and usage history.
DELETE/v1/keys/{id}Revoke. The row survives so usage history does too.
GET/v1/admin/usageUsage for any project or key.

Health and metrics

RouteAuthPurpose
GET /healthznoneLiveness. Returns 200 as soon as the process is up.
GET /readyznoneReadiness. 503 until the model is loaded and warm — point your load balancer here.
GET /metricsnonePrometheus metrics: queue depth, decode latency, GPU memory, rejections by reason.
GET /v1/statusadminDetailed engine and scheduler state.