Skip to content
Sign in

Reading Score API

Upload a student recording and get Thai best-attempt scoring with structured feedback data.

The Reading Score API is a canonical HTTP upload endpoint on this Next.js app. Send multipart form data with a student recording and either reference text or reference audio. The service selects the best-matching reading attempt from the recording and exposes structured comparison fields that callers can render however they want. Most requests return the final score JSON directly with HTTP 200. Longer jobs may return HTTP 202 with `taskId` and `statusPath`; poll that status route with the same API key until `ready=true`. The default ASR engine is `typhoon`, and callers can override it with `asr_engine=whisper` or the equivalent `scoring_options` field. The current service only supports Thai.

Migration note for existing callers

  • Use /api/reading-score as the canonical public route for new integrations. The older /api/low-latency-asr/reading-score path is still accepted for compatibility.
  • Handle two success modes: HTTP 200 returns the final reading-score payload; HTTP 202 returns a queued job with taskId and statusPath.
  • When you receive HTTP 202, call GET /api/reading-score/task/{taskId} with the same x-api-key until ready=true. If successful=true, read the final score from result.
  • Update your response parsing to read student.selectedAttempt, feedback.*, and comparison.wordResults[] instead of treating the API as transcript-only.
  • Displayed scores.* values are usually scaled into the `80-100` band, but VAD-confirmed no-speech detections now return `0.0` with assessment.status=no_speech. Use diagnostics.rawScores.* if you need the underlying raw metrics.
  • If you stay on Typhoon, optionally handle student.meta.silenceRemovedOnTyphoon, student.meta.speechRegionCount, student.meta.windowedRecoveryOnTyphoon and student.meta.windowedRecoveryWindowCount when Typhoon recovery paths activate.
POST/api/reading-score

Canonical public route

Use this route for new integrations. It validates the API key, normalizes form fields, forwards the upload into the reading-score backend stack, and records usage under the calling team.

POST/api/low-latency-asr/reading-score

Legacy compatibility alias

Older clients may still call this path. New integrations should prefer `/api/reading-score`.

GET/api/reading-score/task/{taskId}

Canonical queued-job status route

Use this only after the upload route returns HTTP 202. It validates the same API key and returns task state, readiness, success/failure, progress when available, and the final score under `result` when complete.

GET/api/low-latency-asr/reading-score/task/{taskId}

Legacy status alias

Older clients using the legacy upload path may poll this matching status path. New integrations should prefer `/api/reading-score/task/{taskId}`.

Multipart request fields

FieldTypeRequiredNotes
student_audiofileYesStudent recording to score. This is the canonical field name.
reference_textstringOne of twoReference sentence or passage. Required when `reference_audio` is not sent.
reference_audiofileOne of twoReference recording. If provided without `reference_text`, the service first transcribes this audio.
languagestringNoDefaults to `th`. The current service only accepts Thai.
asr_enginestringNoOptional ASR override. Supported values are `typhoon` and `whisper`. Defaults to `typhoon`. You can also send this inside `scoring_options`.
student_idstringNoOptional caller-supplied student identifier returned in `request.studentId`.
lesson_idstringNoOptional caller-supplied lesson identifier returned in `request.lessonId`.
scoring_optionsJSON stringNoSupported keys today include `{ "include_pronunciation": true }` and `{ "asr_engine": "whisper" }`.
include_pronunciationboolean-like stringNoAlternative top-level form for pronunciation scoring. `true` is the default behavior.

Canonical field names and accepted aliases

The canonical request fields are student_audio, reference_text, reference_audio, student_id, lesson_id, asr_engine, and scoring_options. The backend also accepts a few compatibility aliases such as studentAudio, audio_file, referenceText, referenceAudio, studentId, lessonId, and asrEngine

curl upload example

Sample
curl -sS \
  -H "x-api-key: YOUR_API_KEY" \
  -F "student_audio=@student.wav" \
  -F "reference_text=สวัสดีครับ วันนี้เราจะเรียนภาษาไทย" \
  -F "student_id=student_001" \
  -F "lesson_id=lesson_01" \
  -F "language=th" \
  -F "include_pronunciation=true" \
  "https://kaleidovid.com/api/reading-score"

Python upload example

Sample
# pip install requests

import json
import time
import requests

ENDPOINT_URL = "https://kaleidovid.com/api/reading-score"
BASE_URL = ENDPOINT_URL.removesuffix("/api/reading-score")
API_KEY = "YOUR_API_KEY"

