Sonex Labs

Hindi and Indian-language speech-to-text API in Python

Developers building for Indian users often need to turn Hindi, Tamil or Hinglish speech into text from Python, either from recordings they already have or from a live microphone or phone call. Hosted speech-to-text APIs handle both in the same two ways: you upload a finished file over HTTP with requests, or you stream audio over a WebSocket with the websockets library and read transcripts as they arrive. This guide shows both against the Valmiki STT API, with retries that are never billed twice. It also closes the stream when the speaker goes quiet, because a stream is billed for every second it stays open.

Speech-to-text API in Python: Hindi and Indian-language speech-to-text API in Python
Written by
Anmol Agarwal
Published
Last updated
Reading time
6 min read
On this page
  1. Files or live audio: the two ways to call a speech-to-text API
  2. Transcribe a recording with requests
  3. Stream live audio with websockets
  4. Hindi, Tamil and Hinglish examples
  5. Billing runs per second, so close streams on silence
  6. Sources

Files or live audio: the two ways to call a speech-to-text API

Speech-to-text (STT) APIs split their work by when the audio exists. A finished recording, such as a call recording or a voice note, is sent in one HTTP request and the transcript comes back in one response. Live audio, from a microphone or a phone call in progress, is streamed over a WebSocket, a connection that stays open in both directions. The server sends back partial transcripts, its best guess so far, and final transcripts once each utterance is complete.

For a hosted API you need only two Python libraries: an HTTP client and a WebSocket client. The examples here use requests and websockets 14 or later, whose new asyncio implementation became the default in version 14.0 (websockets changelog). The SpeechRecognition package wraps more than a dozen engines and services behind one interface, from CMU Sphinx to cloud APIs, but not this one (SpeechRecognition on PyPI). Libraries that run a model on your own machine, such as faster-whisper, trade the API bill for a GPU and your own operations work, a trade-off covered in self-hosted Whisper versus an STT API.

Valmiki's two endpoints

Sonex Labs' speech-to-text API for developers follows that split, with one key for both endpoints (Sonex quickstart). `POST https://api.sonexlabs.com/v1/transcribe` takes a file and returns its transcript in one response. `wss://api.sonexlabs.com/v1/transcribe/stream` is the WebSocket for live audio. Every request carries `Authorization: Bearer vsk_...`, with a key created in the dashboard.

There is no language field to set. Valmiki detects the spoken language itself, including recordings that switch languages mid-sentence, which is what Hinglish and Tanglish calls do. Its language list has 67 entries, among them Hindi, Tamil, Telugu, Bengali, Marathi, Gujarati, Kannada, Malayalam, Punjabi, Urdu, Assamese and Nepali. Odia and dialects such as Bhojpuri are not on it yet, and Arabic is a single entry with no dialect choice.

Both endpoints share the account's rate limits of 5 requests a second and 120 a minute, and answer `429 Too Many Requests` with a `Retry-After` header beyond that. The examples below need Python 3.9 or later and `pip install requests "websockets>=14"`.

Flow chart of a speech to text API in Python: file upload with requests or a websockets live stream
The two ways to call the speech-to-text API from Python, and the order of messages on a live stream.

Transcribe a recording with requests

For a call recording, a voice note or any file you already have, send the bytes as the request body with a matching `Content-Type`. The endpoint accepts WAV, MP3, FLAC, OGG, Opus, WebM, AAC and M4A, up to 10 MB and 10 minutes. You can also send a multipart form with the audio in a field called `file`.

The reply is JSON with `text`, `duration_seconds`, `model`, `request_id` and `seconds_billed`. A recording with no intelligible speech returns an empty `text` rather than an error. Audio rejected for its format, size, length or corruption (HTTP 400, 413 or 415) is never billed.

Retries need one detail to be safe. If a request times out on your side after it succeeded on the server, a plain retry would transcribe and bill the file twice. Sending the same `Idempotency-Key` on every attempt makes a repeat within 10 minutes return the original transcript with `seconds_billed` of 0, so the example below generates one key per file and reuses it.

Recordings longer than 10 minutes must be split, not streamed, because the live stream also stops after 10 minutes. Cut them into pieces under 10 minutes and 10 MB, at pauses rather than fixed times so no word is split across two files, then join the texts. The guide to converting Hindi audio and voice notes to text includes an ffmpeg command for this and a folder script.

import time
import uuid

import requests

API_KEY = "YOUR_API_KEY"
URL = "https://api.sonexlabs.com/v1/transcribe"

session = requests.Session()               # one connection, reused for every file
session.headers["Authorization"] = f"Bearer {API_KEY}"


