DEV Community

Joe Lin for BeGoodTool.com

Posted on

How Browser Speech Scoring Turns a Tongue Twister Into an Accuracy Problem

Timing someone reading a tongue twister is easy; deciding whether they said the words correctly is not. I built this challenge to use browser speech recognition when it exists, while keeping a timer-only mode when it does not. That fallback is important because a missing API should be visible, not disguised as a zero score. The article is for a frontend developer who wants useful pronunciation feedback without making the entire game depend on one browser feature.

Speech recognition is optional at runtime

The component checks both browser constructor names and also avoids touching window during server rendering:

function getRecognitionConstructor() {
  if (typeof window === "undefined") return null;
  return window.SpeechRecognition ||
    window.webkitSpeechRecognition || null;
}
Enter fullscreen mode Exit fullscreen mode

When available, recognition uses the selected twister's language, continuous = false, interim results, and one alternative. Each result is concatenated into a transcript. The timer starts independently, so a user can still finish if recognition emits an error. If the constructor is missing or start() fails, the challenge remains usable as a timing-only round and explains why true scoring is unavailable.

The data contains 18 language choices, each with a language-specific recognition code and local phrases. That is more than a translation label: recognition engines use the code to choose an acoustic and language model, while a phrase such as “Peter Piper” should be compared to the exact English target rather than to a translated sentence. The English set includes “Peter Piper,” “Betty Botter,” “Woodchuck Challenge,” “Fuzzy Wuzzy,” and “Seashells on Seashore.”

Accuracy is normalized edit distance

Raw transcripts are messy. The scorer normalizes Unicode with NFKC, lowercases, removes whitespace and punctuation, and converts Chinese number characters before calculating Levenshtein distance:

function normalizeForScore(text) {
  return chineseNumeralsToArabic(
    String(text || "")
      .normalize("NFKC")
      .toLowerCase()
  )
    .replace(/[\u3000\s]+/g, "")
    .replace(/[.,!?;:'"()[\]{},。!?;:「」『』()、¿¡«»„“”‘’—–-]/g, "");
}

function calculateAccuracy(target, spoken) {
  const a = normalizeForScore(target);
  const b = normalizeForScore(spoken);
  if (!a || !b) return 0;
  return Math.max(0, Math.min(100,
    (1 - levenshtein(a, b) /
      Math.max(a.length, b.length)) * 100));
}
Enter fullscreen mode Exit fullscreen mode

NFKC handles compatibility forms, while removing spaces means a recognition engine that inserts a different word boundary is not punished for formatting alone. Punctuation is also not the point of a tongue-twister drill. Levenshtein distance still catches insertions, deletions, and substitutions: if the target has ten normalized characters and two edits are needed, accuracy is 80%.

The final speech score weights accuracy at 78% and speed at 22%. Expected speaking time is Math.max(3, normalizedLength / 5). Speed is capped at zero when a reader takes longer than the expected pace by enough, but it cannot overwhelm a bad transcript. In other words, racing through the phrase does not compensate for saying a different phrase.

There is a subtle consequence of character-based expected time: a long phrase in a language with compact characters does not necessarily correspond to the same number of syllables as an English phrase. The estimate is intentionally a simple baseline, not a language model. The 78/22 weights are product choices that make correctness dominant and keep speed useful as a secondary practice signal.

Timing-only mode needs a separate meaning

A timing-only run records seconds and lets the reader choose a self-rating, but it does not invent transcript accuracy. That distinction is visible in the result mode: “no speech support” is not the same as “speech recognized with 0% accuracy.” It also keeps the game useful on browsers where microphone permission, implementation support, or network-backed recognition is unavailable.

Timing uses a monotonic performance clock when available and falls back to Date.now() only when necessary:

function nowMs() {
  if (typeof performance !== "undefined" &&
      performance.now) return performance.now();
  return Date.now();
}
Enter fullscreen mode Exit fullscreen mode

This measures elapsed reading time without making a missing speech API fatal. A user can practice the phrase and compare times even when the browser cannot provide a transcript. The trade-off is that self-rating is subjective, so it should not be plotted beside machine accuracy as if both were equivalent measurements.

Best records stay scoped and local

The key for saved records combines the selected language and twister. On completion, the store updates only the best accuracy and best time for that pair:

const old = storeData[recordKey.value] || {};
const next = { ...old };
if (typeof accuracy === "number" &&
    (typeof old.bestAccuracy !== "number" ||
      accuracy > old.bestAccuracy)) {
  next.bestAccuracy = accuracy;
}
if (typeof old.bestTime !== "number" ||
    seconds < old.bestTime) {
  next.bestTime = seconds;
}
window.localStorage.setItem(STORAGE_KEY,
  JSON.stringify({ ...storeData, [recordKey.value]: next }));
Enter fullscreen mode Exit fullscreen mode

That means an excellent English reading does not overwrite a French best, and a faster attempt does not erase a better accuracy. The storage is per browser profile and can disappear when site data is cleared. Speech recognition is also not deterministic: microphones, accents, permissions, browser support, network behavior, and selected language all affect transcripts. Even a correct human reading can receive a lower score, so this is practice feedback rather than a pronunciation test. I turned the challenge into a small free tool: Tongue Twister Challenge.

Top comments (0)