def read_result_or_poll(response):
    if response.status_code != 202:
        response.raise_for_status()
        return response.json()

    queued = response.json()
    status_path = queued.get("statusPath")
    if not status_path:
        return queued

    while True:
        status_response = requests.get(
            f"{BASE_URL}{status_path}",
            headers={"x-api-key": API_KEY},
            timeout=30,
        )
        status_response.raise_for_status()
        status_payload = status_response.json()
        if not status_payload.get("ready"):
            time.sleep(2)
            continue
        if status_payload.get("successful"):
            return status_payload.get("result")
        raise RuntimeError(status_payload.get("error", "Reading score failed"))

with open("student.wav", "rb") as student_audio:
    response = requests.post(
        ENDPOINT_URL,
        headers={"x-api-key": API_KEY},
        data={
            "reference_text": "สวัสดีครับ วันนี้เราจะเรียนภาษาไทย",
            "student_id": "student_001",
            "lesson_id": "lesson_01",
            "language": "th",
            "include_pronunciation": "true",
        },
        files={
            "student_audio": ("student.wav", student_audio, "audio/wav"),
        },
        timeout=300,
    )

payload = read_result_or_poll(response)
print(json.dumps(payload, ensure_ascii=False, indent=2))

Response shape

HTTP 200 responses return the normalized request summary, including the resolved ASR engine, the full student transcript plus the best selected attempt, displayed student-facing scores, token-by-token comparison, structured feedback fields, raw diagnostics, and optional forced-alignment metadata. HTTP 202 responses mean the request is still running in the reading-score worker queue; poll the returned `statusPath` with the same auth until `ready=true`, then read `result` when `successful=true`.

Display-score formula: after bounding the raw score to `0-100`, the public `scores.*` value is usually scaled as `80 + raw * 0.2`. VAD-confirmed no-speech detections are the exception: `assessment.status` becomes `no_speech` and `scores.*` are forced to `0.0`.

FieldTypeRequiredNotes
assessment.passedbooleanAlwaysCustomer-facing assessment flag. Typical reads return `true`, while VAD-confirmed no-speech detections return `false`.
assessment.statusstringAlwaysCustomer-facing status string. Typical successful reads return `passed`; no-speech is only returned after the silence detector and VAD both find no usable voice activity.
request.asrEnginestringAlwaysThe resolved ASR engine that actually handled the request. Use this instead of assuming the default from your client code.
student.selectedAttemptobject | nullMaybeDescribes the best-matching reading attempt selected from the full recording before scoring.
scores.overallnumber | nullAlwaysDisplayed student-facing score. The service usually scales raw scores into an encouragement band of `80-100`, but VAD-confirmed no-speech detections return `0.0`. Raw unclamped values live under `diagnostics.rawScores`.
scores.textAccuracynumberAlwaysDisplayed text-accuracy score for the selected best attempt.
scores.pronunciationnumber | nullMaybeDisplayed pronunciation score derived from alignment confidence. `null` means pronunciation scoring was unavailable.
scores.levenshteinSimilaritynumberAlwaysDisplayed edit-distance similarity for the selected attempt.
feedback.pronunciationFocusWords[]arrayAlwaysReference words whose pronunciation confidence was weak enough that you may want to flag them in your own UI.
feedback.missingWords[] / feedback.extraWords[]arrayAlwaysStructured token lists for omitted reference words and extra spoken words.
feedback.substitutions[]arrayAlwaysStructured expected/actual pairs for substitution mismatches.
diagnostics.rawScoresobjectAlwaysUnderlying unclamped metrics for internal review, analytics, or debugging.
student.meta.noSpeechDetected / student.meta.audioDurationboolean / numberMaybeOptional no-speech metadata. When `noSpeechDetected` is `true`, both the silence pass and VAD found no usable student voice, `assessment.status` becomes `no_speech`, and `scores.*` are forced to `0.0`. `audioDuration` reports the analyzed clip length.
student.meta.voiceActivityChecked / voiceActivityDetectedboolean / booleanMaybeOptional VAD metadata for no-speech decisions. `voiceActivityChecked=true` means the service ran the follow-up VAD pass; `voiceActivityDetected=false` is required before the service returns `assessment.status=no_speech`.
student.meta.voiceActivityDuration / voiceActivityDetectornumber / stringMaybeOptional VAD detail. `voiceActivityDuration` reports the estimated voiced duration in seconds, and `voiceActivityDetector` currently reports `torchaudio_vad`.
student.meta.silenceRemovedOnTyphoon / student.meta.speechRegionCountboolean / numberMaybeOptional Typhoon-only metadata for long-gap recovery. `silenceRemovedOnTyphoon` means the service retried on a silence-removed copy, and `speechRegionCount` reports how many speech regions were detected.
student.meta.windowedRecoveryOnTyphoon / student.meta.windowedRecoveryWindowCountboolean / numberMaybeOptional Typhoon-only metadata for reference-aware boundary recovery. `windowedRecoveryOnTyphoon` means the service retried short overlapping windows after the first transcript looked like a strict boundary-truncated slice of the expected text, and `windowedRecoveryWindowCount` reports how many windows were tested.
comparison.wordResults[]arrayAlwaysToken-by-token breakdown with `status` = `correct`, `missing`, `extra`, or `substitution`.
alignment.usedbooleanAlwaysIndicates whether pronunciation alignment was strong enough to contribute to the score.

