프론트엔드 개발자의 실시간 음성 처리 개발기

혜수·2021년 10월 20일
18
post-thumbnail

배경🧐

안녕하세요 저는 프로젝트 개발을 아주아주 좋아하는 주니어 개발자입니다 👩🏻‍💻
제가 요새 실시간으로 음성을 녹음하고 녹음된 음성을 AI 엔진을 이용하여 자막으로 변환한 결과를 바로 보여주는 서비스를 개발 중인데요, 프론트엔드 개발을 하면서 어떤 고민을 했고 어떻게 해결하였는지 공유하고자 합니다. 비슷한 기능을 개발하시는 분들께 도움이 되면 좋겠습니다 :)

마주한 상황

우선 저는 3명이서 진행하는 프로젝트에서 프론트엔드를 전담하고 있구요, 타입스크립트를 적용한 리액트로 개발하고 있습니다.
다른 한 친구가 백엔드를, 다른 친구가 AI 개발을 전담하여 진행하고 있는데 음성 인식 엔진을 개발하는 친구가 아주아주 잘 작동하는 AI 서버를 만들어줬어요.
그래서 저는 AI 서버에 녹음된 음성을 보내서 음성 인식이 된 결과를 받아오면 되는 상황이었어요 😎

따라서 제가 고민해야할 부분은 두가지였습니다.

첫째는 웹 상에서 어떻게 음성을 실시간으로 녹음할 것인지

둘째는 녹음된 음성을 어떤 방식으로 AI 서버에 실시간으로 보내어 음성 인식된 결과를 실시간으로 받아와 사용자에게 보여줄 것인지

만약 사용자로부터 녹음이 완료된 음성을 받아서 음성인식 결과를 사용자에게 한번에 보여주는 식이라면,

  1. 사용자가 편리한 방식으로 강의를 녹음하도록 하고
  2. 웹 상에서는 이미 녹음이 완료된 파일을 input으로 받은 후
  3. http 통신으로 AI 서버에 해당 파일을 담은 요청을 보내고
  4. 그 응답으로 받은 음성 인식 결과를 한번에 페이지에 렌더하면 될 거에요

그러나 저희는 사용자가 미리 준비한 음성 파일 없이, 웹 상에서 현재 음성을 실시간으로 녹음하고 이에 대한 결과를 실시간으로 쭉쭉 받아볼 수 있는 서비스를 기획했기 때문에 다른 방식을 사용해야 했습니다. 계속 강조되는 것처럼 '실시간'으로 어떻게 처리할 것인지가 관건이었지요..!

문제 접근 방법

그래서 일단은 웹 상에서 어떻게 녹음을 할지... 찾아봐야 했구요 🥸

클라이언트의 요청이 있을 때에만 서버가 응답하는 단방향 통신인 HTTP 통신이 아닌,
클라이언트와 서버 양쪽이 서로에게 데이터 전달을 하는 방식인 양방향 통신의 소켓 통신 방식으로 구현해야겠다고 생각했습니다.

우선 음성을 보내고 나서 AI 서버에서 스크립트로 변환이 될 때까지 어느 정도의 딜레이가 발생할지 보장할 수 없고,

음성을 아주 작은 시간 단위마다 계속 보내야만 스트리밍하는 것 처럼 서버에서 음성을 받아볼 수 있을 거라고 생각을 했어요. 그럼 서버에서는 내가 잘게 쪼개서 보낸 음성 하나하나에 대해서 응답을 보내는게 아니라, 그 음성들을 서버에서 합쳐서 음성 인식한 결과가 짜잔- 하고 나오면 그때만 프론트로 그 결과를 보내주면 되겠죠 ??!

문제 해결 - 웹 상에서의 녹음

웹 상에서의 녹음은 Web API 를 사용하여 크게 두가지 방식으로 구현이 가능합니다.

첫번째는, 디바이스의 마이크를 통하여 녹음하는 방식 입니다. 자세한 사용 방법은 getUserMedia 메서드의 명세서 에서 확인해주세요 !

