Задача: Добавить браузерное распознавание отдельных слов по ответу ребёнка

Добавить браузерное распознавание отдельных слов по ответу ребёнка

Распознавать короткий голосовой ответ ребёнка через Web Speech API в заданиях вида «Что изображено на картинке?».

Цель

Добавить голосовой ответ ребёнка в упражнениях вида «Что изображено на картинке?». Браузер распознаёт короткое слово, приложение сравнивает распознанный текст с ожидаемым ответом.

Пример:

  1. На экране изображена собака.
  2. Ребёнок нажимает кнопку микрофона.
  3. Говорит: «Собака».
  4. Браузер возвращает текстовую гипотезу.
  5. Приложение сравнивает её с ожидаемым словом.
  6. Показывает результат и позволяет повторить попытку.

На первом этапе используем встроенный Web Speech API, без LLM и без собственного фонетического анализа.


Основное API

В разных браузерах класс может называться по-разному:

const SpeechRecognitionClass =
  window.SpeechRecognition ||
  window.webkitSpeechRecognition

const recognition = new SpeechRecognitionClass()

Базовая настройка:

recognition.lang = 'ru-RU'
recognition.continuous = false
recognition.interimResults = false
recognition.maxAlternatives = 5

Основные параметры:

  • lang = 'ru-RU' — русский язык;
  • continuous = false — один короткий ответ, затем завершение;
  • interimResults = false — использовать только финальный результат;
  • maxAlternatives = 5 — запросить несколько вариантов распознавания, если браузер их предоставляет.

Получение результата

recognition.onresult = event => {
  const result = event.results[event.results.length - 1]

  const alternatives = Array.from(result).map(item => ({
    transcript: item.transcript,
    confidence: item.confidence,
  }))

  console.log(alternatives)
}

Пример результата:

[
  { transcript: 'собака', confidence: 0.91 },
  { transcript: 'сабака', confidence: 0.62 },
  { transcript: 'собачка', confidence: 0.41 },
]

confidence не гарантированно одинаково полезен во всех браузерах, поэтому основная логика должна опираться на текстовые гипотезы, а confidence использовать как дополнительный сигнал.


Нормализация текста

Перед сравнением нужно привести строки к единому виду:

export const normalizeRecognizedText = (value: string) =>
  value
    .toLowerCase()
    .trim()
    .replace(/ё/g, 'е')
    .replace(/[^а-яa-z0-9\s-]/gi, '')
    .replace(/\s+/g, ' ')

Пример:

normalizeRecognizedText('  Собака! ')
// 'собака'

Точное и приблизительное сравнение

Сначала проверяем точное совпадение:

const exact =
  normalizeRecognizedText(recognized) ===
  normalizeRecognizedText(expected)

Если точного совпадения нет, используем строковую близость. На первом этапе достаточно Levenshtein distance.

Levenshtein

export const levenshteinDistance = (
  a: string,
  b: string,
): number => {
  const rows = a.length + 1
  const cols = b.length + 1

  const matrix = Array.from(
    { length: rows },
    () => Array<number>(cols).fill(0),
  )

  for (let i = 0; i < rows; i++) {
    matrix[i][0] = i
  }

  for (let j = 0; j < cols; j++) {
    matrix[0][j] = j
  }

  for (let i = 1; i < rows; i++) {
    for (let j = 1; j < cols; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1

      matrix[i][j] = Math.min(
        matrix[i - 1][j] + 1,
        matrix[i][j - 1] + 1,
        matrix[i - 1][j - 1] + cost,
      )
    }
  }

  return matrix[a.length][b.length]
}

Нормализованный score:

export const wordSimilarity = (
  a: string,
  b: string,
): number => {
  const left = normalizeRecognizedText(a)
  const right = normalizeRecognizedText(b)

  const maxLength = Math.max(left.length, right.length)

  if (maxLength === 0) {
    return 1
  }

  return 1 - levenshteinDistance(left, right) / maxLength
}

Пример:

wordSimilarity('собака', 'сабака')
// ~0.83

Важно: это только строковое сравнение. Никакого собственного распознавания фонем здесь не делать.


Выбор лучшей гипотезы

type RecognitionAlternative = {
  transcript: string
  confidence: number
}

export const findBestAlternative = (
  expected: string,
  alternatives: RecognitionAlternative[],
) => {
  return alternatives
    .map(item => ({
      ...item,
      normalized: normalizeRecognizedText(item.transcript),
      similarity: wordSimilarity(expected, item.transcript),
    }))
    .sort((a, b) => b.similarity - a.similarity)[0] ?? null
}

Использование:

const best = findBestAlternative('собака', alternatives)

if (best?.similarity === 1) {
  // точное совпадение
} else if (best && best.similarity >= 0.8) {
  // вероятно правильный ответ с небольшой ошибкой распознавания
} else {
  // ответ отличается заметно
}

Порог 0.8 здесь не считать истиной. Его нужно подобрать по реальным детским ответам.


Полный минимальный helper распознавания

export const recognizeSingleWord = () =>
  new Promise<RecognitionAlternative[]>((resolve, reject) => {
    const SpeechRecognitionClass =
      window.SpeechRecognition ||
      window.webkitSpeechRecognition

    if (!SpeechRecognitionClass) {
      reject(new Error('SpeechRecognition is not supported'))
      return
    }

    const recognition = new SpeechRecognitionClass()

    recognition.lang = 'ru-RU'
    recognition.continuous = false
    recognition.interimResults = false
    recognition.maxAlternatives = 5

    recognition.onresult = event => {
      const result = event.results[event.results.length - 1]

      const alternatives = Array.from(result).map(item => ({
        transcript: item.transcript,
        confidence: item.confidence,
      }))

      resolve(alternatives)
    }

    recognition.onerror = event => {
      reject(new Error(event.error))
    }

    recognition.onnomatch = () => {
      resolve([])
    }

    recognition.start()
  })

Состояния UI

Минимальная state machine:

type RecognitionState =
  | 'idle'
  | 'listening'
  | 'processing'
  | 'correct'
  | 'almost-correct'
  | 'incorrect'
  | 'no-match'
  | 'error'

Пример логики:

setState('listening')

try {
  const alternatives = await recognizeSingleWord()
  setState('processing')

  const best = findBestAlternative(expectedWord, alternatives)

  if (!best) {
    setState('no-match')
  } else if (best.similarity === 1) {
    setState('correct')
  } else if (best.similarity >= threshold) {
    setState('almost-correct')
  } else {
    setState('incorrect')
  }
} catch {
  setState('error')
}

Важные ограничения

  • SpeechRecognition поддерживается браузерами неравномерно;
  • в некоторых реализациях распознавание может выполняться внешним сервисом браузера/ОС;
  • доступ к микрофону требует разрешения пользователя;
  • maxAlternatives не гарантирует, что браузер всегда вернёт ровно указанное количество вариантов;
  • confidence зависит от реализации и не должен быть единственным критерием;
  • маленькие слова особенно чувствительны к одной ошибке, поэтому threshold должен учитывать длину слова;
  • не пытаться поверх этого строить собственное распознавание фонем: используем готовый ASR и сравниваем только его текстовые гипотезы.

Критерий готовности

Для задания с картинкой приложение умеет по нажатию кнопки принять одно короткое русское слово, получить несколько текстовых гипотез, нормализовать их, выбрать наиболее близкую к ожидаемому слову и вернуть понятный результат: точное совпадение / близкий ответ / другой ответ / не распознано / ошибка.