Streaming (WebSocket)
Add AI recitation feedback with real-time tajweed correction, tilawa session streaming, and error codes.
Description
Nabr - AI Quran Recitation Corrector, is a service where users can recite and get their recitation corrected in realtime. You will need a subscription to get an API Key and use this feature.
To grade a recording you already have, in a single HTTP request, use the offline endpoint.
The API is a WebSocket that speaks binary MessagePack — every
message you send or receive is encoded/decoded with
@msgpack/msgpack.
The flow is:
- Connect to the WebSocket with your API key.
- Start a session by sending the
START_TILAWA_SESSIONmethod (once per session — when the page loads, for example). Wait forTILAWA_SESSION_STARTEDbefore streaming: audio sent earlier is dropped. - Stream audio chunks with the
CHECK_TILAWA_AUDIOmethod while recording. - Receive
TILAWA_RESULTevents in realtime with confirmed words, words still being heard, and mistakes. The server also sendsKEEP_ALIVEabout every 15 seconds. - Stop by sending the
RESET_TILAWA_SESSIONmethod.
CLI client
A complete standalone client is at nabr-client. It streams a recitation to this WebSocket and prints the mushaf words the server matched, live, one ayah per line. There are two clients with the same flags:
| Client | Runtime | Good for |
|---|---|---|
| Python | Python 3.11+ with uv | Quick runs with no install step |
| Dart | Dart SDK 3.5+ | A single compiled binary, and a reference for Flutter apps |
Audio can come from the microphone, an audio file, or raw PCM piped on stdin. Both clients read the API key from NABR_API_KEY and connect to wss://api.qurani.ai/v2 by default. Pass --url or set NABR_WS_URL for another server.
export NABR_API_KEY="your-api-key"
uv run python/mic_tilawa_client.py --surah 1export NABR_API_KEY="your-api-key"
cd dart
dart pub get
dart run mic_tilawa_client.dart --surah 1The word text comes from the Hafs word-by-word edition. The server sends ayah and word numbers, and the client looks them up. The speech-to-text transcript is not printed. Flags, audio formats, and exit codes are in the repository README.
Installation
npm install @msgpack/msgpackMicrophone audio is captured with the browser's built-in MediaRecorder API — no extra dependency needed. WAV chunks need no extra package. Opus packets need an Opus encoder.
Audio
Audio is 16 kHz, mono. Set audio_format on the WebSocket URL. Each CHECK_TILAWA_AUDIO message is decoded on its own — do not split one file across messages, and do not send a WebM or Ogg container.
The first result is returned after about 3 seconds of audio containing speech. Later results follow about every 500 ms of new audio. Send at roughly playback speed. The server keeps about one second of 20 ms Opus packets in its decode queue and drops older packets when that queue is full.
Opus
audio_format=opus (the server default).
Each audio value is one raw Opus packet:
| Frame | 20 ms |
| Samples | 320 (16 000 × 0.020) |
| Channels | 1 |
| PCM before encode | signed 16-bit little-endian |
| Messages | one packet each, about 50 per second |
const OPUS_FRAME_SAMPLES = 320; // 20 ms at 16 kHz
// `frame` is exactly 320 s16le samples. `packet` is the Opus packet bytes,
// not a WebM or Ogg blob.
const packet: Uint8Array = encoder.encode(frame);
socket.send(
encode({
method: "CHECK_TILAWA_AUDIO",
audio: packet,
client_sent_at_ms: Date.now(),
}),
);WAV
audio_format=wav.
Each audio value is a complete WAV file (RIFF header plus PCM), 16 kHz, mono, 16-bit. A 200 ms chunk is 3 200 samples (6 400 bytes of PCM, plus the 44-byte header). Shorter or longer files are fine as long as each message is a whole WAV.
Privacy mode
By default, recordings may be used to improve the Nabr AI model. To opt out, turn off Help improve Nabr in Dashboard → Settings. The setting applies to every new connection made with your API key; it can't be set from the WebSocket URL.
Usage Example
import { useEffect, useRef, useState } from "react";
import { encode, decode } from "@msgpack/msgpack";
// Methods you send to the server
const WSMethods = {
START_TILAWA_SESSION: "START_TILAWA_SESSION",
CHECK_TILAWA_AUDIO: "CHECK_TILAWA_AUDIO",
RESET_TILAWA_SESSION: "RESET_TILAWA_SESSION",
} as const;
// Events you receive from the server
const WSEvents = {
QRC_CLIENT_CONNECTED: "QRC_CLIENT_CONNECTED",
TILAWA_SESSION_STARTED: "TILAWA_SESSION_STARTED",
TILAWA_SESSION_ENDED: "TILAWA_SESSION_ENDED",
TILAWA_RESULT: "TILAWA_RESULT",
KEEP_ALIVE: "KEEP_ALIVE",
QRC_ERROR: "QRC_ERROR",
} as const;
const SAMPLE_RATE = 16000;
const WAV_CHUNK_SAMPLES = 3200; // 200 ms
const WS_URL =
"wss://api.qurani.ai/v2?api_key=<YOUR_API_KEY>&lang=ar&audio_format=wav";
function pcmToWav(samples: Int16Array): Uint8Array {
const dataBytes = samples.length * 2;
const buffer = new ArrayBuffer(44 + dataBytes);
const view = new DataView(buffer);
const write = (offset: number, value: string) => {
for (let i = 0; i < value.length; i++) {
view.setUint8(offset + i, value.charCodeAt(i));
}
};
write(0, "RIFF");
view.setUint32(4, 36 + dataBytes, true);
write(8, "WAVE");
write(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, SAMPLE_RATE, true);
view.setUint32(28, SAMPLE_RATE * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, "data");
view.setUint32(40, dataBytes, true);
new Int16Array(buffer, 44).set(samples);
return new Uint8Array(buffer);
}
export default function QrcExample() {
const connection = useRef<WebSocket | null>(null);
const audioContext = useRef<AudioContext | null>(null);
const micStream = useRef<MediaStream | null>(null);
const [connected, setConnected] = useState(false);
const [recording, setRecording] = useState(false);
// 1. Connect and listen for messages
useEffect(() => {
const socket = new WebSocket(WS_URL);
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => setConnected(true));
socket.addEventListener("close", () => setConnected(false));
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
// Every message is MessagePack-encoded binary
const message = decode(new Uint8Array(event.data)) as {
event: string;
[key: string]: unknown;
};
switch (message.event) {
case WSEvents.QRC_CLIENT_CONNECTED:
// Connection acknowledged — message.websocket_id, message.credits_balance
break;
case WSEvents.TILAWA_SESSION_STARTED:
// Session is ready — message.session_id, message.exit_code (0–8)
break;
case WSEvents.TILAWA_SESSION_ENDED:
// Session ended (reset or surah finished) — message.session_id, message.asr_call_count
break;
case WSEvents.KEEP_ALIVE:
// Server heartbeat every ~15s — message.timestamp, message.websocket_id
break;
case WSEvents.TILAWA_RESULT:
// Realtime feedback on the recitation:
// message.verse_index / message.word_index -> current position
// message.pending_words -> heard, not confirmed yet (replace the last list)
// message.confirmed_words -> newly confirmed words
// message.skipped_words -> memorization errors
// message.substituted_words -> another word said in place of a confirmed word
// message.tajweed_mistakes -> tajweed errors (error_code)
// message.letter_mistakes -> phonetic errors (error_code)
console.log("result", message);
break;
case WSEvents.QRC_ERROR:
console.error(`${message.code}: ${message.message}`);
break;
}
});
connection.current = socket;
return () => socket.close();
}, []);
// 2. Start the session, then stream microphone audio
const startRecording = async () => {
const socket = connection.current;
if (socket?.readyState !== WebSocket.OPEN) return;
// Start a tilawa session (send once per session)
// Audio sent before TILAWA_SESSION_STARTED arrives is dropped, so wait for it in a real app.
socket.send(
encode({
method: WSMethods.START_TILAWA_SESSION,
chapter_index: 1, // surah number
verse_index: 1, // starting verse
word_index: 1, // starting word
hafz_level: 1, // 1-3
tajweed_level: 1, // 1: memorization + tajweed and phonetic
}),
);
// Capture 16 kHz mono PCM and send a complete 200 ms WAV every chunk.
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, sampleRate: SAMPLE_RATE },
});
micStream.current = stream;
const context = new AudioContext({ sampleRate: SAMPLE_RATE });
audioContext.current = context;
const source = context.createMediaStreamSource(stream);
const processor = context.createScriptProcessor(4096, 1, 1);
const pending: number[] = [];
processor.onaudioprocess = (event) => {
if (connection.current?.readyState !== WebSocket.OPEN) return;
const input = event.inputBuffer.getChannelData(0);
for (let i = 0; i < input.length; i++) {
const sample = Math.max(-1, Math.min(1, input[i]));
pending.push(sample < 0 ? sample * 0x8000 : sample * 0x7fff);
}
while (pending.length >= WAV_CHUNK_SAMPLES) {
const frame = Int16Array.from(pending.splice(0, WAV_CHUNK_SAMPLES));
connection.current.send(
encode({
method: WSMethods.CHECK_TILAWA_AUDIO,
audio: pcmToWav(frame),
client_sent_at_ms: Date.now(),
}),
);
}
};
const mute = context.createGain();
mute.gain.value = 0;
source.connect(processor);
processor.connect(mute);
mute.connect(context.destination);
setRecording(true);
};
// 3. Stop recording and reset the session on the server
const stopRecording = () => {
audioContext.current?.close();
audioContext.current = null;
micStream.current?.getTracks().forEach((track) => track.stop());
micStream.current = null;
if (connection.current?.readyState === WebSocket.OPEN) {
connection.current.send(
encode({ method: WSMethods.RESET_TILAWA_SESSION }),
);
}
setRecording(false);
};
return (
<button
disabled={!connected}
onClick={recording ? stopRecording : startRecording}
>
{recording ? "Stop recording" : "Start recording"}
</button>
);
}How results arrive
Each TILAWA_RESULT reflects all the audio heard so far. A word goes through three stages:
- Heard.
pending_wordslists the words heard after the confirmed position. Each result sends the full list, so replace the previous one and show these words as tentative. The list can also be empty during normal recitation, for example when the reciter repeats earlier words, and at the end of the surah. When the audio matches nothing it usually keeps its last value, so an empty list does not mean the server lost the reciter's place. - Confirmed. A word is confirmed once the server has verified it. It is reported once, in
confirmed_wordsorskipped_words, and leavespending_words. - Graded. The server checks a confirmed word again on each later result that still hears it. Its tajweed mistakes, letter mistakes, or substitution usually arrive a few results after the word was listed in
confirmed_words. If a word leaves the audio window before its grading finishes, no mistakes are reported for it.
Position and exit codes:
-
verse_indexandword_indexgive the last confirmed word whenconfirmed_wordsis not empty. Otherwise they give the next expected word. Soword_indexcan move forward without a new word being confirmed:// Words 1 and 2 of verse 1 were just confirmed: the position is word 2. { confirmed_words: [ { chapter: 1, verse: 1, word: 1 }, { chapter: 1, verse: 1, word: 2 }, ], verse_index: 1, word_index: 2, } // The next result confirms nothing new: the position is the next expected word. { confirmed_words: [], verse_index: 1, word_index: 3, } -
exit_code6with empty lists means nothing new was confirmed. -
exit_code7means the audio was checked but did not match the surah near the current position, or speech recognition timed out. The session stays active. Packets that do not trigger a check get no result. -
After the last word is confirmed, results keep
exit_code6while its grading finishes. Then8is sent. These results only come while audio is arriving, so keep streaming until8arrives, about 2 seconds after the last word.
Grading also follows how the verse was actually read:
- Pauses. Each word is graded as it was read: connected, or stopped on (waqf). Verse ends are always graded as stops. Inside a verse, a word is graded as a stop when a pause is heard after it, and the word after the pause is graded as a fresh start.
- Speech-model slips. If a word has a letter change that readers do not make, such as a swap between unrelated letters, it is treated as a speech-model error and no mistakes are reported for that word. Harakat and madd slips, swaps between letters readers confuse, and a dropped first or last letter are reported.
API versions
Connect to wss://api.qurani.ai/v2, which is what this page describes. wss://api.qurani.ai with no version stays available for existing clients. It uses the older field names below, and its results can differ because /v2 always runs the latest checker.
wss://api.qurani.ai | wss://api.qurani.ai/v2 |
|---|---|
correct_words | confirmed_words |
speachLike, speach_like | speech_like |
errorCode | error_code |
Types
Payloads you send (always MessagePack-encoded):
type StartTilawaSessionPayload = {
method: "START_TILAWA_SESSION";
chapter_index: number;
verse_index: number;
word_index: number;
hafz_level: number; // 1-3
tajweed_level: number; // 1–5. 1: memorization + tajweed and phonetic. 2–5: memorization only
};
type CheckTilawaAudioPayload = {
method: "CHECK_TILAWA_AUDIO";
// Opus: one 20 ms raw packet (320 samples at 16 kHz, mono).
// WAV: one complete 16 kHz mono 16-bit WAV file.
audio: Uint8Array;
client_sent_at_ms?: number;
};
// Signals recording stopped. The server checks the audio it already received,
// then ends the session. Send START_TILAWA_SESSION to begin another.
type ResetTilawaSessionPayload = {
method: "RESET_TILAWA_SESSION";
};
// Optional. The server answers with a KEEP_ALIVE event that echoes sent_at.
type KeepAlivePayload = {
method: "KEEP_ALIVE";
sent_at: number; // client time in ms
};Events you receive (decode each message with MessagePack):
type QRCClientConnected = {
event: "QRC_CLIENT_CONNECTED";
websocket_id: string;
credits_balance?: number | null;
};
type TilawaSessionStarted = {
event: "TILAWA_SESSION_STARTED";
exit_code: number; // 0–8; 6 = success
session_id: string;
};
type TilawaSessionEnded = {
event: "TILAWA_SESSION_ENDED";
session_id: string;
asr_call_count: number;
};
type QRCWord = {
chapter: number;
verse: number;
word: number;
};
type Mistake = {
chapter: number;
verse: number;
word: number;
letter: number; // zero-based index in the word's phonemes, not its Arabic characters
speech_like: string; // the word with tashkeel
message: string; // localized tip; some include the reason
error_code?: string | null; // stable id, e.g. "tadeef_extra"
};
type SubstitutedWord = {
chapter: number; // the expected word
verse: number;
word: number;
said: QRCWord | null; // the Quran word read instead; null if not a Quran word
speech_like: string; // what was heard, in Arabic letters
message: string; // localized, e.g. "You read (X) instead of (Y)"
};
type TilawaResult = {
event: "TILAWA_RESULT";
exit_code: number; // 0–8; 6 = success, 8 = surah finished
websocket_id: string; // the session id
request_id: string;
processing_time_ms: number; // integer; excludes network time
completed_at: number; // unix seconds
client_sent_at_ms: number | null; // echoed from the audio message
chapter_index: number;
verse_index: number;
word_index: number;
pending_words: QRCWord[]; // full snapshot; replaces the previous one
confirmed_words: QRCWord[]; // newly confirmed only
skipped_words: QRCWord[];
substituted_words: SubstitutedWord[];
tajweed_mistakes: Mistake[];
letter_mistakes: Mistake[];
};
type KeepAlive = {
event: "KEEP_ALIVE";
websocket_id: string;
timestamp: number; // unix seconds
client_sent_at_ms?: number | null;
};
type QRCError = {
event: "QRC_ERROR";
websocket_id: string;
code: string; // see QRC_ERROR codes below
message: string;
};Errors
TILAWA_RESULT reports three kinds of deviation. exit_code says whether the check itself succeeded. QRC_ERROR is a separate socket event for connection and request failures, and it does not use these codes. It does not close the socket.
QRC_ERROR codes
code | Meaning | What to do |
|---|---|---|
validation_error | The message is a text frame or not valid MessagePack, or has an unknown method or a missing or invalid field | Fix the message. Do not resend it unchanged |
no_active_session | Audio arrived before a session started, after it was reset, or after a failed start. Sent once, and the audio is ignored | Send START_TILAWA_SESSION and wait for TILAWA_SESSION_STARTED |
invalid_audio | Audio could not be decoded in the audio_format set on the URL, for example a WebM or Ogg container. Sent once per run of bad packets | Send audio in the documented format |
internal_error | The server failed while handling a message. The result for that message is lost, and the session stays active | Keep streaming. Reconnect only if it repeats |
backpressure | An outgoing message could not be queued. Not sent with the default server configuration | Reconnect only if it repeats |
A bad API key or an invalid URL parameter rejects the connection itself, so no QRC_ERROR is sent.
Tajweed level
tajweed_level is sent with START_TILAWA_SESSION. Accepted values are 1–5. Level 1 adds tajweed. Every other level returns memorization errors only:
| Level | Included |
|---|---|
1 | Memorization errors (skipped_words, substituted_words), plus tajweed. Tajweed currently includes both tajweed mistakes (tajweed_mistakes) and phonetic mistakes (letter_mistakes). |
2–5 | Memorization errors only (skipped_words, substituted_words) |
Exit codes
exit_code is an integer from 0 to 8. chapter_index, verse_index, and word_index are the surah, ayah, and word being processed.
| Code | Result |
|---|---|
0 | Chapter is not supported |
1 | Verse was not found |
2 | Word in the verse was not found |
3 | Hafz level is not supported |
4 | Tajweed level is not supported |
5 | Unexpected problem |
6 | Success |
7 | Check failed |
8 | Surah finished |
Memorization
A skipped word is a word missing from the recitation: omitted, or said so differently that it could not be matched in its place. The speech model may also fail to identify it. These words are listed in skipped_words as chapter, verse, and word. They have no error_code.
A word said wrongly but still matched in its place is not skipped. It is listed in confirmed_words and then graded, as letter mistakes or as a substituted word.
Substituted words
A word is listed in substituted_words when a different word was read in its place. The word read can be another Quran word. It can also be a misreading with enough changed letters that it is a different word, not a slip. Substitutions are reported at every tajweed_level. The word was already listed in confirmed_words, so mark it as wrong when the substitution arrives. A substituted word has no tajweed or letter mistakes.
| Field | Meaning |
|---|---|
chapter, verse, word | The expected word |
said | The Quran word read instead, or null when the word read is not in the Quran |
speech_like | What was heard, in Arabic letters. |
message | A localized message, such as "You read (X) instead of (Y)" |
Mistake entries
Tajweed errors are in tajweed_mistakes. Phonetic errors are in letter_mistakes. Both are found on words that were identified (confirmed_words), arrive a few results after them, and both use Mistake:
| Field | Meaning |
|---|---|
letter | Zero-based index in the word's phonemes (consonants and vowels), not its Arabic characters |
speech_like | The word written with tashkeel |
message | A localized tip. Some messages also give the reason |
error_code | Stable id for the case, such as tadeef_extra |
Tajweed codes
These codes are error_code on a tajweed_mistakes entry.
The tajweed and phonetic rules below are under continuous evaluation. Which cases are reported may change depending on the underlying speech model, so handle every code and do not rely on any one being reported.
error_code | Rule | Meaning |
|---|---|---|
ghunna_extra | Ghunnah (الغنة) | Nasalization was added |
ghunna_missed | Ghunnah (الغنة) | Required nasalization is missing |
qalqala_extra | Qalqalah (القلقلة) | An echo was added. Qalqalah applies to saakin ق ط ب ج د |
qalqala_missed | Qalqalah (القلقلة) | The echo on a saakin ق، ط، ب، ج، or د is missing |
iqlab_missed | Iqlab (الإقلاب) | Noon saakin or tanween was not turned into a hidden meem before ب |
idgham_missed | Idgham (الإدغام) | A required merge into ي، ر، م، ل، و، or ن, with or without ghunnah, is missing |
ikhfa_extra | Ikhfa (الإخفاء) | Ikhfa was applied where it does not belong |
ikhfa_missed | Ikhfa (الإخفاء) | Required ikhfa is missing |
tafkhem_extra | Tafkheem / tarqeeq (التفخيم / الترقيق) | The letter was made heavy; it should be light |
tafkhem_missed | Tafkheem / tarqeeq (التفخيم / الترقيق) | Required heaviness is missing |
madd_extra | Madd (المد) | A madd was added |
madd_missed_02 | Madd (المد) | A 2-haraka madd is missing |
madd_12_02 | Madd (المد) | More than 2 harakat were read; only 2 are required |
madd_missed_12 | Madd (المد) | A madd longer than 2 harakat is missing |
madd_02_12 | Madd (المد) | Only 2 harakat were read; more than 2 are required |
Phonetic codes
These codes are error_code on a letter_mistakes entry. They mark a change in the letter itself: a substitution, an extra sound, or a missing sound.
error_code | Kind | Meaning |
|---|---|---|
letters_mismatch | Substitution (استبدال الأحرف) | A letter or vowel was replaced by another |
letters_extra | Insertion (إضافة أحرف) | A letter or vowel was pronounced that is not in the word |
letters_missed | Deletion (حذف الأحرف) | A letter or vowel that should be pronounced was dropped |
tadeef_extra | Tadeef (التضعيف) | Emphasis was added on a letter |
tadeef_missed | Tadeef (التضعيف) | Required emphasis is missing |
Substitution (letters_mismatch) comes from a nearby makhraj, or from a change in vowel length or quality.
Nearby articulation:
- Interdentals and sibilants are confused: ث with س or ت, س with ث or ص, ذ with ز or د, and ز with ظ or ذ.
- An emphatic is read light because the tongue is not raised: ص as س, ض as د or ظ, ط as ت, and ظ as ذ, ز, or ض.
- A throat or glottal letter is swapped with a lighter one: ح with ه, ه as ح, ع with أ, ق with ك, خ with غ, and ياء as ألف مدية.
Vowel length and quality:
- A short vowel is stretched: فتحة as ألف مدية, ضمة as واو مدية, and كسرة as ياء مدية.
- A long vowel is clipped: ألف مدية as فتحة, واو مدية as ضمة, and ياء مدية as كسرة.
- The vowel quality changes: فتحة as كسرة or ضمة, ضمة as فتحة or كسرة, كسرة as فتحة or ضمة, and ألف مدية as ياء or ياء مدية.
Insertion (letters_extra) is an extra sound:
- The lam of ال is pronounced on a solar letter, as in الشمس read al-shams instead of ash-shams.
- Hamzat al-wasl is pronounced in the middle of a phrase, as in والشمس read wa ash-shams instead of wa-shams.
- A haraka is added where the word has none, including at a waqf.
- A silent letter in the Uthmani script is pronounced. In الْحَيَوٰةُ the dagger alif sits above the waw, and that waw is silent. In مَلَائِكَةٍ the yaa after a hamza with kasra is silent.
- A required idgham between two words is split, such as د + ت in راودتن.
Deletion (letters_missed) is a dropped sound:
- The lam of ال is skipped on a lunar letter, where it must be pronounced.
- Hamzat al-qatʿ (أ) or hamzat al-wasl is skipped at the start of a phrase.
- A letter that needs a haraka is read with sukun.
- A small or superscript letter in the Uthmani script is left unpronounced, including the dagger alif (الألف الخنجرية).
- One of two neighboring letters with close articulation is dropped: اضطر (ض + ط), أفضتم (ض + ت), يجزي (ج + ز), قلنا (ل + ن), and د + ض.
Playground
Try it out in the playground below, you can also use the code above to implement it in your own project.
Advanced tools
The advanced tools includes 2 services, the quran recitation corrector and the semantic search, both requires an API key to interact with them.
Offline (audio file)
Grade a recorded Quran recitation in one HTTP request and get memorization, tajweed, and phonetic mistakes for a range of verses.