Build Searchable Arabic Audio with Word Timestamps
A transcript is more useful when a search result can take someone back to the moment the words were spoken. Kalemio returns every word with a start and end time in seconds. Your application supplies the search, the player and the interface.
This guide builds that feature around a saved result. To see the finished version first, open the reference project. It starts on an illustrative saved result, so it needs no API key and costs nothing to run. Download the project to get its tests and an optional local Node server. The saved result was written for the example: it is not the output for a real recording, and no audio comes with it, so Play without audio runs a timer through it. To hear a seek on a recording of your own, run the optional local server with your key.
Start from the result you save
A completed job’s JSON result has text, duration_seconds and words. Each word has its text, its start and end in seconds from the beginning of the submitted recording, and a speaker_id, which is a label within that recording or null. The API guide describes the fields.
Follow the quickstart with a short recording you have permission to process: it submits the recording, waits for the job and saves the JSON. Your application’s search and player are built from that file.
Save the result before it expires
Results are kept for 24 hours after submission, and every job reports the deadline as expires_at. After it, GET /v1/transcriptions/{id}/result returns 410. The job itself stays readable, so you can still tell an expired result from one that never completed. Treat the API as the place a result arrives, not as transcript storage: save the JSON as soon as the job is completed, together with the job ID, expires_at and anything you need to rebuild the timeline.
Plan each state your application can see:
queuedorrunning- Show: That the job was accepted and is waiting.
Do: Keep polling, less often while it is queued. A Free plan job (60 minutes a month, slower queue) can wait a while; don’t upload the recording again. completed- Show: The search page.
Do: Download the JSON and save it. failed- Show: The job’s
errormessage.
Do: Offer a retry with a new Idempotency-Key. Failed jobs return prepaid minutes and a failed Free plan job doesn’t count. - Expired
- Show: That the API copy is gone, and whether you hold a saved copy.
Do:410from/result: use your saved JSON, or send the recording again with a new Idempotency-Key, which starts and bills a new job. - Rejected upload
- Show: The status and message.
Do:402(minutes used up) and429(wait forRetry-After) are ordinary conditions, not crashes. Errors and limits lists the rest.
Keep display text and search text separate
Show the transcript exactly as the API returned it. If you add a normalized form for searching, keep it in a separate value, so search rules can never silently rewrite what people read.
Arabic spelling variants make exact matching too narrow: a query typed without diacritics or with a bare alef would miss the same word written otherwise. Aggressive folding has the opposite fault, merging words you did not mean to treat as equivalent. Start with a small, documented rule set and test it on the recordings your users actually have.
// For matching only. Never write the result back over the text you display.
function fold(text) {
return text.normalize('NFKC')
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '') // direction and zero-width marks
.replace(/[\u064B-\u065F\u0670\u0640]/g, '') // diacritics, superscript alef, tatweel
.replace(/[\u0622\u0623\u0625\u0671]/g, '\u0627') // alef with madda, hamza or wasla -> alef
.replace(/\u0649/g, '\u064A') // alef maqsura -> ya
.replace(/[\u0660-\u0669\u06F0-\u06F9]/g, d => d.charCodeAt(0) & 15) // Arabic-Indic and Persian digits -> 0-9
.toLowerCase()
.replace(/\p{M}+/gu, '') // any other combining mark
.replace(/[^\p{L}\p{N}]+/gu, ' ') // punctuation and symbols -> one space
.trim();
}These rules are deliberately few: they remove marks and unify letter forms that writers use interchangeably, and they do nothing to roots, plurals or the dialect spelling. They leave out folding ة into ه, which often end different words, and the hamza seats ؤ ئ ء. The most aggressive rule folds ى into ي, which makes على and علي equal; keep or drop it after testing on your own recordings. The reference project documents the same rule set in a table and tests each rule.
Find a passage and seek the player
Search one joined string of the folded words, remembering where each word begins in it. A phrase then matches across consecutive words, and a query still matches when a prefix such as و or ال is attached to the word. Return the time to seek to rather than only the matching word. Start playback a little before the match so the listener hears the whole phrase, and make that lead a constant you can see and change.
const SEEK_LEAD = 1; // seconds before the match, so the first sound is heard
const CONTEXT_WORDS = 4; // words shown on each side of the match
// One search string for the whole recording, and where each word begins inside it.
function buildIndex(words) {
let text = '';
const starts = [];
words.forEach((word, i) => {
const key = fold(word.text);
if (!key) return;
if (text) text += ' ';
starts.push({ at: text.length, word: i });
text += key;
});
return { text, starts };
}
function find(words, index, query) {
const needle = fold(query);
if (!needle) return [];
const wordAt = at => index.starts.findLast(entry => entry.at <= at).word;
const show = (from, to) => words.slice(Math.max(0, from), to).map(word => word.text).join(' ');
const hits = [];
for (let at = index.text.indexOf(needle); at !== -1; at = index.text.indexOf(needle, at + needle.length)) {
const first = wordAt(at), last = wordAt(at + needle.length - 1);
hits.push({
seekTo: Math.max(0, words[first].start - SEEK_LEAD),
before: show(first - CONTEXT_WORDS, first),
match: show(first, last + 1),
after: show(last + 1, last + 1 + CONTEXT_WORDS),
});
}
return hits;
}const words = flatten(pieces); // pieces from your saved results, each with its start offset
const index = buildIndex(words); // build once, search many times
const [hit] = find(words, index, query);
if (hit) audio.currentTime = hit.seekTo; // audio is your <audio> elementShow the words around each match, with the match marked, and a control that plays from there. Someone checking a result should be able to hear it and read the words around it before trusting it.
Long recordings and piece offsets
Each upload is limited to 20 MiB and 10 minutes, so a longer recording is split into pieces and submitted as separate jobs, as the API guide shows. Word times start at zero in every piece. To point at the right moment in the original audio, add the piece’s start time to each word’s start and end, then join the words in order.
// pieces: [{ start: 0, result }, { start: 590, result }, ...]
// "start" is the piece's start time in the original recording, in seconds (pieces.csv).
function flatten(pieces) {
return pieces.flatMap((piece, index) => piece.result.words.map(word => ({
text: word.text,
start: piece.start + word.start,
end: piece.start + word.end,
// Speaker labels belong to one piece; keep the piece with the label.
speaker: index + ':' + (word.speaker_id ?? ''),
})));
}Check the joins explicitly. A word spoken exactly at a cut can be split or missed, so a phrase that spans a cut may not match; cut at a pause when you can. The reference project also tries the last word of one piece joined to the first word of the next when they are close in time, without rewriting either word. Speaker labels are assigned per job: speaker_0 in one piece does not establish the same person in the next, so keep the piece with the label. Build the combined text from the joined words, not by concatenating each piece’s text.
Run the reference project
The reference project is a self-contained folder with no dependencies and no build step. Its default mode opens a saved result of a two-piece recording, with an audio player, a word timeline and the search above, and applies each piece’s offset. Open index.html, or download the project; it includes the tests and README.md.
An optional local server shows the integration with your own key. It reads the key from the KALEMIO_API_KEY environment variable only, uploads a recording, polls the job, saves the JSON and serves it to the page, so the key never reaches the browser. Running it submits real jobs, which use real minutes on your plan.
It does not host a public playground, store anything beyond the saved files, or prove who is speaking across pieces, and it is not hardened for production. Treat timing as good for seeking; tight word highlighting needs its own review, since a correctly spelled word can still carry a boundary that is unsuitable for it.
Evaluate one complete path
Check one path from end to end: upload, completion, saved JSON, a search result and playback. Then try a recording with different speakers or conditions. Review the transcription and the timing separately, and keep names, numbers and commitments easy to check against the audio. A demo clip is a starting point, not evidence that every recording will behave the same way.
Follow the API quickstart Open the reference project
Related: what the API returns and its limits, word-level timestamps and long recordings.