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.