package audio; /******************************************************************************* * SDR Trunk * Copyright (C) 2014-2016 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 ******************************************************************************/ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.LinkedTransferQueue; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.Mixer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import properties.SystemProperties; import sample.Broadcaster; import sample.Listener; import source.mixer.MixerChannel; import source.mixer.MixerChannelConfiguration; import source.mixer.MixerManager; import audio.AudioEvent.Type; import audio.AudioPanel.MixerSelectionItem; import audio.output.AudioOutput; import audio.output.MonoAudioOutput; import audio.output.StereoAudioOutput; import controller.ThreadPoolManager; import controller.ThreadPoolManager.ThreadType; public class AudioManager implements Listener, IAudioController { private static final Logger mLog = LoggerFactory.getLogger( AudioManager.class ); public static final int AUDIO_TIMEOUT = 2000; //2 seconds public static final String AUDIO_CHANNELS_PROPERTY = "audio.manager.channels"; public static final String AUDIO_MIXER_PROPERTY = "audio.manager.mixer"; public static final AudioEvent CONFIGURATION_CHANGE_STARTED = new AudioEvent( Type.AUDIO_CONFIGURATION_CHANGE_STARTED, null ); public static final AudioEvent CONFIGURATION_CHANGE_COMPLETE = new AudioEvent( Type.AUDIO_CONFIGURATION_CHANGE_COMPLETE, null ); private LinkedTransferQueue mAudioPacketQueue = new LinkedTransferQueue<>(); private Map mChannelConnectionMap = new HashMap<>(); private List mAudioOutputConnections = new ArrayList<>(); private AudioOutputConnection mLowestPriorityConnection; private int mAvailableConnectionCount; private Map mAudioOutputMap = new HashMap<>(); private Broadcaster mControllerBroadcaster = new Broadcaster<>(); private ThreadPoolManager mThreadPoolManager; private ScheduledFuture mProcessingTask; private MixerChannelConfiguration mMixerChannelConfiguration; /** * Processes all audio produced by the decoding channels and routes audio * packets to any combination of outputs based on any alias audio routing * options specified by the user. */ public AudioManager( ThreadPoolManager manager ) { mThreadPoolManager = manager; loadSettings(); } private void loadSettings() { MixerChannelConfiguration configuration = null; SystemProperties properties = SystemProperties.getInstance(); Mixer defaultMixer = AudioSystem.getMixer( null ); String mixer = properties.get( AUDIO_MIXER_PROPERTY, defaultMixer.getMixerInfo().getName() ); String channels = properties.get( AUDIO_CHANNELS_PROPERTY, MixerChannel.MONO.name() ); MixerChannelConfiguration[] mixerConfigurations = MixerManager.getInstance().getOutputMixers(); for( MixerChannelConfiguration mixerConfig: mixerConfigurations ) { if( mixerConfig.matches( mixer, channels ) ) { configuration = mixerConfig; } } if( configuration == null ) { configuration = getDefaultConfiguration(); } try { setMixerChannelConfiguration( configuration ); } catch( Exception e ) { mLog.error( "Couldn't set stored audio mixer/channel configuration" + " - using default", e ); try { setMixerChannelConfiguration( getDefaultConfiguration() ); } catch( Exception e2 ) { mLog.error( "Couldn't set default audio mixer/channel " + "configuration - no audio will be available", e2 ); } } } private MixerChannelConfiguration getDefaultConfiguration() { /* Use the system default mixer and mono channel as default startup */ Mixer defaultMixer = AudioSystem.getMixer( null ); return new MixerChannelConfiguration( defaultMixer, MixerChannel.MONO ); } public void dispose() { if( mThreadPoolManager != null && mProcessingTask != null ) { mThreadPoolManager.cancel( mProcessingTask ); } mAudioPacketQueue.clear(); mThreadPoolManager = null; mProcessingTask = null; mChannelConnectionMap.clear(); for( AudioOutputConnection connection: mAudioOutputConnections ) { connection.dispose(); } mAudioOutputConnections.clear(); } @Override public synchronized void receive( AudioPacket packet ) { mAudioPacketQueue.add( packet ); } /** * Checks each audio channel assignment and disconnects any inactive connections */ private void disconnectInactiveChannelAssignments() { boolean changed = false; for( AudioOutputConnection connection: mAudioOutputConnections ) { if( connection.isConnected() && connection.isInactive() ) { mChannelConnectionMap.remove( connection.getSource() ); connection.disconnect(); changed = true; } } if( changed ) { updateLowestPriorityAssignment(); } } /** * Identifies the lowest priority channel connection */ private void updateLowestPriorityAssignment() { mLowestPriorityConnection = null; for( AudioOutputConnection connection: mAudioOutputConnections ) { if( connection.isConnected() && ( mLowestPriorityConnection == null || mLowestPriorityConnection.getPriority() > connection.getPriority() ) ) { mLowestPriorityConnection = connection; } } } @Override public void setMixerChannelConfiguration( MixerChannelConfiguration entry ) throws AudioException { if( entry != null && ( entry.getMixerChannel() == MixerChannel.MONO || entry.getMixerChannel() == MixerChannel.STEREO ) ) { mControllerBroadcaster.broadcast( CONFIGURATION_CHANGE_STARTED ); if( mThreadPoolManager != null && mProcessingTask != null ) { mThreadPoolManager.cancel( mProcessingTask ); } disposeCurrentConfiguration(); switch( entry.getMixerChannel() ) { case MONO: AudioOutput mono = new MonoAudioOutput( mThreadPoolManager, entry.getMixer() ); mAudioOutputConnections.add( new AudioOutputConnection( mono ) ); mAvailableConnectionCount++; mAudioOutputMap.put( mono.getChannelName(), mono ); break; case STEREO: AudioOutput left = new StereoAudioOutput( mThreadPoolManager, entry.getMixer(), MixerChannel.LEFT ); mAudioOutputConnections.add( new AudioOutputConnection( left ) ); mAvailableConnectionCount++; mAudioOutputMap.put( left.getChannelName(), left ); AudioOutput right = new StereoAudioOutput( mThreadPoolManager, entry.getMixer(), MixerChannel.RIGHT ); mAudioOutputConnections.add( new AudioOutputConnection( right ) ); mAvailableConnectionCount++; mAudioOutputMap.put( right.getChannelName(), right ); break; default: throw new AudioException( "Unsupported mixer channel " + "configuration: " + entry.getMixerChannel() ); } mProcessingTask = mThreadPoolManager.scheduleFixedRate( ThreadType.AUDIO_PROCESSING, new AudioPacketProcessor(), 15, TimeUnit.MILLISECONDS ); mControllerBroadcaster.broadcast( CONFIGURATION_CHANGE_COMPLETE ); SystemProperties properties = SystemProperties.getInstance(); properties.set( AUDIO_MIXER_PROPERTY, entry.getMixer().getMixerInfo().getName() ); properties.set( AUDIO_CHANNELS_PROPERTY, entry.getMixerChannel().name() ); properties.save(); } } /** * Clears all channel assignments and terminates all audio outputs in * preparation for complete shutdown or change to another mixer/channel * configuration */ private void disposeCurrentConfiguration() { mChannelConnectionMap.clear(); for( AudioOutputConnection connection : mAudioOutputConnections ) { connection.dispose(); } mAvailableConnectionCount = 0; mAudioOutputConnections.clear(); mAudioOutputMap.clear(); mLowestPriorityConnection = null; } @Override public MixerChannelConfiguration getMixerChannelConfiguration() throws AudioException { return mMixerChannelConfiguration; } @Override public List getAudioOutputs() { List outputs = new ArrayList<>( mAudioOutputMap.values() ); Collections.sort( outputs, new Comparator() { @Override public int compare( AudioOutput first, AudioOutput second ) { return first.getChannelName().compareTo( second.getChannelName() ); } } ); return outputs; } @Override public void addControllerListener( Listener listener ) { mControllerBroadcaster.addListener( listener ); } @Override public void removeControllerListener( Listener listener ) { mControllerBroadcaster.removeListener( listener ); } /** * Returns an audio output connection for the packet if one is * available, or overrides an existing lower priority connection. * Returns null if no connection is available for the audio packet. * * @param packet from a source * @return an audio output connection or null */ private AudioOutputConnection getConnection( AudioPacket packet ) { int source = packet.getAudioMetadata().getSource(); //Use an existing connection if( mChannelConnectionMap.containsKey( source ) ) { return mChannelConnectionMap.get( source ); } //Connect to and use an available connection if( mAvailableConnectionCount > 0 ) { for( AudioOutputConnection connection: mAudioOutputConnections ) { if( connection.isDisconnected() ) { connection.connect( source ); mChannelConnectionMap.put( source, connection ); return connection; } } } //Preempt an existing connection and connect it to this higher priority source else //Check for a channel priority override { int priority = packet.getAudioMetadata().getPriority(); if( mLowestPriorityConnection != null && priority < mLowestPriorityConnection.getPriority() ) { AudioOutputConnection connection = mLowestPriorityConnection; mChannelConnectionMap.remove( connection.getSource() ); connection.connect( source ); mChannelConnectionMap.put( source, connection ); updateLowestPriorityAssignment(); return connection; } } return null; } public class AudioPacketProcessor implements Runnable { @Override public void run() { disconnectInactiveChannelAssignments(); if( mAudioPacketQueue != null ) { List packets = new ArrayList(); mAudioPacketQueue.drainTo( packets ); for( AudioPacket packet: packets ) { /* Don't process any packet's marked as do not monitor */ if( !packet.getAudioMetadata().isDoNotMonitor() && packet.getType() == AudioPacket.Type.AUDIO ) { AudioOutputConnection connection = getConnection( packet ); if( connection != null ) { connection.receive( packet ); } } } } } } /** * Audio output connection manages a connection between a soure and an audio * output and maintains current state information about the audio activity * received form the source. */ public class AudioOutputConnection implements Listener { private static final long CALL_END_DELAY = 2000; //2 seconds private static final int DISCONNECTED = -1; private AudioOutput mAudioOutput; private int mPriority = 0; private long mLastActivity = System.currentTimeMillis(); private int mSource = DISCONNECTED; public AudioOutputConnection( AudioOutput audioOutput ) { mAudioOutput = audioOutput; mAudioOutput.addAudioEventListener( this ); } public void receive( AudioPacket packet ) { if( packet.hasAudioMetadata() && packet.getAudioMetadata().getSource() == mSource ) { int priority = packet.getAudioMetadata().getPriority(); if( mPriority != priority ) { mPriority = priority; updateLowestPriorityAssignment(); } updateTimestamp(); if( mAudioOutput != null ) { mAudioOutput.receive( packet ); } } else { if( packet.hasAudioMetadata() ) { mLog.error( "Received audio packet from channel source [" + packet.getAudioMetadata().getSource() + "] however this assignment is currently connected " + "to channel source [" + mSource + "]" ); } else { mLog.error( "Received audio packet with no metadata - " + "cannot route audio packet" ); } } } private void updateTimestamp() { mLastActivity = System.currentTimeMillis(); } /** * Terminates the audio output and prepares this connection for disposal */ public void dispose() { mAudioOutput.dispose(); mAudioOutput = null; } /** * Indicates if this assignment is currently disconnected from a channel source */ public boolean isDisconnected() { return mSource == DISCONNECTED; } /** * Indicates if this assignment is currently connected to a channel source */ public boolean isConnected() { return !isDisconnected(); } /** * Connects this assignment to the indicated source so that audio * packets from this source can be sent to the audio output */ public void connect( int source ) { mSource = source; updateTimestamp(); } /** * Currently connected source or -1 if disconnected */ public int getSource() { return mSource; } /** * Disconnects this assignment from the source and prevents any audio * from being routed to the audio output until another source is assigned */ public void disconnect() { mSource = DISCONNECTED; } /** * Indicates if audio output is current inactive, meaning that the * audio output hasn't recently processed any audio packets. */ public boolean isInactive() { return mLastActivity != Long.MAX_VALUE && mLastActivity + CALL_END_DELAY < System.currentTimeMillis(); } public int getPriority() { return mPriority; } @Override public void receive( AudioEvent event ) { switch( event.getType() ) { case AUDIO_STARTED: case AUDIO_STOPPED: case AUDIO_CONTINUATION: updateTimestamp(); break; default: break; } } } }