#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:
Denny Sheirer
2026-02-08 05:30:39 -05:00
committed by GitHub
parent a2509554db
commit c81e6cccfa
21 changed files with 1817 additions and 1220 deletions

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 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
@@ -61,7 +61,7 @@ import org.slf4j.LoggerFactory;
*
* Producers will add audio buffers and update identifiers throughout the life-cycle of an audio segment. The producer
* will signal the completion of an audio segment by setting the complete property to true. This allows consumers the
* option to process the audio buffers throughout the life-cycle of the segment, or to process all of the buffers once
* option to process the audio buffers throughout the life-cycle of the segment, or to process all the buffers once
* the segment is complete.
*/
public class AudioSegment implements Listener<IdentifierUpdateNotification>

View File

@@ -1,9 +1,27 @@
/*
* *****************************************************************************
* 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;
import io.github.dsheirer.audio.playback.AudioOutput;
import io.github.dsheirer.audio.playback.AudioChannel;
import io.github.dsheirer.audio.playback.AudioPlaybackDeviceDescriptor;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
import java.util.List;
/**
@@ -12,13 +30,13 @@ import java.util.List;
public interface IAudioController
{
/* Current Mixer and MixerChannel configuration */
public void setMixerChannelConfiguration( MixerChannelConfiguration entry ) throws AudioException;
public MixerChannelConfiguration getMixerChannelConfiguration() throws AudioException;
public void setAudioPlaybackDevice(AudioPlaybackDeviceDescriptor device ) throws AudioException;
public AudioPlaybackDeviceDescriptor getAudioPlaybackDevice() throws AudioException;
/* Audio Output(s) */
public List<AudioOutput> getAudioOutputs();
/* Audio Channel(s) */
public List<AudioChannel> getAudioChannels();
/* Controller Audio Event Listener */
public void addControllerListener( Listener<AudioEvent> listener );
public void removeControllerListener( Listener<AudioEvent> listener );
public void addAudioEventListener(Listener<AudioEvent> listener );
public void removeAudioEventListener(Listener<AudioEvent> listener );
}

View File

@@ -0,0 +1,629 @@
/*
* *****************************************************************************
* 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.AudioSegment;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.identifier.IdentifierCollection;
import io.github.dsheirer.identifier.IdentifierUpdateNotification;
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 java.util.Arrays;
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Manages the incoming audio segments and provides access to audio buffers from those segments.
*/
public class AudioChannel implements Listener<IdentifierUpdateNotification>
{
/**
* Quantity of audio samples to deliver per getAudio() method invocation, equivalent to 20 milliseconds at an 8 kHz
* sample rate.
*/
public static final int SAMPLES_PER_INTERVAL = 160;
private static final Logger LOGGER = LoggerFactory.getLogger(AudioChannel.class);
private final AudioBuffer mAudioBuffer = new AudioBuffer();
private final Broadcaster<AudioEvent> mAudioEventBroadcaster = new Broadcaster<>();
private final LinkedTransferQueue<AudioSegment> mAudioSegmentQueue = new LinkedTransferQueue<>();
private final UserPreferences mUserPreferences;
private final String mChannelName;
private AudioSegment mCurrentAudioSegment;
private Listener<IdentifierCollection> mIdentifierCollectionListener;
private boolean mDropDuplicates;
private boolean mMetadataSent = false;
private boolean mMuted;
private boolean mDisabled;
private float[] mAudioSegmentStartTone;
private float[] mAudioSegmentDropTone;
private int mCurrentBufferIndex = -1;
private int mNoAudioFromSegmentIntervalCount = 0;
/**
* Constructs an instance
*/
public AudioChannel(UserPreferences userPreferences, String channelName)
{
mUserPreferences = userPreferences;
mChannelName = channelName;
mDropDuplicates = mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled();
updateToneInsertionAudioClips();
//Register to receive preference update notifications so we can update the preference items
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Enables or disables this audio channel.
* @param disabled state
*/
public void setDisabled(boolean disabled)
{
mDisabled = disabled;
if(disabled)
{
AudioSegment segment = mAudioSegmentQueue.poll();
while(segment != null)
{
segment.decrementConsumerCount();
segment = mAudioSegmentQueue.poll();
}
}
}
/**
* Indicates if this audio channel is enabled.
*/
public boolean isDisabled()
{
return mDisabled;
}
/**
* Switches this channel's muted state
* @param muted true to mute and false to unmute
*/
public void setMuted(boolean muted)
{
mMuted = muted;
notify(muted ? AudioEvent.Type.AUDIO_MUTED : AudioEvent.Type.AUDIO_UNMUTED);
}
/**
* Indicates if this channel is muted
* @return muted state
*/
public boolean isMuted()
{
return mMuted;
}
/**
* Plays the test audio sample data at higher priority that current audio queue.
* @param samples of test audio to play.
*/
public void playTest(float[] samples)
{
if(isDisabled())
{
return;
}
mAudioBuffer.insert(samples);
}
/**
* Indicates if this channel has an audio segment, indicating that it is right in the middle of playback and
* the audio output should temporarily pause until more audio is available from the segment.
* @return true if this channel is processing a non-complete audio segment.
*/
public boolean hasAudioSegment()
{
return mCurrentAudioSegment != null;
}
/**
* Broadcasts a null identifier collection to the UI to clear any displayed metadata (TO/FROM) identifiers
*/
public void clearMetadata()
{
if(mMetadataSent)
{
broadcast(null);
mMetadataSent = false;
}
}
/**
* Provides audio for this channel from the audio segment queue.
* @return audio or null if there are no audio segments or test audio to play.
*/
public float[] getAudio()
{
if(mAudioBuffer.isFull())
{
return mAudioBuffer.get();
}
if(mCurrentAudioSegment == null)
{
loadNextAudioSegment();
}
//Evaluate current audio segment to see if the status has changed for duplicate or do-not-monitor.
while(isThrowaway(mCurrentAudioSegment))
{
if(mCurrentBufferIndex > 0)
{
//Flush the buffer and insert the drop tone
mAudioBuffer.replace(mAudioSegmentDropTone);
}
disposeCurrentAudioSegment();
loadNextAudioSegment();
}
if(mCurrentAudioSegment != null)
{
mNoAudioFromSegmentIntervalCount++;
}
while(!mAudioBuffer.isFull() && mCurrentAudioSegment != null &&
mCurrentBufferIndex < mCurrentAudioSegment.getAudioBufferCount())
{
if(mCurrentBufferIndex < 0)
{
mAudioBuffer.add(mAudioSegmentStartTone);
mAudioBuffer.add(mCurrentAudioSegment.getAudioBuffer(0));
mCurrentBufferIndex = 1;
broadcast(mCurrentAudioSegment.getIdentifierCollection());
mMetadataSent = true;
}
else
{
mAudioBuffer.add(mCurrentAudioSegment.getAudioBuffer(mCurrentBufferIndex++));
}
mNoAudioFromSegmentIntervalCount = 0;
}
//Dispose completed/fully-played audio segments and segments that have stalled/failed to progress
if(mCurrentAudioSegment != null && ((mNoAudioFromSegmentIntervalCount >= 6) ||
(mCurrentAudioSegment.isComplete() && (mCurrentBufferIndex >= mCurrentAudioSegment.getAudioBufferCount()))))
{
disposeCurrentAudioSegment();
mNoAudioFromSegmentIntervalCount = 0;
}
float[] audio = null;
if(mAudioBuffer.isFull())
{
audio = mAudioBuffer.get();
}
else if(mCurrentAudioSegment == null && !mAudioBuffer.isEmpty())
{
audio = mAudioBuffer.flush();
}
//Finally, if we have buffer audio and we're muted, return silence
if(isMuted() && audio != null)
{
audio = new float[SAMPLES_PER_INTERVAL];
}
return audio;
}
/**
* Notifies listener(s) of the audio event.
* @param eventType to notify for this channel
*/
public void notify(AudioEvent.Type eventType)
{
broadcastAudioEvent(new AudioEvent(eventType, getChannelName()));
}
/**
* Registers a single listener to receive audio start and audio stop events
*/
public void addAudioEventListener(Listener<AudioEvent> listener)
{
mAudioEventBroadcaster.addListener(listener);
}
/**
* Removes the audio event listener
* @param listener to remove
*/
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);
}
/**
* 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.
*/
private boolean isThrowaway(AudioSegment audioSegment)
{
return audioSegment != null && (audioSegment.isDoNotMonitor() || mDropDuplicates && (audioSegment.isDuplicate()));
}
/**
* Channel name
*/
public String getChannelName()
{
return mChannelName;
}
/**
* Broadcasts audio identifier collection metadata to the registered listener
*/
private void broadcast(IdentifierCollection identifierCollection)
{
if(mIdentifierCollectionListener != null)
{
mIdentifierCollectionListener.receive(identifierCollection);
}
}
/**
* 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;
}
/**
* Prepares this audio output for disposal.
*/
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);
disposeCurrentAudioSegment();
mAudioEventBroadcaster.clear();
mIdentifierCollectionListener = null;
}
/**
* Guava event bus notifications that the preferences have been updated, so that we can update audio segment tones.
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.PLAYBACK)
{
updateToneInsertionAudioClips();
}
else if(preferenceType == PreferenceType.DUPLICATE_CALL_DETECTION)
{
mDropDuplicates = mUserPreferences.getCallManagementPreference().isDuplicatePlaybackSuppressionEnabled();
}
}
/**
* 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.
*/
public void receive(IdentifierUpdateNotification identifierUpdateNotification)
{
if(mCurrentAudioSegment != null)
{
IdentifierCollection identifierCollection = mCurrentAudioSegment.getIdentifierCollection();
if(identifierCollection != null)
{
broadcast(identifierCollection);
}
}
}
/**
* Updates audio segment start, drop and preempt tone insertion clips
*/
private void updateToneInsertionAudioClips()
{
mAudioSegmentStartTone = mUserPreferences.getPlaybackPreference().getStartTone();
mAudioSegmentDropTone = mUserPreferences.getPlaybackPreference().getDropTone();;
}
/**
* 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 user count has already been incremented by the external caller.
if(isDisabled())
{
audioSegment.decrementConsumerCount();
}
else
{
mAudioSegmentQueue.add(audioSegment);
}
}
}
/**
* Dispose of the currently assigned audio segment.
*/
private void disposeCurrentAudioSegment()
{
if(mCurrentAudioSegment != null)
{
mCurrentAudioSegment.decrementConsumerCount();
mCurrentAudioSegment.removeIdentifierUpdateNotificationListener(this);
mCurrentAudioSegment = null;
}
}
/**
* Loads the next audio segment from the queue.
*/
private void loadNextAudioSegment()
{
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 = -1;
}
/**
* Manages a stream of float PCM audio samples. Employs thread locking of the audio buffer to support both the
* audio processor thread and user initiated test audio replay.
*/
static class AudioBuffer
{
private final Lock mLock = new ReentrantLock();
private float[] mBuffer = new float[0];
/**
* Indicates if the buffer has at least one interval of samples available
*/
public boolean isFull()
{
return mBuffer.length >= SAMPLES_PER_INTERVAL;
}
/**
* Indicates if the buffer is empty
*/
public boolean isEmpty()
{
return mBuffer.length == 0;
}
/**
* Appends the audio to the buffer
* @param audio to add
*/
public void add(float[] audio)
{
if(audio != null)
{
mLock.lock();
try
{
mBuffer = concatenate(mBuffer, audio);
}
finally
{
mLock.unlock();
}
}
}
/**
* Inserts the audio to the front of the buffer. This can be used to insert test audio ahead of any queued audio.
* @param audio to insert
*/
public void insert(float[] audio)
{
if(audio != null)
{
mLock.lock();
try
{
mBuffer = concatenate(audio, mBuffer);
}
finally
{
mLock.unlock();
}
}
}
/**
* Replaces the content in the buffer with the audio argument
* @param audio to replace into the buffer
*/
public void replace(float[] audio)
{
if(audio != null)
{
mLock.lock();
try
{
mBuffer = audio;
}
finally
{
mLock.unlock();
}
}
}
/**
* Concatenate two arrays. Note: this does not null check either array.
* @param a first array
* @param b second array
* @return concatenated array
*/
private static float[] concatenate(float[] a, float[] b)
{
a = Arrays.copyOf(a, a.length + b.length);
System.arraycopy(b, 0, a, a.length - b.length, b.length);
return a;
}
/**
* Retrieves an array of 160 samples if available, otherwise returns null.
* @return 160 samples or null.
*/
public float[] get()
{
if(mBuffer.length >= SAMPLES_PER_INTERVAL)
{
mLock.lock();
try
{
float[] fragment = Arrays.copyOf(mBuffer, SAMPLES_PER_INTERVAL);
mBuffer = Arrays.copyOfRange(mBuffer, SAMPLES_PER_INTERVAL, mBuffer.length);
return fragment;
}
finally
{
mLock.unlock();
}
}
return null;
}
/**
* Flushes remaining samples from the buffer when the quantity is less than a full sample set.
* @return remaining samples or null.
*/
public float[] flush()
{
mLock.lock();
try
{
if(mBuffer.length > 0)
{
float[] fragment = Arrays.copyOf(mBuffer, SAMPLES_PER_INTERVAL);
if(mBuffer.length > SAMPLES_PER_INTERVAL)
{
mBuffer = Arrays.copyOfRange(mBuffer, SAMPLES_PER_INTERVAL, mBuffer.length);
}
else
{
mBuffer = new float[0];
}
return fragment;
}
return null;
}
finally
{
mLock.unlock();
}
}
}
}