navigator.mediaDevices
  .getUserMedia({audio: true})
  .then(
    (stream) => {
    // 받아온 MediaStream을 사용합니다
    });

이 방식의 경우 windows 뿐만 아니라 mac os에서도 사용이 가능하다는 장점이 있지만, 불필요한 소음이 마이크를 통해 함께 녹음될 수 있다는 단점이 있습니다.

두번째는 디바이스의 마이크를 통하지 않고, 디바이스에서 송출되는 소리를 바로 캡쳐하는 방식입니다. 자세한 사용 방법은 getDisplayMedia 메서드의 명세서 에서 확인해주세요 :)

navigator.mediaDevices
  .getDisplayMedia({
    video: true,
    audio: {
      sampleRate: 16000,
    },
  })
  .then(
    (stream) => {
    // 받아온 MediaStream을 사용합니다
    });

이 방식은 만약 노트북으로 비대면 강의를 들으면서 노트북에서 나오는 음성을 사용하고 싶다면 유용한 방식입니다. 마이크를 통하여 외부 소음을 함께 녹음하지 않고, 기기에서 송출되는 소리를 그대로 캡쳐합니다.
또, sampleRate를 특정할 수 있다는 것도 장점입니다 :) 이 방식은 video 옵션을 끄고 audio만 녹음하는 것은 불가능하지만 받아온 MediaStream을 후처리한 후 AI 서버에 보내면 음성 인식이 무리없이 가능하더라구요
다만 이 방식은 오직 windows와 chrome 브라우저의 조합에서만 기능을 전부 사용할 수 있고, mac os에서는 노트북 전체가 아닌 특정 크롬 탭에서 송출되는 소리만 캡쳐가 가능하다는 단점이 있습니다.

따라서 프로젝트 기획에 이를 반영하여, 사용자에게 두가지 방식을 모두 제공하기로 결정했습니다.

다만 navigator.mediaDevices 의 동작이 브라우저마다 약간씩 다른 점을 고려하여, 녹음은 크롬 브라우저에서만 진행하도록 하였고, 다른 브라우저로 접속하는 경우 다른 기능은 모두 사용이 가능하지만 녹음을 시도하는 경우 alert 창으로 크롬 브라우저를 통하여 접속하라고 안내합니다.

두 가지 방식 모두 MediaStream 을 받아올 수 있으며, 이 스트림을 일정한 시간 간격으로 녹음하여 소켓 통신으로 AI 서버에 보내면 AI 서버에서 연속적으로 음성을 받아서 처리할 수 있을 것이라고 생각했습니다.

그래서 일단 웹 상에서의 음성 녹음은 해결되었네요 🤩

문제 해결 - 소켓 통신 구현

이제 녹음한 음성을 일정한 시간 주기마다 녹음하여 서버로 보내야합니다. 우선은 웹소켓 연결부터 해볼까요 ?
자바스크립트의 웹소켓(WebSocket)을 사용하여 소켓 통신을 구현하였습니다.

저는 학부에서 데이터통신 수업을 들으면서 C++로 통신 프로그램을 작성하느라 진땀 흘렸던 기억이 있어서 소켓 통신이라니 덜컥 겁이 나기도 했는데요, 웹소켓을 사용하면 아주 간단하게 소켓 연결이 가능했습니다 🤩

const ws = new WebSocket(url);

이렇게 웹소켓 연결을 하고, 소켓에서 발생하는 아래 네가지의 이벤트에 대해 적절한 핸들러를 설정하면 됩니다.

open : 커넥션이 제대로 만들어졌을 때 발생합니다
message : 데이터를 수신하였을 때 발생합니다
error : 에러가 생겼을 때 발생합니다
close : 커넥션이 종료되었을 때 발생합니다.

