Web Audio API로 브라우저 마이크 입력을 16kHz PCM으로 변환하기

성태경·2026년 3월 22일

프로젝트 소개

SpeakView는 졸업작품으로 만들고 있는 실시간 자막 서비스다.

브라우저에서 들어온 마이크 입력을 받아 자막 형태로 보여주고, 이후에는 요약까지 연결하는 흐름을 목표로 하고 있다.

이번 글에서는 브라우저에서 마이크 입력을 받아 16kHz 16-bit PCM 청크로 변환하는 과정을 정리해보려고 한다.


왜 PCM 변환이 필요할까

실시간 음성 처리를 붙일 때 가장 먼저 해야 할 일은 마이크 입력을 안정적으로 받아오는 것이다.

그런데 브라우저가 처음부터 STT에 바로 넣을 수 있는 형태의 데이터를 주지는 않는다. Web Audio API의 샘플은 Float32이고, 실시간 음성 인식 시스템은 보통 16kHz, mono, 16-bit PCM raw 데이터를 기대한다.

즉, 해야 할 일은 명확하다.

  • 마이크 입력을 받는다
  • Float32 샘플을 Int16 PCM으로 변환한다
  • 일정 크기의 청크로 잘라 다음 단계로 넘긴다

오디오 파이프라인 한눈에 보기

getUserMedia → AudioContext → MediaStreamSourceNode → AudioWorkletNode → PCM 청크 전달

구현

1. getUserMedia로 마이크 입력 받기

const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    sampleRate: 16000,
    channelCount: 1,
    echoCancellation: true,
    noiseSuppression: true,
  },
});

const audioContext = new AudioContext({ sampleRate: 16000 });

한 가지 주의할 점이 있다. getUserMediasampleRate는 강제값이 아니라 요청값이다. 브라우저나 장치 환경에 따라 44100Hz나 48000Hz로 들어오는 경우가 있어서, AudioContext 생성 시에도 sampleRate: 16000을 명시해줘야 한다. 이렇게 하면 내부적으로 리샘플링이 일어나 실제 16kHz로 처리된다.

2. AudioWorklet으로 PCM 변환하기

AudioWorklet은 URL 기반으로 모듈을 등록해야 한다. 별도 파일을 두거나, 아래처럼 Blob URL을 만들어 등록하는 방식을 쓸 수 있다.

const SAMPLE_RATE = 16000;
const CHUNK_INTERVAL_MS = 100;

const processorCode = `
  class PcmProcessor extends AudioWorkletProcessor {
    constructor() {
      super();
      this._buffer = [];
      this._chunkSize = ${Math.floor(SAMPLE_RATE * CHUNK_INTERVAL_MS / 1000)};
    }

    process(inputs) {
      const input = inputs[0];
      if (!input || !input[0]) return true;

      const samples = input[0]; // Float32Array

      for (let i = 0; i < samples.length; i++) {
        this._buffer.push(samples[i]);
      }

      while (this._buffer.length >= this._chunkSize) {
        const chunk = this._buffer.splice(0, this._chunkSize);
        const int16 = new Int16Array(chunk.length);

        for (let i = 0; i < chunk.length; i++) {
          const s = Math.max(-1, Math.min(1, chunk[i]));
          int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
        }

        this.port.postMessage(int16.buffer, [int16.buffer]);
      }

      return true; // 반드시 true를 반환해야 프로세서가 유지된다
    }
  }

  registerProcessor('pcm-processor', PcmProcessor);
`;

const blob = new Blob([processorCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
await audioContext.audioWorklet.addModule(url);
URL.revokeObjectURL(url);

Float32를 Int16으로 바꾸기

Web Audio API의 샘플 값은 -1.0 ~ 1.0 범위의 Float32다. STT는 -32768 ~ 32767 범위의 Int16을 기대하므로 변환이 필요하다.

const s = Math.max(-1, Math.min(1, chunk[i])); // 클리핑
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;

Math.max(-1, Math.min(1, s))로 먼저 클리핑하는 이유는 부동소수점 오차로 인해 ±1을 살짝 넘는 값이 들어오는 경우가 있어서다.

100ms 청크로 자르기

16000Hz × 0.1초 = 1600 샘플 × 2바이트(Int16) = 3200 bytes

100ms 단위로 자르면 청크 하나가 약 3.2KB다. 너무 작으면 처리 횟수가 지나치게 많아지고, 너무 크면 지연이 커진다. STT API의 권장 범위(3~5KB)를 맞추면서 실시간성을 유지하기에 적당한 크기다.

3. 오디오 그래프 연결하기

const source = audioContext.createMediaStreamSource(stream);
const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor');

workletNode.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
  onChunk(e.data); // Int16 PCM이 담긴 ArrayBuffer
};

// 무음 GainNode — 마이크 입력이 스피커로 나오는 것을 막는다
const silentGain = audioContext.createGain();
silentGain.gain.value = 0;

source.connect(workletNode);
workletNode.connect(silentGain);
silentGain.connect(audioContext.destination);

출력 노드에 연결하지 않으면 오디오 그래프가 정상적으로 동작하지 않는 경우가 있다. 무음 GainNode를 두면 그래프는 유지하면서 마이크 입력이 스피커로 다시 나오는 것도 막을 수 있다.


삽질 포인트

상황원인 / 해결
샘플레이트가 요청한 대로 안됨getUserMediasampleRate는 요청값. AudioContext에도 명시할 것
Worklet 등록이 안됨addModule()은 URL 기반. Blob URL 또는 별도 파일 사용
어느 순간 오디오 처리가 멈춤process()에서 return true 누락
권한 요청이 뜨지 않음getUserMedia는 HTTPS 또는 localhost에서만 동작
자기 목소리가 스피커로 들림무음 GainNode 없이 destination에 직접 연결됨

마치며

처음엔 구조가 낯설어 보이지만, 흐름 자체는 단순하다. 마이크 스트림을 받고, Worklet에서 샘플을 가공하고, 청크로 꺼내면 끝이다.

STT 외에도 실시간 볼륨 측정, 파형 시각화, 노이즈 분석 등 다양한 오디오 처리에 그대로 활용할 수 있는 구조다.

다음 글에서는 여기서 만든 PCM 청크를 서버로 전달하는 과정을 이어서 정리해보려고 한다.

0개의 댓글