View File

@@ -1,7 +1,6 @@
/*
* ******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2019 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
@@ -15,7 +14,7 @@
*
* 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;
@@ -40,13 +39,6 @@ import io.github.dsheirer.settings.ColorSetting;
import io.github.dsheirer.settings.Setting;
import io.github.dsheirer.settings.SettingChangeListener;
import io.github.dsheirer.settings.SettingsManager;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.ImageIcon;
import javax.swing.JLabel;
import javax.swing.JPanel;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.Font;
@@ -54,7 +46,17 @@ import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.ImageIcon;
import javax.swing.JLabel;
import javax.swing.JPanel;
/**
* UI to wrap an audio channel and provide display of metadata and playback state information.
*/
public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, SettingChangeListener
{
private static final long serialVersionUID = 1L;
@@ -66,30 +68,36 @@ public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, S
public static final String PROPERTY_COLOR_MUTED = PROPERTY_PREFIX + "muted";
public static final String PROPERTY_COLOR_VALUE = PROPERTY_PREFIX + "value";
private Font mFont = new Font(Font.MONOSPACED, Font.PLAIN, 16);
private Color mBackgroundColor;
private Color mLabelColor;
private Color mMutedColor;
private Color mValueColor;
private IconModel mIconModel;
private SettingsManager mSettingsManager;
private UserPreferences mUserPreferences;
private TalkgroupFormatPreference mTalkgroupFormatPreference;
private AudioOutput mAudioOutput;
private JLabel mMutedLabel = new JLabel("M");
private JLabel mChannelName = new JLabel(" ");
private JLabel mIconLabel = new JLabel(" ");
private JLabel mIdentifierLabel = new JLabel("-----");
private final AudioChannel mAudioChannel;
private final AliasModel mAliasModel;
private final IconModel mIconModel;
private final SettingsManager mSettingsManager;
private final UserPreferences mUserPreferences;
private final TalkgroupFormatPreference mTalkgroupFormatPreference;
private Identifier mIdentifier;
private List<Alias> mAliases = Collections.EMPTY_LIST;
private AliasModel mAliasModel;
private Lock mLock = new ReentrantLock();
private final Lock mLock = new ReentrantLock();
public AudioChannelPanel(IconModel iconModel, UserPreferences userPreferences, SettingsManager settingsManager,
AudioOutput audioOutput, AliasModel aliasModel)
private final Font mFont = new Font(Font.MONOSPACED, Font.PLAIN, 16);
private final Color mBackgroundColor;
private final Color mLabelColor;
private final Color mMutedColor;
private final Color mValueColor;
private final JLabel mMutedLabel = new JLabel("M");
private JLabel mChannelName = new JLabel(" ");
private final JLabel mIconLabel = new JLabel(" ");
private final JLabel mIdentifierLabel = new JLabel("-----");
/**
* Constructs an instance
* @param audioChannel to wrap by this panel
* @param aliasModel for alias lookup
* @param iconModel for icon lookup
* @param settingsManager for monitoring changes to tone insertion
* @param userPreferences for lookup of tone and other preferences
*/
public AudioChannelPanel(AudioChannel audioChannel, AliasModel aliasModel, IconModel iconModel,
SettingsManager settingsManager, UserPreferences userPreferences)
{
mIconModel = iconModel;
mSettingsManager = settingsManager;
@@ -97,12 +105,12 @@ public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, S
mAliasModel = aliasModel;
mUserPreferences = userPreferences;
mTalkgroupFormatPreference = mUserPreferences.getTalkgroupFormatPreference();
mAudioOutput = audioOutput;
mAudioChannel = audioChannel;
if(mAudioOutput != null)
if(mAudioChannel != null)
{
mAudioOutput.addAudioEventListener(this);
mAudioOutput.setIdentifierCollectionListener(new AudioMetadataProcessor());
mAudioChannel.addAudioEventListener(this);
mAudioChannel.setIdentifierCollectionListener(new AudioMetadataProcessor());
}
mBackgroundColor = SystemProperties.getInstance().get(PROPERTY_COLOR_BACKGROUND, Color.BLACK);
@@ -131,10 +139,10 @@ public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, S
//Deregister from receiving preference update notifications
MyEventBus.getGlobalEventBus().unregister(this);
if(mAudioOutput != null)
if(mAudioChannel != null)
{
mAudioOutput.removeAudioEventListener(this);
mAudioOutput.removeAudioMetadataListener();
mAudioChannel.removeAudioEventListener(this);
mAudioChannel.removeAudioMetadataListener();
}
}
@@ -152,7 +160,7 @@ public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, S
mMutedLabel.setVisible(false);
add(mMutedLabel);
mChannelName = new JLabel(mAudioOutput != null ? mAudioOutput.getChannelName() : " ");
mChannelName = new JLabel(mAudioChannel != null ? mAudioChannel.getChannelName() : " ");
mChannelName.setFont(mFont);
mChannelName.setForeground(mLabelColor);
add(mChannelName);
@@ -172,25 +180,11 @@ public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, S
switch(audioEvent.getType())
{
case AUDIO_STOPPED:
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
resetLabels();
}
});
EventQueue.invokeLater(this::resetLabels);
break;
case AUDIO_MUTED:
case AUDIO_UNMUTED:
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
mMutedLabel.setVisible(mAudioOutput.isMuted());
}
});
EventQueue.invokeLater(() -> mMutedLabel.setVisible(mAudioChannel.isMuted()));
break;
default:
break;

