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