[NodeJs] 발음 정확도 평가 정리

지송현·2023년 1월 3일
0

JS

목록 보기
9/9
post-thumbnail

기업 협업 진행 중 발음 정확도 평가 기능을 구현해야 했다.

대략 설명하면 영상에 나오는 대사를 유저가 따라 녹음하고 그에 대해 발음의 정확도를 평가해 점수를 나타내 주어야 한다.
구현 과정에서 꽤 시간이 걸려서 한 번 정리해보고자 한다.

시작

유저가 녹음한 음성 파일을 이용해 발음을 평가할 방법을 가장 먼저 찾았다. 미리 생각한 것은
1. 외부 api 이용
2. 유사한 코드 찾아 리팩토링
3. 직접 음성 분석 후 비교
순으로 다른 방법이 없다면 직접 음성 파형을 분석할 생각을 하고 해당 기능을 맡았다.

결과적으로 1. 외부 api를 이용을 선택하게 되었다. (아쉽게도 찾아버렸다.)

링크 : https://aiopen.etri.re.kr/guide/pronunciation

필요한 것

위 api 문서에 따르면

  • 평가할 음성이 담긴 pcm파일(이후 base64 인코딩)
  • 유저가 발음한 제시 문장

두 가지 정도만 있으면 가능했다.

단점

1,000건/일(60초 이내/건당)의 제한이 있었다. 실제 서비스에는 사용하기 어렵다.


문제, 해결

  1. 가장 첫 번째로 생각난 문제점은 유저가 녹음한 파일을 어떻게 pcm으로 변환하는 가였다.
    -> 프런트에서 어떤 형태로 음성 파일이 오는지 알아야 했다. 무엇을 pcm으로 바꾸는 지 알아야 하기 때문에
    -> 팀의 구성이 프런트 1명, 백 4명이었다. 그리고 내가 맡은 기능이 프런트에서 구현 순서가 거의 마지막이었다.
    -> 프런트의 녹음 후 서버 통신까지 직접 구현했다.

리액트를 써본 적은 없지만 최대한 빠르게 영상/블로그를 보고 리액트 환경을 설정하고 버튼 2개가 있는 페이지를 만들었다. 하나는 녹음 버튼, 하나는 녹음된 파일을 확인하고 fetch하는 버튼이다.

  1. 공부를 하는 과정에서 여러 파일의 형태(wav, pcm의 차이부터 base64, blob, buffer, arraybuffer, audiobuffer)에 대해 상당히 헷갈렸다. 각각이 어떤 형태이고 어떤 차이가 있는지 알기 힘들었다. 차이를 알게 되어도 그래서 어떤 형태로 서버로 보내야 하는지 떠오르지 않았다.

여러 데이터 타입에 대한 것은 다음 포스트에서 따로 다루도록 하고 일단 pcm파일은 wav의 header부분(파일에 대한 정보가 담김)을 자른 raw data인 것을 알았다. 따라서 일단은 wav파일이 필요했다. wav을 buffer 형태로 만든 후에 제일 앞부분 44개를 잘라내면 pcm으로 바꿀 수 있었다.

  1. wav파일을 pcm으로 바꾸기 위해 audiobuffer 형태로 바꾸어야 하는데 서버에서 그 방법을 찾을 수가 없었다. 찾아본 결과 대부분 web api를 사용해 blob을 audiobuffer로 바꾸는데 서버에선 web api를 사용할 수가 없었다.

프런트에서 audiobuffer 형태로 변환 후 보내도록 수정했다.

  1. 프런트에서 fetch할 때 formdata에 담아 보내야 하는가? 보낸다면 Content-Type은 어떻게 해야하는가?

사실 음성파일 뿐만아니라 json도 같이 보내야 했기에 formdata를 쓸 수 밖에 없었다. 결과적으로 audiobuffer와 blob으로 변환한 json을 formdata에 담아 서버로 보냈다.

그 결과 프런트 코드는 이렇게 되었다.(녹음과정은 생략했다.)

const onSubmitAudioFile = useCallback(async () => {
    if (audioUrl) {
      setSound(URL.createObjectURL(audioUrl));
      const arrayBuffer = await audioUrl.arrayBuffer();
      const audioContext = new AudioContext();
      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
      const wav = toWav(audioBuffer);
      const wavFile = new File([wav], 'sound', { type: 'audio/wav' });

      const script = {
        ko: '안녕하세요',
        video_sentence_id: 3,
      };

      const formdata = new FormData();
      formdata.enctype = 'multipart/form-data';
      formdata.append('sound', wavFile);
      formdata.append(
        'script',
        new Blob([JSON.stringify(script)], { type: 'application/json' })
      );

      fetch(`${BASE_URL}/pronounceTest`, {
        method: 'POST',
        body: formdata,
      })
        .then(response => response.json())
        .then(data => {
          setScoreData(data);
        });
    } else {
      console.log('no audio');
    }
  }, [audioUrl]);