View File

@@ -1,7 +1,6 @@
/*
* ******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2019 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
@@ -15,7 +14,7 @@
*
* 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;
@@ -24,18 +23,26 @@ import io.github.dsheirer.audio.IAudioController;
import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.settings.SettingsManager;
import java.awt.Color;
import java.awt.Component;
import net.miginfocom.swing.MigLayout;
import javax.swing.JPanel;
import javax.swing.JSeparator;
import java.awt.Color;
import java.awt.Component;
import java.util.List;
/**
* Displays one or more audio channel panels.
*/
public class AudioChannelsPanel extends JPanel
{
private static final long serialVersionUID = 1L;
/**
* Constructs an instance
* @param iconModel for icon access
* @param userPreferences to monitor for audio playback changes
* @param settingsManager for tone insertion settings
* @param controller for the audio channels
* @param aliasModel for accessing aliases
*/
public AudioChannelsPanel(IconModel iconModel, UserPreferences userPreferences, SettingsManager settingsManager,
IAudioController controller, AliasModel aliasModel)
{
@@ -44,15 +51,13 @@ public class AudioChannelsPanel extends JPanel
setBackground(Color.BLACK);
List<AudioOutput> outputs = controller.getAudioOutputs();
addSeparator();
for(int x = 0; x < outputs.size(); x++)
for(int x = 0; x < controller.getAudioChannels().size(); x++)
{
add(new AudioChannelPanel(iconModel, userPreferences, settingsManager, outputs.get(x), aliasModel));
add(new AudioChannelPanel(controller.getAudioChannels().get(x), aliasModel, iconModel, settingsManager, userPreferences));
if(x < outputs.size() - 1)
if(x < controller.getAudioChannels().size() - 1)
{
addSeparator();
}
@@ -60,10 +65,10 @@ public class AudioChannelsPanel extends JPanel
/* Add an empty channel panel so that the panel is sized appropriately
* for either a single channel or two channels */
if(outputs.size() == 1)
if(controller.getAudioChannels().size() == 1)
{
addSeparator();
add(new AudioChannelPanel(iconModel, userPreferences, settingsManager, null, aliasModel), "growx");
add(new AudioChannelPanel(null, aliasModel, iconModel, settingsManager, userPreferences), "growx");
}
}
@@ -81,6 +86,9 @@ public class AudioChannelsPanel extends JPanel
}
}
/**
* Adds a separator to this panel.
*/
private void addSeparator()
{
JSeparator separator = new JSeparator(JSeparator.VERTICAL);

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 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
@@ -20,8 +20,6 @@ package io.github.dsheirer.audio.playback;
import io.github.dsheirer.alias.AliasModel;
import io.github.dsheirer.audio.AudioEvent;
import io.github.dsheirer.audio.AudioException;
import io.github.dsheirer.audio.IAudioController;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.gui.preference.PreferenceEditorType;
import io.github.dsheirer.gui.preference.ViewUserPreferenceEditorRequest;
@@ -29,81 +27,77 @@ import io.github.dsheirer.icon.IconModel;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import jiconfont.icons.font_awesome.FontAwesome;
import jiconfont.swing.IconFontSwing;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.sound.sampled.FloatControl;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JSlider;
import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
/**
* Audio playback panel
*/
public class AudioPanel extends JPanel implements Listener<AudioEvent>
{
private static final long serialVersionUID = 1L;
private static final Logger mLog = LoggerFactory.getLogger(AudioPanel.class);
private static ImageIcon MUTED_ICON = IconModel.getScaledIcon("images/audio_muted.png", 20);
private static ImageIcon UNMUTED_ICON = IconModel.getScaledIcon("images/audio_unmuted.png", 20);
private IconModel mIconModel;
private SettingsManager mSettingsManager;
private IAudioController mController;
private AliasModel mAliasModel;
private UserPreferences mUserPreferences;
private JButton mMuteButton;
private static final ImageIcon MUTED_ICON = IconModel.getScaledIcon("images/audio_muted.png", 20);
private static final ImageIcon UNMUTED_ICON = IconModel.getScaledIcon("images/audio_unmuted.png", 20);
private final AliasModel mAliasModel;
private final AudioPlaybackManager mAudioPlaybackManager;
private final IconModel mIconModel;
private final SettingsManager mSettingsManager;
private final UserPreferences mUserPreferences;
private AudioChannelsPanel mAudioChannelsPanel;
private JButton mMuteButton;
/**
* Constructs an instance
* @param iconModel for icon lookup
* @param userPreferences for preference lookup
* @param settingsManager to monitor for changes
* @param audioPlaybackManager for accessing the audio output
* @param aliasModel for alias lookup
*/
public AudioPanel(IconModel iconModel, UserPreferences userPreferences, SettingsManager settingsManager,
IAudioController controller, AliasModel aliasModel)
AudioPlaybackManager audioPlaybackManager, AliasModel aliasModel)
{
mIconModel = iconModel;
mSettingsManager = settingsManager;
mController = controller;
mAudioPlaybackManager = audioPlaybackManager;
mAliasModel = aliasModel;
mUserPreferences = userPreferences;
mController.addControllerListener(this);
mAudioPlaybackManager.addAudioEventListener(this);
init();
}
/**
* Initialize the display
*/
private void init()
{
setLayout(new MigLayout("insets 0 0 0 0", "[]0[grow,fill]", "[fill]0[]"));
setBackground(Color.BLACK);
mMuteButton = new MuteButton();
mMuteButton.setBackground(getBackground());
add(mMuteButton);
mAudioChannelsPanel = new AudioChannelsPanel(mIconModel, mUserPreferences, mSettingsManager, mController, mAliasModel);
mAudioChannelsPanel = new AudioChannelsPanel(mIconModel, mUserPreferences, mSettingsManager, mAudioPlaybackManager, mAliasModel);
add(mAudioChannelsPanel);
addMouseListener(new MouseSelectionListener());
}
/**
* Receive audio event notifications from the audio playback controller
*/
@Override
public void receive(AudioEvent event)
{
@@ -112,19 +106,14 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
case AUDIO_CONFIGURATION_CHANGE_STARTED:
break;
case AUDIO_CONFIGURATION_CHANGE_COMPLETE:
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
remove(mAudioChannelsPanel);
mAudioChannelsPanel.dispose();
mAudioChannelsPanel = new AudioChannelsPanel(mIconModel, mUserPreferences, mSettingsManager, mController, mAliasModel);
add(mAudioChannelsPanel);
mAudioChannelsPanel.repaint();
revalidate();
repaint();
}
EventQueue.invokeLater(() -> {
remove(mAudioChannelsPanel);
mAudioChannelsPanel.dispose();
mAudioChannelsPanel = new AudioChannelsPanel(mIconModel, mUserPreferences, mSettingsManager, mAudioPlaybackManager, mAliasModel);
add(mAudioChannelsPanel);
mAudioChannelsPanel.repaint();
revalidate();
repaint();
});
break;
default:
@@ -135,26 +124,19 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
/**
* Audio output mute control menu item.
*/
public class AudioOutputMuteItem extends JMenuItem
public static class AudioOutputMuteItem extends JMenuItem
{
private static final long serialVersionUID = 1L;
private AudioOutput mAudioOutput;
private final AudioOutput mAudioOutput;
/**
* Constructs an instance
* @param audioOutput to mute/unmute
*/
public AudioOutputMuteItem(AudioOutput audioOutput)
{
super(audioOutput.isMuted() ? "Unmute" : "Mute");
mAudioOutput = audioOutput;
addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
mAudioOutput.setMuted(!mAudioOutput.isMuted());
}
});
addActionListener(e -> mAudioOutput.setMuted(!mAudioOutput.isMuted()));
}
}
@@ -169,37 +151,23 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
if(SwingUtilities.isRightMouseButton(event))
{
JPopupMenu popup = new JPopupMenu();
/* Audio mixer/output selection menus */
JMenuItem outputMenu = new JMenuItem("Configure ...");
JMenuItem outputMenu = new JMenuItem("Audio Playback Device ...");
Icon icon = IconFontSwing.buildIcon(FontAwesome.COG, 14);
outputMenu.setIcon(icon);
outputMenu.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
MyEventBus.getGlobalEventBus().post(new ViewUserPreferenceEditorRequest(PreferenceEditorType.AUDIO_OUTPUT));
}
});
outputMenu.addActionListener(e -> MyEventBus.getGlobalEventBus()
.post(new ViewUserPreferenceEditorRequest(PreferenceEditorType.AUDIO_OUTPUT)));
popup.add(outputMenu);
popup.add(new JPopupMenu.Separator());
/* Audio output mute and volume control */
for(AudioOutput output : mController.getAudioOutputs())
//Add optional gain controller for th eaudio output
if(mAudioPlaybackManager.getAudioOutput() != null && mAudioPlaybackManager.getAudioOutput().hasGainControl())
{
JMenu menu = new JMenu("Channel: " + output.getChannelName());
menu.add(new AudioOutputMuteItem(output));
if(output.hasGainControl())
{
JMenu volume = new JMenu("Volume");
volume.add(new VolumeSlider(output.getGainControl()));
menu.add(volume);
}
popup.add(menu);
popup.add(new JPopupMenu.Separator());
JMenuItem volume = new JMenuItem("Audio Volume");
volume.setEnabled(false);
Icon volumeIcon = IconFontSwing.buildIcon(FontAwesome.VOLUME_UP, 14);
volume.setIcon(volumeIcon);
popup.add(volume);
popup.add(new VolumeSlider(mAudioPlaybackManager.getAudioOutput().getGainControl()));
}
popup.show(event.getComponent(), event.getX(), event.getY());
@@ -230,12 +198,14 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
/**
* Audio volume (gain) adjustment slider control
*/
public class VolumeSlider extends JSlider
public static class VolumeSlider extends JSlider
{
private static final long serialVersionUID = 1L;
private FloatControl mFloatControl;
private final FloatControl mFloatControl;
/**
* Constructs an instance
* @param control to be controlled
*/
public VolumeSlider(FloatControl control)
{
super(0, 100, 0);
@@ -244,21 +214,11 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
setMinorTickSpacing(5);
setPaintTicks(true);
setPaintLabels(true);
mFloatControl = control;
setValue(getIntegerValue(mFloatControl.getValue()));
addChangeListener(new ChangeListener()
{
@Override
public void stateChanged(ChangeEvent event)
{
mFloatControl.shift(mFloatControl.getValue(),
getFloatValue(VolumeSlider.this.getValue()),
1000);
}
});
addChangeListener(event -> mFloatControl.shift(mFloatControl.getValue(),
getFloatValue(VolumeSlider.this.getValue()),
1000));
addMouseListener(new MouseListener()
{
@@ -270,22 +230,10 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
VolumeSlider.this.setValue(50);
}
}
public void mouseReleased(MouseEvent arg0)
{
}
public void mousePressed(MouseEvent arg0)
{
}
public void mouseExited(MouseEvent arg0)
{
}
public void mouseEntered(MouseEvent arg0)
{
}
public void mouseReleased(MouseEvent arg0) {}
public void mousePressed(MouseEvent arg0) {}
public void mouseExited(MouseEvent arg0) {}
public void mouseEntered(MouseEvent arg0) {}
});
}
@@ -331,52 +279,12 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
}
}
/**
* Mixer/Channel configuration selection item
*/
public class MixerSelectionItem extends JMenuItem
{
private static final long serialVersionUID = 1L;
private MixerChannelConfiguration mConfiguration;
public MixerSelectionItem(MixerChannelConfiguration config)
{
super(config.toString());
mConfiguration = config;
addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
try
{
mController.setMixerChannelConfiguration(mConfiguration);
}
catch(AudioException e1)
{
mLog.error("Couldn't set mixer channel configuration "
+ "to: " + mConfiguration.toString());
JOptionPane.showMessageDialog(MixerSelectionItem.this,
"Couldn't set [" + mConfiguration.toString() +
"] as the audio output device");
}
}
});
}
}
/**
* Mute button to mute all audio output channels exposed by the audio
* controller
*/
public class MuteButton extends JButton
{
private static final long serialVersionUID = 1L;
private boolean mMuted = false;
public MuteButton()
@@ -384,29 +292,13 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
setIcon(UNMUTED_ICON);
setBorderPainted(false);
getAccessibleContext().setAccessibleName("Mute");
addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
mMuted = !mMuted;
for(AudioOutput output : mController.getAudioOutputs())
{
output.setMuted(mMuted);
}
EventQueue.invokeLater(new Runnable()
{
@Override
public void run()
{
setIcon(mMuted ? MUTED_ICON : UNMUTED_ICON);
getAccessibleContext().setAccessibleName(mMuted ? "Unmute" : "Mute");
}
});
}
addActionListener(e -> {
mMuted = !mMuted;
mAudioPlaybackManager.getAudioOutput().setMuted(mMuted);
EventQueue.invokeLater(() -> {
setIcon(mMuted ? MUTED_ICON : UNMUTED_ICON);
getAccessibleContext().setAccessibleName(mMuted ? "Unmute" : "Mute");
});
});
}
}