Successful response example

Sample
{
  "success": true,
  "assessment": {
    "passed": true,
    "status": "passed",
    "displayScoreMin": 80.0,
    "usedBestAttemptSelection": true
  },
  "request": {
    "language": "th",
    "asrEngine": "typhoon",
    "studentId": "student_001",
    "lessonId": "lesson_01",
    "includePronunciation": true,
    "referenceSource": "referenceText"
  },
  "reference": {
    "text": "สวัสดีครับ วันนี้เราจะเรียนภาษาไทย",
    "normalizedText": "สวัสดีครับ วันนี้เราจะเรียนภาษาไทย",
    "tokens": ["สวัสดีครับ", "วันนี้", "เรา", "จะ", "เรียน", "ภาษาไทย"],
    "tokenCount": 6,
    "meta": {}
  },
  "student": {
    "transcript": "สวัสดีครับ วันนี้ เรียน ภาษาใจ",
    "fullTranscript": "สวัสดีครับ วันนี้เราจะเรียนภาษาไทย สวัสดีครับ วันนี้ เรียน ภาษาใจ",
    "normalizedText": "สวัสดีครับ วันนี้ เรียน ภาษาใจ",
    "tokens": ["สวัสดีครับ", "วันนี้", "เรียน", "ภาษาใจ"],
    "tokenCount": 4,
    "selectedAttempt": {
      "mode": "best_token_window",
      "applied": true,
      "candidateCount": 32,
      "multipleAttemptsDetected": true,
      "start": 2.84,
      "end": 4.96,
      "durationSeconds": 2.12,
      "tokenStartIndex": 6,
      "tokenEndIndex": 9,
      "hasTiming": true
    },
    "meta": {
      "model": "scb10x/typhoon-asr-realtime",
      "device": "cuda",
      "speechRegionCount": 2,
      "silenceRemovedOnTyphoon": true
    }
  },
  "scores": {
    "overall": 89.83,
    "textAccuracy": 90.0,
    "pronunciation": 89.45,
    "levenshteinSimilarity": 90.0
  },
  "comparison": {
    "correctTokenCount": 3,
    "referenceTokenCount": 6,
    "studentTokenCount": 4,
    "missingTokenCount": 2,
    "extraTokenCount": 0,
    "substitutionCount": 1,
    "wordResults": [
      {
        "referenceIndex": 0,
        "studentIndex": 0,
        "status": "correct",
        "referenceToken": "สวัสดีครับ",
        "studentToken": "สวัสดีครับ",
        "pronunciationConfidence": 0.82
      },
      {
        "referenceIndex": 2,
        "studentIndex": null,
        "status": "missing",
        "referenceToken": "เรา",
        "studentToken": null,
        "pronunciationConfidence": 0.33
      },
      {
        "referenceIndex": 5,
        "studentIndex": 3,
        "status": "substitution",
        "referenceToken": "ภาษาไทย",
        "studentToken": "ภาษาใจ",
        "pronunciationConfidence": 0.41
      }
    ]
  },
  "feedback": {
    "pronunciationFocusWords": ["เรา", "จะ", "ภาษาไทย"],
    "missingWords": ["เรา", "จะ"],
    "extraWords": [],
    "substitutions": [
      {
        "expected": "ภาษาไทย",
        "actual": "ภาษาใจ"
      }
    ]
  },
  "diagnostics": {
    "rawScores": {
      "overall": 49.17,
      "textAccuracy": 50.0,
      "pronunciation": 47.25,
      "levenshteinSimilarity": 50.0
    }
  },
  "alignment": {
    "used": true,
    "averageConfidence": 0.4725,
    "words": [
      {
        "index": 0,
        "word": "สวัสดีครับ",
        "start": 0.0,
        "end": 0.34,
        "confidence": 0.82
      },
      {
        "index": 2,
        "word": "เรา",
        "start": 0.62,
        "end": 0.84,
        "confidence": 0.33
      },
      {
        "index": 5,
        "word": "ภาษาไทย",
        "start": 1.55,
        "end": 1.93,
        "confidence": 0.41
      }
    ]
  }
}

