본문 바로가기

개발/회고

[SS회고] Web 환경에서 음성데이터 처리, Web Audio API와 Web Worker

프로젝트를 하면서 어려웠던 점과 느낀 점을 정리하고 싶었던게 원래 목표였는데,

회고록이 점점 길어지고 있다...

 

이러다 10편까지 가는게 아닌지..??

그냥 주제를 나누어서 따로 포스팅을 하는게 맞았나 싶지만...

 

그래도 주제에다가 프로젝트 이야기를 엮어서 할 수 있으니까,

포기하지 않겠다..ㄷㄷ

 

나자신... 제발 이번 달까지 끝내쥬세요....

 

-

 

3편에서는 음성 데이터가 어떻게 생겼는지에 대해서 알아보았다..

 

이번 4편에서는 두구두구두구...;

 

1. 음성 데이터가 뭐지.. 어케 생겼지?
2. 아니 일단 브라우저에서 마이크 접근이 가능???
3. 그래서 이제 음성을 어떻게 받아서 저장하지?..

 

이 두 가지에 대해 다시 정리해보고 회고해보자^^

 

Web 환경에서 음성데이터 처리에 사용된 Web API

MediaDevices.getUserMedia()

첫번째!

브라우저에서 어떻게 마이크에 접근하냐구우!!!?

 

이 API를 쓰면 된다.

 

Internet Explorer 빼고는 모든 브라우저에서 지원하고 있는 API다.

(IE: 제발 죽여줘....)

 

그외 브라우저들은 꽤 이전부터 지원하고 있는 듯하다.

 

우리 프로젝트의 경우에는 마이크에만 접근하면 되었지만,

얠 이용해서 카메라에도 접근할 수 있다.

 

하드웨어 접근은 보안이 중요하다..

 

그래서 getUserMedia에 원하는 어떤 장치에 접근하길 원하는지 constraints를 담아보내면

브라우저에서 자체적으로 팝업을 띄워서 User로부터 permission을 요구한다.

 

Chrome에서 띄운 permission 팝업

 

Chrome은 도메인:포트 단위로 permission이 저장되어서

한번 허용했으면 다음번에는 팝업없이 마이크 접근이 가능하다.

 

하지만 짜치는건.. 거부했을 때인데.

한번 거부하면 다시 팝업을 띄우지 않은채로 getUserMedia에서 error를 뱉는다.

 

그러다 보니 권한 요청을 하기전에 먼저 확인을 해서

허용되지 않았다믄 허용해달라는 alert를 띄워줄 필요가 있었다.

 

  const result = await navigator.permissions.query({name: 'microphone'});
  if (result.state !== 'granted') {
    // 팝업 띠용!!
  }

 

물론 허용해달라는 팝업을 띄워봤자

허용하는 방법이 꽤나 숨겨져있어서;;

 

유저 입장에서는 "아!! 어쩌라고!!" 라는 생각이 들수도 있겠다.

 

여기서 하시면 됩니다..

 

Safari는 또 다른데..

페이지를 리로드할 때까지만 권한이 유지되는걸 확인했다.

 

그리고 좋은 점은 거부를 해도 다시 팝업을 띄워준다.. (고..고마워 갓플!!!)

 

그러나.. 완벽한 브라우저는 없는것인가....

 

위에서 말한 permissions.query는 꽤나 experimental한 기능이라서;;

사파리에서는 지원을 안한다ㅎㅎ

 

그래서 권한 허용이 된 상태에서도 불필요하게 alert가 발생했다..

 

역쒸 Web의 묘미는 브라우저님께서 지원 안해도 마취 지원하는것같은

매쥑코드를 만드는거시지!

(극혐><)

 

function buildPermissions() {
  if (navigator.permissions === undefined) {
    navigator.permissions = {};
  }
}

function buildQuery() {
  if (navigator.permissions.query === undefined) {
    navigator.permissions.query = () => {
      return {
        state: 'permissions-query-not-supported',
      };
    };
  }
}

 

이렇게 코드를 추가하고

state가 not supported도 아닌 경우에만 alert를 해줬따!

 

