Home Reference Source

src/demux/id3.ts

type RawFrame = { type: string; size: number; data: Uint8Array };

// breaking up those two types in order to clarify what is happening in the decoding path.
type DecodedFrame<T> = { key: string; data: T; info?: any };
export type Frame = DecodedFrame<ArrayBuffer | string>;

/**
 * Returns true if an ID3 header can be found at offset in data
 * @param {Uint8Array} data - The data to search in
 * @param {number} offset - The offset at which to start searching
 * @return {boolean} - True if an ID3 header is found
 */
export const isHeader = (data: Uint8Array, offset: number): boolean => {
  /*
   * http://id3.org/id3v2.3.0
   * [0]     = 'I'
   * [1]     = 'D'
   * [2]     = '3'
   * [3,4]   = {Version}
   * [5]     = {Flags}
   * [6-9]   = {ID3 Size}
   *
   * An ID3v2 tag can be detected with the following pattern:
   *  $49 44 33 yy yy xx zz zz zz zz
   * Where yy is less than $FF, xx is the 'flags' byte and zz is less than $80
   */
  if (offset + 10 <= data.length) {
    // look for 'ID3' identifier
    if (
      data[offset] === 0x49 &&
      data[offset + 1] === 0x44 &&
      data[offset + 2] === 0x33
    ) {
      // check version is within range
      if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) {
        // check size is within range
        if (
          data[offset + 6] < 0x80 &&
          data[offset + 7] < 0x80 &&
          data[offset + 8] < 0x80 &&
          data[offset + 9] < 0x80
        ) {
          return true;
        }
      }
    }
  }

  return false;
};

/**
 * Returns true if an ID3 footer can be found at offset in data
 * @param {Uint8Array} data - The data to search in
 * @param {number} offset - The offset at which to start searching
 * @return {boolean} - True if an ID3 footer is found
 */
export const isFooter = (data: Uint8Array, offset: number): boolean => {
  /*
   * The footer is a copy of the header, but with a different identifier
   */
  if (offset + 10 <= data.length) {
    // look for '3DI' identifier
    if (
      data[offset] === 0x33 &&
      data[offset + 1] === 0x44 &&
      data[offset + 2] === 0x49
    ) {
      // check version is within range
      if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) {
        // check size is within range
        if (
          data[offset + 6] < 0x80 &&
          data[offset + 7] < 0x80 &&
          data[offset + 8] < 0x80 &&
          data[offset + 9] < 0x80
        ) {
          return true;
        }
      }
    }
  }

  return false;
};

/**
 * Returns any adjacent ID3 tags found in data starting at offset, as one block of data
 * @param {Uint8Array} data - The data to search in
 * @param {number} offset - The offset at which to start searching
 * @return {Uint8Array | undefined} - The block of data containing any ID3 tags found
 * or *undefined* if no header is found at the starting offset
 */
export const getID3Data = (
  data: Uint8Array,
  offset: number
): Uint8Array | undefined => {
  const front = offset;
  let length = 0;

  while (isHeader(data, offset)) {
    // ID3 header is 10 bytes
    length += 10;

    const size = readSize(data, offset + 6);
    length += size;

    if (isFooter(data, offset + 10)) {
      // ID3 footer is 10 bytes
      length += 10;
    }

    offset += length;
  }

  if (length > 0) {
    return data.subarray(front, front + length);
  }

  return undefined;
};

const readSize = (data: Uint8Array, offset: number): number => {
  let size = 0;
  size = (data[offset] & 0x7f) << 21;
  size |= (data[offset + 1] & 0x7f) << 14;
  size |= (data[offset + 2] & 0x7f) << 7;
  size |= data[offset + 3] & 0x7f;
  return size;
};

export const canParse = (data: Uint8Array, offset: number): boolean => {
  return (
    isHeader(data, offset) &&
    readSize(data, offset + 6) + 10 <= data.length - offset
  );
};

/**
 * Searches for the Elementary Stream timestamp found in the ID3 data chunk
 * @param {Uint8Array} data - Block of data containing one or more ID3 tags
 * @return {number | undefined} - The timestamp
 */
export const getTimeStamp = (data: Uint8Array): number | undefined => {
  const frames: Frame[] = getID3Frames(data);

  for (let i = 0; i < frames.length; i++) {
    const frame = frames[i];

    if (isTimeStampFrame(frame)) {
      return readTimeStamp(frame as DecodedFrame<ArrayBuffer>);
    }
  }

  return undefined;
};

/**
 * Returns true if the ID3 frame is an Elementary Stream timestamp frame
 * @param {ID3 frame} frame
 */
export const isTimeStampFrame = (frame: Frame): boolean => {
  return (
    frame &&
    frame.key === 'PRIV' &&
    frame.info === 'com.apple.streaming.transportStreamTimestamp'
  );
};

const getFrameData = (data: Uint8Array): RawFrame => {
  /*
  Frame ID       $xx xx xx xx (four characters)
  Size           $xx xx xx xx
  Flags          $xx xx
  */
  const type: string = String.fromCharCode(data[0], data[1], data[2], data[3]);
  const size: number = readSize(data, 4);

  // skip frame id, size, and flags
  const offset = 10;

  return { type, size, data: data.subarray(offset, offset + size) };
};

/**
 * Returns an array of ID3 frames found in all the ID3 tags in the id3Data
 * @param {Uint8Array} id3Data - The ID3 data containing one or more ID3 tags
 * @return {ID3.Frame[]} - Array of ID3 frame objects
 */
