음성 녹음 로직을 구현해보자(2)

부루베릐·3일 전
0

TIL

목록 보기
23/23

이전 아티클을 읽고 오면 이해가 더 쉽다!

음성 녹음 로직을 구현해보자(1) - 기본적인 오디오 용어와 미디어 스트림에 대해 알아보자


개요

앞서 getUserMedia를 통해 사용자 기기로부터 미디어 스트림과 미디어 스트림 트랙들을 받아오는 것 까지는 구현할 수 있었다. 그렇다면 이 미디어 스트림에서 어떻게 하면 음성 데이터를 추출해 서버로 보내줄 수 있을까?

이를 위해서 오디오 녹음에 대한 여러 API와 라이브러리를 직접 실험해 보고 도입한 이야기를 하려 한다.

목표

우리의 목표는 100ms의 시간 간격마다 wav 파일의 음성 데이터를 마이크로부터 받아, 웹소켓을 통해 STT 서버로 송신하는 것이다.

이를 위해 어떤 방식으로 문제를 해결했는지 하나하나씩 살펴보자.

첫 번째 후보 - MediaRecorder API

가장 간단하고 기본적인 미디어 녹음 API이다. 이 API를 사용하여 우리가 생성한 미디어 스트림 객체나 <audio> 혹은 <video>와 같은 HTML 미디어 요소가 생성한 미디어 데이터를 추출해 이를 수정하거나 저장할 수 있다.

일단 마이크를 통해 발화된 음성을 일정 time slice마다 받아, 이를 모두 모아 <audio> 요소에서 재생하는 간단한 예시를 만들어 보자. 크게 다음과 같이 구현할 수 있다.

  1. MediaStream 또는 HTMLMediaElement를 통해 미디어 데이터를 받는다.
  2. 그 미디어 데이터를 가지고 MediaRecorder 객체를 생성한다.
  3. ondataavailable을 통해 dataavailable 이벤트를 핸들링한다.
  4. MediaRecorder.start()로 녹음을 시작한다.
    1. 이 때 인자로 timeslice 값을 넣을 수 있다. 이 옵션을 넘겨주면 인자로 받은 시간 간격마다 dataavailable 이벤트가 발생한다.
  5. dataavailable 이벤트를 통해 미디어 데이터를 Blob 형태로 받아 배열에 저장한다.
  6. Blob 데이터 배열을 하나로 묶어 wav 타입의 blob URL을 생성한다.
  7. 해당 URL을 audio 요소에서 재생한다.

예시 코드

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MediaRecorder API</title>
</head>
<body>
  <audio id="audioPlayback" controls></audio>
  <h1>MediaRecorder API</h1>
  <button id="recordStartBtn">Start</button>
  <button id="recordEndBtn">End</button>
  
  <script>
	  const recordStartBtn = document.getElementById('recordStartBtn')
		const recordEndBtn = document.getElementById('recordEndBtn')
		const audioPlayback = document.getElementById('audioPlayback')
		
		let mediaRecorder
		let recordedChunks = []
		
		recordStartBtn.addEventListener('click', startRecording)
		recordEndBtn.addEventListener('click', stopRecording)
		
		function handleDataAvailable(event) {
		  console.log('event', event.data)
		  recordedChunks.push(event.data)
		}
		
		function setAudioSrc() {
		  const audioBlob = new Blob(recordedChunks, { type: 'audio/wav' })
          console.log('audioBlob', audioBlob)
		  const audioUrl = URL.createObjectURL(audioBlob)
		  audioPlayback.src = audioUrl
		}
		
		async function startRecording() {
		  try {
		    console.log('녹음 시작')
		    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
		    mediaRecorder = new MediaRecorder(stream)		
		
		    mediaRecorder.ondataavailable = handleDataAvailable
		    mediaRecorder.onstop = setAudioSrc
		
		    mediaRecorder.start()
		    recordStartBtn.disabled = true
		    recordEndBtn.disabled = false
		  } catch(err) {
		    console.error('녹음 실패!: ', err)
		  }
		}
		
		function stopRecording() {
		  console.log('녹음 종료')
		  mediaRecorder.stop()
		  recordStartBtn.disabled = false
		  recordEndBtn.disabled = true
		}
  </script>