getUserMedia도 올드 브라우저 호환성 때문에

비슷한 빌드 코드를 넣어주자..

 

 

여튼 제대로 권한을 받았다믄 getUserMedia()가

MediaStream를 던져주는 promise를 반환한다.

 

그 stream을 조물조물해서 오디오 데이터를 잘! 가져온다면 된다.

 

 

그리고 추가적으로 알아야 할 것은,

 

이건 모든 브라우저에 해당 되는 이야기로

웹페이지가 https 프로토콜을 사용하지 않는 경우 마이크에 접근할 수 없다.

 

http 환경에서 MediaDevices.getUserMedia()를 하게 되면 에러를 throw한다.

 

MediaDevices.getUserMedia() - Web APIs | MDN

The MediaDevices.getUserMedia() method prompts the user for permission to use a media input which produces a MediaStream with tracks containing the requested types of media.

developer.mozilla.org

 

Thrown if one or more of the requested source devices cannot be used at this time. This will happen if the browsing context is insecure (that is, the page was loaded using HTTP rather than HTTPS). It also happens if the user has specified that the current browsing instance is not permitted access to the device, the user has denied access for the current session, or the user has denied all access to user media devices globally. On browsers that support managing media permissions with Feature Policy, this error is returned if Feature Policy is not configured to allow access to the input source(s). 


하지만 로컬에서 또는 개발 초기부터 https 설정까지 해서 개발하지는 않을테니..
테스트하기 여러모로 불편하지 않을 수 없다. -_-

다행히도...
갓-크롬에서는 곤란에 처한 우릴 위해 chrome://flags를 제공한다.

 


사용법은 크롬 검색창에 chrome://flags를 치고
search flags에 "Insecure origins treated as secure" 키워드를 찾자.


그리고 화이트 리스트에 넣을 도메인들을 반점(,)으로 구분해서 넣고
enabled를 한다음 크롬을 relaunch 하자.

그럼 http 프로토콜에서도 마이크 접근을 할 수 있게 된다.

물론 최종적으로 prd에는 https를 적용했다.

 

 

Web Audio API

ㅇㅣ제 받아온 stream을 어케 쓰지..?

 

Web Audio API를 사용하면 된다.

 

이녀석은 꽤나 방대해서..

링크해둔 모질라 문서를 읽어보는게 좋다.

 

이전에 간단하게 컨셉만 정리해두었다.

더보기
  • Audio Context: 이 안에서 모든 audio operation을 핸들링 (처리가 일어나는 환경이라 하자)
  • Audio Node: 각각 하나의 audio operation을 처리하는 주체들. (GainNode: 음성 소스 받아서 소리 크게, 작게 만들어 아웃풋을 만들어내줌)
  • Audio Graph(Chaning): 이 node들이 *소스 인풋과 아웃풋, 다시 그 아웃풋이 다른 노드에서 처리... 이런식으로 연결됨*소스인풋: audio node, raw data, media stream(우리 마이크), html element...으로 초당 수만 개 가량의 아주 작은 시간 단위의 음향 인텐시티(샘플) 배열

 

나의 경우에는 따로 오디오 데이터를 가공할 필요가 없었다.

 

그래서 코드는 이런식으로 만들었다.

 

  source = audioContext.createMediaStreamSource(stream);
  scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
  
  scriptProcessor.onaudioprocess = (event) => {
    const buffer = event.inputBuffer.getChannelData(0);
    // ~_~
  };

  source.connect(scriptProcessor);
  scriptProcessor.connect(audioContext.destination);

 

AudioContext.createMediaStreamSource는 MediaStreamAudioSourceNode를 만든다.

소스조차 노드당.. (컨셉에 충실한,,, 컨셉충.. 많이 배워간다)

 

AudioContext.createScriptProcessor()는 ScriptProcessorNode를 만든다.

얜 스트림으로부터 버퍼에 데이터를 쌓아 받는 역할을 한다.

 

ScriptProcessorNode에서는 오디오 데이터를 원하는 버퍼 사이즈 만큼씩 받아서

