Вступление

Однажды я подумал, насколько трудно и дорого в наши дни сделать голосового помощника, который будет впопад отвечать на вопросы.

А если конкретнее, то веб-приложение, которое записывает аудио с вопросом, расшифровывает аудио в текст, находит подходящий ответ, тоже текстовый, и возвращает аудио версию ответа - вот функциональные требования, который я себе набросал.

Клиентская часть

Я создал простой React проект с помощью create-react-app и добавил компонент “​​RecorderAndTranscriber”, который и содержит весь функционал клиентской части. Стоит отметить использование метода getUserMedia из MediaDevices API чтобы получить доступ к микрофону. Дальше этот доступ достаётся MediaRecorder, через который уже и записывается аудио. Для таймера я использую setInterval.

Пустой массив необязательным параметром в React hook - useEffect, чтобы он вызывался только раз, при создании компонента.

useEffect(() => {
	const fetchStream = async function() {
  	const stream = 
    	await navigator.mediaDevices.getUserMedia({ audio: true });

    setRecorderState((prevState) => {
      return {
        ...prevState,
        stream,
      };
    });
  }

  fetchStream();
}, []);

Сохранённый поток используем для создания экземпляра MediaRecorder, который я тоже сохраняю.

useEffect(() => {
  if (recorderState.stream) {
    setRecorderState((prevState) => {
      return {
        ...prevState,
        recorder: new MediaRecorder(recorderState.stream),
      };
    });
  }
}, [recorderState.stream]);

Дальше я добавил блок для запуска счётчика секунд, прошедших с начала записи.

useEffect(() => {
  const tick = function() {
    setRecorderState((prevState) => {
      if (0 <= prevState.seconds && 59 > prevState.seconds) {
        return {
          ...prevState,
          seconds: 1 + prevState.seconds,
        };
      } else {
        handleStop();

        return prevState;
      }
    });
  }

  if (recorderState.initTimer) {
    let intervalId = setInterval(tick, 1000);
    return () => clearInterval(intervalId);
  }
}, [recorderState.initTimer]);

Hook срабатывает только при изменении значения initTimer, а callback для setInterval обновляет значение счётчика и останавливает запись если длина записи 60 секунд. Дело в том, что 60 секунд и/или 10Mb это ограничение Speech-to-Text API на аудио файлы, которые можно расшифровать, отправляя файлы напрямую. Большие файлы нужно сначала загружать в файловое хранилище Google Cloud Storage. Подробнее про ограничения можно прочитать тут.

Следующий момент, который стоит упомянуть, это как происходит запись.

const handleStart = function() {
  if (recorderState.recorder 
      && 'inactive' === recorderState.recorder.state) {
    const chunks = [];

    setRecorderState((prevState) => {
      return {
        ...prevState,
        initTimer: true,
      };
    });

    recorderState.recorder.ondataavailable = (e) => {
      chunks.push(e.data);
    };

    recorderState.recorder.onstop = () => {
      const blob = new Blob(chunks, { type: audioType });

      setRecords((prevState) => {
        return [...prevState, 
                {
                  key: uuid(), 
                  audio: window.URL.createObjectURL(blob), 
                  blob: blob
                }
               ];
      });
      setRecorderState((prevState) => {
        return {
          ...prevState,
          initTimer: false,
          seconds: 0,
        };
      });
    };

    recorderState.recorder.start();
  }
}

Для начала я проверяю, что экземпляр класса MediaRecorder существует и его статус inactive, один из трёх возможных. Дальше обновляется переменная initTimer, чтобы создать и запустить interval. Чтобы контролировать запись я подписался на обработку двух событий ondataavailable и onstop. В обработчике для ondataavailable сохраняется новый кусочек аудио в заранее созданный массив. А по срабатыванию onstop, из кусочков создаётся blod файл и добавляется к списку готовых записей. В объекте записи я сохраняю url на аудио файл, чтобы использовать в DOM элементе audio, как значение для src, а поле blob, чтобы отправлять на серверную часть.

Серверная часть

Для поддержания работы клиентской части я выбрал связку Node.js и ​​Express. Создал файл index.js, в котором и собрал API с методами:

        а) getTranscription(audio_blob_file)

        б) getWordErrorRate(text_from_google, text_from_human)

        с) getAnswer(text_from_google)

Чтобы вычислить Word Error Rate я взял скрипт на python из проекта tensorflow/lingvo и переписал его в js. По сути это просто решение задачи Edit Distance, плюс расчёт ошибки по каждому из трёх типов: удаление, добавление, замена. Я получил не самый интеллектуальный метод сравнения текстов, но достаточный, чтобы в дальнейшем можно было добавлять дополнительные параметры к запросам к Speech-to-Text.

Для getTranscription я взял готовый код из документации к Speech-to-Text, а для перевода текста ответа в аудио файл - из документации к Text-to-Speech. Немного запутанным оказалось создание ключа доступа к google cloud с серверной части. Для начала нужно было создать проект, потом включить Speech-to-Text API и Text-to-Speech API, создать ключ доступа и, наконец, добавить путь к ключу в переменную GOOGLE_APPLICATION_CREDENTIALS.

Чтобы json файл с ключом, нужно создать Service account для проекта.

После нажатия Create and Continue и Done во вкладке Credentials, в таблице Service Accounts появиться новый аккаунт. Если перейти в этот аккаунт, на вкладке Keys можно нажать на Add Key, и получить json-файл с ключом. Этот ключ необходим, чтобы серверная часть могла получить доступ к Google Cloud сервисам, активированным в проекте.

На этом предлагаю закончить первую часть. Описание базы данных и моих экспериментов с ненормативной лексикой будет во второй части статьи.

Комментарии (0)