View File

@@ -0,0 +1,107 @@
/*
* *****************************************************************************
* 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 javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Mixer;
/**
* Audio playback device descriptor for a mixer and a supported audio output format (ie number of channels).
*/
public class AudioPlaybackDeviceDescriptor implements Comparable<AudioPlaybackDeviceDescriptor>
{
private final Mixer.Info mMixerInfo;
private final AudioFormat mAudioFormat;
/**
* Constructs an instance
*
* @param mixerInfo for the mixer
* @param audioFormat supported by the mixer
*/
public AudioPlaybackDeviceDescriptor(Mixer.Info mixerInfo, AudioFormat audioFormat)
{
mMixerInfo = mixerInfo;
mAudioFormat = audioFormat;
}
@Override
public String toString()
{
return getCleanDescription(getMixerInfo()) + "(" + getMixerInfo().getName() + ") - " +
getChannelDescription(getAudioFormat());
}
private static String getCleanDescription(Mixer.Info mixerInfo)
{
return mixerInfo.getDescription().replace("Direct Audio Device: ", "");
}
/**
* Provides a description of the channels for the audio format
* @param audioFormat with channels
* @return pretty string
*/
private static String getChannelDescription(AudioFormat audioFormat)
{
return switch(audioFormat.getChannels())
{
case 1 -> "Mono";
case 2 -> "Stereo";
case AudioSystem.NOT_SPECIFIED -> "Unknown number of channels";
default -> audioFormat.getChannels() + " Channels";
};
}
/**
* Audio format supported by the mixer
*
* @return audio format
*/
public AudioFormat getAudioFormat()
{
return mAudioFormat;
}
/**
* Mixer information for the available system mixer.
*/
public Mixer.Info getMixerInfo()
{
return mMixerInfo;
}
@Override
public int compareTo(AudioPlaybackDeviceDescriptor other)
{
if(other == null)
{
return 1;
}
if(other.getMixerInfo().getDescription().equals(getMixerInfo().getDescription()))
{
return Integer.compare(getAudioFormat().getChannels(), other.getAudioFormat().getChannels());
}
return getMixerInfo().getDescription().compareTo(other.getMixerInfo().getDescription());
}
}

View File