</body>
</html>

어렵지 않다. 이벤트 핸들러를 붙이고 start() 메서드를 호출하기만 하면 끝이다.

한계?

MediaRecorder API는 간단하게 미디어 녹음을 할 수 있는 좋은 API이지만 실제로 쓰이기에는 어려움이 좀 있었다. 바로 녹음되는 오디오 파일의 음량과 품질, 타입이 브라우저 환경에 따라 천차만별이었기 때문이다.

MediaRecorder 인스턴스를 생성할 때 다음과 같이 음성 데이터의 MimeType을 지정해줄 수 있는데, 크로스 브라우징 대응 시 알맞은 타입을 설정해주기가 굉장히 번거롭다.

new MediaRecorder(stream, { mimetype: '여기에 타입 입력' })	
  • 지정해주지 않을 때
    • 크롬, 엣지에서는 audio/webm;codecs=opus, 사파리에서는 audio/mp4를 기본값으로 해 타입이 지정된다.
  • audio/wav 지정 시
    • NotSupportedError: Failed to construct 'MediaRecorder': Failed to initialize native MediaRecorder the type provided (audio/wav) is not supported. 에러가 뜬다. MediaRecorder에서 wav 파일이 지원되지 않는다.
  • audio/webm;codecs=opus 지정 시
    • 오디오 코덱으로 Opus를 사용하는 WebM 동영상 컨테이너 규격이란 의미이다. 코덱이란 음성 또는 영상의 신호를 디지털 신호로 변환하는 코더(COder)와 그 반대로 변환시켜주는 디코더(DECoder)를 말한다. PCM은 무손실 무압축 코덱이고 Opus 오디오 코덱은 손실 압축 코덱이다.
    • 사파리에서 지원하지 않는다(NotSupportedError: mimeType is not supported).

이렇듯 MediaRecorder API만을 사용했을 때, 각 사용자 환경마다 고려해야 하는 것들이 너무 많아서 일관된 개발이 쉽지 않다. 따라서 백엔드와 논의 후 음성 데이터 포맷을 하나로 통일하고, 해당 포맷을 지원하는 다른 라이브러리를 사용하는 것이 빠를 것 같다는 결정을 내렸다.

WAV 파일로 포맷을 통일

위와 같은 문제 때문에 백엔드와 논의 후 음성 데이터 포맷을 WAV 파일 하나로 통일하도록 하였다. WAV 포맷으로 정했던 이유는 기기마다의 샘플 레이트나 채널 수 등의 환경이 다르기 때문이다. 따라서 PCM 데이터에 대한 정보 역시 서버에 전달해 주어야 서버에서 일관된 처리가 가능하다. WAV 포맷은 음성 데이터의 메타데이터(샘플 레이트, 채널 수, 비트 깊이 등등)가 헤더에 포함되어 있으므로 STT 서비스가 정확하게 음성을 인식하기 용이하다.

또한 Webm이나 MP3 포맷은 인코딩하기에는 방법이 복잡한데 비해, WAV는 PCM 데이터에 간단한 헤더 정보로 감싸기만 하면 되므로, 클라이언트에서 실시간으로 PCM 데이터를 인코딩하여 서버로 음성을 보내야 하는 우리 서비스의 입장에서 부담이 덜 든다는 장점이 있다. 따라서 다소 다른 손실압축 포맷에 비해 용량이 클지라도 WAV 포맷을 사용하게 되었다.

두 번째 후보 - RecordRTC.js

그래서 대용을 사용한 방식이 바로 RecordRTC.js 라이브러리이다. 이 패키지를 사용하면 간편하게 WAV 파일로 변환된 오디오 데이터를 얻을 수 있다. 사용법도 간단하고 사용자도 많아 괜찮은 라이브러리라는 판단이 들었다.

