실시간 통역 서비스를 개발하면서, 기기의 마이크를 통해 음성을 녹음하고 이 음성 데이터에 대한 STT 데이터를 서버로부터 실시간으로 받아와야 하는 경우가 많았다. 일반적인 상황에서 음성을 다루고 분석하는 경우가 거의 없었던지라, 이번 프로젝트를 기회로 삼아 어떤 식으로 브라우저에서 미디어 기기에 접근해 음성 녹음 기능을 구현할 수 있는지에 대해 살펴보았다.
우선 기본적인 미디어 관련 용어를 살펴보도록 하자.
아날로그 파형 등의 신호를 일정한 작은 시간 단위로 쪼개는 것을 샘플링이라 한다. 샘플링을 거치면 파형이 PAM(Pulse Amplitude Modulation) 펄스로 변환된다.
아날로그 신호를 디지털 형식으로 변환하기 위해 사용하는 방법이다. 아날로그 신호를 샘플링하여 펄스열로 바꾼 후 부호화하여 디지털 신호로 변조(modulation)한다.
PCM를 통해 변환된 결과물은 다음과 같은 일련의 비트 시퀀스가 된다. 이 때 데이터가 스테레오 채널로 들어오는 경우 하나의 프레임에 좌채널 샘플과 우채널 샘플이 모두 담긴다.
무손실 무압축 오디오 파일 형식이다. 이와 다르게 MP3 파일은 유손실 유압축 오디오 파일이므로 음질이 손상될 우려가 커, 개발자 입장에서는 WAV를 사용하는 것이 안전하고 편하다.
크게 세 가지 청크로 이루어져 있다. RIFF, fmt, 데이터 청크가 그것이다. RIFF(Resource Interchange File Format, 리소스 교환 파일 형식) 청크는 WAV 파일 형식과 관련된 데이터를, fmt(format) 청크는 데이터 청크에 담긴 PCM 데이터를 해석하는 데 필요한 메타데이터를 담고 있다.
RIFF란 마이크로소프트에서 만든 일반적인 파일 컨테이너 포맷을 의미한다. RIFF 형식의 데이터는 리틀 엔디안 형식으로 저장되는 것이 특징이다(후에 다시 등장할 예정).
더 자세한 내용은 아티클 하단의 블로그를 참고하는 것이 좋을 거 같다.
어느 정도 기본적인 미디어 데이터에 대한 용어는 알았으니, 브라우저에서 음성 데이터를 다루는 법을 알아보자. 먼저 가장 중요한 것은 MediaStream API가 무엇인지 파악하는 것이다.
Media Streams API 혹은 Media Capture and Streams API로도 불린다. 스트리밍되는 오디오와 비디오 데이터를 다룰 때 사용하는 API이다.
MediaStream 객체는 말 그대로 오디오 혹은 비디오로부터 받은 스트림 데이터를 이야기한다. 이 미디어 스트림은 사용자의 기기(마이크 등)로부터 직접 받을 수도 있고, <audio>
나 <video>
요소를 통해서도 받을 수 있다. 이렇듯 미디어 스트림은 어떻게 생성되었는지에 따라 크게 두 가지 종류로 나눌 수 있는데 다음과 같다.
사용자의 미디어 장치로부터 직접 생성된 로컬local 미디어 스트림
// getUserMedia를 통해 로컬 미디어 장치에서부터 미디어 스트림을 받아옴
const mediaStream = await navigator.mediaDevices.getUserMedia()
그 외 외부 소스로부터 받아오는 비 로컬non-local 미디어 스트림
<audio>
와 <video>
) 혹은 HTMLCanvasElement의 captureStream() 메서드을 통해 생성// <audio> 요소에서부터 미디어 스트림을 받아옴
const audioEl = document.getElementById("audio");
const mediaStream = audio.captureStream();
주의해야 할 것은, 이렇게 받아오는 미디어 스트림 자체가 음성 혹은 비디오 데이터는 아니라는 것이다. 미디어 스트림은 말 그대로 모든 실시간 미디어 입출력을 다루는 인터페이스를 이야기한다.
구조도를 살펴보면 전체 미디어 입출력을 의미하는 미디어 스트림 내부에 미디어 스트림 트랙(MediaStreamTrack) 객체들이 존재한다. 이 미디어 스트림 트랙 각각이 입출력을 하나하나와 대응되는 인터페이스라 생각하면 된다. 미디어 스트림 트랙은 크게 비디오 트랙과 오디오 트랙으로 구분되고, 각 트랙의 소스(source)나 용도에 따라서 여러 값을 가질 수 있다.
미디어 스트림 내의 각 트랙을 확인하기 위해서는 MediaStream 객체의 getTracks() 메서드를 호출하면 된다.
mediaSream.getTracks();
정리하자면 미디어의 소스가 로컬 미디어 기기인지 원격인지에 따라 미디어 스트림이 나뉘고, 각각의 미디어 스트림 내부에서 미디어 입출력의 기기, 소스, 용도 등에 따라 각각의 미디어 스트림 트랙이 생겨난다고 이해할 수 있겠다. 그리고 이 미디어 스트림 내의 미디어 데이터를 추출해서 사용하면 된다.
getUserMedia의 인자로 여러 제약 조건(contraints)들을 줄 수 있고, 이를 통해 내가 원하는 대상의 미디어 스트림만을 받을 수 있다.
예를 들어 오디오 데이터나 비디오 데이터 중 하나만 선택해서 받을 수도 있고,
// video는 제외, 오직 audio 스트림만
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
혹은 내가 원하는 특정 디바이스에서 나오는 데이터만을 선별해서 받을 수 있다.
const deviceList = await navigator.mediaDevices.enumerateDevices();
console.log('deviceList', deviceList);
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceList[0].deviceId } },
});
이제 이렇게 마이크로 들어오는 미디어 스트림을 받았으니, 이 미디어 스트림을 잘 만져서 녹음 기능을 구현해 보도록 하자.