Tuesday, November 11, 2014

Reading MP3/OGG/M4A tags with pure JavaScript

Summary

There aren't many sources in the internet that show you how to read tags (metadata) from music files such as MP3, OGG or even M4A. Not only this, but they do use an external library. I've build an HTML5 player inspired by Foobar2000, called Noisy, and I needed it to be as lightweight as possible, so everything inside I wanted to do by myself and optimize it in time. Unfortunately, this required a lot of specifications reading and many retries to get the things working. Here I'll show you how I did the reading and will try explaining the meaning of the code (by comments). It's far from perfect, but there wasn't much in the internet to help me with that task, so if you have any suggestions you are more than welcome to share them with me. Without further ado, lets begin!

Prequel

All tags we read by getting the file as ArrayBuffer using HTML5 FileReader, so here is a sample of how to do that:


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function onFilesSupplied( e )
{
 // Get all the files from the input of type file
 var files = e.files || e.dataTransfer.files,
  file,
  fr = new FileReader();

 // Handle the typed array read by the FileReader,
 // when it's finished loading
 function _handleFileLoad()
 {
  // This is the point where the real tag
  // read is happening - covered later on
  var tags =readTags( this.result );
 }

 // Listen when FileReader finishes reading and
 // pass result to the handler
 fr.addEventListener( 'load', _handleFileLoad );

 // This example uses only the first file to load,
 // but you can loop them all and read them
 file = files[0];

 // Do the actual read, by saying to the reader
 // we want the result as ArrayBuffer
 fr.readAsArrayBuffer( file );
}

This code is made to run on both input of type file or drag and drop events. Noisy is cloud player, so it rarely reads the files from disk, meaning that code is not used a lot. What Noisy uses instead is to make the cloud service return the file directly as ArrayBuffer. So a simple:


1
xhr.responseType = 'arraybuffer';

to the request does the trick, thus no FileReader is required. Unfortunately this works for Dropbox, but not for Google Drive (update: now it works!). That's the reason Noisy doesn't support tag reading when user uses Google Drive. If he uses Mozilla Firefox, though, Noisy will read the tags using audio.mozGetMetadata(), doesn't matter from where the file is coming. All that said, it really doesn't matter how you get your hands on the ArrayBuffer - what really matters is how we read the tags. I've split them in separate code snippets.

MP3 ID3v2


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
readTags( buffer )
{
 // We need only these tags for now. You can check specifications for other ones, if you need them
 toGet = {
  'TPE1': 'artist',
  'TIT2': 'title',
  'TALB': 'album',
  'TYER': 'date'
 };
 len = Object.keys( toGet ).length;
 i = 0;

 // Loop first 1000 bytes. Here I couldn't find better solution to where the tag entries end, so I just loop the first 1KB of data
 while( i < 1000 && len )
 {
  charCode = dv.getUint8( i++ );

  // Loop through all the characters in the configuration object on top and compare letter by letter with current one
  for( tag in toGet )
  {
   tagCode = tag.charCodeAt( 0 );

   // Check for lower case match
   if( charCode == tagCode )
   {
    match = true;
    // loop through all letters to make sure we have found a match
    for( k = 1; k < tag.length; k++ )
    {
     matchCharCode = dv.getUint8( ( i - 1 ) + k );
     tagCode = tag.charCodeAt( k );
     if( matchCharCode != tagCode )
     {
      match = false;
      break;
     }
    }

    // If a match is found get the tag
    if( match )
    {
     // Pattern for tag: TALB 00 00 00 (HEX for length of tag in bytes, grouped
     // by 2 for each char, the second being the first part in unicode, eg. 0415
     // for cyrillic ? is written 15 04 here) 00 00 (unicode flag byte. If 00 then
     // no unicode, else it's the first part of unicode char) (tag itself). All this
     // I found out by myself. Don't know if it's always true
     tagLength = i + 9 + dv.getUint8( i + 6 );
     tagValue.length = 0;
     // Check for *ÿþ symbols and read after them if found. Some tags have this funky
     // charachers upfront and I don't know why and what they mean
     i = ( 255 == dv.getUint8( i + 10 ) && 254 == dv.getUint8( i + 11 ) ) ? i + 12 : i + 9;

     // First byte shows if the tag is encoded in Unicode or not
     matchCharCode = dv.getUint8( i++ );

     // If unicode
     if( matchCharCode )
     {
      nextMatch = ( '00' + dv.getUint8( i - 1 ).toString( 16 ) ).slice( -2 );
      while( i <= tagLength )
      {
       matchCharCode = ( '00' + dv.getUint8( i ).toString( 16 ) ).slice( -2 );
       tagValue.push( '0x'.concat( matchCharCode, nextMatch ) );
       nextMatch = ( '00' + dv.getUint8( i + 1 ).toString( 16 ) ).slice( -2 );
       i += 2;
      }
     }
     else
     {
      i++;
      matchCharCode = ( '00' + dv.getUint8( i - 1 ).toString( 16 ) ).slice( -2 );
      while( i <= tagLength )
      {
       tagValue.push( '0x00' + matchCharCode );
       matchCharCode = ( '00' + dv.getUint8( i++ ).toString( 16 ) ).slice( -2 );
      }
     }

     // Substract last step increase, as next tag comes right after this one
     i--;

     // Save tag's value as we are done reading it
     metadata[toGet[tag]] = String.fromCharCode.apply( null, tagValue );

     // Remove tag name from the object, so we don't loop it again
     delete toGet[tag];
     len = Object.keys( toGet ).length;
    }
   }
  }
 }
}

