mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 08:37:25 -05:00
#2356 Audio playback redesign. Resolves audio playback issue on some (e.g. Kubuntu) systems. For stereo playback, (#2359)
consolidates left and right channels into a single playback channel. Implements continuous audio streaming using silence to fill the gaps to avoid mixer source data line start/stop issues. Code structure is ready to layer on multi-channel (ie 5.1 or 7.1) audio playback devices, but that hasn't been tested. Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2025 Dennis Sheirer
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -18,22 +18,14 @@
|
||||
*/
|
||||
package io.github.dsheirer.audio.playback;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.audio.AudioEvent;
|
||||
import io.github.dsheirer.audio.AudioSegment;
|
||||
import io.github.dsheirer.controller.NamingThreadFactory;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||
import io.github.dsheirer.identifier.IdentifierUpdateNotification;
|
||||
import io.github.dsheirer.log.LoggingSuppressor;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.mixer.MixerChannel;
|
||||
import io.github.dsheirer.preference.playback.PlayTestAudioRequest;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.LinkedTransferQueue;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -41,11 +33,10 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.sound.sampled.AudioFormat;
|
||||
import javax.sound.sampled.BooleanControl;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import javax.sound.sampled.Control;
|
||||
import javax.sound.sampled.DataLine;
|
||||
import javax.sound.sampled.FloatControl;
|
||||
import javax.sound.sampled.Line;
|
||||
import javax.sound.sampled.LineEvent;
|
||||
import javax.sound.sampled.LineListener;
|
||||
import javax.sound.sampled.LineUnavailableException;
|
||||
@@ -56,418 +47,214 @@ import javax.sound.sampled.SourceDataLine;
|
||||
* Audio output/playback channel for a single audio mixer channel. Providers support for playback of audio segments
|
||||
* and broadcasts audio segment metadata to registered listeners (ie gui components).
|
||||
*/
|
||||
public abstract class AudioOutput implements LineListener, Listener<IdentifierUpdateNotification>
|
||||
public class AudioOutput implements LineListener
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioOutput.class);
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AudioOutput.class);
|
||||
private static final LoggingSuppressor LOGGING_SUPPRESSOR = new LoggingSuppressor(mLog);
|
||||
private int mBufferStartThreshold;
|
||||
private int mBufferStopThreshold;
|
||||
private Listener<IdentifierCollection> mIdentifierCollectionListener;
|
||||
private Broadcaster<AudioEvent> mAudioEventBroadcaster = new Broadcaster<>();
|
||||
private SourceDataLine mOutput;
|
||||
private Mixer mMixer;
|
||||
private MixerChannel mMixerChannel;
|
||||
private static final int BUFFER_SIZE_SAMPLES = 1000; //At 8 kHz audio
|
||||
private final AudioPlaybackDeviceDescriptor mAudioPlaybackDeviceDescriptor;
|
||||
private final AudioProvider mAudioProvider;
|
||||
private FloatControl mGainControl;
|
||||
private BooleanControl mMuteControl;
|
||||
private AudioEvent mAudioStartEvent;
|
||||
private AudioEvent mAudioStopEvent;
|
||||
private boolean mCanProcessAudio = false;
|
||||
private LinkedTransferQueue<AudioSegment> mAudioSegmentQueue = new LinkedTransferQueue<>();
|
||||
private AudioSegment mCurrentAudioSegment;
|
||||
private int mCurrentBufferIndex = 0;
|
||||
private UserPreferences mUserPreferences;
|
||||
private ByteBuffer mAudioSegmentStartTone;
|
||||
private ByteBuffer mAudioSegmentDropTone;
|
||||
private boolean mRunning = false;
|
||||
private ScheduledExecutorService mScheduledExecutorService;
|
||||
private ScheduledFuture<?> mProcessorFuture;
|
||||
private boolean mDropDuplicates;
|
||||
private long mOutputLastTimestamp = 0;
|
||||
private static final long STALE_PLAYBACK_THRESHOLD_MS = 500;
|
||||
private AudioFormat mAudioFormat;
|
||||
private Line.Info mLineInfo;
|
||||
private int mRequestedBufferSize;
|
||||
private SourceDataLine mSourceDataLine;
|
||||
private boolean mCanProcessAudio = false;
|
||||
private boolean mRunning = false;
|
||||
|
||||
|
||||
/**
|
||||
* Single audio channel playback with automatic starting and stopping of the
|
||||
* underlying sourcedataline specified by the mixer and mixer channel
|
||||
* arguments.
|
||||
* Audio output for the selected audio playback device and provider. Opens a SourceDataLine from the miser for the
|
||||
* audio format specified in the descriptor. Employs a single threaded executor to process audio 10x a second and
|
||||
* manages the start/stop control for the dataline based on audio availability.
|
||||
*
|
||||
* Maintains an internal non-blocking audio packet queue and processes this
|
||||
* queue 25 times a second (every 40 ms).
|
||||
*
|
||||
* @param mixer to obtain source data line
|
||||
* @param mixerChannel either mono or left/right stereo
|
||||
* @param audioFormat to use during playback
|
||||
* @param lineInfo to use when obtaining the source data line
|
||||
* @param requestedBufferSize of approximately 1 second of audio
|
||||
* @param descriptor for the mixer and audio format
|
||||
* @param audioProvider for access to audio from audio segments
|
||||
*/
|
||||
public AudioOutput(Mixer mixer, MixerChannel mixerChannel, AudioFormat audioFormat, Line.Info lineInfo,
|
||||
int requestedBufferSize, UserPreferences userPreferences)
|
||||
public AudioOutput(AudioPlaybackDeviceDescriptor descriptor, AudioProvider audioProvider)
|
||||
{
|
||||
mMixer = mixer;
|
||||
mMixerChannel = mixerChannel;
|
||||
mScheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamingThreadFactory(
|
||||
"sdrtrunk audio output " + mixerChannel.name()));
|
||||
mUserPreferences = userPreferences;
|
||||
mDropDuplicates = mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled();
|
||||
mAudioFormat = audioFormat;
|
||||
mLineInfo = lineInfo;
|
||||
mRequestedBufferSize = requestedBufferSize;
|
||||
mAudioProvider = audioProvider;
|
||||
Mixer mixer = AudioSystem.getMixer(descriptor.getMixerInfo());
|
||||
|
||||
try
|
||||
if(mixer == null)
|
||||
{
|
||||
mOutput = (SourceDataLine) mMixer.getLine(mLineInfo);
|
||||
List<AudioPlaybackDeviceDescriptor> descriptors = AudioPlaybackDeviceManager.getAudioPlaybackDevices();
|
||||
|
||||
if(mOutput != null)
|
||||
int selected = 0;
|
||||
|
||||
while((mixer == null) && (selected < descriptors.size()))
|
||||
{
|
||||
mOutput.open(audioFormat, requestedBufferSize);
|
||||
descriptor = descriptors.get(selected++);
|
||||
mixer = AudioSystem.getMixer(descriptor.getMixerInfo());
|
||||
}
|
||||
}
|
||||
|
||||
//Start threshold: buffer is full with 10% or less of capacity remaining
|
||||
mBufferStartThreshold = (int) (mOutput.getBufferSize() * 0.10);
|
||||
mAudioPlaybackDeviceDescriptor = descriptor;
|
||||
|
||||
//Stop threshold: buffer is empty with 90% or more capacity available
|
||||
mBufferStopThreshold = (int) (mOutput.getBufferSize() * 0.90);
|
||||
if(mixer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
mSourceDataLine = (SourceDataLine) mixer.getLine(new DataLine.Info(SourceDataLine.class,
|
||||
descriptor.getAudioFormat()));
|
||||
|
||||
mOutput.addLineListener(this);
|
||||
|
||||
if(mOutput != null)
|
||||
if(mSourceDataLine != null)
|
||||
{
|
||||
mSourceDataLine.addLineListener(this);
|
||||
try
|
||||
{
|
||||
Control gain = mOutput.getControl(FloatControl.Type.MASTER_GAIN);
|
||||
Control gain = mSourceDataLine.getControl(FloatControl.Type.MASTER_GAIN);
|
||||
mGainControl = (FloatControl) gain;
|
||||
}
|
||||
catch(IllegalArgumentException iae)
|
||||
{
|
||||
LOGGING_SUPPRESSOR.error("no gain control", 2, "Couldn't obtain " +
|
||||
"MASTER GAIN control for stereo line [" + mixer.getMixerInfo().getName() + " | " +
|
||||
getChannelName() + "]");
|
||||
"MASTER GAIN control for stereo line [" + mixer.getMixerInfo().getName() + "]");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Control mute = mOutput.getControl(BooleanControl.Type.MUTE);
|
||||
mMuteControl = (BooleanControl) mute;
|
||||
}
|
||||
catch(IllegalArgumentException iae)
|
||||
{
|
||||
LOGGING_SUPPRESSOR.error("no mute control", 2, "Couldn't obtain " +
|
||||
"MUTE control for stereo line [" + mixer.getMixerInfo().getName() + " | " +
|
||||
getChannelName() + "]");
|
||||
}
|
||||
|
||||
//Run the queue processor task every 100 milliseconds or 10 times a second
|
||||
mProcessorFuture = mScheduledExecutorService.scheduleAtFixedRate(new AudioSegmentProcessor(),
|
||||
0, 100, TimeUnit.MILLISECONDS);
|
||||
mCanProcessAudio = true;
|
||||
}
|
||||
}
|
||||
catch(LineUnavailableException e)
|
||||
{
|
||||
mLog.error("Couldn't open source data line for mixer: " + descriptor.getMixerInfo().getName(), e);
|
||||
}
|
||||
|
||||
mAudioStartEvent = new AudioEvent(AudioEvent.Type.AUDIO_STARTED, getChannelName());
|
||||
mAudioStopEvent = new AudioEvent(AudioEvent.Type.AUDIO_STOPPED, getChannelName());
|
||||
mCanProcessAudio = true;
|
||||
if(mCanProcessAudio)
|
||||
{
|
||||
openSourceDataLine();
|
||||
|
||||
//The audio provider gives us audio 160 samples per interval representing 20 milliseconds of audio.
|
||||
// Run the scheduled executor at just slightly faster than that time, recognizing that it will
|
||||
// block against the mixer source data line until it can write all 160 samples.
|
||||
mScheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamingThreadFactory(
|
||||
"sdrtrunk audio output " + descriptor.getMixerInfo().getName()));
|
||||
mProcessorFuture = mScheduledExecutorService.scheduleAtFixedRate(new AudioProcessor(),
|
||||
0, 19, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
else
|
||||
{
|
||||
//Disable each of the audio channels so they don't queue audio segments.
|
||||
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
|
||||
{
|
||||
audioChannel.setDisabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and starts the source data line.
|
||||
*/
|
||||
private void openSourceDataLine()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(mSourceDataLine != null && !mSourceDataLine.isOpen())
|
||||
{
|
||||
int bufferSize = BUFFER_SIZE_SAMPLES * 2 * mAudioPlaybackDeviceDescriptor.getAudioFormat().getChannels();
|
||||
mSourceDataLine.open(mAudioProvider.getAudioFormat(), bufferSize);
|
||||
bufferSize = mSourceDataLine.getBufferSize();
|
||||
//Fill the line with silence so we can start it
|
||||
mSourceDataLine.write(new byte[bufferSize], 0, bufferSize);
|
||||
mSourceDataLine.start();
|
||||
|
||||
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
|
||||
{
|
||||
audioChannel.setDisabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(LineUnavailableException e)
|
||||
{
|
||||
mLog.error("Couldn't obtain audio source data line for audio output - mixer [" +
|
||||
mMixer.getMixerInfo().getName() + "]");
|
||||
}
|
||||
LOGGING_SUPPRESSOR.error("Can't open", 3, "Error opening source data line", e);
|
||||
mRunning = false;
|
||||
|
||||
updateToneInsertionAudioClips();
|
||||
|
||||
//Register to receive preference update notifications so we can update the preference items
|
||||
MyEventBus.getGlobalEventBus().register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this audio output doesn't currently have any audio segments queued for playback.
|
||||
* @return true if empty
|
||||
*/
|
||||
public boolean isEmpty()
|
||||
{
|
||||
return mAudioSegmentQueue.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the audio segment for playback. The audio segment user count should already be incremented by the
|
||||
* calling entity.
|
||||
*
|
||||
* @param audioSegment to schedule for playback.
|
||||
*/
|
||||
public void play(AudioSegment audioSegment)
|
||||
{
|
||||
if(audioSegment != null)
|
||||
{
|
||||
//Audio segment use count has already been incremented by the external caller.
|
||||
mAudioSegmentQueue.add(audioSegment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the audio segment is linked to the current playback audio segment
|
||||
*/
|
||||
public boolean isLinkedTo(AudioSegment audioSegment)
|
||||
{
|
||||
return audioSegment.isLinked() && audioSegment.isLinkedTo(mCurrentAudioSegment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive and process audio identifier update notifications.
|
||||
*/
|
||||
@Override
|
||||
public void receive(IdentifierUpdateNotification identifierUpdateNotification)
|
||||
{
|
||||
if(mCurrentAudioSegment != null)
|
||||
{
|
||||
IdentifierCollection identifierCollection = mCurrentAudioSegment.getIdentifierCollection();
|
||||
|
||||
if(identifierCollection != null)
|
||||
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
|
||||
{
|
||||
broadcast(identifierCollection);
|
||||
audioChannel.setDisabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guava event bus notifications that the preferences have been updated, so that we can update audio segment tones.
|
||||
* Plays the test audio over the selected audio channel(s)
|
||||
* @param request to play test audio
|
||||
*/
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
public void playTestAudio(PlayTestAudioRequest request)
|
||||
{
|
||||
if(preferenceType == PreferenceType.PLAYBACK)
|
||||
//If we're not running, resize the audio samples to fill the buffer enough to trigger playback.
|
||||
float[] audio = mRunning ? request.audio() : Arrays.copyOf(request.audio(), BUFFER_SIZE_SAMPLES);
|
||||
|
||||
List<AudioChannel> channels = mAudioProvider.getAudioChannels();
|
||||
|
||||
if(request.isAllChannels())
|
||||
{
|
||||
updateToneInsertionAudioClips();
|
||||
}
|
||||
else if(preferenceType == PreferenceType.DUPLICATE_CALL_DETECTION)
|
||||
{
|
||||
mDropDuplicates = mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates audio segment start, drop and preempt tone insertion clips
|
||||
*/
|
||||
private void updateToneInsertionAudioClips()
|
||||
{
|
||||
mAudioSegmentStartTone = null;
|
||||
mAudioSegmentDropTone = null;
|
||||
|
||||
float[] start = mUserPreferences.getPlaybackPreference().getStartTone();
|
||||
|
||||
if(start != null)
|
||||
{
|
||||
mAudioSegmentStartTone = convert(start);
|
||||
}
|
||||
|
||||
float[] drop = mUserPreferences.getPlaybackPreference().getDropTone();
|
||||
|
||||
if(drop != null)
|
||||
{
|
||||
mAudioSegmentDropTone = convert(drop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the audio buffer data to the source data line. If the data line is not currently playing, write as
|
||||
* much of the buffer to the data line as will fit, start the dataline, and then finish writing the residual
|
||||
* buffer content to the data line as a blocking call.
|
||||
*
|
||||
* @param buffer of audio to playback
|
||||
*/
|
||||
private void playAudio(ByteBuffer buffer)
|
||||
{
|
||||
if(mOutput == null)
|
||||
{
|
||||
LOGGING_SUPPRESSOR.error("null output", 2, "Audio Output is null - ignoring audio playback request");
|
||||
return;
|
||||
}
|
||||
|
||||
if(buffer != null && buffer.array().length > 0)
|
||||
{
|
||||
int wrote = 0;
|
||||
|
||||
//If the output data line is not running, we can only write up to the available capacity. So, only write
|
||||
//what will fit initially, start playback, and then use a blocking write for the remainder.
|
||||
if(!mOutput.isRunning())
|
||||
for(AudioChannel channel : channels)
|
||||
{
|
||||
int toWrite = mOutput.available();
|
||||
|
||||
if(toWrite > buffer.array().length)
|
||||
{
|
||||
toWrite = buffer.array().length;
|
||||
}
|
||||
|
||||
//Top off the buffer and check if we can start it
|
||||
wrote += mOutput.write(buffer.array(), 0, toWrite);
|
||||
mOutputLastTimestamp = System.currentTimeMillis();
|
||||
|
||||
checkStart();
|
||||
|
||||
//Something is causing the source data line to fail to accept audio byte data via the write() method
|
||||
//and this seems to have started around JDK22, maybe. When this happens, close and then re-open the
|
||||
//data line to clear the error state. Note: in testing this error condition, the line is showing
|
||||
//the buffer is empty and the capacity is fully available, so it should have accepted attempts to write
|
||||
//data, but it failed, as indicated by wrote=0.
|
||||
if(!mOutput.isRunning() && wrote <= 0)
|
||||
{
|
||||
mOutput.close();
|
||||
|
||||
try
|
||||
{
|
||||
mOutput.open();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
mLog.error("Error after closing and attempting to reopen audio output", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(mOutput.isRunning() && wrote < buffer.array().length)
|
||||
{
|
||||
//This will block until the buffer is fully written to the data line
|
||||
mOutput.write(buffer.array(), wrote, buffer.array().length - wrote);
|
||||
mOutputLastTimestamp = System.currentTimeMillis();
|
||||
channel.playTest(audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the currently assigned audio segment.
|
||||
*/
|
||||
private void disposeCurrentAudioSegment()
|
||||
{
|
||||
if(mCurrentAudioSegment != null)
|
||||
else if(request.channel() < channels.size())
|
||||
{
|
||||
mCurrentAudioSegment.decrementConsumerCount();
|
||||
mCurrentAudioSegment.removeIdentifierUpdateNotificationListener(this);
|
||||
mCurrentAudioSegment = null;
|
||||
broadcast(null);
|
||||
channels.get(request.channel()).playTest(audio);
|
||||
}
|
||||
else if(!channels.isEmpty())
|
||||
{
|
||||
channels.getFirst().playTest(audio);
|
||||
}
|
||||
else
|
||||
{
|
||||
LOGGING_SUPPRESSOR.info("No Audio Channels", 2,
|
||||
"Unable to play test audio - no audio channels configured currently");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the next audio segment from the queue.
|
||||
* Audio playback device descriptor for this audio output
|
||||
*/
|
||||
private void loadNextAudioSegment()
|
||||
public AudioPlaybackDeviceDescriptor getAudioPlaybackDeviceDescriptor()
|
||||
{
|
||||
AudioSegment audioSegment = mAudioSegmentQueue.poll();
|
||||
|
||||
boolean verificationInProgress = (audioSegment != null);
|
||||
|
||||
while(verificationInProgress)
|
||||
{
|
||||
if(audioSegment != null)
|
||||
{
|
||||
//Throw away the audio segment if it has been flagged as do not monitor or is duplicate
|
||||
if(isThrowaway(audioSegment))
|
||||
{
|
||||
audioSegment.decrementConsumerCount();
|
||||
audioSegment = mAudioSegmentQueue.poll();
|
||||
}
|
||||
else
|
||||
{
|
||||
verificationInProgress = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
verificationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
mCurrentAudioSegment = audioSegment;
|
||||
|
||||
if(audioSegment != null)
|
||||
{
|
||||
mCurrentAudioSegment.addIdentifierUpdateNotificationListener(this);
|
||||
broadcast(mCurrentAudioSegment.getIdentifierCollection());
|
||||
}
|
||||
|
||||
mCurrentBufferIndex = 0;
|
||||
return mAudioPlaybackDeviceDescriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the audio segment should be thrown away because it is marked as duplicate or do-not-monitor.
|
||||
* @param audioSegment to evaluate
|
||||
* @return true if the audio should be thrown away.
|
||||
* Audio provider managed by this audio output
|
||||
*/
|
||||
private boolean isThrowaway(AudioSegment audioSegment)
|
||||
public AudioProvider getAudioProvider()
|
||||
{
|
||||
return audioSegment != null && (audioSegment.isDoNotMonitor() || mDropDuplicates && (audioSegment.isDuplicate()));
|
||||
return mAudioProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage audio segment playback and process audio segment buffers. This method is designed to be called
|
||||
* by a threaded processor repeatedly to playback the current audio segment and check for and start newly
|
||||
* added audio segments. It also handles starting and stopping the playback source data line to avoid audio
|
||||
* discontinuities due to buffer underruns.
|
||||
* Process audio from the audio provider and writes it to the mixer's source data line. Calls to this method can
|
||||
* block on the source data line until it has capacity available to accept the audio.
|
||||
*/
|
||||
private void processAudio()
|
||||
{
|
||||
if(mCurrentAudioSegment == null)
|
||||
if(mSourceDataLine == null)
|
||||
{
|
||||
loadNextAudioSegment();
|
||||
LOGGING_SUPPRESSOR.error("null output", 2,
|
||||
"Audio Output source data line is null - ignoring audio playback request");
|
||||
return;
|
||||
}
|
||||
|
||||
//Evaluate current audio segment to see if the status has changed for duplicate or do-not-monitor.
|
||||
while(isThrowaway(mCurrentAudioSegment))
|
||||
ByteBuffer buffer = mAudioProvider.getAudio();
|
||||
|
||||
if(buffer != null)
|
||||
{
|
||||
if(mCurrentBufferIndex > 0)
|
||||
//This is a blocking method call.
|
||||
int wrote = mSourceDataLine.write(buffer.array(), 0, buffer.array().length);
|
||||
|
||||
//Around JDK 22 something started causing the source data line to fail to accept audio byte data via the
|
||||
//write() method. When this happens, close and reopen the data line to clear the error state.
|
||||
if(wrote <= 0)
|
||||
{
|
||||
playAudio(mAudioSegmentDropTone);
|
||||
}
|
||||
|
||||
disposeCurrentAudioSegment();
|
||||
loadNextAudioSegment();
|
||||
}
|
||||
|
||||
while(mCurrentAudioSegment != null && mCurrentBufferIndex < mCurrentAudioSegment.getAudioBufferCount())
|
||||
{
|
||||
//Continuously evaluate current audio segment to see if the status has changed for duplicate or do-not-monitor.
|
||||
if(isThrowaway(mCurrentAudioSegment))
|
||||
{
|
||||
if(mCurrentBufferIndex > 0)
|
||||
{
|
||||
playAudio(mAudioSegmentDropTone);
|
||||
}
|
||||
|
||||
disposeCurrentAudioSegment();
|
||||
}
|
||||
else
|
||||
{
|
||||
if(mCurrentBufferIndex == 0)
|
||||
{
|
||||
playAudio(mAudioSegmentStartTone);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
float[] audioBuffer = mCurrentAudioSegment.getAudioBuffers().get(mCurrentBufferIndex++);
|
||||
|
||||
if(audioBuffer != null)
|
||||
{
|
||||
ByteBuffer audio = convert(audioBuffer);
|
||||
//This call blocks until all audio bytes are dumped into the data line.
|
||||
playAudio(audio);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
mLog.error("Error while processing audio for [" + mMixerChannel.name() + "]", e);
|
||||
}
|
||||
LOGGING_SUPPRESSOR.info("Stalled Data Line", 3,
|
||||
"Audio playback data line has stopped accepting samples - recycling to clear the error");
|
||||
mSourceDataLine.close();
|
||||
openSourceDataLine();
|
||||
}
|
||||
}
|
||||
|
||||
//Check for completed and fully-played audio segment to closeout
|
||||
if(mCurrentAudioSegment != null &&
|
||||
mCurrentAudioSegment.isComplete() &&
|
||||
(mCurrentBufferIndex >= mCurrentAudioSegment.getAudioBufferCount()))
|
||||
{
|
||||
disposeCurrentAudioSegment();
|
||||
}
|
||||
|
||||
checkStop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,156 +262,30 @@ public abstract class AudioOutput implements LineListener, Listener<IdentifierUp
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
MyEventBus.getGlobalEventBus().unregister(this);
|
||||
mCanProcessAudio = false;
|
||||
|
||||
if(mProcessorFuture != null)
|
||||
{
|
||||
mProcessorFuture.cancel(true);
|
||||
}
|
||||
|
||||
mProcessorFuture = null;
|
||||
disposeCurrentAudioSegment();
|
||||
mAudioEventBroadcaster.clear();
|
||||
mIdentifierCollectionListener = null;
|
||||
|
||||
if(mOutput != null)
|
||||
if(mScheduledExecutorService != null)
|
||||
{
|
||||
mOutput.close();
|
||||
mScheduledExecutorService.shutdownNow();
|
||||
mScheduledExecutorService = null;
|
||||
}
|
||||
|
||||
mOutput = null;
|
||||
mAudioProvider.dispose();
|
||||
mCanProcessAudio = false;
|
||||
|
||||
if(mSourceDataLine != null)
|
||||
{
|
||||
mSourceDataLine.stop();
|
||||
mSourceDataLine.flush();
|
||||
mSourceDataLine.close();
|
||||
}
|
||||
mSourceDataLine = null;
|
||||
mGainControl = null;
|
||||
mMuteControl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the audio buffer data into a byte buffer format appropriate for the underlying source data line.
|
||||
*/
|
||||
protected abstract ByteBuffer convert(float[] buffer);
|
||||
|
||||
/**
|
||||
* Audio output channel name
|
||||
*/
|
||||
public String getChannelName()
|
||||
{
|
||||
return mMixerChannel.getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixer Channel for this audio output
|
||||
*/
|
||||
protected MixerChannel getMixerChannel()
|
||||
{
|
||||
return mMixerChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single listener to receive audio start and audio stop events
|
||||
*/
|
||||
public void addAudioEventListener(Listener<AudioEvent> listener)
|
||||
{
|
||||
mAudioEventBroadcaster.addListener(listener);
|
||||
}
|
||||
|
||||
public void removeAudioEventListener(Listener<AudioEvent> listener)
|
||||
{
|
||||
mAudioEventBroadcaster.removeListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts an audio event to the registered listener
|
||||
*/
|
||||
private void broadcastAudioEvent(AudioEvent audioEvent)
|
||||
{
|
||||
mAudioEventBroadcaster.broadcast(audioEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single listener to receive the audio metadata from each
|
||||
* audio packet
|
||||
*/
|
||||
public void setIdentifierCollectionListener(Listener<IdentifierCollection> listener)
|
||||
{
|
||||
mIdentifierCollectionListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the current audio metadata listener
|
||||
*/
|
||||
public void removeAudioMetadataListener()
|
||||
{
|
||||
mIdentifierCollectionListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts audio identifier collection metadata to the registered listener
|
||||
*/
|
||||
private void broadcast(IdentifierCollection identifierCollection)
|
||||
{
|
||||
if(mIdentifierCollectionListener != null)
|
||||
{
|
||||
mIdentifierCollectionListener.receive(identifierCollection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts audio playback once audio buffer is almost full and remaining capacity falls below the start threshold.
|
||||
*
|
||||
* Note: this method should only be invoked from the processAudio() method
|
||||
*/
|
||||
private void checkStart()
|
||||
{
|
||||
if(mCanProcessAudio && !mOutput.isRunning() && mOutput.available() <= mBufferStartThreshold)
|
||||
{
|
||||
mOutput.start();
|
||||
mRunning = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops audio playback and drains the audio buffer to empty when the audio buffer is mostly empty and the
|
||||
* available buffer capacity exceeds the stop threshold
|
||||
*
|
||||
* Note: this method should only be invoked from the processAudio() method
|
||||
*/
|
||||
private void checkStop()
|
||||
{
|
||||
if(mRunning)
|
||||
{
|
||||
//If the output buffer falls below the threshold then drain the output and stop playback
|
||||
if(mOutput.isRunning())
|
||||
{
|
||||
if(mOutput.available() >= mBufferStopThreshold)
|
||||
{
|
||||
mOutput.drain();
|
||||
mOutput.stop();
|
||||
mRunning = false;
|
||||
}
|
||||
//Detect when we have a partially filled output that will neither start nor stop and the last write
|
||||
//timestamp was more than the staleness threshold (500 ms) -- stop the output and discard stale contents
|
||||
else if(mOutput.available() < mBufferStopThreshold &&
|
||||
mOutput.available() > mBufferStartThreshold &&
|
||||
(System.currentTimeMillis() - mOutputLastTimestamp >= STALE_PLAYBACK_THRESHOLD_MS))
|
||||
{
|
||||
mOutput.stop();
|
||||
//Discard the stale buffer contents.
|
||||
mOutput.flush();
|
||||
mRunning = false;
|
||||
}
|
||||
}
|
||||
//If output playback stopped on its own because the buffer emptied, then cleanup
|
||||
else
|
||||
{
|
||||
mRunning = false;
|
||||
}
|
||||
|
||||
//If we stopped audio playback, broadcast a null identifier to clear the gui panel
|
||||
if(!mRunning)
|
||||
{
|
||||
broadcast(null);
|
||||
}
|
||||
}
|
||||
mRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -632,11 +293,13 @@ public abstract class AudioOutput implements LineListener, Listener<IdentifierUp
|
||||
*/
|
||||
public void setMuted(boolean muted)
|
||||
{
|
||||
if(mMuteControl != null)
|
||||
for(AudioChannel audioChannel: mAudioProvider.getAudioChannels())
|
||||
{
|
||||
mMuteControl.setValue(muted);
|
||||
broadcastAudioEvent(new AudioEvent(muted ? AudioEvent.Type.AUDIO_MUTED : AudioEvent.Type.AUDIO_UNMUTED, getChannelName()));
|
||||
audioChannel.setMuted(muted);
|
||||
}
|
||||
|
||||
AudioEvent.Type type = muted ? AudioEvent.Type.AUDIO_MUTED : AudioEvent.Type.AUDIO_UNMUTED;
|
||||
mAudioProvider.notify(type);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -644,9 +307,9 @@ public abstract class AudioOutput implements LineListener, Listener<IdentifierUp
|
||||
*/
|
||||
public boolean isMuted()
|
||||
{
|
||||
if(mMuteControl != null)
|
||||
if(!mAudioProvider.getAudioChannels().isEmpty())
|
||||
{
|
||||
return mMuteControl.getValue();
|
||||
return mAudioProvider.getAudioChannels().getFirst().isMuted();
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -679,20 +342,20 @@ public abstract class AudioOutput implements LineListener, Listener<IdentifierUp
|
||||
|
||||
if(type == LineEvent.Type.START)
|
||||
{
|
||||
mAudioEventBroadcaster.broadcast(mAudioStartEvent);
|
||||
mAudioProvider.notify(AudioEvent.Type.AUDIO_STARTED);
|
||||
}
|
||||
else if(type == LineEvent.Type.STOP)
|
||||
{
|
||||
mAudioEventBroadcaster.broadcast(mAudioStopEvent);
|
||||
mAudioProvider.notify(AudioEvent.Type.AUDIO_STOPPED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runnable audio segment processor
|
||||
* Audio Processor thread
|
||||
*/
|
||||
public class AudioSegmentProcessor implements Runnable
|
||||
public class AudioProcessor implements Runnable
|
||||
{
|
||||
private AtomicBoolean mProcessing = new AtomicBoolean();
|
||||
private final AtomicBoolean mProcessing = new AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
|
||||
Reference in New Issue
Block a user