// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.tools;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.awt.GraphicsEnvironment;
import java.io.IOException;
import java.net.URL;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.UnsupportedAudioFileException;
import javax.swing.JOptionPane;
import org.openstreetmap.josm.Main;
/**
* Creates and controls a separate audio player thread.
*
* @author David Earl <david@frankieandshadow.com>
* @since 547
*/
public final class AudioPlayer extends Thread {
private static volatile AudioPlayer audioPlayer;
private enum State { INITIALIZING, NOTPLAYING, PLAYING, PAUSED, INTERRUPTED }
private enum Command { PLAY, PAUSE }
private enum Result { WAITING, OK, FAILED }
private State state;
private URL playingUrl;
private final double leadIn; // seconds
private final double calibration; // ratio of purported duration of samples to true duration
private double position; // seconds
private double bytesPerSecond;
private static long chunk = 4000; /* bytes */
private double speed = 1.0;
/**
* Passes information from the control thread to the playing thread
*/
private class Execute {
private Command command;
private Result result;
private Exception exception;
private URL url;
private double offset; // seconds
private double speed; // ratio
/*
* Called to execute the commands in the other thread
*/
protected void play(URL url, double offset, double speed) throws InterruptedException, IOException {
this.url = url;
this.offset = offset;
this.speed = speed;
command = Command.PLAY;
result = Result.WAITING;
send();
}
protected void pause() throws InterruptedException, IOException {
command = Command.PAUSE;
send();
}
private void send() throws InterruptedException, IOException {
result = Result.WAITING;
interrupt();
while (result == Result.WAITING) {
sleep(10);
}
if (result == Result.FAILED)
throw new IOException(exception);
}
private void possiblyInterrupt() throws InterruptedException {
if (interrupted() || result == Result.WAITING)
throw new InterruptedException();
}
protected void failed(Exception e) {
exception = e;
result = Result.FAILED;
state = State.NOTPLAYING;
}
protected void ok(State newState) {
result = Result.OK;
state = newState;
}
protected double offset() {
return offset;
}
protected double speed() {
return speed;
}
protected URL url() {
return url;
}
protected Command command() {
return command;
}
}
private final Execute command;
/**
* Plays a WAV audio file from the beginning. See also the variant which doesn't
* start at the beginning of the stream
* @param url The resource to play, which must be a WAV file or stream
* @throws InterruptedException thread interrupted
* @throws IOException audio fault exception, e.g. can't open stream, unhandleable audio format
*/
public static void play(URL url) throws InterruptedException, IOException {
AudioPlayer instance = AudioPlayer.getInstance();
if (instance != null)
instance.command.play(url, 0.0, 1.0);
}
/**
* Plays a WAV audio file from a specified position.
* @param url The resource to play, which must be a WAV file or stream
* @param seconds The number of seconds into the audio to start playing
* @throws InterruptedException thread interrupted
* @throws IOException audio fault exception, e.g. can't open stream, unhandleable audio format
*/
public static void play(URL url, double seconds) throws InterruptedException, IOException {
AudioPlayer instance = AudioPlayer.getInstance();
if (instance != null)
instance.command.play(url, seconds, 1.0);
}
/**
* Plays a WAV audio file from a specified position at variable speed.
* @param url The resource to play, which must be a WAV file or stream
* @param seconds The number of seconds into the audio to start playing
* @param speed Rate at which audio playes (1.0 = real time, > 1 is faster)
* @throws InterruptedException thread interrupted
* @throws IOException audio fault exception, e.g. can't open stream, unhandleable audio format
*/
public static void play(URL url, double seconds, double speed) throws InterruptedException, IOException {
AudioPlayer instance = AudioPlayer.getInstance();
if (instance != null)
instance.command.play(url, seconds, speed);
}
/**
* Pauses the currently playing audio stream. Does nothing if nothing playing.
* @throws InterruptedException thread interrupted
* @throws IOException audio fault exception, e.g. can't open stream, unhandleable audio format
*/
public static void pause() throws InterruptedException, IOException {
AudioPlayer instance = AudioPlayer.getInstance();
if (instance != null)
instance.command.pause();
}
/**
* To get the Url of the playing or recently played audio.
* @return url - could be null
*/
public static URL url() {
AudioPlayer instance = AudioPlayer.getInstance();
return instance == null ? null : instance.playingUrl;
}
/**
* Whether or not we are paused.
* @return boolean whether or not paused
*/
public static boolean paused() {
AudioPlayer instance = AudioPlayer.getInstance();
return instance == null ? false : (instance.state == State.PAUSED);
}
/**
* Whether or not we are playing.
* @return boolean whether or not playing
*/
public static boolean playing() {
AudioPlayer instance = AudioPlayer.getInstance();
return instance == null ? false : (instance.state == State.PLAYING);
}
/**
* How far we are through playing, in seconds.
* @return double seconds
*/
public static double position() {
AudioPlayer instance = AudioPlayer.getInstance();
return instance == null ? -1 : instance.position;
}
/**
* Speed at which we will play.
* @return double, speed multiplier
*/
public static double speed() {
AudioPlayer instance = AudioPlayer.getInstance();
return instance == null ? -1 : instance.speed;
}
/**
* Returns the singleton object, and if this is the first time, creates it along with
* the thread to support audio
* @return the unique instance
*/
private static AudioPlayer getInstance() {
if (audioPlayer != null)
return audioPlayer;
try {
audioPlayer = new AudioPlayer();
return audioPlayer;
} catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException ex) {
Main.error(ex);
return null;
}
}
/**
* Resets the audio player.
*/
public static void reset() {
if (audioPlayer != null) {
try {
pause();
} catch (InterruptedException | IOException e) {
Main.warn(e);
}
audioPlayer.playingUrl = null;
}
}
private AudioPlayer() {
state = State.INITIALIZING;
command = new Execute();
playingUrl = null;
leadIn = Main.pref.getDouble("audio.leadin", 1.0 /* default, seconds */);
calibration = Main.pref.getDouble("audio.calibration", 1.0 /* default, ratio */);
start();
while (state == State.INITIALIZING) {
yield();
}
}
/**
* Starts the thread to actually play the audio, per Thread interface
* Not to be used as public, though Thread interface doesn't allow it to be made private
*/
@Override public void run() {
/* code running in separate thread */
playingUrl = null;
AudioInputStream audioInputStream = null;
SourceDataLine audioOutputLine = null;
AudioFormat audioFormat;
byte[] abData = new byte[(int) chunk];
for (;;) {
try {
switch (state) {
case INITIALIZING:
// we're ready to take interrupts
state = State.NOTPLAYING;
break;
case NOTPLAYING:
case PAUSED:
sleep(200);
break;
case PLAYING:
command.possiblyInterrupt();
for (;;) {
int nBytesRead = 0;
if (audioInputStream != null) {
nBytesRead = audioInputStream.read(abData, 0, abData.length);
position += nBytesRead / bytesPerSecond;
}
command.possiblyInterrupt();
if (nBytesRead < 0 || audioInputStream == null || audioOutputLine == null) {
break;
}
audioOutputLine.write(abData, 0, nBytesRead); // => int nBytesWritten
command.possiblyInterrupt();
}
// end of audio, clean up
if (audioOutputLine != null) {
audioOutputLine.drain();
audioOutputLine.close();
}
audioOutputLine = null;
Utils.close(audioInputStream);
audioInputStream = null;
playingUrl = null;
state = State.NOTPLAYING;
command.possiblyInterrupt();
break;
default: // Do nothing
}
} catch (InterruptedException e) {
interrupted(); // just in case we get an interrupt
State stateChange = state;
state = State.INTERRUPTED;
try {
switch (command.command()) {
case PLAY:
double offset = command.offset();
speed = command.speed();
if (playingUrl != command.url() ||
stateChange != State.PAUSED ||
offset != 0) {
if (audioInputStream != null) {
Utils.close(audioInputStream);
}
playingUrl = command.url();
audioInputStream = AudioSystem.getAudioInputStream(playingUrl);
audioFormat = audioInputStream.getFormat();
long nBytesRead;
position = 0.0;
offset -= leadIn;
double calibratedOffset = offset * calibration;
bytesPerSecond = audioFormat.getFrameRate() /* frames per second */
* audioFormat.getFrameSize() /* bytes per frame */;
if (speed * bytesPerSecond > 256_000.0) {
speed = 256_000 / bytesPerSecond;
}
if (calibratedOffset > 0.0) {
long bytesToSkip = (long) (calibratedOffset /* seconds (double) */ * bytesPerSecond);
// skip doesn't seem to want to skip big chunks, so reduce it to smaller ones
while (bytesToSkip > chunk) {
nBytesRead = audioInputStream.skip(chunk);
if (nBytesRead <= 0)
throw new IOException(tr("This is after the end of the recording"));
bytesToSkip -= nBytesRead;
}
while (bytesToSkip > 0) {
long skippedBytes = audioInputStream.skip(bytesToSkip);
bytesToSkip -= skippedBytes;
if (skippedBytes == 0) {
// Avoid inifinite loop
Main.warn("Unable to skip bytes from audio input stream");
bytesToSkip = 0;
}
}
position = offset;
}
if (audioOutputLine != null) {
audioOutputLine.close();
}
audioFormat = new AudioFormat(audioFormat.getEncoding(),
audioFormat.getSampleRate() * (float) (speed * calibration),
audioFormat.getSampleSizeInBits(),
audioFormat.getChannels(),
audioFormat.getFrameSize(),
audioFormat.getFrameRate() * (float) (speed * calibration),
audioFormat.isBigEndian());
DataLine.Info info = new DataLine.Info(SourceDataLine.class, audioFormat);
audioOutputLine = (SourceDataLine) AudioSystem.getLine(info);
audioOutputLine.open(audioFormat);
audioOutputLine.start();
}
stateChange = State.PLAYING;
break;
case PAUSE:
stateChange = State.PAUSED;
break;
default: // Do nothing
}
command.ok(stateChange);
} catch (LineUnavailableException | IOException | UnsupportedAudioFileException |
SecurityException | IllegalArgumentException startPlayingException) {
Main.error(startPlayingException);
command.failed(startPlayingException); // sets state
}
} catch (IOException e) {
state = State.NOTPLAYING;
Main.error(e);
}
}
}
/**
* Shows a popup audio error message for the given exception.
* @param ex The exception used as error reason. Cannot be {@code null}.
*/
public static void audioMalfunction(Exception ex) {
String msg = ex.getMessage();
if (msg == null)
msg = tr("unspecified reason");
else
msg = tr(msg);
Main.error(msg);
if (!GraphicsEnvironment.isHeadless()) {
JOptionPane.showMessageDialog(Main.parent,
"<html><p>" + msg + "</p></html>",
tr("Error playing sound"), JOptionPane.ERROR_MESSAGE);
}
}
}