I do not do ID3v1, but it's very easily implementable if you understand this code. I just decided it's too old to be supported - all modern MP3 files should have ID3v2.

OGG


  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
readTags( buffer )
{
 // We need only these tags for now. You can check specifications for other ones, if you need them
 toGet = [
  'artist',
  'title',
  'album',
  'date'
 ];
 len = toGet.length;
 i = 0;

 // Loop first 1000 bytes. Here I couldn't find better solution to where the tag entries end, so I just loop the first 1KB of data
 while( i < 1000 && len )
 {
  charCode = dv.getUint8( i++ );

  for( j = len; j--; )
  {
   tag = toGet[j];
   tagCode = tag.charCodeAt( 0 );

   // Check for lower case match
   if( charCode == tagCode )
   {
    match = true;
    // loop through all letters to make sure we have found a match
    for( k = 1; k < tag.length; k++ )
    {
     matchCharCode = dv.getUint8( ( i - 1 ) + k );
     tagCode = tag.charCodeAt( k );
     if( matchCharCode != tagCode )
     {
      match = false;
      break;
     }
    }

    // If a match is found get the tag
    if( match )
    {
     // Byte before the 00 00 00 shows how many bytes the tag will be, including the "artist=" part,
     // so we read everything from "=" sign till the length is reached. All this
     // I found out by myself. Don't know if it's always true
     tagLength = i - 1 + dv.getUint8( i - 5 );
     tagValue.length = 0;
     i = i + tag.length;
     matchCharCode = dv.getUint8( i++ );
     while( i <= tagLength )
     {
      tagValue.push( matchCharCode );
      matchCharCode = dv.getUint8( i++ );
     }
     str = '';

     for( k = 0; k < tagValue.length; k++ )
     {
      str += '%' + ( '0' + tagValue[k].toString( 16 ) ).slice( -2 );
     }

     // Save tag's value as we are done reading it
     metadata[tag] = decodeURIComponent( str );

     // Remove tag name from the object, so we don't loop it again
     toGet.splice( j, 1 );
     len = toGet.length;
    }
    continue;
   }

   tagCode = tag.toUpperCase().charCodeAt( 0 );

   // Check for uppercase match
   if( charCode == tagCode )
   {
    match = true;
    tag = tag.toUpperCase();
    // loop through all letters to make sure we have found a match
    for( k = 1; k < tag.length; k++ )
    {
     matchCharCode = dv.getUint8( ( i - 1 ) + k );
     tagCode = tag.charCodeAt( k );
     if( matchCharCode != tagCode )
     {
      match = false;
      break;
     }
    }

    // If a match is found get the tag
    if( match )
    {
     // Byte before the 00 00 00 shows how many bytes the tag will be, including the "artist=" part,
     //so we read everything from "=" sign till the length is reachedl
     tagLength = i - 1 + dv.getUint8( i - 5 );
     tagValue.length = 0;
     i = i + tag.length;
     matchCharCode = dv.getUint8( i++ );
     while( i <= tagLength )
     {
      tagValue.push( matchCharCode );
      matchCharCode = dv.getUint8( i++ );
     }
     str = '';

     for( k = 0; k < tagValue.length; k++ )
     {
      str += '%' + ( '0' + tagValue[k].toString( 16 ) ).slice( -2 );
     }

     // Save tag's value as we are done reading it
     metadata[tag.toLowerCase()] = decodeURIComponent( str );

     // Remove tag name from the object, so we don't loop it again
     toGet.splice( j, 1 );
     len = toGet.length;
    }
   }
  }
 }
}

