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 }