onaudioprocess 콜백으로 보내준다.

 

이 버퍼들을 다 긁어 모으면

완전한 문장 오디오를 얻을 수 있는거다!

 

 

근데 whaaat??

쓸땐 몰랐는데,,, deprecated가 되었다..

 

안 쓰임: 스크립트 프로세서 노드

오디오 worklet이 정의되기 전에, Web Audio API는 JavaScript 기반의 오디오 프로세싱을 위해 ScriptProcessorNode를 사용했습니다. 코드가 메인 스레드에서 실행되기 때문에, 나쁜 성능을 가지고 있었습니다. ScriptProcessorNode는 역사적인 이유로 보존되나 deprecated되었습니다.

 

새롭게 구현한다면 Worklet을 이용하는게 합당해보인다.

 

ScriptProcessorNode가 메인스레드에서 실행된다는 이유 때문에..

다음 나올 Web Worker를 추가로 사용했으니 말이다.

 

아!

그리고 스트리밍이 끝났다면 명시적으로 

노드를 disconnect하는 코드가 필요하다.

 

source.disconnect();
scriptProcessor.disconnect();

 

그러지 않으면 계속 onaudioprocess가 불리는,,,

무서운 상황이 벌어진다... ZOMBIE...

 

필요없는 리소스는 꼭 해제해주는 습관이 필요하다..

 

<2021.10.02 수정>

 

위에서 신나게 Node들을 잘 해제를 해주고는...
2% 부족했다..ㅎㅎ

사파리에서 여러번 녹음을 시도했더니 new AudioContext를 하더라도
AudioContext가 null인 기이한 현상을 겪었다.

처음엔 사파리를 의심했다;;;

하지만 해당 stackoverflow 글을 읽고 아차했다.

 

audiocontext Samplerate returning null after being read 8 times

I have made a function to produce a drum sound how ever after being called 4 times it stops working with the error: TypeError: null is not an object (evaluating 'audioCtx.sampleRate') Showin...

stackoverflow.com


가장 top ranking의 답변처럼 모든 브라우저들이
document에 6개까지만 AudioContext를 허용하는지(?)는 모르겠다.

 

모질라에 의하면 재사용하길 권유하고 있긴 하다..

 

AudioContext - Web APIs | MDN

The AudioContext interface represents an audio-processing graph built from audio modules linked together, each represented by an AudioNode.

developer.mozilla.org

It's recommended to create one AudioContext and reuse it instead of initializing a new one each time, and it's OK to use a single AudioContext for several different audio sources and pipeline concurrently.

 

원래 코드에서는 녹음할때마다 AudioContext를 매번 생성하도록 구현되어 있었다.

그래서 global 변수로 재사용하게 할까하다가

다쓰면 AudioContext.close() 함수를 이용해서
해제해주는 방식을 택했다.

 

그래서 수정된 제대로 된 리소스 해제 코드는,

source.disconnect();
scriptProcessor.disconnect();
audioContext.close();

 

그랬더니 사파리에서도 계속해서 녹음을 시도해도
에러없이 잘 작동하였다.

 

Web Worker

자바스크립트는 싱글스레드로 돌아간다.

 

그런데 브라우저는 알아서 오래 걸릴 network 작업은 다른 스레드에서 처리한다고 한다.

하지만 그 외의 작업들은 모두 메인스레드에서 돌아간다.

 

그러다 보니 무거운 작업은 브라우저의 렌더링을 느리게 만들 수 있다.

 

그래서 백그라운드 스레드를 사용할 수 있도록

만든 기술이 바로.. Web Worker다!

 

더보기
  • window와는 다른 글로벌 컨텍스트에서 실행됨 (워커내에서 window에 접근하는건 당연쓰 안되겟지~~)
  • 종류는 두 가지, Dedicated(처음 정해진 스크립트 한개에만 실행가능) & Shared(여러개 가능)
  • 메인스레드와의 통신은 서로 postMessage와 onmessage를 이용해서 한다.
    • postMessage는 command 이름과 함께 데이터를 보낼 수 있다.
    • onmessage는 command 이름과 함께 데이터를 받아볼 수 있다.

 

