1 /* 2 Copyright (c) 2009, António Afonso 3 All rights reserved. 4 5 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 Neither the name of the António Afonso nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 10 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE 11 */ 12 13 /** 14 * @fileOverview Object for parsing Ultrastar synchronized lyrics. 15 * @author <a href="mailto:antonio.afonso@gmail.com">António Afonso</a> 16 * @version 0.1.0 17 */ 18 19 /** 20 * @class <a href="http://en.wikipedia.org/wiki/UltraStar">Ultrastar</a> synchronized lyrics parser. 21 * 22 * Parses Ultrastar synchronized lyrics into a JSON-like object to make life easier for anyone who wants to use them on a web application. 23 * The current version is incomplete with some missing features, but it works fine for lyrics defined using absolute offset beats. 24 * <h2>TODO</h2> 25 * <ul> 26 * <li>Support RELATIVE offset beats</li> 27 * <li>Support for VIDEO* tags</li> 28 * <li>Line separator parsing</li> 29 * </ul> 30 * Information about this format was gathered in different places of the internet: 31 * <ul> 32 * <li><a href="http://karaoke.kjams.com/forum/viewtopic.php?f=3&t=395">http://karaoke.kjams.com/forum/viewtopic.php?f=3&t=395</a></li> 33 * <li><a href="http://karaoke.kjams.com/forum/viewtopic.php?f=3&t=395">http://www.dwe-games.com/web/ultrastar/ayuda/html/tut1.htm</a></li> 34 * </ul> 35 * 36 * @param {String} text The synchronized lyrics as read from the .txt file. 37 * @param {Object} [options] Currently not in use. 38 * 39 * @see #getLyrics for the documentation of the JSON-like object structure. 40 */ 41 function Ultrastar( text, options ) 42 { 43 /** 44 * Original text given on the constructor. 45 */ 46 var _text; 47 /** 48 * The JSON-like object after {@link #-_text} parsing. 49 */ 50 var _lyrics; 51 52 /** 53 * Initializes private variables and parses the given input into the JSON-like object. 54 * 55 * @private 56 */ 57 function init() 58 { 59 _text = text; 60 _lyrics = parseText( text ); 61 } 62 63 /** 64 * The parser. 65 * 66 * @param {String} text The text with the synchronized lyrics to be parsed. 67 * @returns {Object} The JSON-like object created through parsing. 68 * 69 * @private 70 */ 71 function parseText( text ) 72 { 73 var lyrics = 74 { 75 sentences : [] 76 }; 77 var syllables = []; 78 var lines = text.replace(/\r+/g, '').split( '\n' ); 79 var gap = 0; 80 var bpm = 0; 81 var mspb = 0; 82 83 for( var i = 0; i < lines.length; i++ ) 84 { 85 var line = lines[i].replace(/^\s*/, ''); 86 87 // ignore empty lines 88 if( line.replace(/\s*/g, '') == "" ) { continue; } 89 // parse meta-data 90 if( line[0] == '#' ) 91 { 92 var key = line.slice(1, line.indexOf(':')).toLowerCase(); 93 lyrics[key] = line.slice(line.indexOf(':')+1); 94 if( key == 'gap' ) { gap = parseFloat(lyrics.gap); } 95 if( key == 'bpm' ) 96 { 97 bpm = parseFloat(lyrics.bpm.replace(',','.')); 98 mspb = (60*1000)/(bpm*4); 99 } 100 } 101 // parse syllables 102 if( line[0] == ':' ) 103 { 104 var arrLine = line.split2(' ', 5).slice(1); 105 var offset = Math.floor(parseInt(arrLine[0])*mspb + gap); 106 107 syllables.push 108 ({ 109 start : offset, 110 //time : Math.floor((offset/1000/60)%60)+"m"+Math.floor((offset/1000)%60)+"s"+Math.round(offset%1000)+"ms", 111 length : Math.floor(parseInt(arrLine[1])*mspb), 112 pitch : parseInt(arrLine[2]), 113 text : arrLine[3] 114 }); 115 } 116 // parse new sentence 117 if( line[0] == '-' ) 118 { 119 lyrics.sentences.push 120 ({ 121 start : syllables[0].start, 122 text : syllables.reduce 123 ( 124 function(rv, syllable) 125 { 126 return rv+syllable.text; 127 }, 128 "" 129 ), 130 syllables : syllables 131 }); 132 syllables = []; 133 } 134 } 135 136 return lyrics; 137 } 138 139 /** 140 * The parsed text in a JSON-like object. 141 * <p>Format of the returned object:</p> 142 * <code><pre> 143 * { 144 * <b><i>(</i></b><i><tag></i>: {String},<b><i>)*</i></b> 145 * sentences: 146 * [<b><i>(</i></b>{ 147 * start: {Integer, miliseconds}, 148 * text: {String}, 149 * syllables: 150 * [<b><i>(</i></b>{ 151 * start: {Integer, miliseconds}, 152 * length: {Integer, miliseconds}, 153 * pitch: {Integer}, 154 * text: {String} 155 * },<b><i>)*</i></b>] 156 * },<b><i>)*</i></b>] 157 * } 158 * </pre></code> 159 * All values in miliseconds are absolute.<br> 160 * <code><i><tag></i></code> are meta data values found in the header of the .txt file. 161 * 162 * <h2>Example</h2> 163 * <code><pre> 164 * { 165 * title: "All Star", 166 * artist: "Smash Mouth", 167 * mp3: "05-All Star.mp3", 168 * bpm: "104", 169 * gap: "500", 170 * sentences: 171 * [{ 172 * start: 500, 173 * text: "Somebody once told me ", 174 * syllables: 175 * [{ 176 * start: 500, 177 * length: 432, 178 * pitch: 54, 179 * text: "Some" 180 * }, 181 * { 182 * start: 1076, 183 * length: 288, 184 * pitch: 61, 185 * text: "bo" 186 * }, 187 * { 188 * start: 1365, 189 * length: 288, 190 * pitch: 58, 191 * text: "dy " 192 * }, 193 * { 194 * start: 1653, 195 * length: 432, 196 * pitch: 58, 197 * text: "once " 198 * }, 199 * { 200 * start: 2230, 201 * length: 288, 202 * pitch: 56, 203 * text: "told " 204 * }, 205 * { 206 * start: 2519, 207 * length: 288, 208 * pitch: 54, 209 * text: "me " 210 * }] 211 * }, 212 * { 213 * start: 2807, 214 * text: "the world is gonna roll me, ", 215 * syllables: [<i>...</i>] 216 * }] 217 * } 218 * </pre></code> 219 * 220 * @returns {Object} The JSON-like object. 221 */ 222 this.getLyrics = function() 223 { 224 return _lyrics; 225 } 226 227 init(); 228 } 229 230 /** 231 * Split function where the 2nd argument is based on the PHP version. 232 * 233 * @param {String|Regexp} delimiter The String or RegExp used to split the string. 234 * @param {Integer} [max] If specified, then only substrings up to limit are returned with the rest of the string being placed in the last substring. 235 * @returns {Array} The array with the substrings of the string split along boundaries matched by <code>delimiter</code>. 236 * 237 * @augments String 238 */ 239 String.prototype.split2 = function( delimiter, max ) 240 { 241 max = max || Number.Infinity; 242 var arr = []; 243 244 if( delimiter.constructor != RegExp ) 245 { 246 arr = this.split(delimiter); 247 if( arr.length > max ) 248 { 249 arr.push( arr.splice(max-1, arr.length-max+1).join(delimiter) ); 250 } 251 } 252 else 253 { 254 var old_ix = 0; 255 var match; 256 257 delimiter.lastIndex = 0; // reset the regexp 258 while( match = delimiter.exec(this) ) 259 { 260 arr.push( this.slice(old_ix, delimiter.lastIndex-match[0].length) ); 261 old_ix = delimiter.lastIndex; 262 if( arr.length+1 == max ) { break; } 263 } 264 265 arr.push(this.slice(old_ix)); 266 } 267 268 return arr; 269 } 270 271 /** 272 * {@see https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/reduce} 273 */ 274 if (!Array.prototype.reduce) 275 { 276 Array.prototype.reduce = function(fun /*, initial*/) 277 { 278 var len = this.length >>> 0; 279 if (typeof fun != "function") 280 throw new TypeError(); 281 282 // no value to return if no initial value and an empty array 283 if (len == 0 && arguments.length == 1) 284 throw new TypeError(); 285 286 var i = 0; 287 if (arguments.length >= 2) 288 { 289 var rv = arguments[1]; 290 } 291 else 292 { 293 do 294 { 295 if (i in this) 296 { 297 rv = this[i++]; 298 break; 299 } 300 301 // if array contains no values, no initial value to return 302 if (++i >= len) 303 throw new TypeError(); 304 } 305 while (true); 306 } 307 308 for (; i < len; i++) 309 { 310 if (i in this) 311 rv = fun.call(null, rv, this[i], i, this); 312 } 313 314 return rv; 315 }; 316 } 317 318 /** 319 * Wrapper function around <code>opera.postError</code> or <code>console.log</code> depending on the browser being used. 320 */ 321 function debug() { 322 if( window.opera ) { 323 opera.postError.apply( opera, arguments ); 324 } else if( window.console ) { 325 console.log.apply( console, arguments ); 326 } 327 }