Home Reference Source

src/demux/id3.ts

  1. type RawFrame = { type: string; size: number; data: Uint8Array };
  2.  
  3. // breaking up those two types in order to clarify what is happening in the decoding path.
  4. type DecodedFrame<T> = { key: string; data: T; info?: any };
  5. export type Frame = DecodedFrame<ArrayBuffer | string>;
  6.  
  7. /**
  8. * Returns true if an ID3 header can be found at offset in data
  9. * @param {Uint8Array} data - The data to search in
  10. * @param {number} offset - The offset at which to start searching
  11. * @return {boolean} - True if an ID3 header is found
  12. */
  13. export const isHeader = (data: Uint8Array, offset: number): boolean => {
  14. /*
  15. * http://id3.org/id3v2.3.0
  16. * [0] = 'I'
  17. * [1] = 'D'
  18. * [2] = '3'
  19. * [3,4] = {Version}
  20. * [5] = {Flags}
  21. * [6-9] = {ID3 Size}
  22. *
  23. * An ID3v2 tag can be detected with the following pattern:
  24. * $49 44 33 yy yy xx zz zz zz zz
  25. * Where yy is less than $FF, xx is the 'flags' byte and zz is less than $80
  26. */
  27. if (offset + 10 <= data.length) {
  28. // look for 'ID3' identifier
  29. if (
  30. data[offset] === 0x49 &&
  31. data[offset + 1] === 0x44 &&
  32. data[offset + 2] === 0x33
  33. ) {
  34. // check version is within range
  35. if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) {
  36. // check size is within range
  37. if (
  38. data[offset + 6] < 0x80 &&
  39. data[offset + 7] < 0x80 &&
  40. data[offset + 8] < 0x80 &&
  41. data[offset + 9] < 0x80
  42. ) {
  43. return true;
  44. }
  45. }
  46. }
  47. }
  48.  
  49. return false;
  50. };
  51.  
  52. /**
  53. * Returns true if an ID3 footer can be found at offset in data
  54. * @param {Uint8Array} data - The data to search in
  55. * @param {number} offset - The offset at which to start searching
  56. * @return {boolean} - True if an ID3 footer is found
  57. */
  58. export const isFooter = (data: Uint8Array, offset: number): boolean => {
  59. /*
  60. * The footer is a copy of the header, but with a different identifier
  61. */
  62. if (offset + 10 <= data.length) {
  63. // look for '3DI' identifier
  64. if (
  65. data[offset] === 0x33 &&
  66. data[offset + 1] === 0x44 &&
  67. data[offset + 2] === 0x49
  68. ) {
  69. // check version is within range
  70. if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) {
  71. // check size is within range
  72. if (
  73. data[offset + 6] < 0x80 &&
  74. data[offset + 7] < 0x80 &&
  75. data[offset + 8] < 0x80 &&
  76. data[offset + 9] < 0x80
  77. ) {
  78. return true;
  79. }
  80. }
  81. }
  82. }
  83.  
  84. return false;
  85. };
  86.  
  87. /**
  88. * Returns any adjacent ID3 tags found in data starting at offset, as one block of data
  89. * @param {Uint8Array} data - The data to search in
  90. * @param {number} offset - The offset at which to start searching
  91. * @return {Uint8Array | undefined} - The block of data containing any ID3 tags found
  92. * or *undefined* if no header is found at the starting offset
  93. */
  94. export const getID3Data = (
  95. data: Uint8Array,
  96. offset: number
  97. ): Uint8Array | undefined => {
  98. const front = offset;
  99. let length = 0;
  100.  
  101. while (isHeader(data, offset)) {
  102. // ID3 header is 10 bytes
  103. length += 10;
  104.  
  105. const size = readSize(data, offset + 6);
  106. length += size;
  107.  
  108. if (isFooter(data, offset + 10)) {
  109. // ID3 footer is 10 bytes
  110. length += 10;
  111. }
  112.  
  113. offset += length;
  114. }
  115.  
  116. if (length > 0) {
  117. return data.subarray(front, front + length);
  118. }
  119.  
  120. return undefined;
  121. };
  122.  
  123. const readSize = (data: Uint8Array, offset: number): number => {
  124. let size = 0;
  125. size = (data[offset] & 0x7f) << 21;
  126. size |= (data[offset + 1] & 0x7f) << 14;
  127. size |= (data[offset + 2] & 0x7f) << 7;
  128. size |= data[offset + 3] & 0x7f;
  129. return size;
  130. };
  131.  
  132. export const canParse = (data: Uint8Array, offset: number): boolean => {
  133. return (
  134. isHeader(data, offset) &&
  135. readSize(data, offset + 6) + 10 <= data.length - offset
  136. );
  137. };
  138.  
  139. /**
  140. * Searches for the Elementary Stream timestamp found in the ID3 data chunk
  141. * @param {Uint8Array} data - Block of data containing one or more ID3 tags
  142. * @return {number | undefined} - The timestamp
  143. */
  144. export const getTimeStamp = (data: Uint8Array): number | undefined => {
  145. const frames: Frame[] = getID3Frames(data);
  146.  
  147. for (let i = 0; i < frames.length; i++) {
  148. const frame = frames[i];
  149.  
  150. if (isTimeStampFrame(frame)) {
  151. return readTimeStamp(frame as DecodedFrame<ArrayBuffer>);
  152. }
  153. }
  154.  
  155. return undefined;
  156. };
  157.  
  158. /**
  159. * Returns true if the ID3 frame is an Elementary Stream timestamp frame
  160. * @param {ID3 frame} frame
  161. */
  162. export const isTimeStampFrame = (frame: Frame): boolean => {
  163. return (
  164. frame &&
  165. frame.key === 'PRIV' &&
  166. frame.info === 'com.apple.streaming.transportStreamTimestamp'
  167. );
  168. };
  169.  
  170. const getFrameData = (data: Uint8Array): RawFrame => {
  171. /*
  172. Frame ID $xx xx xx xx (four characters)
  173. Size $xx xx xx xx
  174. Flags $xx xx
  175. */
  176. const type: string = String.fromCharCode(data[0], data[1], data[2], data[3]);
  177. const size: number = readSize(data, 4);
  178.  
  179. // skip frame id, size, and flags
  180. const offset = 10;
  181.  
  182. return { type, size, data: data.subarray(offset, offset + size) };
  183. };
  184.  
  185. /**
  186. * Returns an array of ID3 frames found in all the ID3 tags in the id3Data
  187. * @param {Uint8Array} id3Data - The ID3 data containing one or more ID3 tags
  188. * @return {ID3.Frame[]} - Array of ID3 frame objects
  189. */
  190. export const getID3Frames = (id3Data: Uint8Array): Frame[] => {
  191. let offset = 0;
  192. const frames: Frame[] = [];
  193.  
  194. while (isHeader(id3Data, offset)) {
  195. const size = readSize(id3Data, offset + 6);
  196. // skip past ID3 header
  197. offset += 10;
  198. const end = offset + size;
  199. // loop through frames in the ID3 tag
  200. while (offset + 8 < end) {
  201. const frameData: RawFrame = getFrameData(id3Data.subarray(offset));
  202. const frame: Frame | undefined = decodeFrame(frameData);
  203. if (frame) {
  204. frames.push(frame);
  205. }
  206.  
  207. // skip frame header and frame data
  208. offset += frameData.size + 10;
  209. }
  210.  
  211. if (isFooter(id3Data, offset)) {
  212. offset += 10;
  213. }
  214. }
  215.  
  216. return frames;
  217. };
  218.  
  219. export const decodeFrame = (frame: RawFrame): Frame | undefined => {
  220. if (frame.type === 'PRIV') {
  221. return decodePrivFrame(frame);
  222. } else if (frame.type[0] === 'W') {
  223. return decodeURLFrame(frame);
  224. }
  225.  
  226. return decodeTextFrame(frame);
  227. };
  228.  
  229. const decodePrivFrame = (
  230. frame: RawFrame
  231. ): DecodedFrame<ArrayBuffer> | undefined => {
  232. /*
  233. Format: <text string>\0<binary data>
  234. */
  235. if (frame.size < 2) {
  236. return undefined;
  237. }
  238.  
  239. const owner = utf8ArrayToStr(frame.data, true);
  240. const privateData = new Uint8Array(frame.data.subarray(owner.length + 1));
  241.  
  242. return { key: frame.type, info: owner, data: privateData.buffer };
  243. };
  244.  
  245. const decodeTextFrame = (frame: RawFrame): DecodedFrame<string> | undefined => {
  246. if (frame.size < 2) {
  247. return undefined;
  248. }
  249.  
  250. if (frame.type === 'TXXX') {
  251. /*
  252. Format:
  253. [0] = {Text Encoding}
  254. [1-?] = {Description}\0{Value}
  255. */
  256. let index = 1;
  257. const description = utf8ArrayToStr(frame.data.subarray(index), true);
  258.  
  259. index += description.length + 1;
  260. const value = utf8ArrayToStr(frame.data.subarray(index));
  261.  
  262. return { key: frame.type, info: description, data: value };
  263. }
  264. /*
  265. Format:
  266. [0] = {Text Encoding}
  267. [1-?] = {Value}
  268. */
  269. const text = utf8ArrayToStr(frame.data.subarray(1));
  270. return { key: frame.type, data: text };
  271. };
  272.  
  273. const decodeURLFrame = (frame: RawFrame): DecodedFrame<string> | undefined => {
  274. if (frame.type === 'WXXX') {
  275. /*
  276. Format:
  277. [0] = {Text Encoding}
  278. [1-?] = {Description}\0{URL}
  279. */
  280. if (frame.size < 2) {
  281. return undefined;
  282. }
  283.  
  284. let index = 1;
  285. const description: string = utf8ArrayToStr(
  286. frame.data.subarray(index),
  287. true
  288. );
  289.  
  290. index += description.length + 1;
  291. const value: string = utf8ArrayToStr(frame.data.subarray(index));
  292.  
  293. return { key: frame.type, info: description, data: value };
  294. }
  295. /*
  296. Format:
  297. [0-?] = {URL}
  298. */
  299. const url: string = utf8ArrayToStr(frame.data);
  300. return { key: frame.type, data: url };
  301. };
  302.  
  303. const readTimeStamp = (
  304. timeStampFrame: DecodedFrame<ArrayBuffer>
  305. ): number | undefined => {
  306. if (timeStampFrame.data.byteLength === 8) {
  307. const data = new Uint8Array(timeStampFrame.data);
  308. // timestamp is 33 bit expressed as a big-endian eight-octet number,
  309. // with the upper 31 bits set to zero.
  310. const pts33Bit = data[3] & 0x1;
  311. let timestamp =
  312. (data[4] << 23) + (data[5] << 15) + (data[6] << 7) + data[7];
  313. timestamp /= 45;
  314.  
  315. if (pts33Bit) {
  316. timestamp += 47721858.84;
  317. } // 2^32 / 90
  318.  
  319. return Math.round(timestamp);
  320. }
  321.  
  322. return undefined;
  323. };
  324.  
  325. // http://stackoverflow.com/questions/8936984/uint8array-to-string-in-javascript/22373197
  326. // http://www.onicos.com/staff/iz/amuse/javascript/expert/utf.txt
  327. /* utf.js - UTF-8 <=> UTF-16 convertion
  328. *
  329. * Copyright (C) 1999 Masanao Izumo <iz@onicos.co.jp>
  330. * Version: 1.0
  331. * LastModified: Dec 25 1999
  332. * This library is free. You can redistribute it and/or modify it.
  333. */
  334. export const utf8ArrayToStr = (
  335. array: Uint8Array,
  336. exitOnNull: boolean = false
  337. ): string => {
  338. const decoder = getTextDecoder();
  339. if (decoder) {
  340. const decoded = decoder.decode(array);
  341.  
  342. if (exitOnNull) {
  343. // grab up to the first null
  344. const idx = decoded.indexOf('\0');
  345. return idx !== -1 ? decoded.substring(0, idx) : decoded;
  346. }
  347.  
  348. // remove any null characters
  349. return decoded.replace(/\0/g, '');
  350. }
  351.  
  352. const len = array.length;
  353. let c;
  354. let char2;
  355. let char3;
  356. let out = '';
  357. let i = 0;
  358. while (i < len) {
  359. c = array[i++];
  360. if (c === 0x00 && exitOnNull) {
  361. return out;
  362. } else if (c === 0x00 || c === 0x03) {
  363. // If the character is 3 (END_OF_TEXT) or 0 (NULL) then skip it
  364. continue;
  365. }
  366. switch (c >> 4) {
  367. case 0:
  368. case 1:
  369. case 2:
  370. case 3:
  371. case 4:
  372. case 5:
  373. case 6:
  374. case 7:
  375. // 0xxxxxxx
  376. out += String.fromCharCode(c);
  377. break;
  378. case 12:
  379. case 13:
  380. // 110x xxxx 10xx xxxx
  381. char2 = array[i++];
  382. out += String.fromCharCode(((c & 0x1f) << 6) | (char2 & 0x3f));
  383. break;
  384. case 14:
  385. // 1110 xxxx 10xx xxxx 10xx xxxx
  386. char2 = array[i++];
  387. char3 = array[i++];
  388. out += String.fromCharCode(
  389. ((c & 0x0f) << 12) | ((char2 & 0x3f) << 6) | ((char3 & 0x3f) << 0)
  390. );
  391. break;
  392. default:
  393. }
  394. }
  395. return out;
  396. };
  397.  
  398. export const testables = {
  399. decodeTextFrame: decodeTextFrame,
  400. };
  401.  
  402. let decoder: TextDecoder;
  403.  
  404. function getTextDecoder() {
  405. if (!decoder && typeof self.TextDecoder !== 'undefined') {
  406. decoder = new self.TextDecoder('utf-8');
  407. }
  408.  
  409. return decoder;
  410. }