유의할 점은 Web Worker에서는 window에 접근할 수 없다는 것이다!!

 

Web Worker 코드는 public 디렉토리에 위치시켜서 별개의 script로 import가 가능해야한다.

app.js와 묶여서 함께 번들되는 코드가 아니다.

 

처음에 자꾸 script를 못 가져와서 왜 그런가 했더니

worker script를 import하는 path가 한 depth가 더 들어가서였다..

 

/what/:something 페이지에서 public/somewhere/script.js를 가져온다고 가정하면

script path를 ../somewhere/script.js로 접근해야 한다.

 

난 그냥.. 위와 같이 path를 바꿨다..ㅎㅎ

(다른 방법이 있다면 알고 싶다)

 

그리고 worker script가 다른 js 파일을 사용해야한다면...

이렇게 importScripts를 이용하자..

 

importScripts('Xxx.js');

 

위에서 언급한것처럼

ScriptProcessorNode.onaudioprocess가 메인스레드에서 스트리밍 끝날때까지.. 불리다보니;;

 

다른 ux 애니메이션이 매끄럽지 않았다...

버..버ㅓ..버ㅓ벅.. (브라우저: 죽여줘...)

 

onaudioprocess로부터 buffer를 쌓는 작업과 그 buffer를 wav로 만드는 작업을

Web worker로 실행하도록 변경했다.

 

그랬더니 버벅거리는 현상이 사라졌다.

 

완성된 worker script

importScripts('AudioBuffer.js');

onmessage = (e) => {
  switch (e.data.command) {
    case 'init':
      init(e.data.sampleRate);
      break;
    case 'record':
      record(e.data.buffer);
      break;
    case 'exportWav':
      exportWav();
      break;
  }
};

let audioBuffer;

function init(sampleRate) {
  audioBuffer = new AudioBuffer(sampleRate);
}

function record(buffer) {
  audioBuffer.push(buffer);
}

function exportWav() {
  postMessage({
    file: new Blob([audioBuffer.toBuffer()], {type: 'audio/wav'}),
    uri: audioBuffer.toDataUri(),
  });
}

 

사용한 라이브러리:  rochars/wavefile

wav 파일은 이전 편에서 언급한것처럼

가공하지 않은 순수한 샘플 array에다가 메타 데이터들을 추가한 것이다.

 

그래서 직접 메타데이터를 붙여 만들수도 있다.

(아래 cwilso/AudioRecorder를 참고하면 된다)

 

하지만 나는 wav 파일로 만드는 작업은 라이브러리를 이용했다.

sample rate와 bit depth도 쉽게 변환할 수 있어서 좋았다.

 

buffer를 wav로 만드는 코드

importScripts('https://cdn.jsdelivr.net/npm/wavefile');

class AudioBuffer {
  constructor(sampleRate) {
    this.sampleRate = sampleRate;
    this.buffer = [];
  }

  push(buffer) {
    buffer.forEach((sample) => this.buffer.push(sample));
  }

  toDataUri() {
    return !!this.toWav() ? this.wav.toDataURI() : '';
  }

  toBuffer() {
    return !!this.toWav() ? this.wav.toBuffer() : undefined;
  }

  toWav() {
    return !!this.wav
      ? this.wav
      : this.buffer.length > 0
      ? this.createWav()
      : undefined;
  }

  createWav() {
    this.wav = new wavefile.WaveFile();
    this.wav.fromScratch(1, this.sampleRate, '32f', this.buffer);
    this.wav.toSampleRate(16000);
    this.wav.toBitDepth('16');
    return this.wav;
  }
}

 

wav를 blob으로 만드는 코드

const blob = new Blob([this.wav.toBuffer()], {type: 'audio/wav'});

 

참고한 코드: cwilso/AudioRecorder

Web에서 recorder 기능을 어떻게 구현할 수 있는지

full sequence를 이해할 수 있었던 좋은 코드였다.

 

정말 도움이 많이 되었다👍