1   package org.daisy.pipeline.audio;
2   
3   import java.io.BufferedInputStream;
4   import java.io.ByteArrayInputStream;
5   import java.io.InputStream;
6   import java.io.IOException;
7   import java.nio.ByteBuffer;
8   import java.nio.ByteOrder;
9   import java.time.Duration;
10  import java.util.Arrays;
11  import java.util.Collections;
12  import java.util.Iterator;
13  import java.util.NoSuchElementException;
14  
15  import com.google.common.collect.AbstractIterator;
16  import com.google.common.io.ByteStreams;
17  
18  import javax.sound.sampled.AudioFormat;
19  import javax.sound.sampled.AudioFormat.Encoding;
20  import javax.sound.sampled.AudioInputStream;
21  import javax.sound.sampled.AudioSystem;
22  import javax.sound.sampled.UnsupportedAudioFileException;
23  
24  public final class AudioUtils {
25  
26  	private AudioUtils() {}
27  
28  	/**
29  	 * Create an {@link AudioInputStream} from an {@link AudioFormat} and the audio data.
30  	 *
31  	 * @param format Expected to be PCM.
32  	 */
33  	public static AudioInputStream createAudioStream(AudioFormat format, byte[] data) {
34  		return createAudioStream(format, new ByteArrayInputStream(data));
35  	}
36  
37  	public static AudioInputStream createAudioStream(AudioFormat format, ByteArrayInputStream data) {
38  		if (format.getFrameSize() == AudioSystem.NOT_SPECIFIED)
39  			throw new IllegalArgumentException("unknown frame size");
40  		return new AudioInputStream(data,
41  		                            format,
42  		                            // ByteArrayInputStream.available() returns
43  		                            // the total number of bytes
44  		                            data.available() / format.getFrameSize());
45  	}
46  
47  	/**
48  	 * Create a {@link AudioInputStream} from a {@link InputStream}.
49  	 *
50  	 * This is to work around a bug in {@link javax.sound.sampled.AudioSystem}
51  	 * which may return {@link AudioInputStream} with a wrong {@link
52  	 * AudioInputStream#getFrameLength()}.
53  	 */
54  	public static AudioInputStream createAudioStream(InputStream stream)
55  			throws UnsupportedAudioFileException, IOException {
56  		AudioInputStream audio = AudioSystem.getAudioInputStream(new BufferedInputStream(stream));
57  		AudioFormat format = audio.getFormat();
58  		if (format.getFrameSize() != AudioSystem.NOT_SPECIFIED) {
59  			byte[] data = ByteStreams.toByteArray(audio);
60  			audio.close();
61  			audio = createAudioStream(format, data);
62  		}
63  		return audio;
64  	}
65  
66  	/**
67  	 * Get the duration of a PCM encoded audio stream.
68  	 *
69  	 * @throws IllegalArgumentException if format is not PCM encoded with specified frame rate.
70  	 */
71  	public static Duration getDuration(AudioInputStream stream) {
72  		return getDurationFromFrames(PCMAudioFormat.of(stream.getFormat()), stream.getFrameLength());
73  	}
74  
75  	/**
76  	 * @throws IllegalArgumentException if format is not PCM encoded with specified frame rate
77  	 *                                  and frame size.
78  	 */
79  	public static Duration getDuration(AudioFormat format, int bytes) {
80  		format = PCMAudioFormat.of(format);
81  		return getDurationFromFrames((PCMAudioFormat)format, (long)(bytes / format.getFrameSize()));
82  	}
83  
84  	private static Duration getDurationFromFrames(PCMAudioFormat format, Long frames) {
85  		return Duration.ofMillis((long)(frames.floatValue() / (format.getFrameRate() / 1000)));
86  	}
87  
88  	/**
89  	 * @return the number of frames needed to contain the given duration of audio.
90  	 */
91  	public static long getLengthInFrames(PCMAudioFormat format, Duration duration) {
92  		// rounding to the next natural number if needed
93  		return (long)Math.ceil((double)(format.getFrameRate() / 1000) * duration.toMillis());
94  	}
95  
96  	/**
97  	 * Concatenate a sequence of {@link AudioInputStream} into a single {@link AudioInputStream}.
98  	 *
99  	 * @param streams Must be streams with the same (PCM) audio format.
100 	 */
101 	public static AudioInputStream concat(Iterable<AudioInputStream> streams) {
102 		int count = 0;
103 		long _totalLength = 0;
104 		PCMAudioFormat format = null;
105 		for (AudioInputStream s : streams) {
106 			count++;
107 			_totalLength += s.getFrameLength();
108 			if (format == null) {
109 				try {
110 					format = PCMAudioFormat.of(s.getFormat());
111 				} catch (IllegalArgumentException e) {
112 					throw new IllegalArgumentException("AudioInputStream must be PCM encoded, but got: "+ s.getFormat());
113 				}
114 			} else if (!format.matches(s.getFormat()))
115 				throw new IllegalArgumentException("Can not concatenate AudioInputStream with different audio formats");
116 		}
117 		if (count == 0)
118 			throw new IllegalArgumentException("At least one AudioInputStream expected");
119 		if (count == 1)
120 			return streams.iterator().next();
121 		long totalLength = _totalLength;
122 		int frameSize = format.getFrameSize();
123 		return new AudioInputStream(
124 			new InputStream() {
125 				Iterator<AudioInputStream> nextStreams = streams.iterator();
126 				AudioInputStream stream = null;
127 				byte[] frame = new byte[frameSize]; // always need to read an integral number of frames
128 				                                    // from an AudioInputStream
129 				long availableFrames = totalLength;
130 				int availableInFrame = 0;
131 				@Override
132 				public int read() throws IOException {
133 					if (availableFrames == 0 && availableInFrame == 0)
134 						return -1;
135 					if (availableInFrame > 0)
136 						return frame[frameSize - (availableInFrame--)] & 0xFF;
137 					if (stream != null) {
138 						availableInFrame = stream.read(frame);
139 						if (availableInFrame > 0) {
140 							availableFrames--;
141 							return frame[frameSize - (availableInFrame--)] & 0xFF;
142 						}
143 					}
144 					try {
145 						if (stream != null)
146 							stream.close();
147 						stream = nextStreams.next();
148 					} catch (NoSuchElementException e) {
149 						return -1;
150 					}
151 					return read();
152 				}
153 				@Override
154 				public int read(byte[] b, int off, int len) throws IOException {
155 					if (off < 0 || len < 0 || b.length - off < len)
156 						throw new IndexOutOfBoundsException();
157 					if (len == 0)
158 						return 0;
159 					if (availableFrames == 0 && availableInFrame == 0)
160 						return -1;
161 					int read = 0;
162 					while (true) {
163 						if (availableInFrame > 0) {
164 							System.arraycopy(frame, frameSize - availableInFrame, b, off, Math.min(len, availableInFrame));
165 							if (availableInFrame >= len) {
166 								availableInFrame -= len;
167 								read += len;
168 								len = 0;
169 							} else {
170 								len -= availableInFrame;
171 								off += availableInFrame;
172 								read += availableInFrame;
173 								availableInFrame = 0;
174 							}
175 						}
176 						if (len < frameSize)
177 							return read; // note that it is possible that we have read a number of bytes smaller
178 							             // than the initial value of `len'
179 						if (stream != null) {
180 							int l = stream.read(b, off, len); // this will read an integral number of frames, so it is
181 							                                  // possible that l < len even if the stream has more bytes
182 							                                  // available
183 							if (l > 0) {
184 								if (l % frameSize > 0)
185 									throw new IllegalStateException(); // should not happen
186 								len -= l;
187 								off += l;
188 								read += l;
189 								availableFrames -= (l / frameSize);
190 								continue;
191 							}
192 						}
193 						try {
194 							if (stream != null)
195 								stream.close();
196 							stream = nextStreams.next();
197 						} catch (NoSuchElementException e) {
198 							return read;
199 						}
200 					}
201 				}
202 				@Override
203 				public int available() {
204 					try {
205 						return Math.toIntExact(Math.multiplyExact(availableFrames, frameSize)) + availableInFrame;
206 					} catch (ArithmeticException e) {
207 						return Integer.MAX_VALUE;
208 					}
209 				}
210 				@Override
211 				public void close() throws IOException {
212 					if (stream != null)
213 						stream.close();
214 					while (nextStreams.hasNext())
215 						nextStreams.next().close();
216 				}
217 			},
218 			format,
219 			totalLength);
220 	}
221 
222 	/**
223 	 * Split a {@link AudioInputStream} into a sequence of {@link AudioInputStream}.
224 	 *
225 	 * @param stream Must be PCM encoded. {@link AudioInputStream#getFrameLength()} must return the
226 	 *               actual number of available frames in the stream.
227 	 * @param splitPoints Are relative to the beginning of the stream.
228 	 * @return A sequence of streams. The number of streams is one more than the number of split points.
229 	 *         The durations are approximately (apart from rounding errors due to sampling) the time
230 	 *         between the split points.
231 	 * @throws IllegalArgumentException if a negative split point is provided, a split point exceeds
232 	 *                                  the total length of the input stream, or a split point is not
233 	 *                                  greater than or equal to the preceding split point ,and also
234 	 *                                  if the audio format is not PCM encoded with specified frame
235 	 *                                  rate and frame size.
236 	 */
237 	public static Iterable<AudioInputStream> split(AudioInputStream stream, Duration... splitPoints) {
238 		long[] splitPointsInFrames = new long[splitPoints.length];
239 		PCMAudioFormat format = PCMAudioFormat.of(stream.getFormat());
240 		for (int i = 0; i < splitPoints.length; i++)
241 			splitPointsInFrames[i] = getLengthInFrames(format, splitPoints[i]);
242 		return split(stream, splitPointsInFrames);
243 	}
244 
245 	public static Iterable<AudioInputStream> split(AudioInputStream stream, long... splitPoints) {
246 		if (splitPoints.length == 0)
247 			return Collections.singleton(stream);
248 		PCMAudioFormat format = PCMAudioFormat.of(stream.getFormat());
249 		long totalFrames = stream.getFrameLength();
250 		{ // validate splitPoints
251 			long begin = 0;
252 			for (long end : splitPoints) {
253 				if (end < begin)
254 					throw new IllegalArgumentException();
255 				begin = end; }
256 			if (totalFrames < begin)
257 				// could be a rounding error due to sampling: provide margin of one frame
258 				if (totalFrames + 1 < begin)
259 					throw new IllegalArgumentException(
260 						"invalid split points: " + Arrays.toString(splitPoints)
261 						+ " (total number of frames: " + totalFrames + ")");
262 		}
263 		int frameSize = format.getFrameSize();
264 		return new Iterable<AudioInputStream>() {
265 			public Iterator<AudioInputStream> iterator() {
266 				return new AbstractIterator<AudioInputStream>() {
267 					long framesAvailable = totalFrames;
268 					long framesConsumed = 0;
269 					int i = 0;
270 					protected AudioInputStream computeNext() {
271 						if (i > splitPoints.length)
272 							return endOfData();
273 						long frames = i == splitPoints.length
274 							? framesAvailable
275 							: splitPoints[i] - framesConsumed;
276 						if (frames > framesAvailable) {
277 							if (i == splitPoints.length - 1 && frames == framesAvailable + 1)
278 								frames--;
279 							else
280 								throw new IllegalStateException("coding error"); // can not happen (see splitPoint validation above)
281 						}
282 						// In theory a byte array can hold 2 Gb of memory, which should be more than
283 						// enough for audio. Note that when a lot of AudioInputStreams are open,
284 						// there is still a risk for out-of-memory errors, but normally the streams
285 						// are consumed in order so that should be okay as well. An alternative
286 						// solution would be to not use byte arrays and enforce that streams are
287 						// fully consumed before the next is computed.
288 						byte[] bytes = new byte[Math.toIntExact(frames * frameSize)];
289 						if (frames > 0) {
290 							try {
291 								if (stream.read(bytes) < bytes.length)
292 									// if this happens it means that stream.getFrameLength() did not
293 									// return the actual number of available frames
294 									throw new IllegalStateException("coding error");
295 							} catch (IOException e) {
296 								throw new RuntimeException(e);
297 							}
298 							framesConsumed += frames;
299 							framesAvailable -= frames;
300 						}
301 						i++;
302 						return createAudioStream(format, bytes);
303 					}
304 				};
305 			}
306 		};
307 	}
308 
309 	/**
310 	 * @param duration must not be negative
311 	 * @return <code>null</code> if <code>duration</code> is too short
312 	 */
313 	public static AudioInputStream createSilence(PCMAudioFormat format, Duration duration) {
314 		if (duration.compareTo(Duration.ZERO) < 0)
315 			throw new IllegalArgumentException();
316 		long frames = getLengthInFrames(format, duration);
317 		if (frames == 0)
318 			return null;
319 		else
320 			return createSilence(format, frames);
321 	}
322 
323 	/**
324 	 * @param format must be PCM encoded
325 	 * @param frames must not be negative
326 	 * @return <code>null</code> if <code>frames</code> is <code>0</code>
327 	 */
328 	public static AudioInputStream createSilence(AudioFormat format, long frames) {
329 		format = PCMAudioFormat.of(format);
330 		if (frames < 0)
331 			throw new IllegalArgumentException();
332 		if (frames == 0)
333 			return null;
334 		byte[] data = new byte[Math.multiplyExact(Math.toIntExact(frames), format.getFrameSize())]; // initialized to all zeros
335 		Encoding encoding = format.getEncoding();
336 		if (encoding == Encoding.PCM_SIGNED)
337 			return createAudioStream(format, data);
338 		else if (encoding == Encoding.PCM_UNSIGNED)
339 			return convertAudioStream(
340 				format,
341 				createAudioStream(new AudioFormat(Encoding.PCM_SIGNED,
342 				                                  format.getSampleRate(),
343 				                                  format.getSampleSizeInBits(),
344 				                                  format.getChannels(),
345 				                                  format.getFrameSize(),
346 				                                  format.getFrameRate(),
347 				                                  format.isBigEndian()),
348 				                  data));
349 		else // encoding == Encoding.PCM_FLOAT
350 			throw new UnsupportedOperationException("Not implemented"); // FIXME
351 	}
352 
353 	/**
354 	 * Convert audio from one format to another.
355 	 */
356 	public static AudioInputStream convertAudioStream(AudioFormat newFormat, AudioInputStream stream) {
357 		AudioFormat format = stream.getFormat();
358 		if (format.getSampleSizeInBits() > 8
359 		    && newFormat.getSampleSizeInBits() > 8
360 		    && !format.isBigEndian()
361 		    && !newFormat.isBigEndian()
362 		    && ((format.getEncoding() == Encoding.PCM_UNSIGNED
363 		         && newFormat.getEncoding() == Encoding.PCM_SIGNED)
364 		        || (format.getEncoding() == Encoding.PCM_SIGNED
365 		            && newFormat.getEncoding() == Encoding.PCM_UNSIGNED))) {
366 
367 			// com.sun.media.sound.PCMtoPCMCodec does not correctly convert unsigned little-endian
368 			// to signed little-endian or visa-versa. Work around this by first converting the
369 			// samples to big-endian.
370 			return convertAudioStream(
371 				newFormat,
372 				convertAudioStream(
373 					new AudioFormat(format.getSampleRate(),
374 					                format.getSampleSizeInBits(),
375 					                format.getChannels(),
376 					                format.getEncoding() == Encoding.PCM_SIGNED,
377 					                true), // big endian
378 					stream));
379 		} else if (format.getEncoding() == Encoding.PCM_FLOAT
380 		           && newFormat.getEncoding() == Encoding.PCM_FLOAT
381 		           && format.getSampleSizeInBits() == 64
382 		           && newFormat.getSampleSizeInBits() == 64
383 		           && format.isBigEndian() != newFormat.isBigEndian()) {
384 
385 			// AudioSystem.getAudioInputStream() introduces a rounding error, so we do the byte
386 			// order conversion ourselves.
387 			AudioFormat otherEndian = new AudioFormat(format.getEncoding(),
388 			                                          format.getSampleRate(),
389 			                                          format.getSampleSizeInBits(),
390 			                                          format.getChannels(),
391 			                                          format.getFrameSize(),
392 			                                          format.getFrameRate(),
393 			                                          !format.isBigEndian());
394 			ByteBuffer bytes; {
395 				try {
396 					bytes = ByteBuffer.wrap(ByteStreams.toByteArray(stream));
397 				} catch (IOException e) {
398 					throw new RuntimeException(e); // should not happen
399 				}
400 			}
401 			ByteBuffer reorderedBytes  = ByteBuffer.wrap(new byte[bytes.array().length]);
402 			reorderedBytes = reorderedBytes.order(ByteOrder.LITTLE_ENDIAN);
403 			while (bytes.hasRemaining())
404 				reorderedBytes.putLong(bytes.getLong());
405 			stream = createAudioStream(otherEndian, reorderedBytes.array());
406 			if (otherEndian.matches(newFormat))
407 				return stream;
408 			else
409 				return convertAudioStream(newFormat, stream);
410 		}
411 		return AudioSystem.getAudioInputStream(newFormat, stream);
412 	}
413 
414 	public static boolean isPCM(AudioFormat format) {
415 		Encoding encoding = format.getEncoding();
416 		return encoding == Encoding.PCM_SIGNED
417 			|| encoding == Encoding.PCM_UNSIGNED
418 			|| encoding == Encoding.PCM_FLOAT;
419 	}
420 }