View Javadoc

1   /***
2    *     Ambient - A music player for the Android platform
3    Copyright (C) 2007 Martin Vysny
4    
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU General Public License as published by
7    the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9    
10   This program is distributed in the hope that it will be useful,
11   but WITHOUT ANY WARRANTY; without even the implied warranty of
12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   GNU General Public License for more details.
14  
15   You should have received a copy of the GNU General Public License
16   along with this program.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  
19  package sk.baka.ambient.lrc;
20  
21  import java.io.IOException;
22  import java.io.InputStream;
23  import java.io.InputStreamReader;
24  import java.io.LineNumberReader;
25  import java.text.ParseException;
26  import java.util.Collections;
27  import java.util.Enumeration;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.SortedMap;
31  import java.util.StringTokenizer;
32  import java.util.TreeMap;
33  
34  import sk.baka.ambient.commons.MiscUtils;
35  
36  /***
37   * Parses the LRC karaoke format (more on <a
38   * href="http://en.wikipedia.org/wiki/LRC_(file_format)">wiki</a>). Thread-safe.
39   * 
40   * @author Martin Vysny
41   */
42  public final class LRCLyrics {
43  	/***
44  	 * The karaoke lines. Maps time in milliseconds to the lyrics line.
45  	 */
46  	private final SortedMap<Long, String> lines;
47  
48  	/***
49  	 * Creates new lyrics object.
50  	 * 
51  	 * @param lines
52  	 *            create object from these lines.
53  	 */
54  	private LRCLyrics(final SortedMap<Long, String> lines) {
55  		super();
56  		this.lines = Collections.synchronizedSortedMap(lines);
57  	}
58  
59  	/***
60  	 * Returns all lines in this lyrics instance.
61  	 * 
62  	 * @return unmodifiable map of all lyrics.
63  	 */
64  	public SortedMap<Long, String> getLines() {
65  		return Collections.unmodifiableSortedMap(lines);
66  	}
67  
68  	/***
69  	 * Returns line which should be displayed at given time.
70  	 * 
71  	 * @param time
72  	 *            the time.
73  	 * @return line to be displayed, never <code>null</code>, may be empty.
74  	 */
75  	public String getLineToDisplay(final long time) {
76  		final SortedMap<Long, String> earlierLines = lines.headMap(time + 1);
77  		if (earlierLines.isEmpty())
78  			return "";
79  		return earlierLines.get(earlierLines.lastKey());
80  	}
81  
82  	/***
83  	 * Returns next line time. Returned time is always greater than the time
84  	 * given.
85  	 * 
86  	 * @param time
87  	 *            current time.
88  	 * @return nearest next time of line to be displayed. May return -1 if there
89  	 *         is no such line.
90  	 */
91  	public long getNextLineTime(final long time) {
92  		final SortedMap<Long, String> laterLines = lines.tailMap(time + 1);
93  		if (laterLines.isEmpty())
94  			return -1;
95  		return laterLines.firstKey();
96  	}
97  
98  	/***
99  	 * Parses given LRC file
100 	 * 
101 	 * @param in
102 	 *            the file to parse. The stream is always closed, even in cause
103 	 *            of an exception
104 	 * @return a lyrics holder instance.
105 	 * @throws IOException
106 	 *             if i/o error occurs.
107 	 * @throws ParseException
108 	 *             if parse error occurs
109 	 */
110 	public static LRCLyrics parse(final InputStream in) throws IOException,
111 			ParseException {
112 		final LineNumberReader reader = new LineNumberReader(
113 				new InputStreamReader(in));
114 		try {
115 			final SortedMap<Long, String> lines = new TreeMap<Long, String>();
116 			for (String line = reader.readLine(); line != null; line = reader
117 					.readLine()) {
118 				parseLine(line.trim(), lines);
119 			}
120 			return new LRCLyrics(lines);
121 		} finally {
122 			MiscUtils.closeQuietly(reader);
123 		}
124 	}
125 
126 	/***
127 	 * Parses a single line and adds it into given map.
128 	 * 
129 	 * @param line
130 	 *            the line to parse
131 	 * @param lines
132 	 *            the map containing all lines.
133 	 * @throws ParseException
134 	 *             if parse error occurs
135 	 */
136 	private static void parseLine(String line, Map<Long, String> lines)
137 			throws ParseException {
138 		if (line.length() == 0)
139 			return;
140 		if (!line.startsWith("["))
141 			throw new ParseException("'[' expected but '" + line + "' found", 0);
142 		if (line.length() < 6)
143 			throw new ParseException("'" + line + "' is not a valid LRC line",
144 					0);
145 		if (!Character.isDigit(line.charAt(1))) {
146 			// skip id3 tags
147 			return;
148 		}
149 		int closingBracket = line.indexOf(']');
150 		if (closingBracket < 0) {
151 			throw new ParseException(
152 					"Invalid format: expected [mm:ss.xx] but got '" + line
153 							+ "'", 0);
154 		}
155 		// parse the time of the line
156 		final long millis = parseTime(line.substring(1, closingBracket));
157 		// remove enhanced LRC tags
158 		final String lyricsLine = line.substring(closingBracket + 1);
159 		final StringBuilder lineBuilder = new StringBuilder();
160 		boolean first = true;
161 		boolean inEnhancedTag = false;
162 		for (final Enumeration<Object> tokens = new StringTokenizer(lyricsLine,
163 				"<> ", true); tokens.hasMoreElements();) {
164 			final String token = tokens.nextElement().toString();
165 			if ("<".equals(token)) {
166 				inEnhancedTag = true;
167 				continue;
168 			} else if (">".equals(token)) {
169 				inEnhancedTag = false;
170 				continue;
171 			}
172 			if (inEnhancedTag) {
173 				continue;
174 			}
175 			if (" ".equals(token)) {
176 				continue;
177 			}
178 			if (first) {
179 				first = false;
180 			} else {
181 				lineBuilder.append(' ');
182 			}
183 			lineBuilder.append(token.trim());
184 		}
185 		// register the line to the map
186 		lines.put(millis, lineBuilder.toString());
187 	}
188 
189 	private static long parseTime(String time) throws ParseException {
190 		final List<Object> tokens = Collections.list(new StringTokenizer(time,
191 				":.", true));
192 		if (((tokens.size() != 3) && (tokens.size() != 5))
193 				|| (!":".equals(tokens.get(1)))) {
194 			throw new ParseException("Invalid format: expected mm:ss but got '"
195 					+ time + "'", 0);
196 		}
197 		final boolean hasMillis = (tokens.size() == 5);
198 		if (hasMillis && (!".".equals(tokens.get(3)))) {
199 			throw new ParseException(
200 					"Invalid format: expected mm:ss.xx but got '" + time + "'",
201 					0);
202 		}
203 		long millis = 0;
204 		try {
205 			final long minutes = Integer.parseInt(tokens.get(0).toString());
206 			final long seconds = Integer.parseInt(tokens.get(2).toString());
207 			if (hasMillis) {
208 				final String sMillis = tokens.get(4).toString();
209 				if (sMillis.length() < 2) {
210 					throw new ParseException(
211 							"Invalid format: expected mm:ss.xx but got '"
212 									+ time + "'", 0);
213 				}
214 				millis = Integer.parseInt(sMillis);
215 				if (sMillis.length() < 3)
216 					millis *= 10L;
217 			}
218 			millis += seconds * 1000 + minutes * 60 * 1000;
219 		} catch (NumberFormatException ex) {
220 			throw new ParseException("Invalid number format: "
221 					+ ex.getLocalizedMessage(), -1);
222 		}
223 		return millis;
224 	}
225 }