저는 아래와 같이 각각의 이벤트를 처리하도록 했습니다.

  • ws.onopen 에 웹소켓이 열리면 주기적으로 녹음된 음성을 처리하도록 핸들러를 설정했습니다
  • ws.onclose , ws.onerror 에는 각각 소켓 연결이 종료될 때에 소켓 연결에서 에러가 발생했을 때 로그를 찍어주도록 핸들러를 설정했습니다
  • ws.onmessage 에는 리액트 컴포넌트의 state를 업데이트하여 AI 서버로부터 받아온 음성 인식 결과를 사용자에게 보여줄 수 있도록 핸들러를 설정했습니다.

발생한 이벤트를 처리하는 데에 그치지 않고, 프론트엔드에서도 서버에 메세지를 보내야합니다. 이 때는 ws.send(data) 를 이용합니다. 이 때, send 메서드는 Blob, ArrayBuffer 와 같은 이진 데이터 또는 텍스트 데이터만 보낼 수 있습니다. 따라서 저는 받아온 MediaStream을 가공하여 Blob 형태로 서버에 보내도록 하였습니다.

이 때, MediaSteam 에서 발생한 음성을 버퍼라는 이름의 리스트에 모아두다가 일정한 시간 간격마다 소켓에 전송하도록 구현하였는데요, 이는 불규칙하게 발생하는 오디오를 발생할 때마다 서버에 보내는 것이 아니라 일정한 시간 간격으로 보내는 것이 서버에서 처리하기 좋다고 판단하였기 때문입니다.

이를 구현하기 위하여 웹워커(Web Worker)를 사용하였습니다. 자바스크립트는 기본적으로 메인스레드가 하나인 싱글스레드 언어로 알려져있는데, 브라우저는 싱글 스레드로 동작하지 않습니다. 웹 워커를 사용하면 브라우저의 메인 스레드와 별개로 작동하는 스레드를 생성할 수 있습니다. 따라서 브라우저의 렌더링과 같은 메인 스레드의 작업을 방해하지 않으면서, 녹음된 음성의 버퍼 저장과 주기적 전송을 처리하도록 웹 워커를 사용하였습니다.

웹워커 사용을 위하여 가장 기본적으로 다음 방식에 대한 이해가 필요했습니다. 메인스레드와 워커스레드 사이에는 메세지를 주고 받을 수 있습니다. 메세지의 송수신은 다음과 같은 코드로 이루어집니다.

웹 워커의 동작을 명시하는 recorderWorker.js 파일이 있고, 웹 워커 객체를 만들어서 사용하는 Recorder.js 파일이 있다고 가정합니다.

Recorder.js 파일에서

//Recorder.js
import worker_script from './recorderWorker.js';
export class Recorder {
 ... 
 const worker = new Worker(worker_script);
 ...
 this.getBuffer = function () {
   // 메인스레드에서 워커스레드로 메세지를 보냅니다
   worker.postMessage({...});
   ...
 }
                      
 // 워커스레드로부터 온 메세지를 메인 스레드에서 처리하는 핸들러를 설정합니다
 worker.onmessage = function (e) {
   const { data } = e;
   ...
 }
}

recorderWorker.js 파일에서

// recorderWorker.js 
const workercode = () => {
  ...
  // 메인 스레드에서 보내온 메세지를 워커가 처리하는 핸들러입니다
  this.onmessage = function (e) {
    ...
  };
  ...
  function export16kMono(type) {
    ...
    // 워커에서 메인스레드로 메세지를 보냅니다
    this.postMessage(audioBlob);
  }
  ...
};

// 워커스크립트를 export하는 과정입니다
let code = workercode.toString();
code = code.substring(code.indexOf('{') + 1, code.lastIndexOf('}'));

const blob = new Blob([code], { type: 'application/javascript' });
const worker_script = URL.createObjectURL(blob);

module.exports = worker_script;

classmodule export를 위한 코드가 섞여 있지만 가장 중요한 부분은 메인스레드와 워커스레드에서 각각 메세지를 보낼 수 있으며 받은 메세지를 처리하는 핸들러를 설정할 수 있다는 것입니다.

