001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.IOException;
007import java.net.URL;
008
009import javax.sound.sampled.AudioFormat;
010import javax.sound.sampled.AudioInputStream;
011import javax.sound.sampled.AudioSystem;
012import javax.sound.sampled.DataLine;
013import javax.sound.sampled.LineUnavailableException;
014import javax.sound.sampled.SourceDataLine;
015import javax.sound.sampled.UnsupportedAudioFileException;
016import javax.swing.JOptionPane;
017
018import org.openstreetmap.josm.Main;
019
020/**
021 * Creates and controls a separate audio player thread.
022 *
023 * @author David Earl <david@frankieandshadow.com>
024 * @since 547
025 */
026public final class AudioPlayer extends Thread {
027
028    private static volatile AudioPlayer audioPlayer;
029
030    private enum State { INITIALIZING, NOTPLAYING, PLAYING, PAUSED, INTERRUPTED }
031
032    private enum Command { PLAY, PAUSE }
033
034    private enum Result { WAITING, OK, FAILED }
035
036    private State state;
037    private URL playingUrl;
038    private final double leadIn; // seconds
039    private final double calibration; // ratio of purported duration of samples to true duration
040    private double position; // seconds
041    private double bytesPerSecond;
042    private static long chunk = 4000; /* bytes */
043    private double speed = 1.0;
044
045    /**
046     * Passes information from the control thread to the playing thread
047     */
048    private class Execute {
049        private Command command;
050        private Result result;
051        private Exception exception;
052        private URL url;
053        private double offset; // seconds
054        private double speed; // ratio
055
056        /*
057         * Called to execute the commands in the other thread
058         */
059        protected void play(URL url, double offset, double speed) throws Exception {
060            this.url = url;
061            this.offset = offset;
062            this.speed = speed;
063            command = Command.PLAY;
064            result = Result.WAITING;
065            send();
066        }
067
068        protected void pause() throws Exception {
069            command = Command.PAUSE;
070            send();
071        }
072
073        private void send() throws Exception {
074            result = Result.WAITING;
075            interrupt();
076            while (result == Result.WAITING) {
077                sleep(10);
078            }
079            if (result == Result.FAILED)
080                throw exception;
081        }
082
083        private void possiblyInterrupt() throws InterruptedException {
084            if (interrupted() || result == Result.WAITING)
085                throw new InterruptedException();
086        }
087
088        protected void failed(Exception e) {
089            exception = e;
090            result = Result.FAILED;
091            state = State.NOTPLAYING;
092        }
093
094        protected void ok(State newState) {
095            result = Result.OK;
096            state = newState;
097        }
098
099        protected double offset() {
100            return offset;
101        }
102
103        protected double speed() {
104            return speed;
105        }
106
107        protected URL url() {
108            return url;
109        }
110
111        protected Command command() {
112            return command;
113        }
114    }
115
116    private final Execute command;
117
118    /**
119     * Plays a WAV audio file from the beginning. See also the variant which doesn't
120     * start at the beginning of the stream
121     * @param url The resource to play, which must be a WAV file or stream
122     * @throws Exception audio fault exception, e.g. can't open stream, unhandleable audio format
123     */
124    public static void play(URL url) throws Exception {
125        AudioPlayer.getInstance().command.play(url, 0.0, 1.0);
126    }
127
128    /**
129     * Plays a WAV audio file from a specified position.
130     * @param url The resource to play, which must be a WAV file or stream
131     * @param seconds The number of seconds into the audio to start playing
132     * @throws Exception audio fault exception, e.g. can't open stream, unhandleable audio format
133     */
134    public static void play(URL url, double seconds) throws Exception {
135        AudioPlayer.getInstance().command.play(url, seconds, 1.0);
136    }
137
138    /**
139     * Plays a WAV audio file from a specified position at variable speed.
140     * @param url The resource to play, which must be a WAV file or stream
141     * @param seconds The number of seconds into the audio to start playing
142     * @param speed Rate at which audio playes (1.0 = real time, > 1 is faster)
143     * @throws Exception audio fault exception, e.g. can't open stream,  unhandleable audio format
144     */
145    public static void play(URL url, double seconds, double speed) throws Exception {
146        AudioPlayer.getInstance().command.play(url, seconds, speed);
147    }
148
149    /**
150     * Pauses the currently playing audio stream. Does nothing if nothing playing.
151     * @throws Exception audio fault exception, e.g. can't open stream,  unhandleable audio format
152     */
153    public static void pause() throws Exception {
154        AudioPlayer.getInstance().command.pause();
155    }
156
157    /**
158     * To get the Url of the playing or recently played audio.
159     * @return url - could be null
160     */
161    public static URL url() {
162        return AudioPlayer.getInstance().playingUrl;
163    }
164
165    /**
166     * Whether or not we are paused.
167     * @return boolean whether or not paused
168     */
169    public static boolean paused() {
170        return AudioPlayer.getInstance().state == State.PAUSED;
171    }
172
173    /**
174     * Whether or not we are playing.
175     * @return boolean whether or not playing
176     */
177    public static boolean playing() {
178        return AudioPlayer.getInstance().state == State.PLAYING;
179    }
180
181    /**
182     * How far we are through playing, in seconds.
183     * @return double seconds
184     */
185    public static double position() {
186        return AudioPlayer.getInstance().position;
187    }
188
189    /**
190     * Speed at which we will play.
191     * @return double, speed multiplier
192     */
193    public static double speed() {
194        return AudioPlayer.getInstance().speed;
195    }
196
197    /**
198     * Returns the singleton object, and if this is the first time, creates it along with
199     * the thread to support audio
200     * @return the unique instance
201     */
202    private static AudioPlayer getInstance() {
203        if (audioPlayer != null)
204            return audioPlayer;
205        try {
206            audioPlayer = new AudioPlayer();
207            return audioPlayer;
208        } catch (Exception ex) {
209            Main.error(ex);
210            return null;
211        }
212    }
213
214    /**
215     * Resets the audio player.
216     */
217    public static void reset() {
218        if (audioPlayer != null) {
219            try {
220                pause();
221            } catch (Exception e) {
222                Main.warn(e);
223            }
224            audioPlayer.playingUrl = null;
225        }
226    }
227
228    private AudioPlayer() {
229        state = State.INITIALIZING;
230        command = new Execute();
231        playingUrl = null;
232        leadIn = Main.pref.getDouble("audio.leadin", 1.0 /* default, seconds */);
233        calibration = Main.pref.getDouble("audio.calibration", 1.0 /* default, ratio */);
234        start();
235        while (state == State.INITIALIZING) {
236            yield();
237        }
238    }
239
240    /**
241     * Starts the thread to actually play the audio, per Thread interface
242     * Not to be used as public, though Thread interface doesn't allow it to be made private
243     */
244    @Override public void run() {
245        /* code running in separate thread */
246
247        playingUrl = null;
248        AudioInputStream audioInputStream = null;
249        SourceDataLine audioOutputLine = null;
250        AudioFormat audioFormat = null;
251        byte[] abData = new byte[(int) chunk];
252
253        for (;;) {
254            try {
255                switch (state) {
256                    case INITIALIZING:
257                        // we're ready to take interrupts
258                        state = State.NOTPLAYING;
259                        break;
260                    case NOTPLAYING:
261                    case PAUSED:
262                        sleep(200);
263                        break;
264                    case PLAYING:
265                        command.possiblyInterrupt();
266                        for (;;) {
267                            int nBytesRead = 0;
268                            nBytesRead = audioInputStream.read(abData, 0, abData.length);
269                            position += nBytesRead / bytesPerSecond;
270                            command.possiblyInterrupt();
271                            if (nBytesRead < 0) {
272                                break;
273                            }
274                            audioOutputLine.write(abData, 0, nBytesRead); // => int nBytesWritten
275                            command.possiblyInterrupt();
276                        }
277                        // end of audio, clean up
278                        audioOutputLine.drain();
279                        audioOutputLine.close();
280                        audioOutputLine = null;
281                        Utils.close(audioInputStream);
282                        audioInputStream = null;
283                        playingUrl = null;
284                        state = State.NOTPLAYING;
285                        command.possiblyInterrupt();
286                        break;
287                }
288            } catch (InterruptedException e) {
289                interrupted(); // just in case we get an interrupt
290                State stateChange = state;
291                state = State.INTERRUPTED;
292                try {
293                    switch (command.command()) {
294                        case PLAY:
295                            double offset = command.offset();
296                            speed = command.speed();
297                            if (playingUrl != command.url() ||
298                                    stateChange != State.PAUSED ||
299                                    offset != 0) {
300                                if (audioInputStream != null) {
301                                    Utils.close(audioInputStream);
302                                    audioInputStream = null;
303                                }
304                                playingUrl = command.url();
305                                audioInputStream = AudioSystem.getAudioInputStream(playingUrl);
306                                audioFormat = audioInputStream.getFormat();
307                                long nBytesRead = 0;
308                                position = 0.0;
309                                offset -= leadIn;
310                                double calibratedOffset = offset * calibration;
311                                bytesPerSecond = audioFormat.getFrameRate() /* frames per second */
312                                * audioFormat.getFrameSize() /* bytes per frame */;
313                                if (speed * bytesPerSecond > 256000.0) {
314                                    speed = 256000 / bytesPerSecond;
315                                }
316                                if (calibratedOffset > 0.0) {
317                                    long bytesToSkip = (long) (calibratedOffset /* seconds (double) */ * bytesPerSecond);
318                                    // skip doesn't seem to want to skip big chunks, so reduce it to smaller ones
319                                    while (bytesToSkip > chunk) {
320                                        nBytesRead = audioInputStream.skip(chunk);
321                                        if (nBytesRead <= 0)
322                                            throw new IOException(tr("This is after the end of the recording"));
323                                        bytesToSkip -= nBytesRead;
324                                    }
325                                    while (bytesToSkip > 0) {
326                                        long skippedBytes = audioInputStream.skip(bytesToSkip);
327                                        bytesToSkip -= skippedBytes;
328                                        if (skippedBytes == 0) {
329                                            // Avoid inifinite loop
330                                            Main.warn("Unable to skip bytes from audio input stream");
331                                            bytesToSkip = 0;
332                                        }
333                                    }
334                                    position = offset;
335                                }
336                                if (audioOutputLine != null) {
337                                    audioOutputLine.close();
338                                }
339                                audioFormat = new AudioFormat(audioFormat.getEncoding(),
340                                        audioFormat.getSampleRate() * (float) (speed * calibration),
341                                        audioFormat.getSampleSizeInBits(),
342                                        audioFormat.getChannels(),
343                                        audioFormat.getFrameSize(),
344                                        audioFormat.getFrameRate() * (float) (speed * calibration),
345                                        audioFormat.isBigEndian());
346                                DataLine.Info info = new DataLine.Info(SourceDataLine.class, audioFormat);
347                                audioOutputLine = (SourceDataLine) AudioSystem.getLine(info);
348                                audioOutputLine.open(audioFormat);
349                                audioOutputLine.start();
350                            }
351                            stateChange = State.PLAYING;
352                            break;
353                        case PAUSE:
354                            stateChange = State.PAUSED;
355                            break;
356                    }
357                    command.ok(stateChange);
358                } catch (LineUnavailableException | IOException | UnsupportedAudioFileException |
359                        SecurityException | IllegalArgumentException startPlayingException) {
360                    Main.error(startPlayingException);
361                    command.failed(startPlayingException); // sets state
362                }
363            } catch (Exception e) {
364                state = State.NOTPLAYING;
365            }
366        }
367    }
368
369    /**
370     * Shows a popup audio error message for the given exception.
371     * @param ex The exception used as error reason. Cannot be {@code null}.
372     */
373    public static void audioMalfunction(Exception ex) {
374        String msg = ex.getMessage();
375        if (msg == null)
376            msg = tr("unspecified reason");
377        else
378            msg = tr(msg);
379        JOptionPane.showMessageDialog(Main.parent,
380                "<html><p>" + msg + "</p></html>",
381                tr("Error playing sound"), JOptionPane.ERROR_MESSAGE);
382    }
383}