webm을 wav로 변환하기

이종경·2024년 10월 3일
0
post-thumbnail

서론

지난 포스팅에서 브라우저를 통해 사용자의 음성을 녹음하는 방법을 소개했습니다.
이번 포스팅에선 지난 포스팅에 이어 webm파일을 wav 파일로 변환하는 방법과 wav 파일의 구조에 대해 간략히 살펴보겠습니다.

Q. 왜 하필 webm 파일에서 wav 파일로 변환하나요?
A. 브라우저에서 녹음된 파일은 기본적으로 webm형식이며, 코덱은 opus를 사용합니다.

Q. 그렇다면 wav 파일을 사용하는 이유는 무엇인가요?
A. wav 파일은 기본적으로 무손실 무압축 포맷으로 데이터가 압축되며 손실되는 경우가 발생하지 않으므로 다루기가 매우 용이합니다. 또한, 뒤에서 서술하겠지만, 헤더가 간단한 구조로 되어 있어 파일을 읽고 쓰기 용이합니다.

wav 파일의 구조

우선, 변환을 하기 위해 wav 파일의 구조를 간략히 살펴보겠습니다.
wav 파일은 크게 RIFF, fmt, Data 세 개의 청크로 나뉘어 있습니다.

우리는 PCM 포맷을 이용할 것이므로 구조는 다음과 같습니다.

wav 구조

Endian(엔디안)
컴퓨터는 메모리를 저장할 때 바이트(byte) 단위로 나눠서 저장합니다.
바이트를 저장하는 순서는 크게 Big EndianLittle Endian이 있습니다.

  • Big Endian : 낮은 주소에 데이터의 높은 바이트부터 저장하는 방식
  • Little Endian : 낮은 주소에 데이터의 낮은 바이트부터 저장하는 방식
    쉽게 얘기해서 Little Endian은 컴퓨터가 이해하기 쉽고 Big Endian은 사람이 이해하기 쉬운 방식입니다.

RIFF

RIFF 청크에서는 총 12 Byte가 사용되며 각 offset별 구성은 다음과 같습니다.

오프셋(바이트)필드명설명
0~3Chunk ID (4Byte)RIFF 파일임을 명시하는 고정값 'RIFF'라는 문자의 ASCII 값이다.
4~7Chunk Size (4Byte)전체 데이터 크기 설정 (전체 파일 크기 - Chunk ID + Chunk Size = 전체 파일 크기 - 8)
8~11Format (4Byte)wave 파일임을 명시하는 'WAVE'라는 문자의 ASCII 값

fmt

fmt 청크에서는 총 24 Byte가 사용되며 각 offset별 구성은 다음과 같습니다.
PCM 포맷 기준으로 작성되었으며, 다른 인코딩 알고리즘 사용시 달라질 수 있습니다.

오프셋(바이트)필드명설명
12~15Chunk ID (4Byte)'fmt ' 라는 문자의 ASCII 고정값 / fmt + (space)
16~19Chunk Size (4Byte)PCM 포맷을 사용할 것이므로 16이라는 고정값
20~21Audio Format (2Byte)PCM 포맷을 사용할 것이므로 1이라는 고정값
22~23Number Of Channels (2Byte)음성 파일의 채널 수 (1: 모노, 2: 스테레오)
24~27Sample Rate (4Byte)초당 샘플을 측정하는지 나타내는 지표(단위: Hz) 높을수록 음질이 좋음
28~31Byte Rate (4Byte)초당 소리를 내는데 필요한 바이트 수(sample Rate의 Byte 수 * Block Align의 크기 )
32~33Block Align (2Byte)각 채널당 음량 정밀도의 총합 (Number Of Channels * (Bit Per Sample / 8))
34~35Bit Per Sample (2Byte)Sample 하나당 음량을 얼마나 정밀하게 표현할지 비트로 나타냄

Data

Data는 다음과 같이 구성되며 Raw Data의 크기에 따라 할당되는 용량도 역시 달라집니다.

오프셋(바이트)필드명설명
36~40Chunk ID (4Byte)'data' 라는 문자의 ASCII 고정값
41~44Chunk Size (4Byte)뒤이어 나올 소리 정보 데이터의 크기, 즉 파일 전체 크기에서 헤더를 제외한 크기
45~Raw Data소리 정보 데이터

Wav 파일 변환

wav 형식의 Array Buffer로 변환

위의 구조를 바탕으로 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;
};

wav 형식의 Blob으로 변환

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

profile
작은 성취들이 모여 큰 결과를 만든다고 믿으며, 꾸준함을 바탕으로 개발 역량을 키워가고 있습니다

0개의 댓글