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
});
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();
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").
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); }
}
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"] }] } }
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)
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.