From c81e6cccfa46ed3bfdeb82a1176c35457ac62199 Mon Sep 17 00:00:00 2001 From: Denny Sheirer Date: Sun, 8 Feb 2026 05:30:39 -0500 Subject: [PATCH] #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 --- .../github/dsheirer/audio/AudioSegment.java | 4 +- .../dsheirer/audio/IAudioController.java | 36 +- .../dsheirer/audio/playback/AudioChannel.java | 629 ++++++++++++++++ .../audio/playback/AudioChannelPanel.java | 106 ++- .../audio/playback/AudioChannelsPanel.java | 40 +- .../dsheirer/audio/playback/AudioOutput.java | 675 +++++------------- .../dsheirer/audio/playback/AudioPanel.java | 270 +++---- .../AudioPlaybackDeviceDescriptor.java | 107 +++ .../playback/AudioPlaybackDeviceManager.java | 130 ++++ .../audio/playback/AudioPlaybackManager.java | 169 +++-- .../audio/playback/AudioProvider.java | 72 ++ .../audio/playback/AudioProviderMono.java | 85 +++ .../playback/AudioProviderMultiChannel.java | 123 ++++ .../audio/playback/AudioProviderStereo.java | 111 +++ .../audio/playback/MonoAudioOutput.java | 65 -- .../audio/playback/StereoAudioOutput.java | 79 -- .../gui/preference/PreferenceEditorType.java | 4 +- .../playback/PlaybackPreferenceEditor.java | 105 +-- .../playback/PlayTestAudioRequest.java | 36 + .../playback/PlaybackPreference.java | 76 +- .../source/mixer/SystemMixerDevices.java | 115 --- 21 files changed, 1817 insertions(+), 1220 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioChannel.java create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceDescriptor.java create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceManager.java create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java create mode 100644 src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java delete mode 100644 src/main/java/io/github/dsheirer/audio/playback/MonoAudioOutput.java delete mode 100644 src/main/java/io/github/dsheirer/audio/playback/StereoAudioOutput.java create mode 100644 src/main/java/io/github/dsheirer/preference/playback/PlayTestAudioRequest.java delete mode 100644 src/main/java/io/github/dsheirer/source/mixer/SystemMixerDevices.java diff --git a/src/main/java/io/github/dsheirer/audio/AudioSegment.java b/src/main/java/io/github/dsheirer/audio/AudioSegment.java index 4c852f30..750b84f4 100644 --- a/src/main/java/io/github/dsheirer/audio/AudioSegment.java +++ b/src/main/java/io/github/dsheirer/audio/AudioSegment.java @@ -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 diff --git a/src/main/java/io/github/dsheirer/audio/IAudioController.java b/src/main/java/io/github/dsheirer/audio/IAudioController.java index e374b3ce..de30088e 100644 --- a/src/main/java/io/github/dsheirer/audio/IAudioController.java +++ b/src/main/java/io/github/dsheirer/audio/IAudioController.java @@ -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 + * **************************************************************************** + */ + 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 getAudioOutputs(); + /* Audio Channel(s) */ + public List getAudioChannels(); /* Controller Audio Event Listener */ - public void addControllerListener( Listener listener ); - public void removeControllerListener( Listener listener ); + public void addAudioEventListener(Listener listener ); + public void removeAudioEventListener(Listener listener ); } diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioChannel.java b/src/main/java/io/github/dsheirer/audio/playback/AudioChannel.java new file mode 100644 index 00000000..dfcb6666 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioChannel.java @@ -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 + * **************************************************************************** + */ + +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 +{ + /** + * 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 mAudioEventBroadcaster = new Broadcaster<>(); + private final LinkedTransferQueue mAudioSegmentQueue = new LinkedTransferQueue<>(); + private final UserPreferences mUserPreferences; + private final String mChannelName; + + private AudioSegment mCurrentAudioSegment; + private Listener 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 listener) + { + mAudioEventBroadcaster.addListener(listener); + } + + /** + * Removes the audio event listener + * @param listener to remove + */ + public void removeAudioEventListener(Listener 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 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(); + } + } + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioChannelPanel.java b/src/main/java/io/github/dsheirer/audio/playback/AudioChannelPanel.java index 913090dc..95e0ceeb 100644 --- a/src/main/java/io/github/dsheirer/audio/playback/AudioChannelPanel.java +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioChannelPanel.java @@ -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 - * ***************************************************************************** + * **************************************************************************** */ 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, SettingChangeListener { private static final long serialVersionUID = 1L; @@ -66,30 +68,36 @@ public class AudioChannelPanel extends JPanel implements Listener, 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 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, 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, 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, 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, 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; diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioChannelsPanel.java b/src/main/java/io/github/dsheirer/audio/playback/AudioChannelsPanel.java index 15fd4189..528bbf82 100644 --- a/src/main/java/io/github/dsheirer/audio/playback/AudioChannelsPanel.java +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioChannelsPanel.java @@ -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 - * ***************************************************************************** + * **************************************************************************** */ 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 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); diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioOutput.java b/src/main/java/io/github/dsheirer/audio/playback/AudioOutput.java index bc982ec8..de6b201b 100644 --- a/src/main/java/io/github/dsheirer/audio/playback/AudioOutput.java +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioOutput.java @@ -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 +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 mIdentifierCollectionListener; - private Broadcaster 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 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 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 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 listener) - { - mAudioEventBroadcaster.addListener(listener); - } - - public void removeAudioEventListener(Listener 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 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 { - 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 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 /** * 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 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 /** * 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 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 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 } } - /** - * 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 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"); + }); }); } } diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceDescriptor.java b/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceDescriptor.java new file mode 100644 index 00000000..d1f05b7b --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceDescriptor.java @@ -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 + * **************************************************************************** + */ + +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 +{ + 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()); + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceManager.java b/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceManager.java new file mode 100644 index 00000000..992c491c --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackDeviceManager.java @@ -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 + * **************************************************************************** + */ + +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 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 getAudioPlaybackDevices() + { + List devices = new ArrayList<>(); + + Mixer.Info[] mixerInfos = AudioSystem.getMixerInfo(); + + for(Mixer.Info mixerInfo : mixerInfos) + { + Mixer mixer = AudioSystem.getMixer(mixerInfo); + + if(mixer != null) + { + List 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 devices = getAudioPlaybackDevices(); + for(AudioPlaybackDeviceDescriptor device : devices) + { + System.out.println(device); + } + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackManager.java b/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackManager.java index 2e6b648f..4d80e11f 100644 --- a/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackManager.java +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioPlaybackManager.java @@ -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, 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 mControllerBroadcaster = new Broadcaster<>(); + private final AudioSegmentPrioritySorter mAudioSegmentPrioritySorter = new AudioSegmentPrioritySorter(); + private final Broadcaster mControllerBroadcaster = new Broadcaster<>(); + private final List mAudioSegments = new ArrayList<>(); + private final List mPendingAudioSegments = new ArrayList<>(); + private final LinkedTransferQueue mNewAudioSegmentQueue = new LinkedTransferQueue<>(); + private final ReentrantLock mAudioChannelsLock = new ReentrantLock(); + private final UserPreferences mUserPreferences; + private AudioPlaybackDeviceDescriptor mAudioPlaybackDevice; + private AudioOutput mAudioOutput; private ScheduledFuture mProcessingTask; - private UserPreferences mUserPreferences; - private MixerChannelConfiguration mMixerChannelConfiguration; - private List mAudioOutputs = new ArrayList<>(); - private List mAudioSegments = new ArrayList<>(); - private List mPendingAudioSegments = new ArrayList<>(); - private LinkedTransferQueue 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, 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, 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, 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, IAudioContr } finally { - mAudioOutputLock.unlock(); + mAudioChannelsLock.unlock(); } } } @@ -219,18 +213,16 @@ public class AudioPlaybackManager implements Listener, 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, IAudioContr } finally { - mAudioOutputLock.unlock(); + mAudioChannelsLock.unlock(); } } @@ -273,6 +265,19 @@ public class AudioPlaybackManager implements Listener, 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, 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 getAudioOutputs() + public List getAudioChannels() { - List 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 listener) + public void addAudioEventListener(Listener listener) { mControllerBroadcaster.addListener(listener); } @@ -384,7 +401,7 @@ public class AudioPlaybackManager implements Listener, IAudioContr * Removes an audio event listener from receiving audio event notifications. */ @Override - public void removeControllerListener(Listener listener) + public void removeAudioEventListener(Listener listener) { mControllerBroadcaster.removeListener(listener); } @@ -394,7 +411,7 @@ public class AudioPlaybackManager implements Listener, 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, IAudioContr /** * Audio segment comparator for sorting audio segments by: 1)Playback priority and 2)Segment start time */ - public class AudioSegmentPrioritySorter implements Comparator + public static class AudioSegmentPrioritySorter implements Comparator { @Override public int compare(AudioSegment segment1, AudioSegment segment2) diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java new file mode 100644 index 00000000..f524c3f2 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java @@ -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 + * **************************************************************************** + */ + +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 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(); + } + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java new file mode 100644 index 00000000..7e3d6217 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java @@ -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 + * **************************************************************************** + */ + +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 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; + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java new file mode 100644 index 00000000..274ff9ca --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java @@ -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 + * **************************************************************************** + */ + +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 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 getAudioChannels() + { + return Collections.unmodifiableList(mAudioChannels); + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java new file mode 100644 index 00000000..bf3c59fb --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java @@ -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 + * **************************************************************************** + */ + +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 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; + } +} diff --git a/src/main/java/io/github/dsheirer/audio/playback/MonoAudioOutput.java b/src/main/java/io/github/dsheirer/audio/playback/MonoAudioOutput.java deleted file mode 100644 index 5222f5e4..00000000 --- a/src/main/java/io/github/dsheirer/audio/playback/MonoAudioOutput.java +++ /dev/null @@ -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 - * **************************************************************************** - */ -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; - } -} diff --git a/src/main/java/io/github/dsheirer/audio/playback/StereoAudioOutput.java b/src/main/java/io/github/dsheirer/audio/playback/StereoAudioOutput.java deleted file mode 100644 index e3fffebe..00000000 --- a/src/main/java/io/github/dsheirer/audio/playback/StereoAudioOutput.java +++ /dev/null @@ -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 - * **************************************************************************** - */ -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; - } -} diff --git a/src/main/java/io/github/dsheirer/gui/preference/PreferenceEditorType.java b/src/main/java/io/github/dsheirer/gui/preference/PreferenceEditorType.java index 2967d584..4812716d 100644 --- a/src/main/java/io/github/dsheirer/gui/preference/PreferenceEditorType.java +++ b/src/main/java/io/github/dsheirer/gui/preference/PreferenceEditorType.java @@ -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"), diff --git a/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java b/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java index 29b598b7..c0ed703c 100644 --- a/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java +++ b/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java @@ -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 mMixerComboBox; - private Button mMixerTestButton; + private ComboBox 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 getMixerComboBox() + private ComboBox 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)); } } diff --git a/src/main/java/io/github/dsheirer/preference/playback/PlayTestAudioRequest.java b/src/main/java/io/github/dsheirer/preference/playback/PlayTestAudioRequest.java new file mode 100644 index 00000000..f5afa4c9 --- /dev/null +++ b/src/main/java/io/github/dsheirer/preference/playback/PlayTestAudioRequest.java @@ -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 + * **************************************************************************** + */ + +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; + } +}; diff --git a/src/main/java/io/github/dsheirer/preference/playback/PlaybackPreference.java b/src/main/java/io/github/dsheirer/preference/playback/PlaybackPreference.java index b1457e45..714655c5 100644 --- a/src/main/java/io/github/dsheirer/preference/playback/PlaybackPreference.java +++ b/src/main/java/io/github/dsheirer/preference/playback/PlaybackPreference.java @@ -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(); + } } } diff --git a/src/main/java/io/github/dsheirer/source/mixer/SystemMixerDevices.java b/src/main/java/io/github/dsheirer/source/mixer/SystemMixerDevices.java deleted file mode 100644 index 2642a6a4..00000000 --- a/src/main/java/io/github/dsheirer/source/mixer/SystemMixerDevices.java +++ /dev/null @@ -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 - ******************************************************************************/ -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 getMixers( AudioFormat format ) - { - HashMap retVal = new HashMap(); - - 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(); - } -}