/* * ***************************************************************************** * 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 * **************************************************************************** */ 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, IAudioController { private static final Logger mLog = LoggerFactory.getLogger(AudioPlaybackManager.class); private final AudioSegmentPrioritySorter mAudioSegmentPrioritySorter = new AudioSegmentPrioritySorter(); private final Broadcaster mControllerBroadcaster = new Broadcaster<>(); private final List mAudioSegments = new ArrayList<>(); private final List mPendingAudioSegments = new ArrayList<>(); private final LinkedTransferQueue 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 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 it = mAudioSegments.iterator(); AudioSegment audioSegment; List 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 waiting, List 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 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 listener) { mControllerBroadcaster.addListener(listener); } /** * Removes an audio event listener from receiving audio event notifications. */ @Override public void removeAudioEventListener(Listener 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 { @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()); } } } }