위의 MediaRecorder API를 통해 만든 예시를 RecordRTC를 통해 만들어 보자. 녹음된 음성 데이터를 WAV 파일로 받아 audio 요소에서 재생하는 로직이다.

예시 코드

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>RecordRTC</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/RecordRTC/5.6.2/RecordRTC.min.js"></script>
  </head>
  <body>
    <audio id="audioPlayback" controls></audio>
    <h1>RecordRTC</h1>
    <button id="recordStartBtn">Start</button>
    <button id="recordEndBtn">End</button>

    <script>
      const recordStartBtn = document.getElementById("recordStartBtn");
      const recordEndBtn = document.getElementById("recordEndBtn");
      const audioPlayback = document.getElementById("audioPlayback");

      let mediaStream = null;
      let recorder = null;

      recordStartBtn.addEventListener("click", startRecording);
      recordEndBtn.addEventListener("click", stopRecording);

      function setAudioSrc(blob) {
        const audioUrl = URL.createObjectURL(blob);
        audioPlayback.src = audioUrl;
      }

      async function startRecording() {
        try {
          console.log("녹음 시작");
          if (mediaStream == null) {
            mediaStream = await navigator.mediaDevices.getUserMedia({
              audio: true,
            });
          }

          recorder = new RecordRTC(mediaStream, {
            type: "audio",
            mimeType: "audio/wav",
            recorderType: RecordRTC.StereoAudioRecorder,
          });

          recorder.startRecording();
          recordStartBtn.disabled = true;
          recordEndBtn.disabled = false;
        } catch (err) {
          console.error("녹음 실패!: ", err);
        }
      }

      function stopRecording() {
        recorder.stopRecording(() => {
          console.log("녹음 종료");
          const blob = recorder.getBlob();
          setAudioSrc(blob);
          recordStartBtn.disabled = false;
          recordEndBtn.disabled = true;
        });
      }
    </script>
  </body>
</html>

위의 예시는 녹음된 음성을 모두 재생하는 로직이지만, 실시간으로 음성 데이터를 서버에 보내야 하는 경우 다음과 같이 timeSlice 값을 주어 시간 간격마다 ondataavailable 콜백을 통해 오디오 데이터 blob을 받을 수 있다.

recorder = new RecordRTC(mediaStream, {
  type: 'audio',
  mimeType: 'audio/wav',
  recorderType: RecordRTC.StereoAudioRecorder,
  timeSlice: 100,
  ondataavailable: function(blob) {
    console.log('blob', blob)
    // TODO: blob wav 데이터를 stt 서버로 송신!
  }
})

RecorderType 설정

RecordRTC의 오디오와 관련된 recorderType은 크게 MediaStreamRecorderStereoAudioRecorder 두 가지가 있다. 여기서 MediaStreamRecorder는 MediaRecorder API(코드)를, StereoAudioRecorder는 Web Audio API(코드)를 사용한다.

StereoAudioRecorder를 사용하면 WAV 포맷으로 오디오 바이너리 데이터를 받을 수 있다(코드). 백엔드에서 오디오 파일 포맷을 WAV로 통일하였으므로, Blob 데이터의 mimeType을 audio/wav로 놓기 위해서 recorderType을 StereoAudioRecorder로 설정하였다.

한계

이렇게 RecordRTC를 사용하면 잘 작동하지만, 다음과 같은 경고 문구가 뜬다.