def transcribe(path: str, content_type: str, attempts: int = 3) -> dict:
    key = str(uuid.uuid4())                # same key on every retry: billed once
    for attempt in range(attempts):
        try:
            with open(path, "rb") as audio:
                resp = session.post(
                    URL,
                    data=audio,
                    headers={"Content-Type": content_type, "Idempotency-Key": key},
                    timeout=120,
                )
        except (requests.ConnectionError, requests.Timeout):
            if attempt == attempts - 1:
                raise
            time.sleep(2 ** attempt)
            continue
        if resp.status_code in (429, 503) and attempt < attempts - 1:
            time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
            continue
        resp.raise_for_status()            # 400, 401, 402, 413 and 415 are not worth retrying
        return resp.json()
    raise RuntimeError("unreachable")


for path, content_type in [
    ("hindi_order_query.wav", "audio/wav"),
    ("tamil_appointment.mp3", "audio/mpeg"),
    ("hinglish_voice_note.opus", "audio/ogg"),   # WhatsApp voice notes are Opus in OGG
]:
    result = transcribe(path, content_type)
    print(path, "->", result["text"])
    print("   ", result["duration_seconds"], "s of audio, billed", result["seconds_billed"], "s")
Runs as shown once you replace YOUR_API_KEY and the file names. Tested against a local mock of the API, not the live service.

Stream live audio with websockets

For a live microphone or a phone call, open the WebSocket and send binary frames of 16 kHz mono 16-bit little-endian PCM, about 100 ms each. The server first sends `{"type": "ready"}`; audio you send before that is held and delivered once the session opens, so you can start sending at once. Then `partial` messages show text as it forms, and a `final` message arrives as each utterance completes. Act on finals, and use partials only for live captions or to start work early.

Send audio at the speed it was spoken. The stream is built for live input, and audio sent faster than real time is paced before it reaches the engine, so streaming a file this way is slower and less accurate than uploading it. The example streams a WAV file in real time only to stand in for a microphone.

Phone audio usually arrives at 8 kHz, so resample it once before sending, for example with `ffmpeg -i call.wav -ar 16000 -ac 1 -sample_fmt s16 call_16k.wav`; why 8 kHz phone audio trips up 16 kHz models explains what resampling can and cannot recover. When you send `{"type": "end"}`, the last utterance is finished, a `done` message reports `seconds_billed`, and the connection closes.

The server closes a stream for you with code 4408 after 10 minutes, or 4409 after 30 seconds with no recognised speech. Code 4401 means a missing or invalid key and 4402 an empty balance.

A browser can use the stream too, with one catch: it cannot set headers on a WebSocket, so the key goes in the URL as `?api_key=`. Anything that can read the URL, from proxies to browser history, can then read the key. Issue a short-lived key from your own backend for each session and keep the main key on your server.

import asyncio
import json
import wave

import websockets                          # pip install "websockets>=14"

API_KEY = "YOUR_API_KEY"
URL = "wss://api.sonexlabs.com/v1/transcribe/stream"
FRAME_MS = 100


def pcm_frames(path: str):
    """Yield 100 ms frames of 16 kHz mono 16-bit PCM from a WAV file."""
    with wave.open(path, "rb") as wf:
        if (wf.getframerate(), wf.getnchannels(), wf.getsampwidth()) != (16000, 1, 2):
            raise ValueError("convert first: ffmpeg -i in.wav -ar 16000 -ac 1 -sample_fmt s16 out.wav")
        samples_per_frame = 16000 * FRAME_MS // 1000
        while chunk := wf.readframes(samples_per_frame):
            yield chunk


async def stream(path: str) -> None:
    headers = {"Authorization": f"Bearer {API_KEY}"}
    async with websockets.connect(URL, additional_headers=headers) as ws:

        async def send_audio():
            for frame in pcm_frames(path):
                await ws.send(frame)
                await asyncio.sleep(FRAME_MS / 1000)    # real time, as a microphone would
            await ws.send(json.dumps({"type": "end"}))  # stop the meter as soon as you are done

        sender = asyncio.create_task(send_audio())
        try:
            async for message in ws:
                event = json.loads(message)
                if event["type"] == "partial":
                    print("\r  ...", event["text"], end="", flush=True)
                elif event["type"] == "final":
                    print("\rfinal:", event["text"])
                elif event["type"] == "error":
                    print("\nerror:", event["message"])
                elif event["type"] == "done":
                    print(f"billed {event['seconds_billed']} s, request {event['request_id']}")
                    break
        except websockets.ConnectionClosedError as exc:
            print("closed by server:", exc.rcvd.code if exc.rcvd else "no close frame")
        finally:
            sender.cancel()


asyncio.run(stream("hinglish_call_16k.wav"))
Runs as shown with a 16 kHz mono WAV file. Tested against a local mock that follows the documented message order, not the live service.

Hindi, Tamil and Hinglish examples

The three clips below are test inputs you can download and send through either example. They were made with Pāṇini TTS from the sentences in their captions, so you know exactly what was said and can check the transcript word for word. Synthetic speech is cleaner than a real caller, so treat a good result on these clips as a smoke test, not a benchmark.

