mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 16:47:31 -05:00
513 lines
18 KiB
Java
513 lines
18 KiB
Java
/*
|
|
* *****************************************************************************
|
|
* 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
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
|
* ****************************************************************************
|
|
*/
|
|
package io.github.dsheirer.audio.playback;
|
|
|
|
import com.google.common.eventbus.Subscribe;
|
|
import io.github.dsheirer.audio.AudioEvent;
|
|
import io.github.dsheirer.audio.AudioException;
|
|
import io.github.dsheirer.audio.AudioSegment;
|
|
import io.github.dsheirer.audio.IAudioController;
|
|
import io.github.dsheirer.controller.NamingThreadFactory;
|
|
import io.github.dsheirer.eventbus.MyEventBus;
|
|
import io.github.dsheirer.preference.PreferenceType;
|
|
import io.github.dsheirer.preference.UserPreferences;
|
|
import io.github.dsheirer.preference.playback.PlayTestAudioRequest;
|
|
import io.github.dsheirer.sample.Broadcaster;
|
|
import io.github.dsheirer.sample.Listener;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.Comparator;
|
|
import java.util.Iterator;
|
|
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;
|
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
import java.util.concurrent.locks.ReentrantLock;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
/**
|
|
* Manages scheduling and playback of audio segments to the local users audio system.
|
|
*/
|
|
public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioController
|
|
{
|
|
private static final Logger mLog = LoggerFactory.getLogger(AudioPlaybackManager.class);
|
|
private final AudioSegmentPrioritySorter mAudioSegmentPrioritySorter = new AudioSegmentPrioritySorter();
|
|
private final Broadcaster<AudioEvent> mControllerBroadcaster = new Broadcaster<>();
|
|
private final List<AudioSegment> mAudioSegments = new ArrayList<>();
|
|
private final List<AudioSegment> mPendingAudioSegments = new ArrayList<>();
|
|
private final LinkedTransferQueue<AudioSegment> mNewAudioSegmentQueue = new LinkedTransferQueue<>();
|
|
private final ReentrantLock mAudioChannelsLock = new ReentrantLock();
|
|
private final UserPreferences mUserPreferences;
|
|
private AudioPlaybackDeviceDescriptor mAudioPlaybackDevice;
|
|
private AudioOutput mAudioOutput;
|
|
private ScheduledFuture<?> mProcessingTask;
|
|
|
|
/**
|
|
* Constructs an instance.
|
|
*
|
|
* @param userPreferences for audio playback preferences
|
|
*/
|
|
public AudioPlaybackManager(UserPreferences userPreferences)
|
|
{
|
|
mUserPreferences = userPreferences;
|
|
MyEventBus.getGlobalEventBus().register(this);
|
|
AudioPlaybackDeviceDescriptor device = mUserPreferences.getPlaybackPreference().getAudioPlaybackDevice();
|
|
|
|
if(device != null)
|
|
{
|
|
try
|
|
{
|
|
setAudioPlaybackDevice(device);
|
|
}
|
|
catch(AudioException ae)
|
|
{
|
|
mLog.error("Error during setup of audio playback configuration. Attempted to use device [" +
|
|
device + "]", ae);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
mLog.warn("No audio output devices available");
|
|
}
|
|
|
|
//Even if we don't have an audio device, setup the queue processor to always process the audio segment queue
|
|
ScheduledExecutorService scheduledExecutorService =
|
|
Executors.newSingleThreadScheduledExecutor(new NamingThreadFactory("sdrtrunk audio manager"));
|
|
mProcessingTask = scheduledExecutorService.scheduleAtFixedRate(new AudioSegmentProcessor(),
|
|
0, 100, TimeUnit.MILLISECONDS);
|
|
}
|
|
|
|
/**
|
|
* Receives audio segments from channel audio modules.
|
|
* @param audioSegment to receive and process
|
|
*/
|
|
@Override
|
|
public void receive(AudioSegment audioSegment)
|
|
{
|
|
mNewAudioSegmentQueue.add(audioSegment);
|
|
}
|
|
|
|
/**
|
|
* Processes new audio segments and automatically assigns them to audio outputs.
|
|
*
|
|
* Note: this method is intended to be repeatedly invoked by a scheduled processing thread.
|
|
*/
|
|
private void processAudioSegments()
|
|
{
|
|
//Process new audio segments queue. If segment has audio, queue it for replay, otherwise place in pending queue
|
|
AudioSegment newSegment = mNewAudioSegmentQueue.poll();
|
|
|
|
while(newSegment != null)
|
|
{
|
|
if(newSegment.isDuplicate() &&
|
|
mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled())
|
|
{
|
|
newSegment.decrementConsumerCount();
|
|
}
|
|
else if(newSegment.hasAudio())
|
|
{
|
|
mAudioSegments.add(newSegment);
|
|
}
|
|
else
|
|
{
|
|
mPendingAudioSegments.add(newSegment);
|
|
}
|
|
|
|
newSegment = mNewAudioSegmentQueue.poll();
|
|
}
|
|
|
|
//Transfer pending audio segments that now have audio or that completed without ever having audio
|
|
if(!mPendingAudioSegments.isEmpty())
|
|
{
|
|
Iterator<AudioSegment> it = mPendingAudioSegments.iterator();
|
|
|
|
AudioSegment audioSegment;
|
|
|
|
while(it.hasNext())
|
|
{
|
|
audioSegment = it.next();
|
|
|
|
if(audioSegment.isDuplicate() &&
|
|
mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled())
|
|
{
|
|
it.remove();
|
|
audioSegment.decrementConsumerCount();
|
|
}
|
|
else if(audioSegment.hasAudio())
|
|
{
|
|
//Queue it up for replay
|
|
it.remove();
|
|
mAudioSegments.add(audioSegment);
|
|
}
|
|
else if(audioSegment.completeProperty().get())
|
|
{
|
|
//Rare situation: the audio segment completed but never had audio ... dispose it
|
|
it.remove();
|
|
audioSegment.decrementConsumerCount();
|
|
}
|
|
}
|
|
}
|
|
|
|
//Process all audio segments that have audio
|
|
if(!mAudioSegments.isEmpty())
|
|
{
|
|
Iterator<AudioSegment> it = mAudioSegments.iterator();
|
|
AudioSegment audioSegment;
|
|
List<AudioSegment> displaced = new ArrayList<>();
|
|
|
|
//Remove any audio segments flagged as do not monitor. Don't remove completed segments yet, because
|
|
//we want to give them a brief chance at playback. Automatically assign linked audio segments to the
|
|
//current audio output for audio continuity
|
|
while(it.hasNext())
|
|
{
|
|
audioSegment = it.next();
|
|
|
|
if(audioSegment.isDoNotMonitor() || (audioSegment.isDuplicate() &&
|
|
mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled()))
|
|
{
|
|
it.remove();
|
|
audioSegment.decrementConsumerCount();
|
|
}
|
|
else if(audioSegment.isLinked())
|
|
{
|
|
if(mAudioOutput != null)
|
|
{
|
|
mAudioChannelsLock.lock();
|
|
|
|
try
|
|
{
|
|
for(AudioChannel audioOutput: mAudioOutput.getAudioProvider().getAudioChannels())
|
|
{
|
|
if(audioOutput.isLinkedTo(audioSegment))
|
|
{
|
|
it.remove();
|
|
|
|
//Linked audio continues the current call, so it goes ahead of anything queued
|
|
AudioSegment queued = audioOutput.pollQueuedAudioSegment();
|
|
|
|
while(queued != null)
|
|
{
|
|
displaced.add(queued);
|
|
queued = audioOutput.pollQueuedAudioSegment();
|
|
}
|
|
|
|
audioOutput.play(audioSegment);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
mAudioChannelsLock.unlock();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
mAudioSegments.addAll(displaced);
|
|
|
|
//Sort audio segments by playback priority and assign to empty audio outputs
|
|
if(!mAudioSegments.isEmpty() && mAudioOutput != null)
|
|
{
|
|
mAudioSegments.sort(mAudioSegmentPrioritySorter);
|
|
mAudioChannelsLock.lock();
|
|
|
|
try
|
|
{
|
|
//Assign empty audio outputs first
|
|
for(AudioChannel audioChannel: mAudioOutput.getAudioProvider().getAudioChannels())
|
|
{
|
|
if(audioChannel.isEmpty())
|
|
{
|
|
audioChannel.play(mAudioSegments.removeFirst());
|
|
if(mAudioSegments.isEmpty())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
replaceLowerPriorityQueuedSegments(mAudioSegments, mAudioOutput.getAudioProvider().getAudioChannels());
|
|
}
|
|
finally
|
|
{
|
|
mAudioChannelsLock.unlock();
|
|
}
|
|
}
|
|
|
|
//Remove any audio segments marked as complete that didn't get assigned to an output
|
|
it = mAudioSegments.iterator(); //reset the iterator
|
|
while(it.hasNext())
|
|
{
|
|
audioSegment = it.next();
|
|
|
|
if(audioSegment.completeProperty().get() || (audioSegment.isDuplicate() &&
|
|
mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled()))
|
|
{
|
|
it.remove();
|
|
audioSegment.decrementConsumerCount();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Swaps audio segments queued on the audio channels for waiting audio segments that have a higher playback priority,
|
|
* so that a segment queued behind the current playback can't hold a higher priority segment out of the channel.
|
|
* Audio that is currently playing is not interrupted. Displaced segments are returned to the waiting list.
|
|
*
|
|
* @param waiting audio segments, sorted by playback priority.
|
|
* @param audioChannels to evaluate.
|
|
*/
|
|
static void replaceLowerPriorityQueuedSegments(List<AudioSegment> waiting, List<AudioChannel> audioChannels)
|
|
{
|
|
for(AudioChannel audioChannel: audioChannels)
|
|
{
|
|
if(waiting.isEmpty())
|
|
{
|
|
return;
|
|
}
|
|
|
|
AudioSegment queued = audioChannel.peekQueuedAudioSegment();
|
|
|
|
if(queued != null &&
|
|
waiting.getFirst().monitorPriorityProperty().get() < queued.monitorPriorityProperty().get())
|
|
{
|
|
//The audio thread may have taken the queued segment since the peek, so only return what we removed.
|
|
queued = audioChannel.pollQueuedAudioSegment();
|
|
audioChannel.play(waiting.removeFirst());
|
|
|
|
if(queued != null)
|
|
{
|
|
waiting.add(queued);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void dispose()
|
|
{
|
|
MyEventBus.getGlobalEventBus().unregister(this);
|
|
if(mProcessingTask != null)
|
|
{
|
|
mProcessingTask.cancel(true);
|
|
mProcessingTask = null;
|
|
}
|
|
|
|
mNewAudioSegmentQueue.clear();
|
|
mAudioSegments.clear();
|
|
}
|
|
|
|
/**
|
|
* Receives a request from the global event bus to playback a test audio sequence via the specified audio channel
|
|
* @param request with test audio and channel number
|
|
*/
|
|
@Subscribe
|
|
public void playTestAudio(PlayTestAudioRequest request)
|
|
{
|
|
if(mAudioOutput != null)
|
|
{
|
|
mAudioOutput.playTestAudio(request);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Receive user preference update notifications so that we can detect when the user changes the audio output
|
|
* device in the user preferences editor.
|
|
*/
|
|
@Subscribe
|
|
public void preferenceUpdated(PreferenceType preferenceType)
|
|
{
|
|
if(preferenceType == PreferenceType.PLAYBACK)
|
|
{
|
|
AudioPlaybackDeviceDescriptor device = mUserPreferences.getPlaybackPreference().getAudioPlaybackDevice();
|
|
|
|
if(device != null && !device.equals(getAudioPlaybackDevice()))
|
|
{
|
|
try
|
|
{
|
|
setAudioPlaybackDevice(device);
|
|
}
|
|
catch(AudioException ae)
|
|
{
|
|
mLog.error("Error changing audio output to [" + device + "]", ae);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configures audio playback to use the audio device argument.
|
|
*
|
|
* @param audioDevice to use in configuring the audio playback setup.
|
|
* @throws AudioException if there is an error
|
|
*/
|
|
@Override
|
|
public void setAudioPlaybackDevice(AudioPlaybackDeviceDescriptor audioDevice) throws AudioException
|
|
{
|
|
if(audioDevice != null)
|
|
{
|
|
mControllerBroadcaster.broadcast(new AudioEvent(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_STARTED,
|
|
audioDevice.getMixerInfo().getName()));
|
|
|
|
mAudioChannelsLock.lock();
|
|
|
|
try
|
|
{
|
|
if(mAudioOutput != null)
|
|
{
|
|
mAudioOutput.dispose();
|
|
}
|
|
|
|
int channelCount = audioDevice.getAudioFormat().getChannels();
|
|
|
|
switch(channelCount)
|
|
{
|
|
case 1:
|
|
mAudioOutput = new AudioOutput(audioDevice, new AudioProviderMono(mUserPreferences));
|
|
break;
|
|
case 2:
|
|
mAudioOutput = new AudioOutput(audioDevice, new AudioProviderStereo(mUserPreferences));
|
|
break;
|
|
default:
|
|
// mAudioOutput = new AudioOutput(audioDevice, new AudioProviderMultiChannel(mUserPreferences,
|
|
// audioDevice.getAudioFormat()));
|
|
throw new AudioException("Unsupported mixer channel configuration channel count: " + channelCount);
|
|
}
|
|
|
|
//Note: audio output can use an alternate device if the requested device can't be used, so we assign
|
|
//the descriptor that was actually used by the audio output
|
|
mAudioPlaybackDevice = mAudioOutput.getAudioPlaybackDeviceDescriptor();
|
|
}
|
|
finally
|
|
{
|
|
mAudioChannelsLock.unlock();
|
|
}
|
|
|
|
mControllerBroadcaster.broadcast(new AudioEvent(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_COMPLETE,
|
|
mAudioPlaybackDevice.getMixerInfo().getName()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Audio output device. Note: this can be null depending on when it is accessed.
|
|
* @return audio output or null.
|
|
*/
|
|
public AudioOutput getAudioOutput()
|
|
{
|
|
return mAudioOutput;
|
|
}
|
|
|
|
/**
|
|
* Current audio playback mixer channel configuration setting.
|
|
*/
|
|
@Override
|
|
public AudioPlaybackDeviceDescriptor getAudioPlaybackDevice()
|
|
{
|
|
return mAudioPlaybackDevice;
|
|
}
|
|
|
|
/**
|
|
* List of sorted audio outputs available for the current mixer channel configuration
|
|
*/
|
|
@Override
|
|
public List<AudioChannel> getAudioChannels()
|
|
{
|
|
if(mAudioOutput != null)
|
|
{
|
|
return mAudioOutput.getAudioProvider().getAudioChannels();
|
|
}
|
|
|
|
return Collections.emptyList();
|
|
}
|
|
|
|
/**
|
|
* Adds an audio event listener to receive audio event notifications.
|
|
*/
|
|
@Override
|
|
public void addAudioEventListener(Listener<AudioEvent> listener)
|
|
{
|
|
mControllerBroadcaster.addListener(listener);
|
|
}
|
|
|
|
/**
|
|
* Removes an audio event listener from receiving audio event notifications.
|
|
*/
|
|
@Override
|
|
public void removeAudioEventListener(Listener<AudioEvent> listener)
|
|
{
|
|
mControllerBroadcaster.removeListener(listener);
|
|
}
|
|
|
|
/**
|
|
* Scheduled runnable to process incoming audio segments
|
|
*/
|
|
public class AudioSegmentProcessor implements Runnable
|
|
{
|
|
private final AtomicBoolean mProcessing = new AtomicBoolean();
|
|
|
|
@Override
|
|
public void run()
|
|
{
|
|
if(mProcessing.compareAndSet(false, true))
|
|
{
|
|
try
|
|
{
|
|
processAudioSegments();
|
|
}
|
|
catch(Throwable t)
|
|
{
|
|
mLog.error("Encountered error while processing audio segments", t);
|
|
}
|
|
|
|
mProcessing.set(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Audio segment comparator for sorting audio segments by: 1)Playback priority and 2)Segment start time
|
|
*/
|
|
public static class AudioSegmentPrioritySorter implements Comparator<AudioSegment>
|
|
{
|
|
@Override
|
|
public int compare(AudioSegment segment1, AudioSegment segment2)
|
|
{
|
|
if(segment1 == null || segment2 == null)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
//If priority is the same, sort by start time
|
|
if(segment1.monitorPriorityProperty().get() == segment2.monitorPriorityProperty().get())
|
|
{
|
|
return Long.compare(segment1.getStartTimestamp(), segment2.getStartTimestamp());
|
|
}
|
|
//Otherwise, sort by priority
|
|
else
|
|
{
|
|
return Integer.compare(segment1.monitorPriorityProperty().get(), segment2.monitorPriorityProperty().get());
|
|
}
|
|
}
|
|
}
|
|
}
|