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        streamingStop = false;
168        if (streaming) {
169            Runnable streamSound = new StreamingSound(this.url);
170            Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound);
171            String path = url.getPath();
172            tStream.setName("Play " + path.substring(path.lastIndexOf('/') + 1));
173            tStream.start();
174        } else {
175            clipRef.updateAndGet(clip -> {
176                if (clip == null) {
177                    clip = openClip();
178                }
179                if (clip != null) {
180                    if (autoClose) {
181                        clip.addLineListener((event) -> {
182                            if (event.getType() == LineEvent.Type.STOP) {
183                                event.getLine().close();
184                            }
185                        });
186                    }
187                    clip.start();
188                }
189                return clip;
190            });
191        }
192    }
193
194    /**
195     * Play the sound as an endless loop
196     */
197    public void loop() {
198        this.loop(Clip.LOOP_CONTINUOUSLY);
199    }
200
201    /**
202     * Play the sound in a loop count times. Use
203     * {@link javax.sound.sampled.Clip#LOOP_CONTINUOUSLY} to create an endless
204     * loop.
205     *
206     * @param count the number of times to loop
207     */
208    public void loop(int count) {
209        streamingStop = false;
210        if (streaming) {
211            Runnable streamSound = new StreamingSound(this.url, count);
212            Thread tStream = jmri.util.ThreadingUtil.newThread(streamSound);
213            String path = url.getPath();
214            tStream.setName("Loop " + path.substring(path.lastIndexOf('/') + 1) );
215            tStream.start();
216        } else {
217            clipRef.updateAndGet(clip -> {
218                if (clip == null) {
219                    clip = openClip();
220                }
221                if (clip != null) {
222                    clip.loop(count);
223                }
224                return clip;
225            });
226        }
227    }
228
229    /**
230     * Stop playing a loop.
231     */
232    public void stop() {
233        if (streaming) {
234            streamingStop = true;
235        } else {
236            clipRef.updateAndGet(clip -> {
237                if (clip != null) {
238                    clip.stop();
239                }
240                return clip;
241            });
242        }
243    }
244
245    private boolean needStreaming() throws URISyntaxException, IOException {
246        if (url != null) {
247            if ("file".equals(this.url.getProtocol())) {
248                return (new File(this.url.toURI()).length() > LARGE_SIZE);
249            } else {
250                return this.url.openConnection().getContentLengthLong() > LARGE_SIZE;
251            }
252        }
253        return false;
254    }
255
256    /**
257     * Play a sound from a buffer
258     *
259     * @param wavData data to play
260     */
261    public static void playSoundBuffer(byte[] wavData) {
262
263        // get characteristics from buffer
264        float sampleRate = 11200.0f;
265        int sampleSizeInBits = 8;
266        int channels = 1;
267        boolean signed = (sampleSizeInBits > 8);
268        boolean bigEndian = true;
269
270        AudioFormat format = new AudioFormat(sampleRate, sampleSizeInBits, channels, signed, bigEndian);
271        SourceDataLine line;
272        DataLine.Info info = new DataLine.Info(SourceDataLine.class, format); // format is an AudioFormat object
273        if (!AudioSystem.isLineSupported(info)) {
274            // Handle the error.
275            log.warn("line not supported: {}", info);
276            return;
277        }
278        // Obtain and open the line.
279        try {
280            line = (SourceDataLine) AudioSystem.getLine(info);
281            line.open(format);
282        } catch (LineUnavailableException ex) {
283            // Handle the error.
284            log.error("error opening line", ex);
285            return;
286        }
287        line.start();
288        // write(byte[] b, int off, int len)
289        line.write(wavData, 0, wavData.length);
290
291    }
292
293    /**
294     * Dispose this sound.
295     */
296    public void dispose() {
297        if (!streaming) {
298            clipRef.updateAndGet(clip -> {
299                if (clip != null) {
300                    clip.close();
301                }
302                return null;
303            });
304        }
305    }
306
307    public static class WavBuffer {
308
309        public WavBuffer(byte[] content) {
310            buffer = Arrays.copyOf(content, content.length);
311
312            // find fmt chunk and set offset
313            int index = 12; // skip RIFF header
314            while (index < buffer.length) {
315                // new chunk
316                if (buffer[index] == 0x66
317                        && buffer[index + 1] == 0x6D
318                        && buffer[index + 2] == 0x74
319                        && buffer[index + 3] == 0x20) {
320                    // found it
321                    fmtOffset = index;
322                    return;
323                } else {
324                    // skip
325                    index = index + 8
326                            + buffer[index + 4]
327                            + buffer[index + 5] * 256
328                            + buffer[index + 6] * 256 * 256
329                            + buffer[index + 7] * 256 * 256 * 256;
330                    log.debug("index now {}", index);
331                }
332            }
333            log.error("Didn't find fmt chunk");
334
335        }
336
337        // we maintain this, but don't use it for anything yet
338        @SuppressFBWarnings(value = "URF_UNREAD_FIELD")
339        int fmtOffset;
340
341        byte[] buffer;
342
343        float getSampleRate() {
344            return 11200.0f;
345        }
346
347        int getSampleSizeInBits() {
348            return 8;
349        }
350
351        int getChannels() {
352            return 1;
353        }
354
355        boolean getBigEndian() {
356            return false;
357        }
358
359        boolean getSigned() {
360            return (getSampleSizeInBits() > 8);
361        }
362    }
363
364    public class StreamingSound implements Runnable {
365
366        private final URL localUrl;
367        private AudioInputStream stream = null;
368        private AudioFormat format = null;
369        private SourceDataLine line = null;
370        private jmri.Sensor streamingSensor = null;
371        private final int count;
372
373        /**
374         * A runnable to stream in sound and play it This method does not read
375         * in an entire large sound file at one time, but instead reads in
376         * smaller chunks as needed.
377         *
378         * @param url the URL containing audio media
379         */
380        public StreamingSound(URL url) {
381            this(url, 1);
382        }
383
384        /**
385         * A runnable to stream in sound and play it This method does not read
386         * in an entire large sound file at one time, but instead reads in
387         * smaller chunks as needed.
388         *
389         * @param url the URL containing audio media
390         * @param count the number of times to loop
391         */
392        public StreamingSound(URL url, int count) {
393            this.localUrl = url;
394            this.count = count;
395        }
396
397        /** {@inheritDoc} */
398        @Override
399        public void run() {
400            // Note: some of the following is based on code from
401            //      "Killer Game Programming in Java" by A. Davidson.
402            // Set up the audio input stream from the sound file
403            try {
404                // link an audio stream to the sampled sound's file
405                stream = AudioSystem.getAudioInputStream(localUrl);
406                format = stream.getFormat();
407                log.debug("Audio format: {}", format);
408                // convert ULAW/ALAW formats to PCM format
409                if ((format.getEncoding() == AudioFormat.Encoding.ULAW)
410                        || (format.getEncoding() == AudioFormat.Encoding.ALAW)) {
411                    AudioFormat newFormat
412                            = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
413                                    format.getSampleRate(),
414                                    format.getSampleSizeInBits() * 2,
415                                    format.getChannels(),
416                                    format.getFrameSize() * 2,
417                                    format.getFrameRate(), true);  // big endian
418                    // update stream and format details
419                    stream = AudioSystem.getAudioInputStream(newFormat, stream);
420                    log.info("Converted Audio format: {}", newFormat);
421                    format = newFormat;
422                    log.debug("new converted Audio format: {}", format);
423                }
424            } catch (UnsupportedAudioFileException e) {
425                log.error("AudioFileException {}", e.getMessage());
426                return;
427            } catch (IOException e) {
428                log.error("IOException {}", e.getMessage());
429                return;
430            }
431
432            if (streamingSensor == null) {
433                streamingSensor = jmri.InstanceManager.sensorManagerInstance().provideSensor("ISSOUNDSTREAMING");
434            }
435
436            setSensor(jmri.Sensor.ACTIVE);
437
438            if (!streamingStop) {
439                // set up the SourceDataLine going to the JVM's mixer
440                try {
441                    // gather information for line creation
442                    DataLine.Info info
443                            = new DataLine.Info(SourceDataLine.class, format);
444                    if (!AudioSystem.isLineSupported(info)) {
445                        log.error("Audio play() does not support: {}", format);
446                        return;
447                    }
448                    // get a line of the required format
449                    line = (SourceDataLine) AudioSystem.getLine(info);
450                    line.open(format);
451                } catch (Exception e) {
452                    log.error("Exception while creating Audio out {}", e.getMessage());
453                    return;
454                }
455            }
456            if (streamingStop) {
457                if ( line != null ) {
458                    line.close();
459                }
460                setSensor(jmri.Sensor.INACTIVE);
461                return;
462            }
463            // Read  the sound file in chunks of bytes into buffer, and
464            //   pass them on through the SourceDataLine
465            int numRead;
466            byte[] buffer = new byte[line.getBufferSize()];
467            log.debug("streaming sound buffer size = {}", line.getBufferSize());
468            line.start();
469            // read and play chunks of the audio
470            try {
471                if (stream.markSupported()) stream.mark(Integer.MAX_VALUE);
472
473                int i=0;
474                while (!streamingStop && ((i++ < count) || (count == Clip.LOOP_CONTINUOUSLY))) {
475                    int offset;
476                    while ((numRead = stream.read(buffer, 0, buffer.length)) >= 0) {
477                        offset = 0;
478                        while (offset < numRead) {
479                            offset += line.write(buffer, offset, numRead - offset);
480                        }
481                    }
482                    if (stream.markSupported()) {
483                        stream.reset();
484                    } else {
485                        stream.close();
486                        try {
487                            stream = AudioSystem.getAudioInputStream(localUrl);
488                        } catch (UnsupportedAudioFileException e) {
489                            log.error("AudioFileException {}", e.getMessage());
490                            closeLine();
491                            return;
492                        } catch (IOException e) {
493                            log.error("IOException {}", e.getMessage());
494                            closeLine();
495                            return;
496                        }
497                    }
498                }
499            } catch (IOException e) {
500                log.error("IOException while reading sound file {}", e.getMessage());
501            }
502            closeLine();
503        }
504
505        private void closeLine() {
506            // wait until all data is played, then close the line
507            line.drain();
508            line.stop();
509            line.close();
510            setSensor(jmri.Sensor.INACTIVE);
511        }
512
513        private void setSensor(int mode) {
514            if (streamingSensor != null) {
515                try {
516                    streamingSensor.setState(mode);
517                } catch (jmri.JmriException ex) {
518                    log.error("Exception while setting ISSOUNDSTREAMING sensor {} to {}",
519                        streamingSensor.getDisplayName(), mode);
520                }
521            }
522        }
523
524    }
525
526    private final static org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Sound.class);
527}