Advanced toolsNabr — Recitation Corrector

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:

  1. Connect to the WebSocket with your API key.
  2. Start a session by sending the START_TILAWA_SESSION method (once per session — when the page loads, for example). Wait for TILAWA_SESSION_STARTED before streaming: audio sent earlier is dropped.
  3. Stream audio chunks with the CHECK_TILAWA_AUDIO method while recording.
  4. Receive TILAWA_RESULT events in realtime with confirmed words, words still being heard, and mistakes. The server also sends KEEP_ALIVE about every 15 seconds.
  5. Stop by sending the RESET_TILAWA_SESSION method.

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:

ClientRuntimeGood for
PythonPython 3.11+ with uvQuick runs with no install step
DartDart 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 1
export NABR_API_KEY="your-api-key"
cd dart
dart pub get
dart run mic_tilawa_client.dart --surah 1

The 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/msgpack

Microphone 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:

Frame20 ms
Samples320 (16 000 × 0.020)
Channels1
PCM before encodesigned 16-bit little-endian
Messagesone 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:

  1. Heard. pending_words lists 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.
  2. Confirmed. A word is confirmed once the server has verified it. It is reported once, in confirmed_words or skipped_words, and leaves pending_words.
  3. 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_index and word_index give the last confirmed word when confirmed_words is not empty. Otherwise they give the next expected word. So word_index can 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_code 6 with empty lists means nothing new was confirmed.

  • exit_code 7 means 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_code 6 while its grading finishes. Then 8 is sent. These results only come while audio is arriving, so keep streaming until 8 arrives, 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.aiwss://api.qurani.ai/v2
correct_wordsconfirmed_words
speachLike, speach_likespeech_like
errorCodeerror_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

codeMeaningWhat to do
validation_errorThe message is a text frame or not valid MessagePack, or has an unknown method or a missing or invalid fieldFix the message. Do not resend it unchanged
no_active_sessionAudio arrived before a session started, after it was reset, or after a failed start. Sent once, and the audio is ignoredSend START_TILAWA_SESSION and wait for TILAWA_SESSION_STARTED
invalid_audioAudio 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 packetsSend audio in the documented format
internal_errorThe server failed while handling a message. The result for that message is lost, and the session stays activeKeep streaming. Reconnect only if it repeats
backpressureAn outgoing message could not be queued. Not sent with the default server configurationReconnect 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:

LevelIncluded
1Memorization errors (skipped_words, substituted_words), plus tajweed. Tajweed currently includes both tajweed mistakes (tajweed_mistakes) and phonetic mistakes (letter_mistakes).
2–5Memorization 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.

CodeResult
0Chapter is not supported
1Verse was not found
2Word in the verse was not found
3Hafz level is not supported
4Tajweed level is not supported
5Unexpected problem
6Success
7Check failed
8Surah 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.

FieldMeaning
chapter, verse, wordThe expected word
saidThe Quran word read instead, or null when the word read is not in the Quran
speech_likeWhat was heard, in Arabic letters.
messageA 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:

FieldMeaning
letterZero-based index in the word's phonemes (consonants and vowels), not its Arabic characters
speech_likeThe word written with tashkeel
messageA localized tip. Some messages also give the reason
error_codeStable 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_codeRuleMeaning
ghunna_extraGhunnah (الغنة)Nasalization was added
ghunna_missedGhunnah (الغنة)Required nasalization is missing
qalqala_extraQalqalah (القلقلة)An echo was added. Qalqalah applies to saakin ق ط ب ج د
qalqala_missedQalqalah (القلقلة)The echo on a saakin ق، ط، ب، ج، or د is missing
iqlab_missedIqlab (الإقلاب)Noon saakin or tanween was not turned into a hidden meem before ب
idgham_missedIdgham (الإدغام)A required merge into ي، ر، م، ل، و، or ن, with or without ghunnah, is missing
ikhfa_extraIkhfa (الإخفاء)Ikhfa was applied where it does not belong
ikhfa_missedIkhfa (الإخفاء)Required ikhfa is missing
tafkhem_extraTafkheem / tarqeeq (التفخيم / الترقيق)The letter was made heavy; it should be light
tafkhem_missedTafkheem / tarqeeq (التفخيم / الترقيق)Required heaviness is missing
madd_extraMadd (المد)A madd was added
madd_missed_02Madd (المد)A 2-haraka madd is missing
madd_12_02Madd (المد)More than 2 harakat were read; only 2 are required
madd_missed_12Madd (المد)A madd longer than 2 harakat is missing
madd_02_12Madd (المد)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_codeKindMeaning
letters_mismatchSubstitution (استبدال الأحرف)A letter or vowel was replaced by another
letters_extraInsertion (إضافة أحرف)A letter or vowel was pronounced that is not in the word
letters_missedDeletion (حذف الأحرف)A letter or vowel that should be pronounced was dropped
tadeef_extraTadeef (التضعيف)Emphasis was added on a letter
tadeef_missedTadeef (التضعيف)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.

بِسۡمِ ٱللَّهِ ٱلرَّحۡمَٰنِ ٱلرَّحِيمِ 1ٱلۡحَمۡدُ لِلَّهِ رَبِّ ٱلۡعَٰلَمِينَ 2ٱلرَّحۡمَٰنِ ٱلرَّحِيمِ 3مَٰلِكِ يَوۡمِ ٱلدِّينِ 4إِيَّاكَ نَعۡبُدُ وَإِيَّاكَ نَسۡتَعِينُ 5ٱهۡدِنَا ٱلصِّرَٰطَ ٱلۡمُسۡتَقِيمَ 6صِرَٰطَ ٱلَّذِينَ أَنۡعَمۡتَ عَلَيۡهِمۡ غَيۡرِ ٱلۡمَغۡضُوبِ عَلَيۡهِمۡ وَلَا ٱلضَّآلِّينَ 7