M4A


  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
readTags( buffer )
{
 // We need only these tags for now. You can check specifications for other ones, if you need them
 toGet = {
  '©art': 'artist',
  '©nam': 'title',
  '©alb': 'album',
  '©day': 'date'
 };
 len = Object.keys( toGet ).length;
 i = 50000;

 // Loop second 50000 bytes. Here I couldn't find better solution to where 
 // the tag entries end, so I just loop the characters between 50KB and 100KB
 while( i < 100000 && len )
 {
  charCode = dv.getUint8( i++ );

  for( tag in toGet )
  {
   tagCode = tag.charCodeAt( 0 );

   // Check for lower case match
   if( charCode == tagCode )
   {
    match = true;
    // loop through all letters to make sure we have found a match
    for( k = 1; k < tag.length; k++ )
    {
     matchCharCode = dv.getUint8( ( i - 1 ) + k );
     tagCode = tag.charCodeAt( k );
     if( matchCharCode != tagCode )
     {
      match = false;
      break;
     }
    }

    // If a match is found get the tag
    if( match )
    {
     // Pattern: @nam 00 00 00 (byte showing length of tag, starting from next
     // byte) data 00 00 00 (byte showing I don't know what) 00 00 00 00 (text
     // for tag) 00 00 00 (byte showing I don't know what)
     i += 6;
     matchCharCode = dv.getUint8( i );
     tagLength = i + matchCharCode;
     i += 13;
     matchCharCode = dv.getUint8( i );

     tagValue.length = 0;

     matchCharCode = dv.getUint8( i++ );
     while( i <= tagLength && matchCharCode )
     {
      tagValue.push( matchCharCode );
      matchCharCode = dv.getUint8( i++ );
     }
     str = '';

     for( k = 0; k < tagValue.length; k++ )
     {
      str += '%' + ( '0' + tagValue[k].toString( 16 ) ).slice( -2 );
     }

     // Save tag's value as we are done reading it
     metadata[toGet[tag]] = decodeURIComponent( str );

     // Remove tag name from the object, so we don't loop it again
     delete toGet[tag];
     len = Object.keys( toGet ).length;
     continue;
    }
   }

   tagCode = tag.toUpperCase().charCodeAt( 0 );

   // Check for lower case match
   if( charCode == tagCode )
   {
    match = true;
    tag = tag.toUpperCase();
    // loop through all letters to make sure we have found a match
    for( k = 1; k < tag.length; k++ )
    {
     matchCharCode = dv.getUint8( ( i - 1 ) + k );
     tagCode = tag.charCodeAt( k );
     if( matchCharCode != tagCode )
     {
      match = false;
      break;
     }
    }

    // If a match is found get the tag
    if( match )
    {
     // Pattern: @nam 00 00 00 (byte showing length of tag, starting from next
     // byte) data 00 00 00 (byte showing I don't know what) 00 00 00 00 (text
     // for tag) 00 00 00 (byte showing I don't know what)
     i += 6;
     matchCharCode = dv.getUint8( i );
     tagLength = i + matchCharCode;
     i += 13;
     matchCharCode = dv.getUint8( i );

     tagValue.length = 0;

     matchCharCode = dv.getUint8( i++ );
     while( i <= tagLength && matchCharCode )
     {
      tagValue.push( matchCharCode );
      matchCharCode = dv.getUint8( i++ );
     }
     str = '';

     for( k = 0; k < tagValue.length; k++ )
     {
      str += '%' + ( '0' + tagValue[k].toString( 16 ) ).slice( -2 );
     }

     tag = tag.toLowerCase();

     // Save tag's value as we are done reading it
     metadata[toGet[tag]] = decodeURIComponent( str );

     // Remove tag name from the object, so we don't loop it again
     delete toGet[tag];
     len = Object.keys( toGet ).length;
    }
   }
  }
 }
}

I'm sure this code can be optimized a lot, but I'm still keeping it as simple as it can be so it's clear how things work. When I understand all the little details, then I'll merge things and make the code more compact.

Final words

Don't know if this article can help you, but I hope it can add to the end result you are looking for. If you know something that I missed, please do say - I'll be very happy to any optimizations and suggestions.