[Deprecation] The ScriptProcessorNode is deprecated. Use AudioWorkletNode instead. (https://bit.ly/audio-worklet)

ScriptProcessorNode는 자바스크립트를 이용해서 오디오를 직접 생성, 관리 혹은 분석하기 위해 사용한다(AudioNode의 인터페이스 중 하나임). 이제는 AudioWorkletNode로 대체된 deprecated 인터페이스이다. RecordRTC의 StereoAudioRecorder 소스 코드에서 이를 사용하는 로직이 있다(코드). 자세히 얘기하면 채널에서 받은 PCM 데이터를 받아 변수에 보관 후(leftchannel, 스테레오일 경우 rightchannel도) ondataavailable 이벤트 핸들러 혹은 onStop 핸들러에서 음성 데이터를 가지고 WAV 파일을 만들기 위한 코드이다.

여기서 사용하면서 문제가 된 듯하다. 잘 작동하는 코드임은 분명하지만, deprecated된 스펙을 사용하기에는 리스크가 있다 판단하였다. RecordRTC 대신 직접 Web Audio API를 통해 녹음 로직을 작성하기로 의견이 모아졌다.

최종 후보 - Web Audio API

Web Audio API는 브라우저에서 오디오를 다룰 때 사용하는 대표적인 API이다. 웹사이트의 오디오를 컨트롤하거나 음향을 시각화하거나, 심지어는 가상 악기까지 만드는 등 거의 모든 것을 할 수 있다고 보면 된다.

용어 정리

Web Audio API는 오디오 컨텍스트(Audio Context) 내의 오디오 노드(Audio Node)들을 통해 소리를 제어한다. 오디오를 다루기 위해서는 일단 무조건 오디오 컨텍스트부터 만들어야 한다. 이 컨텍스트 안에서 각자의 역할을 가진 여러 오디오 노드를 만들어 그들을 체인처럼 연결하는 방식으로 소리를 다룬다고 생각하면 된다. 이렇게 오디오 노드들이 연결되어 소리를 컨트롤하는 형태를 오디오 라우팅 그래프(Audio Routing Graph)라 한다. 이에 대한 간단한 예시를 살펴보자.

간단한 Web Audio 사용 흐름

  1. 오디오 컨텍스트를 생성한다.
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  1. 컨텍스트 내에서 <audio> 요소 혹은 미디어 스트림과 같은 오디오 입력 소스 노드를 생성한다.
// <audio> 요소 사용 시
const audioElement = document.querySelector('audio');
const sourceNode = audioContext.createMediaElementSource(audioElement);

// 미디어 스트림 사용 시
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const sourceNode = audioContext.createMediaStreamSource(stream);
  1. 오디오에 주고 싶은 효과(effect) 노드를 생성한다.
// 예) 게인 조절
const gainNode = audioContext.createGain();
gainNode.gain.value = 0.5;
  1. 오디오가 출력될 목적지(destination) 노드를 생성한다.
const destinationNode = audioContext.destination;
  1. 입력 노드를 이펙트 노드에, 이펙트 노드를 출력 노드에 연결한다.
sourceNode.connect(gainNode);      // 소스 -> 이펙트
gainNode.connect(destinationNode); // 이펙트 -> 출력

위의 오디오 라우팅 그래프를 간단하게 이미지로 나타내면 다음과 같다(출처).

AudioWorklet을 사용하여 PCM 데이터를 WAV로 변경하기

AudioContext 미디어 스트림으로부터 받은 데이터를 WAV 포맷으로 변경하는 작업을 진행하려 한다. Web Audio API에서는 이렇게 블로킹을 발생시킬 가능성이 있는 로직들을 렌더링 스레드라는 별도의 스레드로 넘겨줄 수 있다.

Control Thread와 Rendering Thread

Web Audio API는 그게 두 가지의 스레드로 구성된다. 메인으로 동작하는 컨트롤 스레드와 자체적인 이벤트 루프(렌더링 루프)를 가지는 렌더링 스레드이다.

보통 사용자가 자바스크립트를 이용해서 Web Audio API를 다룰 때, 다시 말해 사용자가 오디오 그래프를 변경하는 작업(예를 들어 오디오 노드를 추가 or 삭제하거나 노드 사이의 연결을 수정하는 등)은 컨트롤 스레드에서 일어난다.

렌더링 스레드에서는 컨트롤 스레드에서 일어난 변경 사항을 실제 오디오 그래프에 반영해서 다시 그래프를 렌더링하는 작업과 소리가 하드웨어에서 재생될 수 있도록 오디오 샘플을 실제 OS에 전달하는 역할을 수행한다(Matuszewski & Rottier (2023). The Web Audio API as a Standardized Interface Beyond Web Browsers). 즉 웹 워커와 비슷하게 백그라운드에서 동작하는 스레드이지만, 좀 더 오디오 데이터를 처리하는 역할에 집중한 스레드라 할 수 있다.

100ms마다 PCM 데이터를 받아 웹소켓을 통해 서버로 보내주어야 하는 우리 서비스 특성상, PCM를 WAV 포맷으로 인코딩하는 작업을 렌더링 스레드에서 진행하는 것이 알맞다는 판단을 하였다.

AudioWorklet과 AudioWorkletProcessor

렌더링 스레드에서 동작하는 오디오 프로세싱 코드를 만들기 위해서는 별도로 자바스크립트 파일을 생성해야 한다.

AudioWorkletProcessor 클래스를 확장

커스텀 오디오 프로세싱 코드는 무조건 AudioWorkletProcessor 인터페이스에서부터 클래스를 extend해야 한다.

// recorder-worklet-processor.js
class RecorderWokletProcessor extends AudioWorkletProcessor {
  constructor() {
    super()
  }
}

process() 메서드를 작성

process 메서드를 통해서 오디오 프로세싱 로직을 구현할 수 있다. 이 메서드는 AudioWorkletNode를 통해서 들어오고 있는 오디오 블럭 하나가 준비될 때마다 호출된다. 이 오디오 블럭을 받아서 사용하면 된다.

인자로는 (inputs, outputs, parameters) 총 3개를 갖는다. 이 중 inputs와 outputs는 3중 배열로 되어 있다. inputs[n][m][i]는 n 번째 입력에서 m 번째 채널의 i 번째 샘플을 뜻한다. 우리 서비스에서는 마이크가 하나이므로 입력도 하나, 채널도 하나(모노 채널)이다. 각 채널은 128개의 샘플을 가지고 있는(변동 가능) Float32Array이므로 new Float32Array() 생성자를 통해 bufferChunks에 추가한다.

마지막으로 true를 반환함으로써 Web Audio API로 하여금 노드를 멈추지 않고 계속 동작하도록 할 수 있다. 만약 false를 반환하거나 반환값을 작성하지 않는 경우 브라우저가 노드를 멈출 수 있다.

// recorder-worklet-processor.js
class RecorderWokletProcessor extends AudioWorkletProcessor {
	constructor() {
    super()
    this.bufferChunks = []
  }
  
	process(inputs) {
	  const input = inputs[0] // 첫 번째 입력
	  if (input.length > 0) {
	    const channelData = input[0] // 모노 채널 사용
	    this.bufferChunks.push(new Float32Array(channelData))
	  }
	  return true
	}
}

샘플 하나하나가 32비트 부동소수점 숫자인 이유는, 일반 배열의 경우 한 요소마다 64비트(8바이트)인 반면 Float32Array는 32비트(4바이트)이므로 메모리 효율성 측면에서 좋고, 타입이 고정되어 있으므로 자바스크립트 엔진이 요소들의 타입을 추론할 필요가 없으므로 하드웨어 처리 최적화 측면에서 유리하기 때문이다.

WAV 파일 변환 로직 작성

PCM 로우 데이터를 메타 데이터 정보로 감싸 WAV 데이터로 인코딩하는 로직이다.

sample은 위 process()에서 bufferChunks에 추가한 채널 하나의 샘플 데이터 배열이다. 즉 [총 오디오 데이터 크기 + 메타데이터 헤더 길이]를 담은 ArrayBuffer를 만든다고 생각하면 된다.

sampleRate는 클래스를 정의할 때 전역 실행 컨텍스트로 설정된 AudioWorkletGlobalScope 인터페이스 내의 속성이다. 전역 접근 가능하므로 메서드 호출 시 인자로 받기만 하면 된다.

ArrayBuffer 내의 다양한 숫자 자료형의 데이터를 읽고 쓰기 위해 DataView 인터페이스를 사용한다. RIFF 형식에서는 바이너리 데이터가 리틀 엔디안으로 저장되어야 한다. DataView의 setUintXX 메서드의 세 번째 인자로 true 값을 넣으면 데이터를 ArrayBuffer에 리틀 엔디안으로 삽입할 수 있다.

// recorder-worklet-processor.js
class RecorderWokletProcessor extends AudioWorkletProcessor {
	//...
	encodeWAV(samples, sampleRate) {
	  const WAV_HEADER_LENGTH = 44 // bytes
	
	  // 오디오 데이터 블록의 길이를 확인하는 로직
	  // samples.length: 청크의 개수
	  // samples[0].length: 각 청크의 샘플 수
	  // 2: 16비트(2바이트) 샘플을 사용하므로
	  const bufferLength = samples.length * samples[0].length * 2
	  const buffer = new ArrayBuffer(WAV_HEADER_LENGTH + bufferLength)
	  const view = new DataView(buffer)
	
	  // WAV 헤더 작성 -> 리틀 엔디언 형식으로 작성
	  this.writeString(view, 0, 'RIFF')
	  view.setUint32(4, 36 + bufferLength, true)
	  this.writeString(view, 8, 'WAVE')
	  this.writeString(view, 12, 'fmt ')
	  view.setUint32(16, 16, true) // SubChunk1Size
	  view.setUint16(20, 1, true) // AudioFormat (PCM)
	  view.setUint16(22, 1, true) // NumChannels
	  view.setUint32(24, sampleRate, true) // SampleRate
	  view.setUint32(28, sampleRate * 2, true) // ByteRate
	  view.setUint16(32, 2, true) // BlockAlign
	  view.setUint16(34, 16, true) // BitsPerSample
	  this.writeString(view, 36, 'data')
	  view.setUint32(40, bufferLength, true)
	
	  // 오디오 데이터 작성
	  let offset = WAV_HEADER_LENGTH
	  for (let i = 0; i < samples.length; i++) {
	    const sample = samples[i]
	    for (let j = 0; j < sample.length; j++) {
	      const s = Math.max(-1, Math.min(1, sample[j]))
	      view.setInt16(offset, s * 0x7fff, true)
	      offset += 2 // 2bytes -> 16bits씩
	    }
	  }
	
	  return buffer
	}
	
	writeString(view, offset, string) {
	  for (let i = 0; i < string.length; i++) {
	    view.setUint8(offset + i, string.charCodeAt(i)) // 문자열 한 글자당 8bits
	  }
	}
}

일반 배열 대신 ArrayBuffer를 쓰는 이유

  • 바이트 단위로 일정하게 메모리를 할당함으로써 메모리를 효율적으로 관리
  • 바이트 단위로 데이터를 쓰고 수정하기 편함

port 인스턴스를 사용하여 메인 스레드와 소통

port 속성의 onmessage를 통해 메인 스레드로 메세지를 보낼 수 있고, postMessage를 통해 메세지를 수신할 수 있다. Worklet 프로세서 코드에서 다음과 같이 flush 메세지를 받았을 때 bufferChunks의 오디오 데이터를 WAV로 변환한 뒤 메세지에 넣어 송신하는 로직을 작성한다.

// recorder-worklet-processor.js
class RecorderWokletProcessor extends AudioWorkletProcessor {
	constructor() {
	  super()
	  this.bufferChunks = []
	  this.port.onmessage = (event) => {
	    if (event.data === 'flush') {
	      this.flush()
	    }
	  }
	}
	
	flush() {
	  if (this.bufferChunks.length > 0) {
	    const wavBuffer = this.encodeWAV(this.bufferChunks, sampleRate)
	    this.port.postMessage({ wavBuffer }, [wavBuffer])
	    this.bufferChunks = []
	  }
	}
}

registerProcessor를 사용하여 클래스 생성자 등록

마지막으로 해당 프로세싱 코드를 AudioWorklet 노드에 적용하기 위해 생성자를 등록해야 한다. 이 때 registerProcessor의 첫 번째 인자로 해당 프로세서의 이름을 넘겨줘 후에 Audio 노드에 적용 시 사용한다.

// recorder-worklet-processor.js
class RecorderWokletProcessor extends AudioWorkletProcessor {
	//...
}
registerProcessor('recorder-worklet-processor', RecorderWokletProcessor)

메인 스레드에서 AudioWorklet에 오디오 프로세싱 코드를 등록

이제 AudioWorklet을 사용하여 지금까지 작성한 로직을 AudioWorklet 노드에 추가하자.

  • Worklet 인터페이스의 addModule()로 오디오 프로세싱 자바스크립트를 추가
  • AudioWorkletNode() 생성자를 사용하여 오디오 컨텍스트 내에 AudioWorklet 노드를 생성

이 때 AudioWorkletNode() 생성자의 두 번째 인자 name은 우리가 AudioWorklet에 추가한 자바스크립트 모듈에서 등록한 이름과 일치해야 한다.

const audioContext = new AudioContext()
await audioContext.audioWorklet.addModule('/recorder-worklet-processor.js')
const audioWorkletNode = new AudioWorkletNode(audioContext, 'recorder-worklet-processor')

예시 코드

// recorder-worklet-processor.js
class RecorderWokletProcessor extends AudioWorkletProcessor {
  constructor() {
    super()
    this.bufferChunks = []
    this.port.onmessage = (event) => {
      if (event.data === 'flush') {
        this.flush()
      }
    }
  }

  process(inputs) {
    const input = inputs[0]
    if (input.length > 0) {
      const channelData = input[0] // 모노 채널 사용
      this.bufferChunks.push(new Float32Array(channelData))
    }
    return true
  }

  flush() {
    if (this.bufferChunks.length > 0) {
      const wavBuffer = this.encodeWAV(this.bufferChunks, sampleRate)
      this.port.postMessage({ wavBuffer }, [wavBuffer])
      this.bufferChunks = []
    }
  }

  encodeWAV(samples, sampleRate) {
    const WAV_HEADER_LENGTH = 44
    const bufferLength = samples.length * samples[0].length * 2
    const buffer = new ArrayBuffer(WAV_HEADER_LENGTH + bufferLength)
    const view = new DataView(buffer)

    // WAV 헤더 작성
    this.writeString(view, 0, 'RIFF')
    view.setUint32(4, 36 + bufferLength, true)
    this.writeString(view, 8, 'WAVE')
    this.writeString(view, 12, 'fmt ')
    view.setUint32(16, 16, true) // SubChunk1Size
    view.setUint16(20, 1, true) // AudioFormat (PCM)
    view.setUint16(22, 1, true) // NumChannels
    view.setUint32(24, sampleRate, true) // SampleRate
    view.setUint32(28, sampleRate * 2, true) // ByteRate
    view.setUint16(32, 2, true) // BlockAlign
    view.setUint16(34, 16, true) // BitsPerSample
    this.writeString(view, 36, 'data')
    view.setUint32(40, bufferLength, true)

    // 오디오 데이터 작성
    let offset = WAV_HEADER_LENGTH
    for (let i = 0; i < samples.length; i++) {
      const sample = samples[i]
      for (let j = 0; j < sample.length; j++) {
        const s = Math.max(-1, Math.min(1, sample[j]))
        view.setInt16(offset, s * 0x7fff, true)
        offset += 2 // 2bytes -> 16bits씩
      }
    }

    return buffer
  }

  writeString(view, offset, string) {
    for (let i = 0; i < string.length; i++) {
      view.setUint8(offset + i, string.charCodeAt(i))
    }
  }
}

registerProcessor('recorder-worklet-processor', RecorderWokletProcessor)
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Audio API</title>
  </head>
  <body>
    <audio id="audioPlayback" controls></audio>
    <h1>Web Audio API</h1>
    <button id="recordStartBtn">Start</button>
    <button id="recordEndBtn">End</button>

    <script>
      const recordStartBtn = document.getElementById("recordStartBtn")
      const recordEndBtn = document.getElementById("recordEndBtn")
      const audioPlayback = document.getElementById("audioPlayback")

      let mediaStream = null
      let audioContext = null
      let audioWorkletNode = null

      recordStartBtn.addEventListener("click", startRecording)
      recordEndBtn.addEventListener("click", stopRecording)

      function setAudioSrc(blob) {
        const audioUrl = URL.createObjectURL(blob)
        audioPlayback.src = audioUrl
      }

      async function startRecording() {
        try {
          console.log("녹음 시작")
          if (audioContext == null) {
            audioContext = new (window.AudioContext || window.webkitAudioContext)()
            // worklet node 생성
            await audioContext.audioWorklet.addModule('/recorder-worklet-processor.js')
            audioWorkletNode = new AudioWorkletNode(audioContext, 'recorder-worklet-processor')
          }

          if (mediaStream == null) {
            mediaStream = await navigator.mediaDevices.getUserMedia({
              audio: true,
            })
          }

          inputNode = audioContext.createMediaStreamSource(mediaStream)
          inputNode.connect(audioWorkletNode)
          audioWorkletNode.connect(audioContext.destination)

          // audio worklet 메세지 핸들러
          audioWorkletNode.port.onmessage = (event) => {
            const { data } = event
            if (data.wavBuffer != null) {
              const wavBuffer = data.wavBuffer
              const blob = new Blob([wavBuffer], { type: 'audio/wav' })
              setAudioSrc(blob)
            }
          }

          recordStartBtn.disabled = true
          recordEndBtn.disabled = false
        } catch (err) {
          console.error("녹음 실패!: ", err)
        }
      }

      function stopRecording() {
        console.log("녹음 종료")
        audioWorkletNode.port.postMessage('flush')

        inputNode.disconnect(audioWorkletNode)
        audioWorkletNode.disconnect(audioContext.destination)

        recordStartBtn.disabled = false
        recordEndBtn.disabled = true
      }
    </script>
  </body>
</html>

역시 실시간으로 음성 데이터를 보내고 싶다면 다음과 같이 메인 스레드에서 다음과 같이 타임 슬라이스마다 flush 메세지를 보내 onmessage를 통해 WAV 데이터를 받아서 STT 서버로 보내면 된다.

// 메인 스레드
setInterval(() => {
  audioWorkletNode.port.postMessage('flush')
}, 100) // Time Slice

audioWorkletNode.port.onmessage = (e: MessageEvent) => {
  const { data } = e // flush된 데이터
  const wavBuffer = data.wavBuffer as ArrayBuffer
  const blob = new Blob([wavBuffer], { type: 'audio/wav' })
	// WAV 데이터를 서버로 보내기!
}

마무리

좋은 기회가 와서 깊게 오디오를 다뤄볼 수 있었다. 물론 모든 것을 다 공부한 게 아니라서 부족한 감이 없지 않지만 말이다. 실시간 음성 혹은 비디오 데이터를 다루는 서비스를 개발하는 입장에서 좀 더 제대로 미디어에 대해 공부해 볼 수 있었던 의미 있는 시간이었다. 실제로 Web Audio API를 사용해서 개러지 밴드와 같은 서비스를 만드는 사람들도 많다고 하니, 나중에라도 연습 삼아 해 보는 것도 재밌을 것 같다는 생각이 든다.

참고 자료

[Javascript] Web에서의 마이크 음성녹음 방법(MediaRecorder, Web Audio API, WebWoker, AudioWorklet)

Playing Sounds with the Web Audio API

Web Audio API 1.1

post-custom-banner

0개의 댓글