export const getID3Frames = (id3Data: Uint8Array): Frame[] => {
  let offset = 0;
  const frames: Frame[] = [];

  while (isHeader(id3Data, offset)) {
    const size = readSize(id3Data, offset + 6);
    // skip past ID3 header
    offset += 10;
    const end = offset + size;
    // loop through frames in the ID3 tag
    while (offset + 8 < end) {
      const frameData: RawFrame = getFrameData(id3Data.subarray(offset));
      const frame: Frame | undefined = decodeFrame(frameData);
      if (frame) {
        frames.push(frame);
      }

      // skip frame header and frame data
      offset += frameData.size + 10;
    }

    if (isFooter(id3Data, offset)) {
      offset += 10;
    }
  }

  return frames;
};

export const decodeFrame = (frame: RawFrame): Frame | undefined => {
  if (frame.type === 'PRIV') {
    return decodePrivFrame(frame);
  } else if (frame.type[0] === 'W') {
    return decodeURLFrame(frame);
  }

  return decodeTextFrame(frame);
};

const decodePrivFrame = (
  frame: RawFrame
): DecodedFrame<ArrayBuffer> | undefined => {
  /*
  Format: <text string>\0<binary data>
  */
  if (frame.size < 2) {
    return undefined;
  }

  const owner = utf8ArrayToStr(frame.data, true);
  const privateData = new Uint8Array(frame.data.subarray(owner.length + 1));

  return { key: frame.type, info: owner, data: privateData.buffer };
};

const decodeTextFrame = (frame: RawFrame): DecodedFrame<string> | undefined => {
  if (frame.size < 2) {
    return undefined;
  }

  if (frame.type === 'TXXX') {
    /*
    Format:
    [0]   = {Text Encoding}
    [1-?] = {Description}\0{Value}
    */
    let index = 1;
    const description = utf8ArrayToStr(frame.data.subarray(index), true);

    index += description.length + 1;
    const value = utf8ArrayToStr(frame.data.subarray(index));

    return { key: frame.type, info: description, data: value };
  }
  /*
  Format:
  [0]   = {Text Encoding}
  [1-?] = {Value}
  */
  const text = utf8ArrayToStr(frame.data.subarray(1));
  return { key: frame.type, data: text };
};

const decodeURLFrame = (frame: RawFrame): DecodedFrame<string> | undefined => {
  if (frame.type === 'WXXX') {
    /*
    Format:
    [0]   = {Text Encoding}
    [1-?] = {Description}\0{URL}
    */
    if (frame.size < 2) {
      return undefined;
    }

    let index = 1;
    const description: string = utf8ArrayToStr(
      frame.data.subarray(index),
      true
    );

    index += description.length + 1;
    const value: string = utf8ArrayToStr(frame.data.subarray(index));

    return { key: frame.type, info: description, data: value };
  }
  /*
  Format:
  [0-?] = {URL}
  */
  const url: string = utf8ArrayToStr(frame.data);
  return { key: frame.type, data: url };
};

const readTimeStamp = (
  timeStampFrame: DecodedFrame<ArrayBuffer>
): number | undefined => {
  if (timeStampFrame.data.byteLength === 8) {
    const data = new Uint8Array(timeStampFrame.data);
    // timestamp is 33 bit expressed as a big-endian eight-octet number,
    // with the upper 31 bits set to zero.
    const pts33Bit = data[3] & 0x1;
    let timestamp =
      (data[4] << 23) + (data[5] << 15) + (data[6] << 7) + data[7];
    timestamp /= 45;

    if (pts33Bit) {
      timestamp += 47721858.84;
    } // 2^32 / 90

    return Math.round(timestamp);
  }

  return undefined;
};

// http://stackoverflow.com/questions/8936984/uint8array-to-string-in-javascript/22373197
// http://www.onicos.com/staff/iz/amuse/javascript/expert/utf.txt
/* utf.js - UTF-8 <=> UTF-16 convertion
 *
 * Copyright (C) 1999 Masanao Izumo <iz@onicos.co.jp>
 * Version: 1.0
 * LastModified: Dec 25 1999
 * This library is free.  You can redistribute it and/or modify it.
 */
export const utf8ArrayToStr = (
  array: Uint8Array,
  exitOnNull: boolean = false
): string => {
  const decoder = getTextDecoder();
  if (decoder) {
    const decoded = decoder.decode(array);

    if (exitOnNull) {
      // grab up to the first null
      const idx = decoded.indexOf('\0');
      return idx !== -1 ? decoded.substring(0, idx) : decoded;
    }

    // remove any null characters
    return decoded.replace(/\0/g, '');
  }

  const len = array.length;
  let c;
  let char2;
  let char3;
  let out = '';
  let i = 0;
  while (i < len) {
    c = array[i++];
    if (c === 0x00 && exitOnNull) {
      return out;
    } else if (c === 0x00 || c === 0x03) {
      // If the character is 3 (END_OF_TEXT) or 0 (NULL) then skip it
      continue;
    }
    switch (c >> 4) {
      case 0:
      case 1:
      case 2:
      case 3:
      case 4:
      case 5:
      case 6:
      case 7:
        // 0xxxxxxx
        out += String.fromCharCode(c);
        break;
      case 12:
      case 13:
        // 110x xxxx   10xx xxxx
        char2 = array[i++];
        out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f));
        break;
      case 14:
        // 1110 xxxx  10xx xxxx  10xx xxxx
        char2 = array[i++];
        char3 = array[i++];
        out += String.fromCharCode(
          ((c & 0x0f) << 12) | ((char2 & 0x3f) << 6) | ((char3 & 0x3f) << 0)
        );
        break;
      default:
    }
  }
  return out;
};

export const testables = {
  decodeTextFrame: decodeTextFrame,
};

let decoder: TextDecoder;

function getTextDecoder() {
  if (!decoder && typeof self.TextDecoder !== 'undefined') {
    decoder = new self.TextDecoder('utf-8');
  }

  return decoder;
}