001package jmri.jmrit;
002
003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
004import java.io.File;
005import java.io.IOException;
006import java.net.MalformedURLException;
007import java.net.URISyntaxException;
008import java.net.URL;
009import java.util.Arrays;
010import java.util.concurrent.atomic.AtomicReference;
011import javax.annotation.Nonnull;
012import javax.sound.sampled.AudioFormat;
013import javax.sound.sampled.AudioInputStream;
014import javax.sound.sampled.AudioSystem;
015import javax.sound.sampled.Clip;
016import javax.sound.sampled.DataLine;
017import javax.sound.sampled.LineEvent;
018import javax.sound.sampled.LineUnavailableException;
019import javax.sound.sampled.SourceDataLine;
020import javax.sound.sampled.UnsupportedAudioFileException;
021import jmri.util.FileUtil;
022
023/**
024 * Provide simple way to load and play sounds in JMRI.
025 * <p>
026 * This is placed in the jmri.jmrit package by process of elimination. It
027 * doesn't belong in the base jmri package, as it's not a basic interface. Nor
028 * is it a specific implementation of a basic interface, which would put it in
029 * jmri.jmrix. It seems most like a "tool using JMRI", or perhaps a tool for use
030 * with JMRI, so it was placed in jmri.jmrit.
031 *
032 * @see jmri.jmrit.sound
033 *
034 * @author Bob Jacobsen Copyright (C) 2004, 2006
035 * @author Dave Duchamp Copyright (C) 2011 - add streaming play of large files
036 */
037public class Sound {
038
039    // files over this size will be streamed
040    public static final long LARGE_SIZE = 100000;
041    private final URL url;
042    private boolean streaming = false;
043    private boolean streamingStop = false;
044    private AtomicReference<Clip> clipRef = new AtomicReference<>();
045    private boolean autoClose = true;
046
047    /**
048     * Create a Sound object using the media file at path
049     *
050     * @param path path, portable or absolute, to the media
051     * @throws NullPointerException if path cannot be converted into a URL by
052     *                              {@link jmri.util.FileUtilSupport#findURL(java.lang.String)}
053     */
054    public Sound(@Nonnull String path) throws NullPointerException {
055        this(FileUtil.findURL(path));
056    }
057
058    /**
059     * Create a Sound object using the media file
060     *
061     * @param file reference to the media
062     * @throws java.net.MalformedURLException if file cannot be converted into a
063     *                                        valid URL
064     */
065    public Sound(@Nonnull File file) throws MalformedURLException {
066        this(file.toURI().toURL());
067    }
068
069    /**
070     * Create a Sound object using the media URL
071     *
072     * @param url path to the media
073     * @throws NullPointerException if URL is null
074     */
075    public Sound(@Nonnull URL url) throws NullPointerException {
076        if (url == null) {
077            throw new NullPointerException();
078        }
079        this.url = url;
080        try {
081            streaming = this.needStreaming();
082            if (!streaming) {
083                clipRef.updateAndGet(clip -> {
084                    return openClip();
085                });
086            }
087        } catch (URISyntaxException ex) {
088            streaming = false;
089        } catch (IOException ex) {
090            log.error("Unable to open {}", url);
091        }
092    }
093
094    private Clip openClip() {
095        Clip newClip = null;
096        try {
097            newClip = AudioSystem.getClip(null);
098            newClip.addLineListener(event -> {
099                if (LineEvent.Type.STOP.equals(event.getType())) {
100                    if (autoClose) {
101                        clipRef.updateAndGet(clip -> {
102                            if (clip != null) {
103                                clip.close();
104                            }
105                            return null;
106                        });
107                    }
108                }
109            });
110            newClip.open(AudioSystem.getAudioInputStream(url));
111        } catch (IOException ex) {
112            log.error("Unable to open {}", url);
113        } catch (LineUnavailableException ex) {
114            log.error("Unable to provide audio playback", ex);
115        } catch (UnsupportedAudioFileException ex) {
116            log.error("{} is not a recognised audio format", url);
117        }
118
119        return newClip;
120    }
121
122    /**
123     * Set if the clip be closed automatically.
124     * @param autoClose true if closed automatically
125     */
126    public void setAutoClose(boolean autoClose) {
127        this.autoClose = autoClose;
128    }
129
130    /**
131     * Get if the clip is closed automatically.
132     * @return true if closed automatically
133     */
134    public boolean getAutoClose() {
135        return autoClose;
136    }
137
138    /**
139     * Closes the sound.
140     */
141    public void close() {
142        if (streaming) {
143            streamingStop = true;
144        } else {
145            clipRef.updateAndGet(clip -> {
146                if (clip != null) {
147                    clip.close();
148                }
149                return null;
150            });
151        }
152    }
153
154    /**
155     * Play the sound once.
156     */
157    public void play() {
158        play(false);
159    }
160
161    /**
162     * Play the sound once.
163     * @param autoClose true if auto close clip, false otherwise. Only
164     *                  valid for clips. For streams, autoClose is ignored.
165     */
166    public void play(boolean autoClose) {
167        if (streaming) {
168            Runnable streamSound = new StreamingSound(this.url);
169            Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound);
170            tStream.start();
171        } else {
172            clipRef.updateAndGet(clip -> {
173                if (clip == null) {
174                    clip = openClip();
175                }
176                if (clip != null) {
177                    if (autoClose) {
178                        clip.addLineListener((event) -> {
179                            if (event.getType() == LineEvent.Type.STOP) {
180                                event.getLine().close();
181                            }
182                        });
183                    }
184                    clip.start();
185                }
186                return clip;
187            });
188        }
189    }
190
191    /**
192     * Play the sound as an endless loop
193     */
194    public void loop() {
195        this.loop(Clip.LOOP_CONTINUOUSLY);
196    }
197
198    /**
199     * Play the sound in a loop count times. Use
200     * {@link javax.sound.sampled.Clip#LOOP_CONTINUOUSLY} to create an endless
201     * loop.
202     *
203     * @param count the number of times to loop
204     */
205    public void loop(int count) {
206        if (streaming) {
207            Runnable streamSound = new StreamingSound(this.url, count);
208            Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound);
209            tStream.start();
210        } else {
211            clipRef.updateAndGet(clip -> {
212                if (clip == null) {
213                    clip = openClip();
214                }
215                if (clip != null) {
216                    clip.loop(count);
217                }
218                return clip;
219            });
220        }
221    }
222
223    /**
224     * Stop playing a loop.
225     */
226    public void stop() {
227        if (streaming) {
228            streamingStop = true;
229        } else {
230            clipRef.updateAndGet(clip -> {
231                if (clip != null) {
232                    clip.stop();
233                }
234                return clip;
235            });
236        }
237    }
238
239    private boolean needStreaming() throws URISyntaxException, IOException {
240        if (url != null) {
241            if ("file".equals(this.url.getProtocol())) {
242                return (new File(this.url.toURI()).length() > LARGE_SIZE);
243            } else {
244                return this.url.openConnection().getContentLengthLong() > LARGE_SIZE;
245            }
246        }
247        return false;
248    }
249
250    /**
251     * Play a sound from a buffer
252     *
253     * @param wavData data to play
254     */
255    public static void playSoundBuffer(byte[] wavData) {
256
257        // get characteristics from buffer
258        float sampleRate = 11200.0f;
259        int sampleSizeInBits = 8;
260        int channels = 1;
261        boolean signed = (sampleSizeInBits > 8);
262        boolean bigEndian = true;
263
264        AudioFormat format = new AudioFormat(sampleRate, sampleSizeInBits, channels, signed, bigEndian);
265        SourceDataLine line;
266        DataLine.Info info = new DataLine.Info(SourceDataLine.class, format); // format is an AudioFormat object
267        if (!AudioSystem.isLineSupported(info)) {
268            // Handle the error.
269            log.warn("line not supported: {}", info);
270            return;
271        }
272        // Obtain and open the line.
273        try {
274            line = (SourceDataLine) AudioSystem.getLine(info);
275            line.open(format);
276        } catch (LineUnavailableException ex) {
277            // Handle the error.
278            log.error("error opening line", ex);
279            return;
280        }
281        line.start();
282        // write(byte[] b, int off, int len)
283        line.write(wavData, 0, wavData.length);
284
285    }
286
287    /**
288     * Dispose this sound.
289     */
290    public void dispose() {
291        if (!streaming) {
292            clipRef.updateAndGet(clip -> {
293                if (clip != null) {
294                    clip.close();
295                }
296                return null;
297            });
298        }
299    }
300
301    public static class WavBuffer {
302
303        public WavBuffer(byte[] content) {
304            buffer = Arrays.copyOf(content, content.length);
305
306            // find fmt chunk and set offset
307            int index = 12; // skip RIFF header
308            while (index < buffer.length) {
309                // new chunk
310                if (buffer[index] == 0x66
311                        && buffer[index + 1] == 0x6D
312                        && buffer[index + 2] == 0x74
313                        && buffer[index + 3] == 0x20) {
314                    // found it
315                    fmtOffset = index;
316                    return;
317                } else {
318                    // skip
319                    index = index + 8
320                            + buffer[index + 4]
321                            + buffer[index + 5] * 256
322                            + buffer[index + 6] * 256 * 256
323                            + buffer[index + 7] * 256 * 256 * 256;
324                    log.debug("index now {}", index);
325                }
326            }
327            log.error("Didn't find fmt chunk");
328
329        }
330
331        // we maintain this, but don't use it for anything yet
332        @SuppressFBWarnings(value = "URF_UNREAD_FIELD")
333        int fmtOffset;
334
335        byte[] buffer;
336
337        float getSampleRate() {
338            return 11200.0f;
339        }
340
341        int getSampleSizeInBits() {
342            return 8;
343        }
344
345        int getChannels() {
346            return 1;
347        }
348
349        boolean getBigEndian() {
350            return false;
351        }
352
353        boolean getSigned() {
354            return (getSampleSizeInBits() > 8);
355        }
356    }
357
358    public class StreamingSound implements Runnable {
359
360        private final URL localUrl;
361        private AudioInputStream stream = null;
362        private AudioFormat format = null;
363        private SourceDataLine line = null;
364        private jmri.Sensor streamingSensor = null;
365        private final int count;
366
367        /**
368         * A runnable to stream in sound and play it This method does not read
369         * in an entire large sound file at one time, but instead reads in
370         * smaller chunks as needed.
371         *
372         * @param url the URL containing audio media
373         */
374        public StreamingSound(URL url) {
375            this(url, 1);
376        }
377
378        /**
379         * A runnable to stream in sound and play it This method does not read
380         * in an entire large sound file at one time, but instead reads in
381         * smaller chunks as needed.
382         *
383         * @param url the URL containing audio media
384         * @param count the number of times to loop
385         */
386        public StreamingSound(URL url, int count) {
387            this.localUrl = url;
388            this.count = count;
389        }
390
391        /** {@inheritDoc} */
392        @Override
393        public void run() {
394            // Note: some of the following is based on code from
395            //      "Killer Game Programming in Java" by A. Davidson.
396            // Set up the audio input stream from the sound file
397            try {
398                // link an audio stream to the sampled sound's file
399                stream = AudioSystem.getAudioInputStream(localUrl);
400                format = stream.getFormat();
401                log.debug("Audio format: {}", format);
402                // convert ULAW/ALAW formats to PCM format
403                if ((format.getEncoding() == AudioFormat.Encoding.ULAW)
404                        || (format.getEncoding() == AudioFormat.Encoding.ALAW)) {
405                    AudioFormat newFormat
406                            = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
407                                    format.getSampleRate(),
408                                    format.getSampleSizeInBits() * 2,
409                                    format.getChannels(),
410                                    format.getFrameSize() * 2,
411                                    format.getFrameRate(), true);  // big endian
412                    // update stream and format details
413                    stream = AudioSystem.getAudioInputStream(newFormat, stream);
414                    log.info("Converted Audio format: {}", newFormat);
415                    format = newFormat;
416                    log.debug("new converted Audio format: {}", format);
417                }
418            } catch (UnsupportedAudioFileException e) {
419                log.error("AudioFileException {}", e.getMessage());
420                return;
421            } catch (IOException e) {
422                log.error("IOException {}", e.getMessage());
423                return;
424            }
425            streamingStop = false;
426            if (streamingSensor == null) {
427                streamingSensor = jmri.InstanceManager.sensorManagerInstance().provideSensor("ISSOUNDSTREAMING");
428            }
429
430            setSensor(jmri.Sensor.ACTIVE);
431
432            if (!streamingStop) {
433                // set up the SourceDataLine going to the JVM's mixer
434                try {
435                    // gather information for line creation
436                    DataLine.Info info
437                            = new DataLine.Info(SourceDataLine.class, format);
438                    if (!AudioSystem.isLineSupported(info)) {
439                        log.error("Audio play() does not support: {}", format);
440                        return;
441                    }
442                    // get a line of the required format
443                    line = (SourceDataLine) AudioSystem.getLine(info);
444                    line.open(format);
445                } catch (Exception e) {
446                    log.error("Exception while creating Audio out {}", e.getMessage());
447                    return;
448                }
449            }
450            if (streamingStop) {
451                line.close();
452                setSensor(jmri.Sensor.INACTIVE);
453                return;
454            }
455            // Read  the sound file in chunks of bytes into buffer, and
456            //   pass them on through the SourceDataLine
457            int numRead;
458            byte[] buffer = new byte[line.getBufferSize()];
459            log.debug("streaming sound buffer size = {}", line.getBufferSize());
460            line.start();
461            // read and play chunks of the audio
462            try {
463                if (stream.markSupported()) stream.mark(Integer.MAX_VALUE);
464
465                int i=0;
466                while (!streamingStop && ((i++ < count) || (count == Clip.LOOP_CONTINUOUSLY))) {
467                    int offset;
468                    while ((numRead = stream.read(buffer, 0, buffer.length)) >= 0) {
469                        offset = 0;
470                        while (offset < numRead) {
471                            offset += line.write(buffer, offset, numRead - offset);
472                        }
473                    }
474                    if (stream.markSupported()) {
475                        stream.reset();
476                    } else {
477                        stream.close();
478                        try {
479                            stream = AudioSystem.getAudioInputStream(localUrl);
480                        } catch (UnsupportedAudioFileException e) {
481                            log.error("AudioFileException {}", e.getMessage());
482                            closeLine();
483                            return;
484                        } catch (IOException e) {
485                            log.error("IOException {}", e.getMessage());
486                            closeLine();
487                            return;
488                        }
489                    }
490                }
491            } catch (IOException e) {
492                log.error("IOException while reading sound file {}", e.getMessage());
493            }
494            closeLine();
495        }
496
497        private void closeLine() {
498            // wait until all data is played, then close the line
499            line.drain();
500            line.stop();
501            line.close();
502            setSensor(jmri.Sensor.INACTIVE);
503        }
504
505        private void setSensor(int mode) {
506            if (streamingSensor != null) {
507                try {
508                    streamingSensor.setState(mode);
509                } catch (jmri.JmriException ex) {
510                    log.error("Exception while setting ISSOUNDSTREAMING sensor {} to {}", streamingSensor.getDisplayName(), mode);
511                }
512            }
513        }
514
515    }
516
517    private final static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Sound.class);
518}