Playing Base64-Encoded PCM Audio in a Web Browser

Handling audio in a browser becomes tricky when dealing with non-standard formats like raw PCM. In this post, I’ll walk through how I worked with 24 kHz, 16-bit PCM audio encoded in Base64 and made it playable using the <audio> element and TypeScript.

Thank me by sharing on Twitter 🙏

Browsers cannot play raw PCM directly, so the goal is to wrap the PCM data in a WAV container that provides the necessary metadata, making it browser-compatible. Below, I’ll break down the process into manageable steps and provide a full working HTML demo to help you try it out yourself.

Breaking Down the Process

To convert and play the PCM audio, the following steps are necessary:

1. Decode the Base64-encoded PCM data into a binary ArrayBuffer.

2. Wrap the PCM data with a valid WAV header.

3. Create a Blob URL for the audio and play it using the <audio> element.

This will ensure the browser can recognize the audio format and play it smoothly.

Step 1: Decoding Base64 PCM Data

The Base64-encoded PCM data must first be decoded into a binary format that JavaScript can manipulate. Here’s the TypeScript function I used:

TypeScript
function base64ToArrayBuffer(base64: string): ArrayBuffer {
  const binaryString = atob(base64); 
  const length = binaryString.length;
  const bytes = new Uint8Array(length);

  for (let i = 0; i < length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }

  return bytes.buffer;
}

This function converts a Base64 string to an ArrayBuffer by decoding the string into binary form and placing it in a Uint8Array.

Step 2: Creating the WAV Header

The PCM data alone isn’t enough. We need a WAV header that provides details like sample rate, bit depth, and channel count. Here’s the function to generate that header:

TypeScript
function createWAVHeader(
  sampleRate: number,
  numChannels: number,
  bitsPerSample: number,
  dataLength: number
): ArrayBuffer {
  const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
  const blockAlign = (numChannels * bitsPerSample) / 8;
  const buffer = new ArrayBuffer(44);
  const view = new DataView(buffer);

  function writeString(view: DataView, offset: number, text: string) {
    for (let i = 0; i < text.length; i++) {
      view.setUint8(offset + i, text.charCodeAt(i));
    }
  }

  writeString(view, 0, 'RIFF');
  view.setUint32(4, 36 + dataLength, true);
  writeString(view, 8, 'WAVE');
  
  writeString(view, 12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, numChannels, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, byteRate, true);
  view.setUint16(32, blockAlign, true);
  view.setUint16(34, bitsPerSample, true);

  writeString(view, 36, 'data');
  view.setUint32(40, dataLength, true);

  return buffer;
}

This function prepares the necessary header for a WAV file, ensuring the browser can interpret the PCM data correctly.

Step 3: Combining the Header and PCM Data

Now we combine the WAV header with the decoded PCM data to form a complete audio file.

TypeScript
function createWAVBlob(
  pcmData: ArrayBuffer,
  sampleRate: number,
  numChannels: number,
  bitsPerSample: number
): Blob {
  const wavHeader = createWAVHeader(
    sampleRate,
    numChannels,
    bitsPerSample,
    pcmData.byteLength
  );

  const wavBuffer = new Uint8Array(wavHeader.byteLength + pcmData.byteLength);
  wavBuffer.set(new Uint8Array(wavHeader), 0);
  wavBuffer.set(new Uint8Array(pcmData), wavHeader.byteLength);

  return new Blob([wavBuffer], { type: 'audio/wav' });
}

This function merges the WAV header and PCM data into a single audio file represented as a Blob.

Step 4: HTML Demo to Play the Audio

Below is a complete HTML file that demonstrates how to play the audio using the <audio> tag. Save it as index.html and open it in a browser to see it in action.

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PCM Audio Player</title>
  <script type="module">
    function base64ToArrayBuffer(base64) {
      const binaryString = atob(base64);
      const length = binaryString.length;
      const bytes = new Uint8Array(length);

      for (let i = 0; i < length; i++) {
        bytes[i] = binaryString.charCodeAt(i);
      }
      return bytes.buffer;
    }

    function createWAVHeader(sampleRate, numChannels, bitsPerSample, dataLength) {
      const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
      const blockAlign = (numChannels * bitsPerSample) / 8;
      const buffer = new ArrayBuffer(44);
      const view = new DataView(buffer);

      function writeString(view, offset, text) {
        for (let i = 0; i < text.length; i++) {
          view.setUint8(offset + i, text.charCodeAt(i));
        }
      }

      writeString(view, 0, 'RIFF');
      view.setUint32(4, 36 + dataLength, true);
      writeString(view, 8, 'WAVE');
      writeString(view, 12, 'fmt ');
      view.setUint32(16, 16, true);
      view.setUint16(20, 1, true);
      view.setUint16(22, numChannels, true);
      view.setUint32(24, sampleRate, true);
      view.setUint32(28, byteRate, true);
      view.setUint16(32, blockAlign, true);
      view.setUint16(34, bitsPerSample, true);
      writeString(view, 36, 'data');
      view.setUint32(40, dataLength, true);

      return buffer;
    }

    function createWAVBlob(pcmData, sampleRate, numChannels, bitsPerSample) {
      const wavHeader = createWAVHeader(
        sampleRate,
        numChannels,
        bitsPerSample,
        pcmData.byteLength
      );

      const wavBuffer = new Uint8Array(wavHeader.byteLength + pcmData.byteLength);
      wavBuffer.set(new Uint8Array(wavHeader), 0);
      wavBuffer.set(new Uint8Array(pcmData), wavHeader.byteLength);

      return new Blob([wavBuffer], { type: 'audio/wav' });
    }

    window.onload = () => {
      const base64PCM = "YOUR_BASE64_PCM_STRING"; // Replace with your Base64 string
      const pcmData = base64ToArrayBuffer(base64PCM);
      const wavBlob = createWAVBlob(pcmData, 24000, 1, 16);

      const audioURL = URL.createObjectURL(wavBlob);
      const audioElement = document.createElement('audio');
      audioElement.src = audioURL;
      audioElement.controls = true;
      document.body.appendChild(audioElement);
    };
  </script>
</head>
<body>
  <h1>PCM Audio Player</h1>
</body>
</html>

Conclusion

Working with raw PCM audio may seem intimidating at first, but wrapping it in a WAV container makes it much easier to play in browsers. By following the steps in this guide, you can convert Base64-encoded PCM audio into a playable WAV file and embed it into your web pages effortlessly. This approach gives you full control over how the audio is handled and ensures compatibility with browsers.

Share this:

Leave a Reply