그럼 이제 발생된 오디오를 어떻게 처리하는지 조금더 자세히 보겠습니다.

  1. 위에서 언급한 녹음 방식을 이용하여 받아온 MediaSteamScriptProcessorNode 형태로 변환한 후 onaudioprecess 메서드를 사용하면 발생된 오디오에 대한 핸들러를 설정할 수 있습니다.

    //Recorder.js
    import worker_script from './recorderWorker.js';
    export class Recorder {
     ... 
     const worker = new Worker(worker_script);
     ...
     this.node.onaudioprocess = function (e) {
       if (!recording) return;
       worker.postMessage({
         command: 'record',
         buffer: [e.inputBuffer.getChannelData(0)],
       });
     };                  
     ...
    }

    즉, 발생한 오디오를 worker.postMessage로 버퍼에 다음 음성을 저장하라는 요청과 함께 버퍼에 저장할 음성을 보냅니다.

  2. 워커에서는 해당 요청을 받으면 자신의 버퍼에 해당 음성을 저장합니다.

    // recorderWorker.js 
    const workercode = () => {
      ...
      this.onmessage = function (e) {
        switch (e.data.command) {
            ...
            case 'record':
            record(e.data.buffer);
            break;
            ...
            default:
            break;
        }
      };
      ...
      function record(inputBuffer) {
        recBuffers.push(inputBuffer[0]);
        recLength += inputBuffer[0].length;
      }
      ...
    };
    ...

    즉, 자신의 recBuffers 리스트에 메인 스레드로부터 받은 음성을 저장합니다.

위와 같은 과정을 통하여 스트림의 음성이 지속적으로 워커의 버퍼에 쌓이게 됩니다.

그럼 이제 쌓이고 있는 음성을 일정한 시간 주기마다 웹소켓으로 서버에 보내야합니다. 이를 위하여, 녹음을 시작할 때에 setInterval 함수로 일정한 시간 간격으로 작업이 수행되도록 하며, 사용자가 녹음을 마치면 clearInterval 함수로 이 작업을 종료합니다.

일정한 주기로 수행되는 작업은 다음과 같습니다.

  1. 메인스레드에서 worker.postMessage를 이용하여 워커의 버퍼에 쌓인 음성을 내보내라고 요청합니다.
  2. 워커에서 this.onmessage로 해당 요청을 받은 후, 현재 버퍼에 쌓인 음성들을 합쳐서 보낼 준비를 합니다.
  3. 워커에서 this.postMessage를 이용하여 메인스레드로 해당 음성을 보내고, 자신의 버퍼를 비웁니다.
  4. 메인스레드에서 worker.onmessage로 음성을 전달 받은 뒤, ws.send로 소켓에 해당 음성을 보냅니다.

이 과정을 통하면, 스트림에서 발생하는 음성들이 웹 워커에 지속적으로 저장되고, 일정한 시간 간격마다 워커에 저장된 음성들이 메인스레드로 보내진 후 소켓으로 전송되는 과정을 반복하게 됩니다.

이 과정을 구현하는 데에 전체적인 흐름은 kaldi-gstreamer-server 에서 안내한 데모 버전의 코드를 참고하였으며, 저희 프로젝트에 적용하기 위하여 타입스크립트 코드로 바꾸면서 클래스화하였습니다.

마무리

세부 코드는 훨씬 복잡해서 하나하나 설명을 드리기 어렵지만 전체적으로 실시간 음성을 어떻게 처리하고 있는지 그 방식을 소개하고자 하였습니다. 비슷한 기능을 구현하시는 분들에게 조금이나마 도움이 되면 좋겠습니다 :)

현재 저희 프로젝트는 비전노트 라는 이름으로 배포되어 있는데 놀러오셔서 음성 인식 부분 체험해보셔도 좋을 것 같아요. 아직 음성 인식의 정확도가 높지 않지만 모델 학습을 계속하며 성능을 개선하는 중입니다💪 그럼 긴 글 읽어주셔서 감사합니다 :)

0개의 댓글