
SpeakView는 졸업작품으로 만들고 있는 실시간 자막 서비스다.
브라우저에서 들어온 마이크 입력을 받아 자막 형태로 보여주고, 이후에는 요약까지 연결하는 흐름을 목표로 하고 있다.
이번 글에서는 브라우저에서 마이크 입력을 받아 16kHz 16-bit PCM 청크로 변환하는 과정을 정리해보려고 한다.
실시간 음성 처리를 붙일 때 가장 먼저 해야 할 일은 마이크 입력을 안정적으로 받아오는 것이다.
그런데 브라우저가 처음부터 STT에 바로 넣을 수 있는 형태의 데이터를 주지는 않는다. Web Audio API의 샘플은 Float32이고, 실시간 음성 인식 시스템은 보통 16kHz, mono, 16-bit PCM raw 데이터를 기대한다.
즉, 해야 할 일은 명확하다.
getUserMedia → AudioContext → MediaStreamSourceNode → AudioWorkletNode → PCM 청크 전달
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
});
const audioContext = new AudioContext({ sampleRate: 16000 });
한 가지 주의할 점이 있다. getUserMedia의 sampleRate는 강제값이 아니라 요청값이다. 브라우저나 장치 환경에 따라 44100Hz나 48000Hz로 들어오는 경우가 있어서, AudioContext 생성 시에도 sampleRate: 16000을 명시해줘야 한다. 이렇게 하면 내부적으로 리샘플링이 일어나 실제 16kHz로 처리된다.
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);
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을 살짝 넘는 값이 들어오는 경우가 있어서다.
16000Hz × 0.1초 = 1600 샘플 × 2바이트(Int16) = 3200 bytes
100ms 단위로 자르면 청크 하나가 약 3.2KB다. 너무 작으면 처리 횟수가 지나치게 많아지고, 너무 크면 지연이 커진다. STT API의 권장 범위(3~5KB)를 맞추면서 실시간성을 유지하기에 적당한 크기다.
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를 두면 그래프는 유지하면서 마이크 입력이 스피커로 다시 나오는 것도 막을 수 있다.
| 상황 | 원인 / 해결 |
|---|---|
| 샘플레이트가 요청한 대로 안됨 | getUserMedia의 sampleRate는 요청값. AudioContext에도 명시할 것 |
| Worklet 등록이 안됨 | addModule()은 URL 기반. Blob URL 또는 별도 파일 사용 |
| 어느 순간 오디오 처리가 멈춤 | process()에서 return true 누락 |
| 권한 요청이 뜨지 않음 | getUserMedia는 HTTPS 또는 localhost에서만 동작 |
| 자기 목소리가 스피커로 들림 | 무음 GainNode 없이 destination에 직접 연결됨 |
처음엔 구조가 낯설어 보이지만, 흐름 자체는 단순하다. 마이크 스트림을 받고, Worklet에서 샘플을 가공하고, 청크로 꺼내면 끝이다.
STT 외에도 실시간 볼륨 측정, 파형 시각화, 노이즈 분석 등 다양한 오디오 처리에 그대로 활용할 수 있는 구조다.
다음 글에서는 여기서 만든 PCM 청크를 서버로 전달하는 과정을 이어서 정리해보려고 한다.