@@ -0,0 +1,130 @@
/*
* *****************************************************************************
* 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 java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.Line;
import javax.sound.sampled.Mixer;
/**
* Audio playback device manager.
*/
public class AudioPlaybackDeviceManager
{
/**
* Always returns the first available device in the system.
*/
public static AudioPlaybackDeviceDescriptor getDefaultAudioPLaybackDevice()
{
List<AudioPlaybackDeviceDescriptor> devices = getAudioPlaybackDevices();
if(!devices.isEmpty())
{
return devices.getFirst();
}
return null;
}
/**
* Finds the matching audio playback device
* @param name of the device
* @param channels count
* @return matching entry or the default playback device.
*/
public static AudioPlaybackDeviceDescriptor getAudioPlaybackDevice(String name, int channels)
{
for(AudioPlaybackDeviceDescriptor device: getAudioPlaybackDevices())
{
if(device.getMixerInfo().getName().equals(name) && device.getAudioFormat().getChannels() == channels)
{
return device;
}
}
return getDefaultAudioPLaybackDevice();
}
public static List<AudioPlaybackDeviceDescriptor> getAudioPlaybackDevices()
{
List<AudioPlaybackDeviceDescriptor> devices = new ArrayList<>();
Mixer.Info[] mixerInfos = AudioSystem.getMixerInfo();
for(Mixer.Info mixerInfo : mixerInfos)
{
Mixer mixer = AudioSystem.getMixer(mixerInfo);
if(mixer != null)
{
List<AudioFormat> discoveredFormats = new ArrayList<>();
for(Line.Info sourceLineInfo : mixer.getSourceLineInfo())
{
if(sourceLineInfo instanceof DataLine.Info dataLineInfo)
{
for(AudioFormat audioFormat : dataLineInfo.getFormats())
{
if(!audioFormat.isBigEndian() &&
audioFormat.getSampleSizeInBits() == 16 &&
!discoveredFormats.contains(audioFormat) &&
audioFormat.getChannels() <= 2) //TODO: remove the 2 channels max restriction once supported
{
if(audioFormat.getSampleRate() == AudioSystem.NOT_SPECIFIED)
{
discoveredFormats.add(audioFormat);
//Modify the format for 8 kHz sample rate
AudioFormat updatedFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
8000, 16, audioFormat.getChannels(), audioFormat.getFrameSize(),
8000, false);
devices.add(new AudioPlaybackDeviceDescriptor(mixerInfo, updatedFormat));
}
else if(audioFormat.getSampleRate() == 8000)
{
discoveredFormats.add(audioFormat);
devices.add(new AudioPlaybackDeviceDescriptor(mixerInfo, audioFormat));
}
}
}
}
}
}
}
Collections.sort(devices);
return devices;
}
static void main()
{
List<AudioPlaybackDeviceDescriptor> devices = getAudioPlaybackDevices();
for(AudioPlaybackDeviceDescriptor device : devices)
{
System.out.println(device);
}
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 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
@@ -27,11 +27,11 @@ 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 io.github.dsheirer.source.mixer.MixerChannel;
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
@@ -51,23 +51,16 @@ import org.slf4j.LoggerFactory;
public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioController
{
private static final Logger mLog = LoggerFactory.getLogger(AudioPlaybackManager.class);
public static final AudioEvent CONFIGURATION_CHANGE_STARTED =
new AudioEvent(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_STARTED, null);
public static final AudioEvent CONFIGURATION_CHANGE_COMPLETE =
new AudioEvent(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_COMPLETE, null);
private Broadcaster<AudioEvent> mControllerBroadcaster = new Broadcaster<>();
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;
private UserPreferences mUserPreferences;
private MixerChannelConfiguration mMixerChannelConfiguration;
private List<AudioOutput> mAudioOutputs = new ArrayList<>();
private List<AudioSegment> mAudioSegments = new ArrayList<>();
private List<AudioSegment> mPendingAudioSegments = new ArrayList<>();
private LinkedTransferQueue<AudioSegment> mNewAudioSegmentQueue = new LinkedTransferQueue<>();
private ScheduledExecutorService mScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor(new NamingThreadFactory("sdrtrunk audio manager"));
private AudioSegmentPrioritySorter mAudioSegmentPrioritySorter = new AudioSegmentPrioritySorter();
private ReentrantLock mAudioOutputLock = new ReentrantLock();
/**
* Constructs an instance.
@@ -78,20 +71,18 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
{
mUserPreferences = userPreferences;
MyEventBus.getGlobalEventBus().register(this);
AudioPlaybackDeviceDescriptor device = mUserPreferences.getPlaybackPreference().getAudioPlaybackDevice();
MixerChannelConfiguration configuration = mUserPreferences.getPlaybackPreference().getMixerChannelConfiguration();
if(configuration != null)
if(device != null)
{
try
{
setMixerChannelConfiguration(configuration);
setAudioPlaybackDevice(device);
}
catch(AudioException ae)
{
mLog.error("Error during setup of audio playback configuration. Attempted to use audio mixer [" +
(configuration != null ? configuration.getMixer().toString() : "null") + "] and channel [" +
(configuration != null ? configuration.getMixerChannel().name() : "null") + "]", ae);
mLog.error("Error during setup of audio playback configuration. Attempted to use device [" +
device + "]", ae);
}
}
else
@@ -99,13 +90,16 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
mLog.warn("No audio output devices available");
}
mProcessingTask = mScheduledExecutorService.scheduleAtFixedRate(new AudioSegmentProcessor(),
//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
* @param audioSegment to receive and process
*/
@Override
public void receive(AudioSegment audioSegment)
@@ -195,11 +189,11 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
}
else if(audioSegment.isLinked())
{
mAudioOutputLock.lock();
mAudioChannelsLock.lock();
try
{
for(AudioOutput audioOutput: mAudioOutputs)
for(AudioChannel audioOutput: mAudioOutput.getAudioProvider().getAudioChannels())
{
if(audioOutput.isLinkedTo(audioSegment))
{
@@ -210,7 +204,7 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
}
finally
{
mAudioOutputLock.unlock();
mAudioChannelsLock.unlock();
}
}
}
@@ -219,18 +213,16 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
if(!mAudioSegments.isEmpty())
{
mAudioSegments.sort(mAudioSegmentPrioritySorter);
mAudioOutputLock.lock();
mAudioChannelsLock.lock();
try
{
//Assign empty audio outputs first
for(AudioOutput audioOutput: mAudioOutputs)
for(AudioChannel audioChannel: mAudioOutput.getAudioProvider().getAudioChannels())
{
if(audioOutput.isEmpty())
if(audioChannel.isEmpty())
{
audioOutput.play(mAudioSegments.remove(0));
audioChannel.play(mAudioSegments.removeFirst());
if(mAudioSegments.isEmpty())
{
return;
@@ -240,7 +232,7 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
}
finally
{
mAudioOutputLock.unlock();
mAudioChannelsLock.unlock();
}
}
@@ -273,6 +265,19 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
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.
@@ -282,100 +287,112 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
{
if(preferenceType == PreferenceType.PLAYBACK)
{
MixerChannelConfiguration configuration = mUserPreferences.getPlaybackPreference().getMixerChannelConfiguration();
AudioPlaybackDeviceDescriptor device = mUserPreferences.getPlaybackPreference().getAudioPlaybackDevice();
if(configuration != null && !configuration.equals(mMixerChannelConfiguration))
if(device != null && !device.equals(getAudioPlaybackDevice()))
{
try
{
setMixerChannelConfiguration(configuration);
setAudioPlaybackDevice(device);
}
catch(AudioException ae)
{
mLog.error("Error changing audio output to [" + configuration + "]", ae);
mLog.error("Error changing audio output to [" + device + "]", ae);
}
}
}
}
/**
* Configures audio playback to use the configuration specified in the entry argument.
* Configures audio playback to use the audio device argument.
*
* @param entry to use in configuring the audio playback setup.
* @param audioDevice to use in configuring the audio playback setup.
* @throws AudioException if there is an error
*/
@Override
public void setMixerChannelConfiguration(MixerChannelConfiguration entry) throws AudioException
public void setAudioPlaybackDevice(AudioPlaybackDeviceDescriptor audioDevice) throws AudioException
{
if(entry != null)
if(audioDevice != null)
{
mControllerBroadcaster.broadcast(CONFIGURATION_CHANGE_STARTED);
mControllerBroadcaster.broadcast(new AudioEvent(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_STARTED,
audioDevice.getMixerInfo().getName()));
mAudioOutputLock.lock();
mAudioChannelsLock.lock();
try
{
for(AudioOutput audioOutput: mAudioOutputs)
if(mAudioOutput != null)
{
audioOutput.dispose();
mAudioOutput.dispose();
}
mAudioOutputs.clear();
int channelCount = audioDevice.getAudioFormat().getChannels();
switch(entry.getMixerChannel())
switch(channelCount)
{
case MONO:
AudioOutput mono = new MonoAudioOutput(entry.getMixer(), mUserPreferences);
mAudioOutputs.add(mono);
case 1:
mAudioOutput = new AudioOutput(audioDevice, new AudioProviderMono(mUserPreferences));
break;
case STEREO:
AudioOutput left = new StereoAudioOutput(entry.getMixer(), MixerChannel.LEFT, mUserPreferences);
mAudioOutputs.add(left);
AudioOutput right = new StereoAudioOutput(entry.getMixer(), MixerChannel.RIGHT, mUserPreferences);
mAudioOutputs.add(right);
case 2:
mAudioOutput = new AudioOutput(audioDevice, new AudioProviderStereo(mUserPreferences));
break;
default:
throw new AudioException("Unsupported mixer channel configuration: " + entry.getMixerChannel());
// 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
{
mAudioOutputLock.unlock();
mAudioChannelsLock.unlock();
}
mControllerBroadcaster.broadcast(CONFIGURATION_CHANGE_COMPLETE);
mMixerChannelConfiguration = entry;
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 MixerChannelConfiguration getMixerChannelConfiguration()
public AudioPlaybackDeviceDescriptor getAudioPlaybackDevice()
{
return mMixerChannelConfiguration;
return mAudioPlaybackDevice;
}
/**
* List of sorted audio outputs available for the current mixer channel configuration
*/
@Override
public List<AudioOutput> getAudioOutputs()
public List<AudioChannel> getAudioChannels()
{
List<AudioOutput> outputs = new ArrayList<>(mAudioOutputs);
if(mAudioOutput != null)
{
return mAudioOutput.getAudioProvider().getAudioChannels();
}
outputs.sort(Comparator.comparing(AudioOutput::getChannelName));
return outputs;
return Collections.emptyList();
}
/**
* Adds an audio event listener to receive audio event notifications.
*/
@Override
public void addControllerListener(Listener<AudioEvent> listener)
public void addAudioEventListener(Listener<AudioEvent> listener)
{
mControllerBroadcaster.addListener(listener);
}
@@ -384,7 +401,7 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
* Removes an audio event listener from receiving audio event notifications.
*/
@Override
public void removeControllerListener(Listener<AudioEvent> listener)
public void removeAudioEventListener(Listener<AudioEvent> listener)
{
mControllerBroadcaster.removeListener(listener);
}
@@ -394,7 +411,7 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
*/
public class AudioSegmentProcessor implements Runnable
{
private AtomicBoolean mProcessing = new AtomicBoolean();
private final AtomicBoolean mProcessing = new AtomicBoolean();
@Override
public void run()
@@ -418,7 +435,7 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
/**
* Audio segment comparator for sorting audio segments by: 1)Playback priority and 2)Segment start time
*/
public class AudioSegmentPrioritySorter implements Comparator<AudioSegment>
public static class AudioSegmentPrioritySorter implements Comparator<AudioSegment>
{
@Override
public int compare(AudioSegment segment1, AudioSegment segment2)

View File

@@ -0,0 +1,72 @@
/*
* *****************************************************************************
* 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 io.github.dsheirer.audio.AudioEvent;
import java.nio.ByteBuffer;
import java.util.List;
import javax.sound.sampled.AudioFormat;
/**
* Abstract audio provider implementation
*/
public abstract class AudioProvider
{
/**
* Audio format for this provider
*/
public abstract AudioFormat getAudioFormat();
/**
* Provide a non-null, 160-sample/20-millisecond buffer of audio at 8 kHz
* @return audio buffer
*/
public abstract ByteBuffer getAudio();
/**
* Provides access to the audio channels for this provider
* @return list of audio channels
*/
public abstract List<AudioChannel> getAudioChannels();
/**
* Notifies audio event listener(s) of the event
* @param eventType to broadcast
*/
public void notify(AudioEvent.Type eventType)
{
for(AudioChannel audioChannel : getAudioChannels())
{
audioChannel.notify(eventType);
}
}
/**
* Disposes the audio channel to prepare for audio output changes or shutdown.
*/
public void dispose()
{
for(AudioChannel audioChannel: getAudioChannels())
{
audioChannel.dispose();
}
}
}

View File

@@ -0,0 +1,85 @@
/*
* *****************************************************************************
* 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 io.github.dsheirer.audio.AudioFormats;
import io.github.dsheirer.preference.UserPreferences;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.List;
import javax.sound.sampled.AudioFormat;
/**
* Single channel (mono) audio provider wraps a single audio channel and provides converted audio.
*/
public class AudioProviderMono extends AudioProvider
{
private static final ByteBuffer SILENCE = ByteBuffer.allocate(AudioChannel.SAMPLES_PER_INTERVAL * 2);
private final AudioChannel mAudioChannel;
/**
* Constructs an instance
* @param userPreferences for monitoring preference changes
*/
public AudioProviderMono(UserPreferences userPreferences)
{
mAudioChannel = new AudioChannel(userPreferences, "MONO");
}
@Override
public List<AudioChannel> getAudioChannels()
{
return List.of(mAudioChannel);
}
@Override
public AudioFormat getAudioFormat()
{
return AudioFormats.PCM_SIGNED_8000_HZ_16_BIT_MONO;
}
@Override
public ByteBuffer getAudio()
{
float[] audio = mAudioChannel.getAudio();
if(audio == null)
{
if(!mAudioChannel.hasAudioSegment())
{
mAudioChannel.clearMetadata();
}
return SILENCE;
}
ByteBuffer buffer = ByteBuffer.allocate(AudioChannel.SAMPLES_PER_INTERVAL * 2).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer shortBuffer = buffer.asShortBuffer();
for(int x = 0; x < AudioChannel.SAMPLES_PER_INTERVAL; x++)
{
shortBuffer.put((short) (audio[x] * Short.MAX_VALUE));
}
return buffer;
}
}

View File

@@ -0,0 +1,123 @@
/*
* *****************************************************************************
* 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 io.github.dsheirer.preference.UserPreferences;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.sound.sampled.AudioFormat;
/**
* Multi-Channel audio provider implementation. Provides an interleaved stream of audio from multiple channels.
*
* Note: this implementation has not been tested for use.
*/
public class AudioProviderMultiChannel extends AudioProvider
{
private static ByteBuffer SILENCE;
private final List<AudioChannel> mAudioChannels = new ArrayList<>();
private final AudioFormat mAudioFormat;
/**
* Constructs a multi-channel audio provider
* @param userPreferences for this provider
* @param audioFormat for this provider
*/
public AudioProviderMultiChannel(UserPreferences userPreferences, AudioFormat audioFormat)
{
SILENCE = ByteBuffer.allocate(AudioChannel.SAMPLES_PER_INTERVAL * audioFormat.getChannels());
for(int channel = 0; channel < audioFormat.getChannels(); channel++)
{
mAudioChannels.add(new AudioChannel(userPreferences, String.valueOf(channel)));
}
mAudioFormat = audioFormat;
}
@Override
public AudioFormat getAudioFormat()
{
return mAudioFormat;
}
/**
* Access the next buffer of audio.
* @return next buffer or null if there is no audio available
*/
@Override
public ByteBuffer getAudio()
{
float[][] samples = new float[mAudioChannels.size()][];
boolean hasAudio = false;
AudioChannel channel = null;
for(int x = 0; x < mAudioChannels.size(); x++)
{
channel = mAudioChannels.get(x);
samples[x] = channel.getAudio();
if(samples[x] == null)
{
if(!channel.hasAudioSegment())
{
channel.clearMetadata();
}
samples[x] = new float[AudioChannel.SAMPLES_PER_INTERVAL];
}
else
{
hasAudio = true;
}
}
if(!hasAudio)
{
return SILENCE;
}
ByteBuffer buffer = ByteBuffer.allocate(AudioChannel.SAMPLES_PER_INTERVAL * 2 *
mAudioChannels.size()).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer shortBuffer = buffer.asShortBuffer();
for(int sampleCounter = 0; sampleCounter < AudioChannel.SAMPLES_PER_INTERVAL; sampleCounter++)
{
for(int channelNumber = 0; channelNumber < mAudioChannels.size(); channelNumber++)
{
shortBuffer.put((short)(samples[channelNumber][sampleCounter] * Short.MAX_VALUE));
}
}
return buffer;
}
@Override
public List<AudioChannel> getAudioChannels()
{
return Collections.unmodifiableList(mAudioChannels);
}
}

View File

@@ -0,0 +1,111 @@
/*
* *****************************************************************************
* 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 io.github.dsheirer.audio.AudioFormats;
import io.github.dsheirer.preference.UserPreferences;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.List;
import javax.sound.sampled.AudioFormat;
/**
* Provides an interleaved stream of left and right audio samples from audio channels.
*
* Note: the getAudio() method is optimized to provide buffers of 160 samples (x2) of audio per interval, equivalent to
* 20 milliseconds of audio at 8,000 Hertz sample rate.
*/
public class AudioProviderStereo extends AudioProvider
{
private final AudioChannel mAudioChannelLeft;
private final AudioChannel mAudioChannelRight;
private static final ByteBuffer SILENCE = ByteBuffer.allocate(AudioChannel.SAMPLES_PER_INTERVAL * 4);
/**
* Constructs an instance.
* @param userPreferences for monitoring preference changes
*/
public AudioProviderStereo(UserPreferences userPreferences)
{
mAudioChannelLeft = new AudioChannel(userPreferences, "LEFT");
mAudioChannelRight = new AudioChannel(userPreferences, "RIGHT");
}
@Override
public AudioFormat getAudioFormat()
{
return AudioFormats.PCM_SIGNED_8000_HZ_16BITS_STEREO;
}
@Override
public List<AudioChannel> getAudioChannels()
{
return List.of(mAudioChannelLeft, mAudioChannelRight);
}
/**
* Access the next buffer of audio.
* @return next buffer or null if there is no audio available
*/
@Override
public ByteBuffer getAudio()
{
float[] left = mAudioChannelLeft.getAudio();
if(left == null && !mAudioChannelLeft.hasAudioSegment())
{
mAudioChannelLeft.clearMetadata();
}
float[] right = mAudioChannelRight.getAudio();
if(right == null && !mAudioChannelRight.hasAudioSegment())
{
mAudioChannelRight.clearMetadata();
}
//If we didn't get audio from either channel send silence buffer
if(left == null && right == null)
{
return SILENCE;
}
else if(left == null)
{
left = new float[AudioChannel.SAMPLES_PER_INTERVAL];
}
else if(right == null)
{
right = new float[AudioChannel.SAMPLES_PER_INTERVAL];
}
ByteBuffer buffer = ByteBuffer.allocate(AudioChannel.SAMPLES_PER_INTERVAL * 4).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer shortBuffer = buffer.asShortBuffer();
for(int x = 0; x < AudioChannel.SAMPLES_PER_INTERVAL; x++)
{
shortBuffer.put((short)(left[x] * Short.MAX_VALUE));
shortBuffer.put((short)(right[x] * Short.MAX_VALUE));
}
return buffer;
}
}

View File

@@ -1,65 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 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 io.github.dsheirer.audio.AudioFormats;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.mixer.MixerChannel;
import javax.sound.sampled.Mixer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
/**
* Mono Audio output implementation
*/
public class MonoAudioOutput extends AudioOutput
{
private final static int BUFFER_SIZE = 8000;
public MonoAudioOutput(Mixer mixer, UserPreferences userPreferences)
{
super(mixer, MixerChannel.MONO, AudioFormats.PCM_SIGNED_8000_HZ_16_BIT_MONO,
AudioFormats.MONO_SOURCE_DATALINE_INFO, BUFFER_SIZE, userPreferences);
}
/**
* Converts the audio packet data into mono audio frames.
*/
protected ByteBuffer convert(float[] samples)
{
ByteBuffer buffer = null;
if(samples.length > 0)
{
/* Little-endian byte buffer */
buffer = ByteBuffer.allocate(samples.length * 2).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer shortBuffer = buffer.asShortBuffer();
for(float sample : samples)
{
shortBuffer.put((short) (sample * Short.MAX_VALUE));
}
}
return buffer;
}
}

View File

@@ -1,79 +0,0 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2022 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 io.github.dsheirer.audio.AudioFormats;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.source.mixer.MixerChannel;
import javax.sound.sampled.Mixer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
/**
* Stereo audio output implementation.
*/
public class StereoAudioOutput extends AudioOutput
{
private final static int BUFFER_SIZE = 16000;
public StereoAudioOutput(Mixer mixer, MixerChannel channel, UserPreferences userPreferences)
{
super(mixer, channel, AudioFormats.PCM_SIGNED_8000_HZ_16BITS_STEREO, AudioFormats.STEREO_SOURCE_DATALINE_INFO,
BUFFER_SIZE, userPreferences);
}
/**
* Converts the audio packet data into stereo audio frames with the mixer
* channel containing the audio and the other channel containing zero
* valued (silent) samples.
*/
protected ByteBuffer convert(float[] samples)
{
ByteBuffer buffer = null;
if(samples.length > 0)
{
/* Little-endian byte buffer */
buffer = ByteBuffer.allocate(samples.length * 4).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer shortBuffer = buffer.asShortBuffer();
if(getMixerChannel() == MixerChannel.LEFT)
{
for(float sample : samples)
{
shortBuffer.put((short) (sample * Short.MAX_VALUE));
shortBuffer.put((short) 0);
}
}
else
{
for(float sample : samples)
{
shortBuffer.put((short) 0);
shortBuffer.put((short) (sample * Short.MAX_VALUE));
}
}
}
return buffer;
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 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
@@ -30,7 +30,7 @@ public enum PreferenceEditorType
JMBE_LIBRARY("JMBE Audio Library"),
AUDIO_MP3("MP3"),
AUDIO_RECORD("Record"),
AUDIO_OUTPUT("Output/Tones"),
AUDIO_OUTPUT("Playback/Tones"),
AUDIO_CALL_MANAGEMENT("Call Management"),
SOURCE_TUNERS("Tuners"),
TALKGROUP_FORMAT("Talkgroup & Radio ID"),

View File

@@ -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
@@ -19,14 +19,12 @@
package io.github.dsheirer.gui.preference.playback;
import io.github.dsheirer.audio.AudioFormats;
import io.github.dsheirer.audio.playback.AudioPlaybackDeviceDescriptor;
import io.github.dsheirer.audio.playback.AudioPlaybackDeviceManager;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.playback.PlayTestAudioRequest;
import io.github.dsheirer.preference.playback.PlaybackPreference;
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
import io.github.dsheirer.source.mixer.MixerManager;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
@@ -45,10 +43,6 @@ import org.controlsfx.control.ToggleSwitch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import javax.sound.sampled.DataLine;
/**
* Preference settings for audio playback
@@ -58,8 +52,8 @@ public class PlaybackPreferenceEditor extends HBox
private final static Logger mLog = LoggerFactory.getLogger(PlaybackPreferenceEditor.class);
private final PlaybackPreference mPlaybackPreference;
private GridPane mEditorPane;
private ComboBox<MixerChannelConfiguration> mMixerComboBox;
private Button mMixerTestButton;
private ComboBox<AudioPlaybackDeviceDescriptor> mAudioPlaybackDevicesCombo;
private Button mPlaybackDeviceTestButton;
private ToggleSwitch mUseAudioSegmentStartToneSwitch;
private Button mTestStartToneButton;
private ToggleSwitch mUseAudioSegmentDropToneSwitch;
@@ -86,11 +80,11 @@ public class PlaybackPreferenceEditor extends HBox
mEditorPane.setPadding(new Insets(10, 10, 10, 10));
mEditorPane.setHgap(10);
mEditorPane.setVgap(10);
Label outputLabel = new Label("Audio Output Device");
Label outputLabel = new Label("Audio Playback Device");
GridPane.setHalignment(outputLabel, HPos.RIGHT);
mEditorPane.add(outputLabel, 0, row, 2, 1);
mEditorPane.add(getMixerComboBox(), 2, row, 3, 1);
mEditorPane.add(getMixerTestButton(), 5, row);
mEditorPane.add(getAudioPlaybackDevicesCombo(), 2, row, 3, 1);
mEditorPane.add(getPlaybackDeviceTestButton(), 5, row);
mEditorPane.add(new Separator(Orientation.HORIZONTAL), 0, ++row, 6, 1);
mEditorPane.add(new Label("Audio Playback Insert Tones"), 0, ++row, 2, 1);
@@ -122,32 +116,34 @@ public class PlaybackPreferenceEditor extends HBox
return mEditorPane;
}
private ComboBox<MixerChannelConfiguration> getMixerComboBox()
private ComboBox<AudioPlaybackDeviceDescriptor> getAudioPlaybackDevicesCombo()
{
if(mMixerComboBox == null)
if(mAudioPlaybackDevicesCombo == null)
{
mMixerComboBox = new ComboBox<>();
mMixerComboBox.getItems().addAll(MixerManager.getOutputMixers());
mMixerComboBox.getSelectionModel().select(mPlaybackPreference.getMixerChannelConfiguration());
mMixerComboBox.getSelectionModel().selectedItemProperty()
.addListener((observable, oldValue, newValue) -> mPlaybackPreference.setMixerChannelConfiguration(newValue));
mAudioPlaybackDevicesCombo = new ComboBox<>();
mAudioPlaybackDevicesCombo.getItems().addAll(AudioPlaybackDeviceManager.getAudioPlaybackDevices());
mAudioPlaybackDevicesCombo.getSelectionModel().select(mPlaybackPreference.getAudioPlaybackDevice());
mAudioPlaybackDevicesCombo.getSelectionModel().selectedItemProperty()
.addListener((observable, oldValue,
newValue) -> mPlaybackPreference.setAudioPlaybackDevice(newValue));
}
return mMixerComboBox;
return mAudioPlaybackDevicesCombo;
}
public Button getMixerTestButton()
public Button getPlaybackDeviceTestButton()
{
if(mMixerTestButton == null)
if(mPlaybackDeviceTestButton == null)
{
mMixerTestButton = new Button("Test");
mPlaybackDeviceTestButton = new Button("Test");
IconNode iconNode = new IconNode(FontAwesome.PLAY);
iconNode.setFill(Color.CORNFLOWERBLUE);
mMixerTestButton.setGraphic(iconNode);
mMixerTestButton.setOnAction(event -> play(mPlaybackPreference.getMixerTestTone()));
mPlaybackDeviceTestButton.setGraphic(iconNode);
mPlaybackDeviceTestButton.setOnAction(event ->
play(mPlaybackPreference.getAudioPlaybackTestTone(), PlayTestAudioRequest.ALL_CHANNELS));
}
return mMixerTestButton;
return mPlaybackDeviceTestButton;
}
private ToggleSwitch getUseAudioSegmentStartToneSwitch()
@@ -185,8 +181,9 @@ public class PlaybackPreferenceEditor extends HBox
IconNode iconNode = new IconNode(FontAwesome.PLAY);
iconNode.setFill(Color.CORNFLOWERBLUE);
mTestStartToneButton.setGraphic(iconNode);
mTestStartToneButton.setOnAction(event -> play(mPlaybackPreference.getStartTone()));
mTestStartToneButton.setOnAction(_ ->
play(mPlaybackPreference.getStartTone(PlaybackPreference.TONE_LENGTH_SAMPLES * 3),
PlayTestAudioRequest.ALL_CHANNELS));
mTestStartToneButton.disableProperty().bind(getUseAudioSegmentStartToneSwitch().selectedProperty().not());
}
@@ -201,8 +198,9 @@ public class PlaybackPreferenceEditor extends HBox
IconNode iconNode = new IconNode(FontAwesome.PLAY);
iconNode.setFill(Color.CORNFLOWERBLUE);
mTestDropToneButton.setGraphic(iconNode);
mTestDropToneButton.setOnAction(event -> play(mPlaybackPreference.getDropTone()));
mTestDropToneButton.setOnAction(_ ->
play(mPlaybackPreference.getDropTone(PlaybackPreference.TONE_LENGTH_SAMPLES * 3),
PlayTestAudioRequest.ALL_CHANNELS));
mTestDropToneButton.disableProperty().bind(getUseAudioSegmentDropToneSwitch().selectedProperty().not());
}
@@ -270,43 +268,12 @@ public class PlaybackPreferenceEditor extends HBox
}
/**
* Plays the audio buffer over the default mono playback device
* Sends a request to play the audio samples over the specified audio playback channel
* @param audioSamples with 8 kHz mono PCM samples
* @param channel number (0=mono/left, 1=right, etc.)
*/
private void play(float[] audioSamples)
private void play(float[] audioSamples, int channel)
{
if(audioSamples != null)
{
/* Little-endian byte buffer */
ByteBuffer buffer = ByteBuffer.allocate(audioSamples.length * 2).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer shortBuffer = buffer.asShortBuffer();
for(float sample : audioSamples)
{
shortBuffer.put((short) (sample * Short.MAX_VALUE));
}
byte[] bytes = buffer.array();
DataLine.Info info = new DataLine.Info(Clip.class, AudioFormats.PCM_SIGNED_8000_HZ_16_BIT_MONO);
if(!AudioSystem.isLineSupported(info))
{
mLog.error("Audio clip playback is not supported on this system");
return;
}
try
{
Clip clip = (Clip)AudioSystem.getLine(info);
clip.open(AudioFormats.PCM_SIGNED_8000_HZ_16_BIT_MONO, bytes, 0, bytes.length);
clip.start();
}
catch(Exception e)
{
mLog.error("Error attempting to play audio test tone", e);
}
}
MyEventBus.getGlobalEventBus().post(new PlayTestAudioRequest(audioSamples, channel));
}
}

View File

@@ -0,0 +1,36 @@
/*
* *****************************************************************************
* 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.preference.playback;
/**
* Request to play a test tone over the currently employed audio playback device.
*
* @param audio to play (160 samples in length = 20 milliseconds)
* @param channel number for the audio channel (0 = mono/left, 1 = right, etc.)
*/
public record PlayTestAudioRequest(float[] audio, int channel)
{
public static final int ALL_CHANNELS = -1;
public boolean isAllChannels()
{
return channel == ALL_CHANNELS;
}
};

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 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
@@ -19,14 +19,14 @@
package io.github.dsheirer.preference.playback;
import io.github.dsheirer.audio.playback.AudioPlaybackDeviceDescriptor;
import io.github.dsheirer.audio.playback.AudioPlaybackDeviceManager;
import io.github.dsheirer.gui.preference.playback.ToneFrequency;
import io.github.dsheirer.gui.preference.playback.ToneUtil;
import io.github.dsheirer.gui.preference.playback.ToneVolume;
import io.github.dsheirer.preference.Preference;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
import io.github.dsheirer.source.mixer.MixerManager;
import java.util.prefs.Preferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -44,8 +44,9 @@ public class PlaybackPreference extends Preference
private static final String PREFERENCE_KEY_START_TONE_FREQUENCY = "audio.playback.segment.start.frequency";
private static final String PREFERENCE_KEY_START_TONE_VOLUME = "audio.playback.segment.start.volume";
private static final String PREFERENCE_KEY_MIXER_CHANNEL_CONFIG = "audio.playback.mixer.channel.configuration";
private static final int TONE_LENGTH_SAMPLES = 180;
private static final String PREFERENCE_KEY_AUDIO_DEVICE_NAME = "audio.playback.device.name";
private static final String PREFERENCE_KEY_AUDIO_CHANNEL_COUNT = "audio.playback.channel.count";
public static final int TONE_LENGTH_SAMPLES = 160;
private final static Logger mLog = LoggerFactory.getLogger(PlaybackPreference.class);
private Preferences mPreferences = Preferences.userNodeForPackage(PlaybackPreference.class);
@@ -55,7 +56,7 @@ public class PlaybackPreference extends Preference
private ToneVolume mStartToneVolume;
private ToneFrequency mDropToneFrequency;
private ToneVolume mDropToneVolume;
private MixerChannelConfiguration mMixerChannelConfiguration;
private AudioPlaybackDeviceDescriptor mAudioPlaybackDeviceDescriptor;
/**
* Constructs this preference with an update listener
@@ -221,12 +222,17 @@ public class PlaybackPreference extends Preference
{
if(getUseAudioSegmentStartTone())
{
return ToneUtil.getTone(getStartToneFrequency(), getStartToneVolume(), TONE_LENGTH_SAMPLES);
return getStartTone(TONE_LENGTH_SAMPLES);
}
return null;
}
public float[] getStartTone(int length)
{
return ToneUtil.getTone(getStartToneFrequency(), getStartToneVolume(), length);
}
/**
* Buffer with samples for the audio segment drop tone
*/
@@ -234,62 +240,60 @@ public class PlaybackPreference extends Preference
{
if(getUseAudioSegmentDropTone())
{
return ToneUtil.getTone(getDropToneFrequency(), getDropToneVolume(), TONE_LENGTH_SAMPLES);
return getDropTone(TONE_LENGTH_SAMPLES);
}
return null;
}
public float[] getDropTone(int length)
{
return ToneUtil.getTone(getDropToneFrequency(), getDropToneVolume(), length);
}
/**
* Test tone to use for testing the currently selected mixer output
*/
public float[] getMixerTestTone()
public float[] getAudioPlaybackTestTone()
{
return ToneUtil.getTone(ToneFrequency.F1200, ToneVolume.V10, 800);
return ToneUtil.getTone(ToneFrequency.F1200, ToneVolume.V10, TONE_LENGTH_SAMPLES * 4);
}
/**
* Gets the preferred output mixer to use
* Preferred audio playback device
*/
public MixerChannelConfiguration getMixerChannelConfiguration()
public AudioPlaybackDeviceDescriptor getAudioPlaybackDevice()
{
if(mMixerChannelConfiguration == null)
if(mAudioPlaybackDeviceDescriptor == null)
{
MixerChannelConfiguration defaultConfig = MixerManager.getDefaultOutputMixer();
AudioPlaybackDeviceDescriptor descriptor = AudioPlaybackDeviceManager.getDefaultAudioPLaybackDevice();
if(defaultConfig != null)
if(descriptor != null)
{
String configName = mPreferences.get(PREFERENCE_KEY_MIXER_CHANNEL_CONFIG, defaultConfig.toString());
for(MixerChannelConfiguration config: MixerManager.getOutputMixers())
{
if(config.toString().contentEquals(configName))
{
mMixerChannelConfiguration = config;
}
}
if(mMixerChannelConfiguration == null)
{
mMixerChannelConfiguration = defaultConfig;
}
String name = mPreferences.get(PREFERENCE_KEY_AUDIO_DEVICE_NAME, descriptor.getMixerInfo().getName());
int channelCount = mPreferences.getInt(PREFERENCE_KEY_AUDIO_CHANNEL_COUNT, descriptor.getAudioFormat().getChannels());
mAudioPlaybackDeviceDescriptor = AudioPlaybackDeviceManager.getAudioPlaybackDevice(name, channelCount);
}
else
{
mLog.error("Error - no system audio devices available");
mLog.error("Error - no audio playback devices available");
}
}
return mMixerChannelConfiguration;
return mAudioPlaybackDeviceDescriptor;
}
/**
* Sets the preferred output mixer to use
* Sets the preferred audio playback device
*/
public void setMixerChannelConfiguration(MixerChannelConfiguration configuration)
public void setAudioPlaybackDevice(AudioPlaybackDeviceDescriptor descriptor)
{
mMixerChannelConfiguration = configuration;
mPreferences.put(PREFERENCE_KEY_MIXER_CHANNEL_CONFIG, configuration.toString());
notifyPreferenceUpdated();
if(descriptor != null)
{
mAudioPlaybackDeviceDescriptor = descriptor;
mPreferences.put(PREFERENCE_KEY_AUDIO_DEVICE_NAME, descriptor.getMixerInfo().getName());
mPreferences.putInt(PREFERENCE_KEY_AUDIO_CHANNEL_COUNT, descriptor.getAudioFormat().getChannels());
notifyPreferenceUpdated();
}
}
}

View File

@@ -1,115 +0,0 @@
/*******************************************************************************
* SDR Trunk
* Copyright (C) 2014 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.source.mixer;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.Line;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.TargetDataLine;
import java.util.HashMap;
/**
* THIS CLASS IS SCHEDULED FOR DELETION *****************************
*/
public class SystemMixerDevices {
/**
* Returns a hashmap of mixer names and target data lines that support the
* audio format argument
*
* @param format - requested audio format
* @return - mixer target data lines that support the requested audio format
*/
public static HashMap<String,TargetDataLine> getMixers( AudioFormat format )
{
HashMap<String,TargetDataLine> retVal = new HashMap<String,TargetDataLine>();
DataLine.Info datalineInfo=new DataLine.Info(TargetDataLine.class, format );
for( Mixer.Info mixerInfo: AudioSystem.getMixerInfo() )
{
Mixer mixer = AudioSystem.getMixer( mixerInfo );
if( mixer != null )
{
try
{
TargetDataLine tdl = (TargetDataLine) mixer.getLine( datalineInfo );
if( tdl != null )
{
retVal.put( mixerInfo.getName(), tdl );
}
}
catch( IllegalArgumentException iae )
{
//Do nothing ... mixer doesn't support the audio format
}
catch( LineUnavailableException e )
{
//Do nothing ... mixer doesn't support the audio format
}
}
}
return retVal;
}
public static String getSoundDevices()
{
StringBuilder sb = new StringBuilder();
for( Mixer.Info mixerInfo: AudioSystem.getMixerInfo() )
{
sb.append( "\n--------------------------------------------------" );
sb.append("\nMIXER name:").append(mixerInfo.getName())
.append("\n desc:").append(mixerInfo.getDescription())
.append("\n vendor:").append(mixerInfo.getVendor())
.append("\n version:").append(mixerInfo.getVersion())
.append("\n");
Mixer mixer = AudioSystem.getMixer( mixerInfo );
Line.Info[] sourceLines = mixer.getSourceLineInfo();
for( Line.Info lineInfo: sourceLines )
{
sb.append(" SOURCE LINE desc:").append(lineInfo)
.append("\n class:").append(lineInfo.getClass())
.append("\n lineclass:").append(lineInfo.getLineClass())
.append("\n");
}
Line.Info[] targetLines = mixer.getTargetLineInfo();
for( Line.Info lineInfo: targetLines )
{
sb.append(" TARGET LINE desc:").append(lineInfo)
.append("\n class:").append(lineInfo.getClass())
.append("\n lineclass:").append(lineInfo.getLineClass())
.append("\n");
}
}
return sb.toString();
}
}