001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.audio;
003
004import java.io.IOException;
005import java.net.URL;
006import java.util.Objects;
007
008import org.openstreetmap.josm.spi.preferences.Config;
009import org.openstreetmap.josm.tools.JosmRuntimeException;
010import org.openstreetmap.josm.tools.Logging;
011
012/**
013 * Creates and controls a separate audio player thread.
014 *
015 * @author David Earl <david@frankieandshadow.com>
016 * @since 12326 (move to new package)
017 * @since 547
018 */
019public final class AudioPlayer extends Thread implements AudioListener {
020
021    private static volatile AudioPlayer audioPlayer;
022
023    /**
024     * Audio player state.
025     */
026    public enum State {
027        /** Initializing */
028        INITIALIZING,
029        /** Not playing */
030        NOTPLAYING,
031        /** Playing */
032        PLAYING,
033        /** Paused */
034        PAUSED,
035        /** Interrupted */
036        INTERRUPTED
037    }
038
039    /**
040     * Audio player command.
041     */
042    public enum Command { /** Audio play */ PLAY, /** Audio pause */ PAUSE }
043
044    /**
045     * Audio player result.
046     */
047    public enum Result { /** In progress */ WAITING, /** Success */ OK, /** Failure */ FAILED }
048
049    private State state;
050    private static volatile Class<? extends SoundPlayer> soundPlayerClass;
051    private SoundPlayer soundPlayer;
052    private URL playingUrl;
053
054    /**
055     * Passes information from the control thread to the playing thread
056     */
057    public class Execute {
058        private Command command;
059        private Result result;
060        private Exception exception;
061        private URL url;
062        private double offset; // seconds
063        private double speed; // ratio
064
065        /*
066         * Called to execute the commands in the other thread
067         */
068        protected void play(URL url, double offset, double speed) throws InterruptedException, IOException {
069            this.url = url;
070            this.offset = offset;
071            this.speed = speed;
072            command = Command.PLAY;
073            result = Result.WAITING;
074            send();
075        }
076
077        protected void pause() throws InterruptedException, IOException {
078            command = Command.PAUSE;
079            send();
080        }
081
082        private void send() throws InterruptedException, IOException {
083            result = Result.WAITING;
084            interrupt();
085            while (result == Result.WAITING) {
086                sleep(10);
087            }
088            if (result == Result.FAILED)
089                throw new IOException(exception);
090        }
091
092        protected void possiblyInterrupt() throws InterruptedException {
093            if (interrupted() || result == Result.WAITING)
094                throw new InterruptedException();
095        }
096
097        protected void failed(Exception e) {
098            exception = e;
099            result = Result.FAILED;
100            state = State.NOTPLAYING;
101        }
102
103        protected void ok(State newState) {
104            result = Result.OK;
105            state = newState;
106        }
107
108        /**
109         * Returns the offset.
110         * @return the offset, in seconds
111         */
112        public double offset() {
113            return offset;
114        }
115
116        /**
117         * Returns the speed.
118         * @return the speed (ratio)
119         */
120        public double speed() {
121            return speed;
122        }
123
124        /**
125         * Returns the URL.
126         * @return The resource to play, which must be a WAV file or stream
127         */
128        public URL url() {
129            return url;
130        }
131
132        /**
133         * Returns the command.
134         * @return the command
135         */
136        public Command command() {
137            return command;
138        }
139    }
140
141    private final Execute command;
142
143    /**
144     * Plays a WAV audio file from the beginning. See also the variant which doesn't
145     * start at the beginning of the stream
146     * @param url The resource to play, which must be a WAV file or stream
147     * @throws InterruptedException thread interrupted
148     * @throws IOException audio fault exception, e.g. can't open stream, unhandleable audio format
149     */
150    public static void play(URL url) throws InterruptedException, IOException {
151        AudioPlayer instance = AudioPlayer.getInstance();
152        if (instance != null)
153            instance.command.play(url, 0.0, 1.0);
154    }
155
156    /**
157     * Plays a WAV audio file from a specified position.
158     * @param url The resource to play, which must be a WAV file or stream
159     * @param seconds The number of seconds into the audio to start playing
160     * @throws InterruptedException thread interrupted
161     * @throws IOException audio fault exception, e.g. can't open stream, unhandleable audio format
162     */
163    public static void play(URL url, double seconds) throws InterruptedException, IOException {
164        AudioPlayer instance = AudioPlayer.getInstance();
165        if (instance != null)
166            instance.command.play(url, seconds, 1.0);
167    }
168
169    /**
170     * Plays a WAV audio file from a specified position at variable speed.
171     * @param url The resource to play, which must be a WAV file or stream
172     * @param seconds The number of seconds into the audio to start playing
173     * @param speed Rate at which audio playes (1.0 = real time, &gt; 1 is faster)
174     * @throws InterruptedException thread interrupted
175     * @throws IOException audio fault exception, e.g. can't open stream,  unhandleable audio format
176     */
177    public static void play(URL url, double seconds, double speed) throws InterruptedException, IOException {
178        AudioPlayer instance = AudioPlayer.getInstance();
179        if (instance != null)
180            instance.command.play(url, seconds, speed);
181    }
182
183    /**
184     * Pauses the currently playing audio stream. Does nothing if nothing playing.
185     * @throws InterruptedException thread interrupted
186     * @throws IOException audio fault exception, e.g. can't open stream,  unhandleable audio format
187     */
188    public static void pause() throws InterruptedException, IOException {
189        AudioPlayer instance = AudioPlayer.getInstance();
190        if (instance != null)
191            instance.command.pause();
192    }
193
194    /**
195     * To get the Url of the playing or recently played audio.
196     * @return url - could be null
197     */
198    public static URL url() {
199        AudioPlayer instance = AudioPlayer.getInstance();
200        return instance == null ? null : instance.playingUrl;
201    }
202
203    /**
204     * Whether or not we are paused.
205     * @return boolean whether or not paused
206     */
207    public static boolean paused() {
208        AudioPlayer instance = AudioPlayer.getInstance();
209        return instance != null && instance.state == State.PAUSED;
210    }
211
212    /**
213     * Whether or not we are playing.
214     * @return boolean whether or not playing
215     */
216    public static boolean playing() {
217        AudioPlayer instance = AudioPlayer.getInstance();
218        return instance != null && instance.state == State.PLAYING;
219    }
220
221    /**
222     * How far we are through playing, in seconds.
223     * @return double seconds
224     */
225    public static double position() {
226        AudioPlayer instance = AudioPlayer.getInstance();
227        return instance == null ? -1 : instance.soundPlayer.position();
228    }
229
230    /**
231     * Speed at which we will play.
232     * @return double, speed multiplier
233     */
234    public static double speed() {
235        AudioPlayer instance = AudioPlayer.getInstance();
236        return instance == null ? -1 : instance.soundPlayer.speed();
237    }
238
239    /**
240     * Returns the singleton object, and if this is the first time, creates it along with
241     * the thread to support audio
242     * @return the unique instance
243     */
244    private static AudioPlayer getInstance() {
245        if (audioPlayer != null)
246            return audioPlayer;
247        try {
248            audioPlayer = new AudioPlayer();
249            return audioPlayer;
250        } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException ex) {
251            Logging.error(ex);
252            return null;
253        }
254    }
255
256    /**
257     * Resets the audio player.
258     */
259    public static void reset() {
260        if (audioPlayer != null) {
261            try {
262                pause();
263            } catch (InterruptedException | IOException e) {
264                Logging.warn(e);
265            }
266            audioPlayer.playingUrl = null;
267        }
268    }
269
270    @SuppressWarnings("unchecked")
271    private AudioPlayer() {
272        state = State.INITIALIZING;
273        command = new Execute();
274        playingUrl = null;
275        double leadIn = Config.getPref().getDouble("audio.leadin", 1.0 /* default, seconds */);
276        double calibration = Config.getPref().getDouble("audio.calibration", 1.0 /* default, ratio */);
277        try {
278            if (soundPlayerClass == null) {
279                // To remove when switching to Java 11
280                soundPlayerClass = (Class<? extends SoundPlayer>) Class.forName(
281                        "org.openstreetmap.josm.io.audio.fx.JavaFxMediaPlayer");
282            }
283            soundPlayer = soundPlayerClass.getDeclaredConstructor().newInstance();
284        } catch (ReflectiveOperationException | IllegalArgumentException | SecurityException e) {
285            Logging.debug(e);
286            Logging.warn("JOSM compiled without Java FX support. Falling back to Java Sound API");
287        } catch (NoClassDefFoundError | JosmRuntimeException e) {
288            Logging.debug(e);
289            Logging.warn("Java FX is unavailable. Falling back to Java Sound API");
290        }
291        if (soundPlayer == null) {
292            soundPlayer = new JavaSoundPlayer(leadIn, calibration);
293        }
294        soundPlayer.addAudioListener(this);
295        start();
296        while (state == State.INITIALIZING) {
297            Thread.yield();
298        }
299    }
300
301    /**
302     * Starts the thread to actually play the audio, per Thread interface
303     * Not to be used as public, though Thread interface doesn't allow it to be made private
304     */
305    @Override
306    public void run() {
307        /* code running in separate thread */
308
309        playingUrl = null;
310
311        for (;;) {
312            try {
313                switch (state) {
314                    case INITIALIZING:
315                        // we're ready to take interrupts
316                        state = State.NOTPLAYING;
317                        break;
318                    case NOTPLAYING:
319                    case PAUSED:
320                        sleep(200);
321                        break;
322                    case PLAYING:
323                        command.possiblyInterrupt();
324                        if (soundPlayer.playing(command)) {
325                            playingUrl = null;
326                            state = State.NOTPLAYING;
327                        }
328                        command.possiblyInterrupt();
329                        break;
330                    default: // Do nothing
331                }
332            } catch (InterruptedException e) {
333                interrupted(); // just in case we get an interrupt
334                State stateChange = state;
335                state = State.INTERRUPTED;
336                try {
337                    switch (command.command()) {
338                        case PLAY:
339                            soundPlayer.play(command, stateChange, playingUrl);
340                            stateChange = State.PLAYING;
341                            break;
342                        case PAUSE:
343                            soundPlayer.pause(command, stateChange, playingUrl);
344                            stateChange = State.PAUSED;
345                            break;
346                        default: // Do nothing
347                    }
348                    command.ok(stateChange);
349                } catch (AudioException | IOException | SecurityException | IllegalArgumentException startPlayingException) {
350                    Logging.error(startPlayingException);
351                    command.failed(startPlayingException); // sets state
352                }
353            } catch (AudioException | IOException e) {
354                state = State.NOTPLAYING;
355                Logging.error(e);
356            }
357        }
358    }
359
360    @Override
361    public void playing(URL playingUrl) {
362        this.playingUrl = playingUrl;
363    }
364
365    /**
366     * Returns the custom sound player class, if any.
367     * @return the custom sound player class, or {@code null}
368     * @since 14183
369     */
370    public static Class<? extends SoundPlayer> getSoundPlayerClass() {
371        return soundPlayerClass;
372    }
373
374    /**
375     * Sets the custom sound player class to override default core player.
376     * Must be called before the first audio method invocation.
377     * @param playerClass custom sound player class to override default core player
378     * @since 14183
379     */
380    public static void setSoundPlayerClass(Class<? extends SoundPlayer> playerClass) {
381        if (audioPlayer != null) {
382            throw new IllegalStateException("Audio player already initialized");
383        }
384        soundPlayerClass = Objects.requireNonNull(playerClass);
385    }
386}