지난 포스팅에서 브라우저를 통해 사용자의 음성을 녹음하는 방법을 소개했습니다.
이번 포스팅에선 지난 포스팅에 이어 webm파일을 wav 파일로 변환하는 방법과 wav 파일의 구조에 대해 간략히 살펴보겠습니다.
Q. 왜 하필 webm 파일에서 wav 파일로 변환하나요?
A. 브라우저에서 녹음된 파일은 기본적으로webm
형식이며, 코덱은opus
를 사용합니다.
Q. 그렇다면 wav 파일을 사용하는 이유는 무엇인가요?
A. wav 파일은 기본적으로 무손실 무압축 포맷으로 데이터가 압축되며 손실되는 경우가 발생하지 않으므로 다루기가 매우 용이합니다. 또한, 뒤에서 서술하겠지만, 헤더가 간단한 구조로 되어 있어 파일을 읽고 쓰기 용이합니다.
우선, 변환을 하기 위해 wav 파일의 구조를 간략히 살펴보겠습니다.
wav 파일은 크게 RIFF
, fmt
, Data
세 개의 청크로 나뉘어 있습니다.
우리는 PCM 포맷을 이용할 것이므로 구조는 다음과 같습니다.
Endian(엔디안)
컴퓨터는 메모리를 저장할 때 바이트(byte) 단위로 나눠서 저장합니다.
바이트를 저장하는 순서는 크게Big Endian
과Little Endian
이 있습니다.
- Big Endian : 낮은 주소에 데이터의 높은 바이트부터 저장하는 방식
- Little Endian : 낮은 주소에 데이터의 낮은 바이트부터 저장하는 방식
쉽게 얘기해서 Little Endian은 컴퓨터가 이해하기 쉽고 Big Endian은 사람이 이해하기 쉬운 방식입니다.
RIFF 청크에서는 총 12 Byte가 사용되며 각 offset별 구성은 다음과 같습니다.
오프셋(바이트) | 필드명 | 설명 |
---|---|---|
0~3 | Chunk ID (4Byte) | RIFF 파일임을 명시하는 고정값 'RIFF'라는 문자의 ASCII 값이다. |
4~7 | Chunk Size (4Byte) | 전체 데이터 크기 설정 (전체 파일 크기 - Chunk ID + Chunk Size = 전체 파일 크기 - 8) |
8~11 | Format (4Byte) | wave 파일임을 명시하는 'WAVE'라는 문자의 ASCII 값 |
fmt 청크에서는 총 24 Byte가 사용되며 각 offset별 구성은 다음과 같습니다.
PCM 포맷 기준으로 작성되었으며, 다른 인코딩 알고리즘 사용시 달라질 수 있습니다.
오프셋(바이트) | 필드명 | 설명 |
---|---|---|
12~15 | Chunk ID (4Byte) | 'fmt ' 라는 문자의 ASCII 고정값 / fmt + (space) |
16~19 | Chunk Size (4Byte) | PCM 포맷을 사용할 것이므로 16이라는 고정값 |
20~21 | Audio Format (2Byte) | PCM 포맷을 사용할 것이므로 1이라는 고정값 |
22~23 | Number Of Channels (2Byte) | 음성 파일의 채널 수 (1: 모노, 2: 스테레오) |
24~27 | Sample Rate (4Byte) | 초당 샘플을 측정하는지 나타내는 지표(단위: Hz) 높을수록 음질이 좋음 |
28~31 | Byte Rate (4Byte) | 초당 소리를 내는데 필요한 바이트 수(sample Rate의 Byte 수 * Block Align의 크기 ) |
32~33 | Block Align (2Byte) | 각 채널당 음량 정밀도의 총합 (Number Of Channels * (Bit Per Sample / 8)) |
34~35 | Bit Per Sample (2Byte) | Sample 하나당 음량을 얼마나 정밀하게 표현할지 비트로 나타냄 |
Data는 다음과 같이 구성되며 Raw Data의 크기에 따라 할당되는 용량도 역시 달라집니다.
오프셋(바이트) | 필드명 | 설명 |
---|---|---|
36~40 | Chunk ID (4Byte) | 'data' 라는 문자의 ASCII 고정값 |
41~44 | Chunk Size (4Byte) | 뒤이어 나올 소리 정보 데이터의 크기, 즉 파일 전체 크기에서 헤더를 제외한 크기 |
45~ | Raw Data | 소리 정보 데이터 |
위의 구조를 바탕으로 Media Recorder를 통해 입력받은 webm 파일을 wav 형식으로 다시 작성하면 아래와 같은 과정을 거치게 됩니다.
const audioBufferToWav = (buffer: AudioBuffer): ArrayBuffer => {
const numOfChannels = buffer.numberOfChannels; // 채널 수 가져오기
const sampleRate = buffer.sampleRate; // 샘플 레이트 가져오기
const format = numOfChannels === 1 ? 1 : 2; // 포맷 설정 (1: mono, 2: stereo)
// WAV 데이터의 총 바이트 길이 계산 (헤더 44바이트 포함)
const byteLength = buffer.length * numOfChannels * 2 + 44; // 파일 전체 크기 (오디오 샘플의 총 수 * 오디오 채널 수 * 2(16bit-PCM) + 44(wav 헤더의 크기))
const wavArrayBuffer = new ArrayBuffer(byteLength); // WAV 데이터 저장을 위한 ArrayBuffer 생성
const wavView = new DataView(wavArrayBuffer); // ArrayBuffer를 다루기 위한 DataView 생성
// WAV 헤더 작성
let offset = 0; // 현재 오프셋 초기화
const writeString = (str: string) => {
// 문자열을 DataView에 쓰는 함수
for (let i = 0; i < str.length; i++) {
wavView.setUint8(offset + i, str.charCodeAt(i)); // 문자열의 각 문자에 대한 ASCII 값을 설정
}
offset += str.length; // 오프셋 업데이트
};
writeString("RIFF"); // Chunk ID : RIFF 헤더 시작
wavView.setUint32(offset, byteLength - 8, true); // Chunk Size : 전체 데이터 크기 설정(전체 파일 크기 - Chunk ID, Chunk Size)
offset += 4; // 오프셋 업데이트
writeString("WAVE"); // Format
writeString("fmt "); // fmt ChunkID : fmt 청크 시작
wavView.setUint32(offset, 16, true); // Subchunk1Size (16바이트)
offset += 4; // 오프셋 업데이트
wavView.setUint16(offset, 1, true); // AudioFormat (1: PCM)
offset += 2; // 오프셋 업데이트
wavView.setUint16(offset, format, true); // NumChannels 설정
offset += 2; // 오프셋 업데이트
wavView.setUint32(offset, sampleRate, true); // SampleRate 설정
offset += 4; // 오프셋 업데이트
wavView.setUint32(offset, sampleRate * numOfChannels * 2, true); // ByteRate 설정
offset += 4; // 오프셋 업데이트
wavView.setUint16(offset, numOfChannels * 2, true); // BlockAlign 설정
offset += 2; // 오프셋 업데이트
wavView.setUint16(offset, 16, true); // BitsPerSample 설정
offset += 2; // 오프셋 업데이트
writeString("data"); // data 청크 시작
wavView.setUint32(offset, byteLength - offset - 4, true); // Subchunk2Size 설정
offset += 4; // 오프셋 업데이트
// PCM 데이터 복사, Raw Data 삽입
for (let channel = 0; channel < numOfChannels; channel++) {
// 각 채널에 대해 반복
const channelData = buffer.getChannelData(channel); // 해당 채널의 데이터 가져오기
for (let i = 0; i < channelData.length; i++) {
// 각 샘플에 대해 반복
wavView.setInt16(
// PCM 데이터를 WAV 포맷으로 변환하여 DataView에 설정
44 + i * 2 * numOfChannels + channel * 2, // WAV 데이터 시작 위치 계산
channelData[i] * 0x7fff, // 샘플 값을 16-bit PCM 형식으로 변환
true // little-endian 형식
);
}
}
return wavArrayBuffer;
};
const webmToWav = async (webmBlob: Blob) => {
try {
const arrayBuffer = await webmBlob.arrayBuffer() // webm Blob으로부터 ArrayBuffer 추출
const audioContext = new AudioContext()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) // AudioBuffer 추출
const wavBlob = audioBufferToWav(audioBuffer) // wav 형식으로 변환
return new Blob([wavBlob], { type: 'audio/wav' })
} catch (error) {
throw new Error(error as string)
}
}
참고
WAV - Waveform Audio File Format
WAV 파일의 헤더 구조와 Raw Data(pcm data)
코딩의 시작, TCP School