Home Reference Source

src/demux/mpegaudio.ts

/**
 *  MPEG parser helper
 */
import { DemuxedAudioTrack } from '../types/demuxer';

let chromeVersion: number | null = null;

const BitratesMap = [
  32,
  64,
  96,
  128,
  160,
  192,
  224,
  256,
  288,
  320,
  352,
  384,
  416,
  448,
  32,
  48,
  56,
  64,
  80,
  96,
  112,
  128,
  160,
  192,
  224,
  256,
  320,
  384,
  32,
  40,
  48,
  56,
  64,
  80,
  96,
  112,
  128,
  160,
  192,
  224,
  256,
  320,
  32,
  48,
  56,
  64,
  80,
  96,
  112,
  128,
  144,
  160,
  176,
  192,
  224,
  256,
  8,
  16,
  24,
  32,
  40,
  48,
  56,
  64,
  80,
  96,
  112,
  128,
  144,
  160,
];

const SamplingRateMap = [
  44100,
  48000,
  32000,
  22050,
  24000,
  16000,
  11025,
  12000,
  8000,
];

const SamplesCoefficients = [
  // MPEG 2.5
  [
    0, // Reserved
    72, // Layer3
    144, // Layer2
    12, // Layer1
  ],
  // Reserved
  [
    0, // Reserved
    0, // Layer3
    0, // Layer2
    0, // Layer1
  ],
  // MPEG 2
  [
    0, // Reserved
    72, // Layer3
    144, // Layer2
    12, // Layer1
  ],
  // MPEG 1
  [
    0, // Reserved
    144, // Layer3
    144, // Layer2
    12, // Layer1
  ],
];

const BytesInSlot = [
  0, // Reserved
  1, // Layer3
  1, // Layer2
  4, // Layer1
];

export function appendFrame(
  track: DemuxedAudioTrack,
  data: Uint8Array,
  offset: number,
  pts: number,
  frameIndex: number
) {
  // Using http://www.datavoyage.com/mpgscript/mpeghdr.htm as a reference
  if (offset + 24 > data.length) {
    return;
  }

  const header = parseHeader(data, offset);
  if (header && offset + header.frameLength <= data.length) {
    const frameDuration = (header.samplesPerFrame * 90000) / header.sampleRate;
    const stamp = pts + frameIndex * frameDuration;
    const sample = {
      unit: data.subarray(offset, offset + header.frameLength),
      pts: stamp,
      dts: stamp,
    };

    track.config = [];
    track.channelCount = header.channelCount;
    track.samplerate = header.sampleRate;
    track.samples.push(sample);

    return { sample, length: header.frameLength };
  }
}

export function parseHeader(data: Uint8Array, offset: number) {
  const mpegVersion = (data[offset + 1] >> 3) & 3;
  const mpegLayer = (data[offset + 1] >> 1) & 3;
  const bitRateIndex = (data[offset + 2] >> 4) & 15;
  const sampleRateIndex = (data[offset + 2] >> 2) & 3;
  if (
    mpegVersion !== 1 &&
    bitRateIndex !== 0 &&
    bitRateIndex !== 15 &&
    sampleRateIndex !== 3
  ) {
    const paddingBit = (data[offset + 2] >> 1) & 1;
    const channelMode = data[offset + 3] >> 6;
    const columnInBitrates =
      mpegVersion === 3 ? 3 - mpegLayer : mpegLayer === 3 ? 3 : 4;
    const bitRate =
      BitratesMap[columnInBitrates * 14 + bitRateIndex - 1] * 1000;
    const columnInSampleRates =
      mpegVersion === 3 ? 0 : mpegVersion === 2 ? 1 : 2;
    const sampleRate =
      SamplingRateMap[columnInSampleRates * 3 + sampleRateIndex];
    const channelCount = channelMode === 3 ? 1 : 2; // If bits of channel mode are `11` then it is a single channel (Mono)
    const sampleCoefficient = SamplesCoefficients[mpegVersion][mpegLayer];
    const bytesInSlot = BytesInSlot[mpegLayer];
    const samplesPerFrame = sampleCoefficient * 8 * bytesInSlot;
    const frameLength =
      Math.floor((sampleCoefficient * bitRate) / sampleRate + paddingBit) *
      bytesInSlot;

    if (chromeVersion === null) {
      const userAgent = navigator.userAgent || '';
      const result = userAgent.match(/Chrome\/(\d+)/i);
      chromeVersion = result ? parseInt(result[1]) : 0;
    }
    const needChromeFix = !!chromeVersion && chromeVersion <= 87;

    if (
      needChromeFix &&
      mpegLayer === 2 &&
      bitRate >= 224000 &&
      channelMode === 0
    ) {
      // Work around bug in Chromium by setting channelMode to dual-channel (01) instead of stereo (00)
      data[offset + 3] = data[offset + 3] | 0x80;
    }

    return { sampleRate, channelCount, frameLength, samplesPerFrame };
  }
}

export function isHeaderPattern(data: Uint8Array, offset: number): boolean {
  return (
    data[offset] === 0xff &&
    (data[offset + 1] & 0xe0) === 0xe0 &&
    (data[offset + 1] & 0x06) !== 0x00
  );
}

export function isHeader(data: Uint8Array, offset: number): boolean {
  // Look for MPEG header | 1111 1111 | 111X XYZX | where X can be either 0 or 1 and Y or Z should be 1
  // Layer bits (position 14 and 15) in header should be always different from 0 (Layer I or Layer II or Layer III)
  // More info http://www.mp3-tech.org/programmer/frame_header.html
  return offset + 1 < data.length && isHeaderPattern(data, offset);
}

export function canParse(data: Uint8Array, offset: number): boolean {
  const headerSize = 4;

  return isHeaderPattern(data, offset) && headerSize <= data.length - offset;
}

export function probe(data: Uint8Array, offset: number): boolean {
  // same as isHeader but we also check that MPEG frame follows last MPEG frame
  // or end of data is reached
  if (offset + 1 < data.length && isHeaderPattern(data, offset)) {
    // MPEG header Length
    const headerLength = 4;
    // MPEG frame Length
    const header = parseHeader(data, offset);
    let frameLength = headerLength;
    if (header?.frameLength) {
      frameLength = header.frameLength;
    }

    const newOffset = offset + frameLength;
    return newOffset === data.length || isHeader(data, newOffset);
  }
  return false;
}