요약하면
audioUrl은 blob이다. URL.createObjectURL을 통해 브라우저에서 바로 녹음한 파일을 재생할 수 있게 했고, 그 후로는
1. blob -> arraybuffer
2. arraybuffer -> audioBuffer
3. audioBuffer -> wav
4. wav을 파일에 담음
5. json과 함께 formdata에 담음


아래는 서버 쪽 코드이다.

const storage = multer.memoryStorage();
const upload = multer({
  storage,
  limits: {
    fileSize: 100000000000,
  },
});
-------------

routes.use('/pronounceTest', upload.fields([{ name: 'sound' }, { name: 'script' }]), pronounceTestRouter);

multer를 사용해 데이터를 받아왔다.

이후

export const pronounceTestController = catchAsync(
  async (req: Request, res: Response) => {
    const userId: number = 1;

    interface obj {
      sound: [
        {
          fieldname: 'sound';
          originalname: 'sound';
          encoding: '7bit';
          mimetype: 'audio/wav';
          buffer: {
            type: 'Buffer';
            data: Buffer;
          };
          size: number;
        }
      ];
      script: [
        {
          fieldname: 'script';
          originalname: 'blob';
          encoding: '7bit';
          mimetype: 'application/json';
          buffer: {
            type: 'Buffer';
            data: Buffer;
          };
          size: number;
        }
      ];
    }

    const obj: obj = JSON.parse(JSON.stringify(req.files));

    const jsonBuffer = obj.script[0].buffer.data;
    const buffer = Buffer.from(jsonBuffer);
    const bufferToString = buffer.toString('utf-8');
    const json = JSON.parse(bufferToString);

    const script: string = json.ko;
    const videoSentenceId: number = json.video_sentence_id;

    const wavData = obj.sound[0].buffer.data; // 유저가 녹음한 음성 파일 buffer형태로 받음
    const wavBuffer = Buffer.from(wavData);
    const pcm = wavBuffer?.slice(44); // buffer의 앞 44개를 잘라 pcm파일로 변환(wav->pcm)

    if (!script || !videoSentenceId || !pcm) {
      throw new CustomError({
        statusCode: StatusCode.BAD_REQUEST,
        description: 'Wrong Body Data',
      });
    }

    const score = await pronounceTestService(
      userId,
      pcm,
      script,
      videoSentenceId
    );
    return res.status(StatusCode.OK).json({ score: score });
  }
);

req.file에서 받아온 데이터들을 원래 필요한 형식에 맞게 변환해 주었다.(모두 buffer형태로 담겨 있었다.) wav에서 pcm 변환 시 wav의 헤더 부분을 어떻게 자를 수 있는지 고민했었는데 여러 npm package를 찾아본 결과 slice를 사용해 앞 44개를 자르면 되었다.(필요한 코드가 생각보다 아주 간단했고 그에 비해 불필요한 코드가 많아 패키지는 사용하지 않았다.) 이렇게 만들어진 buffer 형태의 pcm을 이용해 외부 api와 통신했다.

아래는 etri api 통신 과정이다.

const requestJson: requestJson = {
    argument: {
      language_code: 'korean',
      script: text,
      audio: audio.toString('base64'),
    },
  };

  const options: options = {
    url: 'http://aiopen.etri.re.kr:8000/WiseASR/PronunciationKor',
    body: JSON.stringify(requestJson),
    headers: {
      'Content-Type': 'application/json',
      Authorization: process.env.ETRI_ACCESSKEY!,
    },
  };

  const etriRequest = async () => {
    return new Promise((resolve) => {
      request.post(options, function (error, response, body) {
        if (error) {
          console.log(error);
        }
        resolve(body);
        console.log('responseCode = ' + response.statusCode);
        console.log('responseBody = ' + body);
      });
    });
  };
  const result: string = (await etriRequest()) as string;
  const bodyJson: bodyJson = JSON.parse(result);
  const score = bodyJson.return_object.score;

score를 출력해보면 잘 나오는 것을 확인할 수 있었다.(5점 만점)

profile
백엔드 개발자

0개의 댓글