The JSON below shows the shape you get back and the kind of text to expect for each clip. It is illustrative: it was written from the clip texts and the documented response fields, not from a recorded run. Hinglish is where to look hardest. Depending on the audio, English words in a Hindi sentence may come back in Latin script or in Devanagari, so if your system needs one script, normalise it after transcription rather than assuming either.

For accuracy on real phone audio, see how word error rate is measured on 8 kHz calls in the Hindi and Hinglish speech-to-text test, and try the language pages for Hindi and Tamil in the browser first.

[
  {
    "text": "नमस्ते, मेरा ऑर्डर अभी तक नहीं आया है, क्या आप एक बार चेक कर सकते हैं?",
    "duration_seconds": 0.0,
    "model": "valmiki",
    "request_id": "<uuid>",
    "seconds_billed": 0.0
  },
  {
    "text": "நாளைக்கு காலையில பத்து மணிக்கு டாக்டரை பார்க்க முடியுமா?",
    "duration_seconds": 0.0,
    "model": "valmiki",
    "request_id": "<uuid>",
    "seconds_billed": 0.0
  },
  {
    "text": "<Hinglish sentence: English words may be in Latin script or Devanagari>",
    "duration_seconds": 0.0,
    "model": "valmiki",
    "request_id": "<uuid>",
    "seconds_billed": 0.0
  }
]
Illustrative output, not a recorded run. Durations, ids and the Hinglish script are placeholders; run the clips yourself to see real values.
Hindi test clip: an order query

नमस्ते, मेरा ऑर्डर अभी तक नहीं आया है, क्या आप एक बार चेक कर सकते हैं?

Tamil test clip: asking for a 10 a.m. appointment tomorrow

நாளைக்கு காலையில பத்து மணிக்கு டாக்டரை பார்க்க முடியுமா?

Hinglish test clip: Hindi and English in one breath

Mera order kal deliver hona tha, abhi tak nahi aaya. Can you check the status please?

Billing runs per second, so close streams on silence

Valmiki STT costs $0.00525 per minute of audio, billed by the second, which is about ₹0.50 a minute at ₹95.04 to the dollar (open.er-api.com, 24 October 2026). For files, the billed seconds are the recording's real length. For streams, they are every second the connection is open, including silence and the time spent opening the session, because a streaming transcriber is working for as long as you hold it open (API reference).

The arithmetic is simple and worth doing. A 45-second voice note costs 45 × $0.0000875 = $0.0039 (₹0.37). A 5-minute call streamed end to end costs $0.02625 (₹2.49). A stream you forget to end keeps running until the server's 30-second no-speech timeout, which adds $0.002625 (₹0.25) to every session; across 10,000 dictation sessions a month, that is $26.25 for nothing.

For push-to-talk, dictation and voice notes, end the stream yourself when the speaker stops. The helper below watches the loudness of each frame and sends `end` after 1.5 seconds of quiet, once someone has spoken. On a phone agent, keep one stream open for the whole call instead: every new session spends time opening, and that time is billed too. Log `seconds_billed` from every `done` message so idle streams show up in your own numbers, and see how per-second STT billing treats silence for the wider picture.

import array
import json
import math

SILENCE_RMS = 300      # PCM16 level treated as quiet; tune it on your own line
END_AFTER_S = 1.5      # send "end" after this much quiet, once someone has spoken
FRAME_S = 0.1          # 100 ms frames


def rms(frame: bytes) -> float:
    samples = array.array("h", frame)       # native byte order: little-endian on x86 and ARM
    return math.sqrt(sum(s * s for s in samples) / max(len(samples), 1))


async def send_until_quiet(ws, frames) -> None:
    """Forward live frames, then close the turn after END_AFTER_S of quiet."""
    heard_speech, quiet = False, 0.0
    async for frame in frames:              # e.g. an async generator over your microphone
        await ws.send(frame)
        if rms(frame) >= SILENCE_RMS:
            heard_speech, quiet = True, 0.0
        else:
            quiet += FRAME_S
        if heard_speech and quiet >= END_AFTER_S:
            break
    await ws.send(json.dumps({"type": "end"}))
An excerpt to drop into the streaming example in place of send_audio. The 300 level and 1.5 s are starting points; tune them on your own microphones or lines.

Sources

  1. Sonex Labs API quickstart: transcribe a recording, transcribe a live stream, rate limitsOfficial
  2. Sonex Labs API reference (POST /v1/transcribe, WS /v1/transcribe/stream)Official
  3. websockets changelog: new asyncio implementation is the default from 14.0Official
  4. SpeechRecognition on PyPI (supported engines; version 3.17.0, June 2026)Official
  5. ExchangeRate-API open access rates (USD to INR, 24 October 2026)Official

Change log: 24 Oct 2026, First published. Later the same day, rewritten as one continuous guide: opens with how STT APIs split files from live audio and which Python libraries you need; the browser-key, long-recording and library questions moved from the FAQs into the body; the takeaways, steps and checklist folded into the sections. All code, the audio clips and the illustrative JSON unchanged

Questions about your calling setup?

Tell us what you call about, where and in which languages.