DEV Community

Low Key Builds
Low Key Builds

Posted on

Urdu voice notes to text on a free tier: Whisper pinned to Urdu, Roman Urdu without drift, and an honest error rate

Pakistan runs on WhatsApp voice notes. WhatsApp now writes transcripts for some languages, but Urdu is not in any list of supported languages I could find, and a Hindi transcript of Urdu speech comes out in Devanagari, which most Pakistanis do not read.

So I built Sunlo, a free web app: drop in a voice note, get Urdu script, Roman Urdu (the way people actually type on WhatsApp) and an English translation. Disclosure: I made it, and this post is how it works, including what it gets wrong.

The constraints: no budget, no audio stored anywhere, and a cheap Android phone as the main client.

1. Whisper on Workers AI, language pinned to Urdu

Cloudflare Workers AI hosts whisper-large-v3-turbo. The one setting that matters is language: 'ur'. Left to auto-detect, Whisper often decides Urdu speech is Hindi (the spoken languages are close) and writes Devanagari.

const w = await env.AI.run('@cf/openai/whisper-large-v3-turbo', {
  audio: base64Audio,      // WhatsApp .opus works as-is
  language: 'ur',          // never auto-detect for Urdu
  task: 'transcribe',
  vad_filter: true,        // skips silence in voice notes
});
Enter fullscreen mode Exit fullscreen mode

WhatsApp's .opus files go straight in; no ffmpeg needed on the server.

2. A hard daily cap, enforced before the model runs

The free Workers AI allocation is 10,000 "neurons" a day. Whisper turbo costs about 46 neurons per audio minute ($0.00051/min at $0.011 per 1k neurons). The Worker keeps a per-day counter in D1 and reserves the estimated cost before calling the model, so parallel requests cannot overshoot, then corrects it with the real duration afterwards:

const est = seconds / 60 * 46.4 + 40;
const b = await env.DB.prepare('SELECT neurons FROM budget WHERE day=?').bind(day).first();
if ((b?.neurons || 0) + est > 8000) return json({ error: 'daily_cap' }, 503);
await env.DB.prepare(`INSERT INTO budget (day, neurons) VALUES (?, ?)
  ON CONFLICT(day) DO UPDATE SET neurons = neurons + excluded.neurons`).bind(day, est).run();
Enter fullscreen mode Exit fullscreen mode

Per-visitor limits use a salted daily hash of the IP, so nothing identifying is kept. Audio and text are never written anywhere; D1 only holds counts, durations and timings.

3. Roman Urdu: transliterate, do not translate

Roman Urdu and English come from one call to Llama 4 Scout (about 11 neurons for a short note). The first prompt said "write it in Roman Urdu" and the model drifted into English words ("patient ko dusre se alag karna include hai"). Saying TRANSLITERATE, with two examples of how people type, fixed it:

"roman": TRANSLITERATE, do not translate. Write every Urdu word as the same
Urdu word in Latin letters, the way Pakistanis type on WhatsApp
(for example "main kal aaunga, aap fikar na karein").
Enter fullscreen mode Exit fullscreen mode

Llama 3.1 8B was cheaper but changed words ("aaj", today, became "kal"). Scout, Llama 3.3 70B and gpt-oss-120b were all fine on clean text.

4. Long recordings: cut at silence, in the browser

Lectures and interviews are longer than one request should be. The browser decodes the file with AudioContext({ sampleRate: 16000 }), which also resamples to what Whisper wants, then cuts it into ~150-second pieces. Each cut goes at the quietest 50 ms window in the last quarter of the piece, so words are not split:

for (let w0 = lo; w0 < hi; w0 += win) {
  let e = 0;
  for (let j = w0; j < w0 + win; j++) e += mix[j] * mix[j];
  if (e < bestE) { bestE = e; best = w0 + (win >> 1); }
}
Enter fullscreen mode Exit fullscreen mode

Pieces are transcribed one by one, joined, and then translated in a single pass, because translating fragments separately produced nonsense at the seams.

5. Share target: one tap from WhatsApp

On Android, an installed PWA can appear in WhatsApp's Share sheet. The manifest declares a share_target with a multipart POST; the service worker catches it, stashes the file in Cache Storage and redirects to the page, which picks it up:

"share_target": { "action": "/share-target", "method": "POST",
  "enctype": "multipart/form-data",
  "params": { "files": [{ "name": "audio", "accept": ["audio/*", ".opus", ".ogg"] }] } }
Enter fullscreen mode Exit fullscreen mode

How accurate is it?

I ran the first 30 clips of the FLEURS Urdu test set (28 downloaded) through the live API, encoded as Opus like a WhatsApp note:

Condition Word error rate Character error rate
Clean 20.8% 8.5%
White noise at 15 dB SNR 23.0% 9.3%

That counts spelling variants and "70" against "ستر" as errors, so the text reads better than one wrong word in five suggests. Real voice notes are harder than read sentences.

The failure I worry about most: in a noisy test, "jald rihaa" (released quickly) was heard as "jal raha" (burning), and every model I tried, including the 70B and 120B ones, then translated the sentence as hostages being set on fire. A recognition slip becomes an alarming English sentence. The prompt now tells the model not to invent dramatic meanings, but the honest fix is in the interface: every result says it is a machine transcript and to check money, medicine, addresses and times against the audio. The method and examples are on the accuracy page.

Try it

sunlo.appin60.com is free, needs no account, and handles notes up to 10 minutes. If you speak Urdu, I would like to hear where it goes wrong: sunlo@appin60.com.

Top comments (1)

Collapse
 
starlightxbaby profile image
Lolita Starlight •

The jald rihaa → jal raha miss is worse than the 20.8% WER table, because the translation pass then has a fluent sentence to defend and every model you tried turned a release into a fire. Translating only after the pieces are joined avoids seam nonsense, but it also means one bad token in piece two can rewrite the English for the whole note. The quietest 50 ms cut in the last quarter still worries me when a lecture has no real pause there — that window can sit inside a word and the join hides it.