package net.sf.fmj.media.renderer.audio;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.media.Buffer;
import javax.media.Codec;
import javax.media.Format;
import javax.media.Owned;
import javax.media.Renderer;
import javax.media.ResourceUnavailableException;
import javax.media.control.BufferControl;
import javax.media.control.FrameProcessingControl;
import javax.media.format.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.BooleanControl;
import javax.sound.sampled.CompoundControl;
import javax.sound.sampled.Control;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.SourceDataLine;
import javax.sound.sampled.Control.Type;
import net.sf.fmj.media.AudioFormatCompleter;
import net.sf.fmj.utility.ControlCollection;
import net.sf.fmj.utility.LoggerSingleton;
import net.sf.fmj.media.AbstractGainControl;
/**
* Audio Renderer which uses JavaSound.
*
* @author Warren Bloomer
* @author Ken Larson
*
*/
public class JavaSoundRenderer implements Renderer {
private static final Logger logger = LoggerSingleton.logger;
private String name = "FMJ Audio Renderer";
// output buffer in bytes
private int buflen;
// output buffer length in milliseconds
private long buflenMS = -1;
private Boolean bufferSizeChanged = new Boolean(false);
/** the DataLine to write audio data to. */
private SourceDataLine sourceLine;
/** javax.media version of audio format*/
private AudioFormat inputFormat;
private javax.sound.sampled.AudioFormat audioFormat;
/** javax.sound version of audio format */
private javax.sound.sampled.AudioFormat sampledFormat;
/** set of controls */
private final ControlCollection controls = new ControlCollection();
// To support ULAW, we use a codec which can convert from ULAW to LINEAR.
// JMF's renderer can do this, although it may be overkill to use a codec.
// TODO: support ULAW directly by simply converting the samples.
// Same for ALAW.
private Codec codec; // in case we need to do any conversions
private final Buffer codecBuffer = new Buffer();
private long lastSequenceNumber = -1;
private int framesDropped = 0;
private PeakVolumeMeter levelControl;
public JavaSoundRenderer()
{
// is disabled by default, should be enabled (unmuted) if needed
levelControl = new PeakVolumeMeter();
levelControl.setMute(true);
}
/* ----------------------- Renderer interface ------------------------- */
/**
* Returns the name of the pluging.
*/
public String getName() {
return name;
}
private Format[] supportedInputFormats = new Format[] {
new AudioFormat(AudioFormat.LINEAR, -1.0, -1, -1, -1, -1, -1, -1.0, Format.byteArray),
new AudioFormat(AudioFormat.ULAW, -1.0, -1, -1, -1, -1, -1, -1.0, Format.byteArray), // TODO: our codec doesn't support all ULAW input formats.
new AudioFormat(AudioFormat.ALAW, -1.0, -1, -1, -1, -1, -1, -1.0, Format.byteArray), // TODO: our codec doesn't support all ALAW input formats.
};
/**
* Set supported input formats for the default or selected Mixer.
* Perhaps just list generic LINEAR, ALAW and ULAW. At the moment, we are
* returning all the formats handled by the current default mixer.
*/
public Format[] getSupportedInputFormats()
{
// mgodehardt: JavaSound Renderer has multiple output devices, its ok to not return all details
return supportedInputFormats; // JMF doesn't return all the details.
}
public Format setInputFormat(Format format) {
logger.info("JavaSoundRenderer setting input format to: " + format);
if ( !(format instanceof AudioFormat) ) {
return null;
}
this.inputFormat = (AudioFormat) format;
return inputFormat;
}
public Object getControl(String controlType) {
return controls.getControl(controlType);
}
public Object[] getControls() {
return controls.getControls();
}
/**
* Open the plugin. Must be called after the formats have been determined
* and before "process" is called.
*
* Open the DataLine.
*/
public void open() throws ResourceUnavailableException {
audioFormat = JavaSoundUtils.convertFormat(inputFormat);
logger.info("JavaSoundRenderer opening with javax.sound format: " + audioFormat);
try {
if (!inputFormat.getEncoding().equals(AudioFormat.LINEAR))
{
logger.info("JavaSoundRenderer: Audio format is not linear, creating conversion");
if (inputFormat.getEncoding().equals(AudioFormat.ULAW))
codec = new net.sf.fmj.media.codec.audio.ulaw.Decoder(); // much more efficient than JavaSoundCodec
else if (inputFormat.getEncoding().equals(AudioFormat.ALAW))
codec = new net.sf.fmj.media.codec.audio.alaw.Decoder(); // much more efficient than JavaSoundCodec
else
throw new ResourceUnavailableException("Unsupported input format encoding: " + inputFormat.getEncoding());
if (codec.setInputFormat(inputFormat) == null)
throw new ResourceUnavailableException("Codec rejected input format: " + inputFormat);
final Format[] outputFormats = codec.getSupportedOutputFormats(inputFormat);
if (outputFormats.length < 1)
throw new ResourceUnavailableException("Unable to get an output format for input format: " + inputFormat);
final AudioFormat codecOutputFormat = AudioFormatCompleter.complete((AudioFormat) outputFormats[0]); // TODO: choose the best quality one.
// specify any unspecified parameters:
if (codec.setOutputFormat(codecOutputFormat) == null)
throw new ResourceUnavailableException("Codec rejected output format: " + codecOutputFormat);
audioFormat = JavaSoundUtils.convertFormat(codecOutputFormat);
codec.open();
logger.info("JavaSoundRenderer: Audio format is not linear, created conversion from " + inputFormat + " to " + codecOutputFormat);
}
// mgodehardt: we must use this, this will get a working mixer for windows, linux and mac
DataLine.Info info = new DataLine.Info(SourceDataLine.class, audioFormat);
sourceLine = (SourceDataLine)AudioSystem.getLine(info);
logger.info("JavaSoundRenderer: sourceLine=" + sourceLine);
sourceLine.open(audioFormat);
logger.info("JavaSoundRenderer: buflen=" + sourceLine.getBufferSize());
// fetch gain control
FloatControl gainFloatControl = null;
try
{
gainFloatControl = (FloatControl)sourceLine.getControl(FloatControl.Type.MASTER_GAIN);
}
catch (Exception e)
{
logger.log(Level.WARNING, "" + e, e);
}
logger.fine("JavaSoundRenderer: gainFloatControl=" + gainFloatControl);
// fecth mute control
BooleanControl muteBooleanControl = null;
try
{
muteBooleanControl = (BooleanControl)sourceLine.getControl(BooleanControl.Type.MUTE);
}
catch (Exception e)
{
logger.log(Level.WARNING, "" + e, e);
}
logger.fine("JavaSoundRenderer: muteBooleanControl=" + muteBooleanControl);
JavaSoundGainControl gainControl = new JavaSoundGainControl(gainFloatControl, muteBooleanControl);
controls.addControl(gainControl);
controls.addControl(new JavaSoundRendererBufferControl());
controls.addControl(new FPC());
controls.addControl(levelControl);
///logControls(sourceLine.getControls());
}
catch (LineUnavailableException e) {
throw new ResourceUnavailableException(e.getMessage());
}
}
/**
* Free the data line.
*/
public void close() {
logger.info("JavaSoundRenderer closing...");
controls.clear();
if (codec != null)
{ codec.close();
codec = null;
}
sourceLine.close();
sourceLine = null;
}
/**
* Reset the state of the plugin.
* The reset method is typically called if the end of media is reached or the media is repositioned.
*/
public void reset() {
logger.info("JavaSoundRenderer resetting...");
}
/**
* Start the rendering process
*/
public void start() {
logger.info("JavaSoundRenderer starting...");
sourceLine.start();
}
/**
* Stop the rendering process.
*/
public void stop() {
logger.info("JavaSoundRenderer stopping...");
sourceLine.stop();
}
// the problem with not blocking is that we can get choppy audio. This would be
// solved theoretically by having the filter graph infrastructure pre-buffer some
// data. The other problem with non-blocking is that the filter graph has to
// repeatedly call process, and it has no idea when it can call again and have some
// input consumed. This is, I think, kind of a rough spot in the JMF architecture.
// the filter graph could sleep, but how long should it sleep?
// the problem with blocking, is that (if we allow it, which we don't) stop will interrupt any write to sourceLine,
// and basically, data will be lost. This will result in a gap in the audio upon
// start. If we don't interrupt with a stop, then the track can only fully stop after process
// has written all of the data.
private static final boolean NON_BLOCKING = false;
/**
* Write the buffer to the SourceDataLine.
*/
public int process(Buffer buffer)
{
// if we need to convert the format, do so using the codec.
if (codec != null)
{ final int codecResult = codec.process(buffer, codecBuffer);
if (codecResult == BUFFER_PROCESSED_FAILED)
return BUFFER_PROCESSED_FAILED;
if (codecResult == OUTPUT_BUFFER_NOT_FILLED)
return BUFFER_PROCESSED_OK;
codecBuffer.setTimeStamp(buffer.getTimeStamp());
codecBuffer.setFlags(buffer.getFlags());
codecBuffer.setSequenceNumber(buffer.getSequenceNumber());
buffer = codecBuffer;
}
levelControl.processData(buffer);
int length = buffer.getLength();
int offset = buffer.getOffset();
final Format format = buffer.getFormat();
final Class type = format.getDataType();
if (type != Format.byteArray) {
return BUFFER_PROCESSED_FAILED;
}
final byte[] data = (byte[]) buffer.getData();
final boolean bufferNotConsumed;
final int newBufferLength; // only applicable if bufferNotConsumed
final int newBufferOffset; // only applicable if bufferNotConsumed
// Buffer size changed
try
{
synchronized ( bufferSizeChanged )
{
if ( bufferSizeChanged.booleanValue() )
{
bufferSizeChanged = Boolean.FALSE;
sourceLine.stop();
sourceLine.flush();
sourceLine.close();
buflen = (int)((audioFormat.getFrameSize() * audioFormat.getSampleRate() * buflenMS) / 1000);
sourceLine.open(audioFormat, buflen);
logger.info("JavaSoundRenderer: buflen=" + sourceLine.getBufferSize());
sourceLine.start();
}
}
}
catch ( Exception ex )
{
logger.log(Level.WARNING, "" + ex, ex);
}
if (NON_BLOCKING)
{
// TODO: handle sourceLine.available(). This code currently causes choppy audio.
if (length > sourceLine.available())
{
// we should only write sourceLine.available() bytes, then return INPUT_BUFFER_NOT_CONSUMED.
length = sourceLine.available(); // don't try to write more than available
bufferNotConsumed = true;
newBufferLength = buffer.getLength() - length;
newBufferOffset = buffer.getOffset() + length;
}
else
{ bufferNotConsumed = false;
newBufferLength = length;
newBufferOffset = offset;
}
}
else
{ bufferNotConsumed = false;
newBufferLength = 0;
newBufferOffset = 0;
}
if (length == 0)
{
logger.finer("Buffer has zero length, flags = " + buffer.getFlags());
}
if ( -1 == lastSequenceNumber )
{
lastSequenceNumber = buffer.getSequenceNumber();
}
else
{
if ( (short)(lastSequenceNumber + 1) != (short)buffer.getSequenceNumber() )
{
int count = (((short)buffer.getSequenceNumber() - (short)lastSequenceNumber) & 0xffff) - 1;
///System.out.println("### PACKET LOST " + lastSequenceNumber + " " + buffer.getSequenceNumber() + " lost=" + count);
framesDropped += count;
}
lastSequenceNumber = buffer.getSequenceNumber();
}
// make sure all the bytes are written.
while (length > 0)
{
//logger.fine("Available: " + sourceLine.available());
//logger.fine("length: " + length);
//logger.fine("sourceLine.getBufferSize(): " + sourceLine.getBufferSize());
final int n = sourceLine.write(data, offset, length);
Thread.yield();
if (n >= length)
break;
else if (n == 0)
{
// TODO: we could choose to handle a write failure this way,
// assuming that it is considered legal to call stop while process is being called.
// however, that seems like a bad idea in general.
// if (!sourceLine.isRunning())
// {
// buffer.setLength(offset);
// buffer.setOffset(length);
// return INPUT_BUFFER_NOT_CONSUMED; // our write was interrupted.
// }
logger.warning("sourceLine.write returned 0, offset=" + offset + "; length=" + length + "; available=" + sourceLine.available() + "; frame size in bytes" + sourceLine.getFormat().getFrameSize() + "; sourceLine.isActive() = " + sourceLine.isActive() + "; " + sourceLine.isOpen() + "; sourceLine.isRunning()=" + sourceLine.isRunning() );
return BUFFER_PROCESSED_FAILED; // sourceLine.write docs indicate that this will only happen if there is an error.
}
else
{ offset += n;
length -= n;
}
}
if (bufferNotConsumed)
{
// return INPUT_BUFFER_NOT_CONSUMED if not all bytes were written
buffer.setLength(newBufferLength);
buffer.setOffset(newBufferOffset);
return INPUT_BUFFER_NOT_CONSUMED;
}
if (buffer.isEOM())
{
// TODO: the proper way to do this is to implement Drainable, and let the processor call our drain method.
sourceLine.drain(); // we need to ensure that the media finishes playing, otherwise the EOM event will
// be posted before the media finishes playing.
}
return BUFFER_PROCESSED_OK;
}
// dbFS using peak level
private class PeakVolumeMeter extends AbstractGainControl
{
float peakLevel = 0.0f;
public void processData(Buffer buf)
{
if ( getMute() || buf.isDiscard() || (buf.getLength() <= 0) )
{
return;
}
AudioFormat af = (AudioFormat) buf.getFormat();
byte [] data = (byte[]) buf.getData();
if ( af.getEncoding().equalsIgnoreCase("LINEAR") )
{
if ( af.getSampleSizeInBits() == 16 )
{
int msb = 0;
int lsb = 1;
if ( af.getEndian() == AudioFormat.LITTLE_ENDIAN )
{
msb = 1;
lsb = 0;
}
if ( af.getSigned() == AudioFormat.SIGNED )
{
int peak = 0;
int samples = data.length / 2;
for (int i=0; i<samples; i++)
{
int value = (data[(i*2) + msb] << 8) + (data[(i*2) + lsb] & 0xff);
if ( value < 0 )
{
value = -value;
}
if ( value > peak )
{
peak = value;
}
}
peakLevel = (float)peak / 32768.0f;
}
}
}
}
public float setLevel(float level)
{
float result = getLevel();
return result;
}
public float getLevel()
{
return peakLevel;
}
}
private class JavaSoundRendererBufferControl implements BufferControl, Owned
{
public Object getOwner()
{
return JavaSoundRenderer.this;
}
public long getBufferLength()
{
return buflenMS;
}
public long setBufferLength(long time)
{
buflenMS = time;
synchronized ( bufferSizeChanged )
{
bufferSizeChanged = Boolean.TRUE;
}
return buflenMS;
}
public long getMinimumThreshold()
{
return -1;
}
public long setMinimumThreshold(long time)
{
return -1;
}
public void setEnabledThreshold(boolean b)
{
}
public boolean getEnabledThreshold()
{
return false;
}
public java.awt.Component getControlComponent()
{
return null;
}
}
private class FPC implements FrameProcessingControl, Owned
{
public Object getOwner()
{
return JavaSoundRenderer.this;
}
public int getFramesDropped()
{
return framesDropped;
}
public void setFramesBehind(float numFrames)
{
}
public boolean setMinimalProcessing(boolean newMinimalProcessing)
{
return false;
}
public java.awt.Component getControlComponent()
{
return null;
}
}
/* ----------------------------- */
@Override
public int hashCode() {
return super.hashCode(); // TODO: trying to change this hash code change is useless.
// TODO: for putting entries into the plugin manager, PlugInManager.addPlugIn appears to
// create a hash only based on the full class name, and only the last 22 chars of it.
// that is, ClassNameInfo.makeHashValue("com.sun.media.renderer.audio.JavaSoundRenderer")
// and ClassNameInfo.makeHashValue("net.sf.fmj.media.renderer.audio.JavaSoundRenderer")
// both return the same value, as does
// ClassNameInfo.makeHashValue("udio.JavaSoundRenderer")
// therefore, this trick of creating a different hash code for this class, does nothing to avoid the
// warnings, when JMF is ahead in the classpath:
// Problem adding net.sf.fmj.media.renderer.audio.JavaSoundRenderer to plugin table.
// Already hash value of 1262232571547748861 in plugin table for class name of com.sun.media.renderer.audio.JavaSoundRenderer
}
/* -------------------- private methods ----------------------- */
private void logControls(Control[] controls) {
for (int i=0; i<controls.length; i++) {
Control control = controls[i];
logger.fine("control: " + control);
Type controlType = control.getType();
if (controlType instanceof CompoundControl.Type) {
logControls(((CompoundControl)control).getMemberControls());
}
}
}
}