No-speech response example

Sample
{
  "success": true,
  "assessment": {
    "passed": false,
    "status": "no_speech",
    "displayScoreMin": 0.0,
    "usedBestAttemptSelection": false
  },
  "request": {
    "language": "th",
    "asrEngine": "typhoon",
    "studentId": null,
    "lessonId": null,
    "includePronunciation": true,
    "referenceSource": "referenceText"
  },
  "reference": {
    "text": "สวัสดีครับ",
    "normalizedText": "สวัสดีครับ",
    "tokens": ["สวัสดี", "ครับ"],
    "tokenCount": 2,
    "meta": {}
  },
  "student": {
    "transcript": "",
    "fullTranscript": "",
    "normalizedText": "",
    "tokens": [],
    "tokenCount": 0,
    "selectedAttempt": {
      "mode": "no_speech_detected",
      "applied": false,
      "candidateCount": 0,
      "multipleAttemptsDetected": false,
      "start": null,
      "end": null,
      "durationSeconds": null,
      "tokenStartIndex": null,
      "tokenEndIndex": null,
      "hasTiming": false
    },
    "meta": {
      "noSpeechDetected": true,
      "speechRegionCount": 0,
      "audioDuration": 1.0,
      "voiceActivityChecked": true,
      "voiceActivityDetected": false,
      "voiceActivityDuration": 0.0,
      "voiceActivityDetector": "torchaudio_vad"
    }
  },
  "scores": {
    "overall": 0.0,
    "textAccuracy": 0.0,
    "pronunciation": 0.0,
    "levenshteinSimilarity": 0.0
  },
  "comparison": {
    "correctTokenCount": 0,
    "referenceTokenCount": 2,
    "studentTokenCount": 0,
    "missingTokenCount": 2,
    "extraTokenCount": 0,
    "substitutionCount": 0,
    "wordResults": [
      {
        "referenceIndex": 0,
        "studentIndex": null,
        "status": "missing",
        "referenceToken": "สวัสดี",
        "studentToken": null,
        "pronunciationConfidence": null
      },
      {
        "referenceIndex": 1,
        "studentIndex": null,
        "status": "missing",
        "referenceToken": "ครับ",
        "studentToken": null,
        "pronunciationConfidence": null
      }
    ]
  },
  "feedback": {
    "pronunciationFocusWords": [],
    "missingWords": ["สวัสดี", "ครับ"],
    "extraWords": [],
    "substitutions": []
  },
  "diagnostics": {
    "rawScores": {
      "overall": 0.0,
      "textAccuracy": 0.0,
      "pronunciation": 0.0,
      "levenshteinSimilarity": 0.0
    }
  },
  "alignment": {
    "used": false,
    "averageConfidence": null,
    "words": [],
    "meta": {
      "reason": "no_speech_detected"
    }
  }
}

Queued upload response example

Sample
{
  "success": true,
  "queued": true,
  "taskId": "a1f4387a-09fd-4ac9-8cd5-4eac34f6f6cc",
  "state": "PENDING",
  "ready": false,
  "statusPath": "/api/reading-score/task/a1f4387a-09fd-4ac9-8cd5-4eac34f6f6cc"
}

Completed status response example

Sample
{
  "taskId": "a1f4387a-09fd-4ac9-8cd5-4eac34f6f6cc",
  "state": "SUCCESS",
  "ready": true,
  "successful": true,
  "result": {
    "success": true,
    "assessment": {
      "passed": true,
      "status": "passed",
      "displayScoreMin": 80.0,
      "usedBestAttemptSelection": true
    },
    "scores": {
      "overall": 89.83,
      "textAccuracy": 90.0,
      "pronunciation": 89.45,
      "levenshteinSimilarity": 90.0
    }
  }
}
If alignment.used is false, pronunciation scoring was unavailable or too weak to trust. On non-silent reads, scores.pronunciation may become null, while the customer-facing scores.* fields usually still stay in the encouragement range. If the service confirms no speech with VAD, assessment.status becomes no_speech, assessment.passed becomes false, and scores.* return 0.0. Check student.meta.voiceActivity* for the VAD decision and diagnostics.rawScores when you need the underlying unclamped metrics or the raw text-only calculation.