mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 16:47:31 -05:00
#660 Updates audio processing subsystem to use audio segments. Updates audio playback, recording and streaming to use audio segments. Adds support to UserPreferences for sound card selection and audio tone insertion during playback. Adds record preference for WAVE or MP3. Resolves issues with metadata in recordings. (#675)
This commit is contained in:
@@ -20,41 +20,134 @@
|
||||
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.audio.squelch.ISquelchStateListener;
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.identifier.IdentifierUpdateListener;
|
||||
import io.github.dsheirer.identifier.IdentifierUpdateNotification;
|
||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||
import io.github.dsheirer.module.Module;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Base audio module implementation.
|
||||
*/
|
||||
public abstract class AbstractAudioModule extends Module implements IAudioPacketProvider, IdentifierUpdateListener,
|
||||
ISquelchStateListener
|
||||
public abstract class AbstractAudioModule extends Module implements IAudioSegmentProvider, IdentifierUpdateListener
|
||||
{
|
||||
//Static unique audio channel identifier.
|
||||
private static int AUDIO_CHANNEL_ID_GENERATOR = 1;
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AbstractAudioModule.class);
|
||||
private static final int MAX_SEGMENT_AUDIO_SAMPLE_LENGTH = 8000 * 60 * 1; //8 kHz - 1 minute
|
||||
|
||||
private int mAudioChannelId = AUDIO_CHANNEL_ID_GENERATOR++;
|
||||
private Listener<ReusableAudioPacket> mAudioPacketListener;
|
||||
private Listener<AudioSegment> mAudioSegmentListener;
|
||||
private MutableIdentifierCollection mIdentifierCollection = new MutableIdentifierCollection();
|
||||
private Broadcaster<IdentifierUpdateNotification> mIdentifierUpdateNotificationBroadcaster = new Broadcaster<>();
|
||||
private AliasList mAliasList;
|
||||
private AudioSegment mAudioSegment;
|
||||
private int mAudioSampleCount = 0;
|
||||
private boolean mRecordAudioOverride;
|
||||
|
||||
/**
|
||||
* Constructs an abstract audio module
|
||||
*/
|
||||
public AbstractAudioModule()
|
||||
public AbstractAudioModule(AliasList aliasList)
|
||||
{
|
||||
mAliasList = aliasList;
|
||||
mIdentifierUpdateNotificationBroadcaster.addListener(mIdentifierCollection);
|
||||
}
|
||||
|
||||
protected abstract int getTimeslot();
|
||||
|
||||
/**
|
||||
* Closes the current audio segment
|
||||
*/
|
||||
protected void closeAudioSegment()
|
||||
{
|
||||
synchronized(this)
|
||||
{
|
||||
if(mAudioSegment != null)
|
||||
{
|
||||
mAudioSegment.completeProperty().set(true);
|
||||
mIdentifierUpdateNotificationBroadcaster.removeListener(mAudioSegment);
|
||||
mAudioSegment = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
closeAudioSegment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique channel identifier for use in tagging audio packets with an audio channel ID so that they can
|
||||
* be identified within a combined audio packet stream
|
||||
* Gets the current audio segment, or creates a new audio segment as necessary and broadcasts it to any registered
|
||||
* listener(s).
|
||||
*/
|
||||
public int getAudioChannelId()
|
||||
protected AudioSegment getAudioSegment()
|
||||
{
|
||||
return mAudioChannelId;
|
||||
synchronized(this)
|
||||
{
|
||||
if(mAudioSegment == null)
|
||||
{
|
||||
mAudioSegment = new AudioSegment(mAliasList, getTimeslot());
|
||||
mAudioSegment.addIdentifiers(mIdentifierCollection.getIdentifiers());
|
||||
mIdentifierUpdateNotificationBroadcaster.addListener(mAudioSegment);
|
||||
|
||||
if(mRecordAudioOverride)
|
||||
{
|
||||
mAudioSegment.recordAudioProperty().set(true);
|
||||
}
|
||||
|
||||
if(mAudioSegmentListener != null)
|
||||
{
|
||||
mAudioSegment.incrementConsumerCount();
|
||||
mAudioSegmentListener.receive(mAudioSegment);
|
||||
}
|
||||
|
||||
mAudioSampleCount = 0;
|
||||
}
|
||||
|
||||
return mAudioSegment;
|
||||
}
|
||||
}
|
||||
|
||||
protected void addAudio(float[] audioBuffer)
|
||||
{
|
||||
AudioSegment audioSegment = getAudioSegment();
|
||||
|
||||
//If the current segment exceeds the max samples length, close it so that a new segment gets generated
|
||||
//and then link the segments together
|
||||
if(mAudioSampleCount >= MAX_SEGMENT_AUDIO_SAMPLE_LENGTH)
|
||||
{
|
||||
AudioSegment previous = getAudioSegment();
|
||||
closeAudioSegment();
|
||||
audioSegment = getAudioSegment();
|
||||
audioSegment.linkTo(previous);
|
||||
}
|
||||
|
||||
audioSegment.addAudio(audioBuffer);
|
||||
mAudioSampleCount += audioBuffer.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all audio segments as recordable when the argument is true. Otherwise, defers to the aliased identifiers
|
||||
* from the identifier collection to determine whether to record the audio or not.
|
||||
* @param recordAudio set to true to mark all audio as recordable.
|
||||
*/
|
||||
public void setRecordAudio(boolean recordAudio)
|
||||
{
|
||||
mRecordAudioOverride = recordAudio;
|
||||
|
||||
if(mRecordAudioOverride)
|
||||
{
|
||||
synchronized(this)
|
||||
{
|
||||
if(mAudioSegment != null)
|
||||
{
|
||||
mAudioSegment.recordAudioProperty().set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +156,7 @@ public abstract class AbstractAudioModule extends Module implements IAudioPacket
|
||||
@Override
|
||||
public Listener<IdentifierUpdateNotification> getIdentifierUpdateListener()
|
||||
{
|
||||
return mIdentifierCollection;
|
||||
return mIdentifierUpdateNotificationBroadcaster;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,37 +168,20 @@ public abstract class AbstractAudioModule extends Module implements IAudioPacket
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an audio packet listener to receive the output from this audio module.
|
||||
* Registers an audio segment listener to receive the output from this audio module.
|
||||
*/
|
||||
@Override
|
||||
public void setAudioPacketListener(Listener<ReusableAudioPacket> listener)
|
||||
public void setAudioSegmentListener(Listener<AudioSegment> listener)
|
||||
{
|
||||
mAudioPacketListener = listener;
|
||||
mAudioSegmentListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the audio packet listener from receiving audio packets from this module.
|
||||
* Unregisters the audio segment listener from receiving audio segments from this module.
|
||||
*/
|
||||
@Override
|
||||
public void removeAudioPacketListener()
|
||||
public void removeAudioSegmentListener()
|
||||
{
|
||||
mAudioPacketListener = null;
|
||||
mAudioSegmentListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered listener for receiving audio packets produced by this module
|
||||
*/
|
||||
public Listener<ReusableAudioPacket> getAudioPacketListener()
|
||||
{
|
||||
return mAudioPacketListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if there is a listener registered to receive audio packets from this audio module.
|
||||
*/
|
||||
public boolean hasAudioPacketListener()
|
||||
{
|
||||
return mAudioPacketListener != null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* ******************************************************************************
|
||||
* sdrtrunk
|
||||
* Copyright (C) 2014-2019 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* *****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.alias.AliasModel;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Processes audio packets to assign attributes to each packet based on alias list settings. Updates the
|
||||
* monitoring, recording and streaming attributes.
|
||||
*/
|
||||
public class AudioMetadataProcessor
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AudioMetadataProcessor.class);
|
||||
private AliasModel mAliasModel;
|
||||
private Map<Integer, AliasList> mAliasListMap = new HashMap<>();
|
||||
private Set<Integer> mAliasListRetrievedSet = new TreeSet<>();
|
||||
|
||||
public AudioMetadataProcessor(AliasModel aliasModel)
|
||||
{
|
||||
mAliasModel = aliasModel;
|
||||
}
|
||||
|
||||
public void process(ReusableAudioPacket audioPacket)
|
||||
{
|
||||
if(audioPacket.hasIdentifierCollection() && audioPacket.getIdentifierCollection().hasAliasListConfiguration())
|
||||
{
|
||||
AliasList aliasList = null;
|
||||
|
||||
if(mAliasListRetrievedSet.contains(audioPacket.getAudioChannelId()))
|
||||
{
|
||||
aliasList = mAliasListMap.get(audioPacket.getAudioChannelId());
|
||||
}
|
||||
else
|
||||
{
|
||||
aliasList = mAliasModel.getAliasList(audioPacket.getIdentifierCollection().getAliasListConfiguration());
|
||||
|
||||
if(aliasList != null)
|
||||
{
|
||||
mAliasListMap.put(audioPacket.getAudioChannelId(), aliasList);
|
||||
mAliasListRetrievedSet.add(audioPacket.getAudioChannelId());
|
||||
}
|
||||
}
|
||||
|
||||
if(aliasList != null)
|
||||
{
|
||||
audioPacket.addBroadcastChannels(aliasList.getBroadcastChannels(audioPacket.getIdentifierCollection()));
|
||||
audioPacket.setMonitoringPriority(aliasList.getAudioPlaybackPriority(audioPacket.getIdentifierCollection()));
|
||||
|
||||
//If the audio packet is already marked recordable, leave it alone, otherwise attempt to determine
|
||||
//if we should record the audio packet from the aliased identifiers
|
||||
if(!audioPacket.isRecordable())
|
||||
{
|
||||
audioPacket.setRecordable(aliasList.isRecordable(audioPacket.getIdentifierCollection()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
*/
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.audio.squelch.ISquelchStateListener;
|
||||
import io.github.dsheirer.audio.squelch.SquelchState;
|
||||
import io.github.dsheirer.audio.squelch.SquelchStateEvent;
|
||||
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
|
||||
@@ -29,23 +31,19 @@ import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter2;
|
||||
import io.github.dsheirer.dsp.filter.fir.remez.RemezFIRFilterDesigner;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.IReusableBufferListener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacketQueue;
|
||||
import io.github.dsheirer.sample.buffer.ReusableFloatBuffer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Provides packaging of demodulated audio sample buffers into audio packets for broadcast to registered audio packet
|
||||
* listeners. Includes audio packet metadata in constructed audio packets.
|
||||
* Provides packaging of demodulated audio sample buffers into audio segments for broadcast to registered listeners.
|
||||
* Includes audio packet metadata in constructed audio segments.
|
||||
*
|
||||
* Incorporates audio squelch state listener to control if audio packets are broadcast or ignored.
|
||||
*
|
||||
* This class is designed to support 8 kHz sample rate demodulated audio.
|
||||
*/
|
||||
public class AudioModule extends AbstractAudioModule implements IReusableBufferListener, Listener<ReusableFloatBuffer>
|
||||
public class AudioModule extends AbstractAudioModule implements ISquelchStateListener, IReusableBufferListener,
|
||||
Listener<ReusableFloatBuffer>
|
||||
{
|
||||
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AudioModule.class);
|
||||
private static float[] sHighPassFilterCoefficients;
|
||||
|
||||
@@ -75,36 +73,31 @@ public class AudioModule extends AbstractAudioModule implements IReusableBufferL
|
||||
}
|
||||
}
|
||||
|
||||
private ReusableAudioPacketQueue mAudioPacketQueue = new ReusableAudioPacketQueue("AudioModule");
|
||||
private RealFIRFilter2 mHighPassFilter = new RealFIRFilter2(sHighPassFilterCoefficients);
|
||||
private SquelchStateListener mSquelchStateListener = new SquelchStateListener();
|
||||
private SquelchState mSquelchState = SquelchState.SQUELCH;
|
||||
private boolean mRecordAudioOverride;
|
||||
|
||||
/**
|
||||
* Creates an Audio Module.
|
||||
*/
|
||||
public AudioModule()
|
||||
public AudioModule(AliasList aliasList)
|
||||
{
|
||||
super(aliasList);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getTimeslot()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose()
|
||||
{
|
||||
removeAudioPacketListener();
|
||||
removeAudioSegmentListener();
|
||||
mSquelchStateListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all audio packets as recordable when the argument is true. Otherwise, defers to the aliased identifiers
|
||||
* from the identifier collection to determine whether to record the audio or not.
|
||||
* @param recordAudio set to true to mark all audio as recordable.
|
||||
*/
|
||||
public void setRecordAudio(boolean recordAudio)
|
||||
{
|
||||
mRecordAudioOverride = recordAudio;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset()
|
||||
{
|
||||
@@ -114,30 +107,8 @@ public class AudioModule extends AbstractAudioModule implements IReusableBufferL
|
||||
@Override
|
||||
public void start()
|
||||
{
|
||||
/* No start operations provided */
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
/* Issue an end-audio packet in case a recorder is still rolling */
|
||||
if(hasAudioPacketListener())
|
||||
{
|
||||
ReusableAudioPacket endAudioPacket = mAudioPacketQueue.getEndAudioBuffer();
|
||||
endAudioPacket.resetAttributes();
|
||||
endAudioPacket.setAudioChannelId(getAudioChannelId());
|
||||
endAudioPacket.setIdentifierCollection(getIdentifierCollection().copyOf());
|
||||
|
||||
if(mRecordAudioOverride)
|
||||
{
|
||||
endAudioPacket.setRecordable(true);
|
||||
}
|
||||
endAudioPacket.incrementUserCount();
|
||||
getAudioPacketListener().receive(endAudioPacket);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Listener<SquelchStateEvent> getSquelchStateListener()
|
||||
{
|
||||
@@ -147,23 +118,11 @@ public class AudioModule extends AbstractAudioModule implements IReusableBufferL
|
||||
@Override
|
||||
public void receive(ReusableFloatBuffer reusableFloatBuffer)
|
||||
{
|
||||
if(hasAudioPacketListener() && mSquelchState == SquelchState.UNSQUELCH)
|
||||
if(mSquelchState == SquelchState.UNSQUELCH)
|
||||
{
|
||||
ReusableFloatBuffer highPassFiltered = mHighPassFilter.filter(reusableFloatBuffer);
|
||||
|
||||
ReusableAudioPacket audioPacket = mAudioPacketQueue.getBuffer(highPassFiltered.getSampleCount());
|
||||
audioPacket.resetAttributes();
|
||||
audioPacket.setAudioChannelId(getAudioChannelId());
|
||||
audioPacket.loadAudioFrom(highPassFiltered);
|
||||
if(mRecordAudioOverride)
|
||||
{
|
||||
audioPacket.setRecordable(true);
|
||||
}
|
||||
audioPacket.setIdentifierCollection(getIdentifierCollection().copyOf());
|
||||
|
||||
getAudioPacketListener().receive(audioPacket);
|
||||
|
||||
highPassFiltered.decrementUserCount();
|
||||
ReusableFloatBuffer buffer = mHighPassFilter.filter(reusableFloatBuffer);
|
||||
addAudio(buffer.getSamples());
|
||||
buffer.decrementUserCount();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -186,16 +145,12 @@ public class AudioModule extends AbstractAudioModule implements IReusableBufferL
|
||||
@Override
|
||||
public void receive(SquelchStateEvent event)
|
||||
{
|
||||
if(event.getSquelchState() == SquelchState.SQUELCH && hasAudioPacketListener())
|
||||
{
|
||||
ReusableAudioPacket endAudioPacket = mAudioPacketQueue.getEndAudioBuffer();
|
||||
endAudioPacket.resetAttributes();
|
||||
endAudioPacket.setAudioChannelId(getAudioChannelId());
|
||||
endAudioPacket.setIdentifierCollection(getIdentifierCollection().copyOf());
|
||||
getAudioPacketListener().receive(endAudioPacket);
|
||||
}
|
||||
|
||||
mSquelchState = event.getSquelchState();
|
||||
|
||||
if(mSquelchState == SquelchState.SQUELCH)
|
||||
{
|
||||
closeAudioSegment();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* ******************************************************************************
|
||||
* sdrtrunk
|
||||
* Copyright (C) 2014-2018 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* *****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.alias.AliasModel;
|
||||
import io.github.dsheirer.dsp.filter.channelizer.ContinuousReusableBufferProcessor;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.sample.buffer.ReusableBufferBroadcaster;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Processes audio packets from all decoder channels, assigns alias attributes and distributes packets
|
||||
* for playback, streaming and recording.
|
||||
*/
|
||||
public class AudioPacketManager implements Listener<ReusableAudioPacket>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioPacketManager.class);
|
||||
private AudioMetadataProcessor mAudioMetadataProcessor;
|
||||
private ReusableBufferBroadcaster<ReusableAudioPacket> mBroadcaster = new ReusableBufferBroadcaster<>();
|
||||
private ContinuousReusableBufferProcessor<ReusableAudioPacket> mBufferProcessor;
|
||||
|
||||
public AudioPacketManager(AliasModel aliasModel)
|
||||
{
|
||||
mAudioMetadataProcessor = new AudioMetadataProcessor(aliasModel);
|
||||
mBufferProcessor = new ContinuousReusableBufferProcessor<>(10000, 500);
|
||||
mBufferProcessor.setListener(new Listener<List<ReusableAudioPacket>>()
|
||||
{
|
||||
@Override
|
||||
public void receive(List<ReusableAudioPacket> reusableAudioPackets)
|
||||
{
|
||||
for(ReusableAudioPacket audioPacket: reusableAudioPackets)
|
||||
{
|
||||
mAudioMetadataProcessor.process(audioPacket);
|
||||
mBroadcaster.broadcast(audioPacket);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void start()
|
||||
{
|
||||
mLog.info("Audio packet manager started");
|
||||
mBufferProcessor.start();
|
||||
}
|
||||
|
||||
public void stop()
|
||||
{
|
||||
mLog.info("Audio packet manager stopped");
|
||||
mBufferProcessor.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary processing method for receiving audio packets from all processing channels.
|
||||
*/
|
||||
@Override
|
||||
public void receive(ReusableAudioPacket audioPacket)
|
||||
{
|
||||
mBufferProcessor.receive(audioPacket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the listener to receive enriched audio packets from this manager
|
||||
*/
|
||||
public void addListener(Listener<ReusableAudioPacket> listener)
|
||||
{
|
||||
mBroadcaster.addListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the listener from receiving enriched audio packets from this manager
|
||||
*/
|
||||
public void removeListener(Listener<ReusableAudioPacket> listener)
|
||||
{
|
||||
mBroadcaster.removeListener(listener);
|
||||
}
|
||||
}
|
||||
401
src/main/java/io/github/dsheirer/audio/AudioSegment.java
Normal file
401
src/main/java/io/github/dsheirer/audio/AudioSegment.java
Normal file
@@ -0,0 +1,401 @@
|
||||
/*******************************************************************************
|
||||
* sdr-trunk
|
||||
* Copyright (C) 2014-2020 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public
|
||||
* License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with this program.
|
||||
* If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
******************************************************************************/
|
||||
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.alias.Alias;
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.alias.id.broadcast.BroadcastChannel;
|
||||
import io.github.dsheirer.alias.id.priority.Priority;
|
||||
import io.github.dsheirer.identifier.Identifier;
|
||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||
import io.github.dsheirer.identifier.IdentifierUpdateNotification;
|
||||
import io.github.dsheirer.identifier.MutableIdentifierCollection;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableSet;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Audio segment containing all related metadata and a dynamic collection of audio packets. An audio segment can be
|
||||
* a discrete (ie start/stop) audio event, or it may be a time-constrained portion of an ongoing continuous audio
|
||||
* broadcast. Since the audio segment is held in memory until all consumers have finished processing the segment,
|
||||
* producers should constrain the duration of each audio segment to a reasonable duration.
|
||||
*
|
||||
* Producers can link time-constrained audio segments from a continous broadcast (e.g. FM radio station) so that
|
||||
* consumers can identify audio segments that belong to a continous stream. This linkage presents a memory leak
|
||||
* potential. Therefore, the audio segment uses the consumer counter to trigger an unlinking via the dispose() mehtod.
|
||||
* Accurate accounting of consumer count via the increment/decrementConsumerCount() methods is essential for good
|
||||
* memory management.
|
||||
*
|
||||
* 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
|
||||
* the segment is complete.
|
||||
*/
|
||||
public class AudioSegment implements Listener<IdentifierUpdateNotification>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioSegment.class);
|
||||
private BooleanProperty mComplete = new SimpleBooleanProperty(false);
|
||||
private BooleanProperty mRecordAudio = new SimpleBooleanProperty(false);
|
||||
private IntegerProperty mMonitorPriority = new SimpleIntegerProperty(Priority.DEFAULT_PRIORITY);
|
||||
private ObservableSet<BroadcastChannel> mBroadcastChannels = FXCollections.observableSet(new HashSet<>());
|
||||
private MutableIdentifierCollection mIdentifierCollection = new MutableIdentifierCollection();
|
||||
private Broadcaster<IdentifierUpdateNotification> mIdentifierUpdateNotificationBroadcaster = new Broadcaster<>();
|
||||
private List<float[]> mAudioBuffers = new CopyOnWriteArrayList();
|
||||
private AtomicInteger mConsumerCount = new AtomicInteger();
|
||||
private AliasList mAliasList;
|
||||
private long mStartTimestamp = System.currentTimeMillis();
|
||||
private boolean mDisposing = false;
|
||||
private AudioSegment mLinkedAudioSegment;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*
|
||||
* @param aliasList for accessing aliases associated with identifiers for this audio segment.
|
||||
*/
|
||||
public AudioSegment(AliasList aliasList, int timeslot)
|
||||
{
|
||||
mAliasList = aliasList;
|
||||
mIdentifierCollection.setTimeslot(timeslot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation timestamp for this audio segment
|
||||
* @return long milliseconds since epoch
|
||||
*/
|
||||
public long getStartTimestamp()
|
||||
{
|
||||
return mStartTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete property is used by the audio segment producer to signal that the segment is complete and no
|
||||
* additional audio or identifiers will be added to the segment.
|
||||
*
|
||||
* @return complete property
|
||||
*/
|
||||
public BooleanProperty completeProperty()
|
||||
{
|
||||
return mComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable list of broadcast channels for this audio segment. Broadcast channels are added to this segment
|
||||
* across the life-cycle of the segment. As each new alias identifier is added to the segment, any broadcast
|
||||
* channels assigned to the alias are added to this list. The audio segment producer can also add broadcast
|
||||
* channels to this list.
|
||||
*
|
||||
* @return observable set of broadcast channels
|
||||
*/
|
||||
public ObservableSet<BroadcastChannel> broadcastChannelsProperty()
|
||||
{
|
||||
return mBroadcastChannels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of broadcast channels from identifier associated aliases for this segment.
|
||||
*/
|
||||
public Set<BroadcastChannel> getBroadcastChannels()
|
||||
{
|
||||
return Collections.unmodifiableSet(mBroadcastChannels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this segment has audio streaming broadcast channels specified.
|
||||
*/
|
||||
public boolean hasBroadcastChannels()
|
||||
{
|
||||
return !mBroadcastChannels.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Property to signal that this audio segment should be recorded. This property can either be set by the producer
|
||||
* of the audio segment, or it can be flipped to true by any aliases that are added to this segment that require
|
||||
* audio associated with the identifier to be recorded.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public BooleanProperty recordAudioProperty()
|
||||
{
|
||||
return mRecordAudio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio playback/monitor priority specified by identifier associated aliases for this segment.
|
||||
*/
|
||||
public IntegerProperty monitorPriorityProperty()
|
||||
{
|
||||
return mMonitorPriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if at least one of the identifier associated aliases for this segments specifies Do Not Monitor.
|
||||
*/
|
||||
public boolean isDoNotMonitor()
|
||||
{
|
||||
return mMonitorPriority.get() == Priority.DO_NOT_MONITOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias list for this audio segment
|
||||
*/
|
||||
public AliasList getAliasList()
|
||||
{
|
||||
return mAliasList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this audio segment is linked to a preceding audio segment.
|
||||
*/
|
||||
public boolean isLinked()
|
||||
{
|
||||
return mLinkedAudioSegment != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this audio segment is linked to the argument audio segment
|
||||
* @param audioSegment to check for linkage
|
||||
* @return true if this segment is linked to the argument
|
||||
*/
|
||||
public boolean isLinkedTo(AudioSegment audioSegment)
|
||||
{
|
||||
return isLinked() && audioSegment != null && mLinkedAudioSegment.equals(audioSegment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional linked audio segment is the audio segment that precedes this audio segment in a continuous audio
|
||||
* broadcast.
|
||||
*
|
||||
* @return linked audio segment or null.
|
||||
*/
|
||||
public AudioSegment getLinkedAudioSegment()
|
||||
{
|
||||
return mLinkedAudioSegment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links this audio segment to a preceeding/previous audio segment
|
||||
* @param previousAudioSegment to set as the predecessor
|
||||
*/
|
||||
public void linkTo(AudioSegment previousAudioSegment)
|
||||
{
|
||||
mLinkedAudioSegment = previousAudioSegment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier collection for this audio segment
|
||||
*/
|
||||
public IdentifierCollection getIdentifierCollection()
|
||||
{
|
||||
return mIdentifierCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the collection of identifiers to this segment's identifier collection
|
||||
* @param identifiers to pre-load into this audio segment
|
||||
*/
|
||||
void addIdentifiers(Collection<Identifier> identifiers)
|
||||
{
|
||||
for(Identifier identifier: identifiers)
|
||||
{
|
||||
mIdentifierCollection.update(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmodifiable copy of the list of audio buffers for this segment.
|
||||
*
|
||||
* @return list of audio buffers
|
||||
*/
|
||||
public List<float[]> getAudioBuffers()
|
||||
{
|
||||
return Collections.unmodifiableList(mAudioBuffers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of audio buffers contained in this segment.
|
||||
*
|
||||
* Note: audio buffers can be added to an audio segment throughout the segment's life-cycle by the audio producer.
|
||||
*/
|
||||
public int getAudioBufferCount()
|
||||
{
|
||||
return mAudioBuffers.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the audio buffer at the specified index
|
||||
* @param index of the buffer to fetch
|
||||
* @return audio buffer
|
||||
* @throws IllegalArgumentException if requested index is not valid
|
||||
*/
|
||||
public float[] getAudioBuffer(int index)
|
||||
{
|
||||
if(0 <= index && index < getAudioBufferCount())
|
||||
{
|
||||
return mAudioBuffers.get(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IllegalArgumentException("Requested audio buffer at index [" + index + "] does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this audio segment has one or more audio buffers
|
||||
*/
|
||||
public boolean hasAudio()
|
||||
{
|
||||
return !mAudioBuffers.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all audio buffers and decrements the user count on each so that the audio buffer can be reclaimed.
|
||||
*/
|
||||
private void dispose()
|
||||
{
|
||||
mDisposing = true;
|
||||
mAudioBuffers.clear();
|
||||
mIdentifierCollection.clear();
|
||||
mIdentifierUpdateNotificationBroadcaster.clear();
|
||||
mLinkedAudioSegment = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the consumer count to indicate that a consumer is currently processing this segment. When the
|
||||
* consumer count returns to zero, this indicates that all consumers are finished with the audio segment and the
|
||||
* resources can be reclaimed.
|
||||
*
|
||||
* Consumer count should only be increased by the producer of the audio segment, or if a consumer distributes the
|
||||
* segment to additional consumers.
|
||||
*/
|
||||
public void incrementConsumerCount()
|
||||
{
|
||||
mConsumerCount.incrementAndGet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrements the consumer count. Consumers of this audio segment should invoke this method to signal that they
|
||||
* will no longer need this audio segment. When all consumers are finished with an audio segment, the audio
|
||||
* segment resources will be reclaimed.
|
||||
*/
|
||||
public void decrementConsumerCount()
|
||||
{
|
||||
int count = mConsumerCount.decrementAndGet();
|
||||
|
||||
if(count <= 0)
|
||||
{
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an audio buffer to this segment. Note the producer of the audio buffer should increment the user count
|
||||
* of the buffer prior to adding it to this segment. This segment will decrement the audio buffer user count once
|
||||
* all consumers of this audio segment have de-registered via the decrementConsumerCount() method.
|
||||
*
|
||||
* @param audioBuffer to add to this segment
|
||||
*/
|
||||
public void addAudio(float[] audioBuffer)
|
||||
{
|
||||
if(audioBuffer == null)
|
||||
{
|
||||
throw new IllegalArgumentException("Can't add null audio buffer");
|
||||
}
|
||||
|
||||
if(mDisposing)
|
||||
{
|
||||
throw new IllegalStateException("Can't add audio to an audio segment that is being disposed");
|
||||
}
|
||||
|
||||
mAudioBuffers.add(audioBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a listener to receive identifier update notifications
|
||||
*/
|
||||
public void addIdentifierUpdateNotificationListener(Listener<IdentifierUpdateNotification> listener)
|
||||
{
|
||||
mIdentifierUpdateNotificationBroadcaster.addListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the identifier update listener.
|
||||
*/
|
||||
public void removeIdentifierUpdateNotificationListener(Listener<IdentifierUpdateNotification> listener)
|
||||
{
|
||||
mIdentifierUpdateNotificationBroadcaster.removeListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes identifier update notifications and updates audio segment properties using the associated aliases
|
||||
* from the alias list.
|
||||
*
|
||||
* @param identifierUpdateNotification containing an identifier.
|
||||
*/
|
||||
@Override
|
||||
public void receive(IdentifierUpdateNotification identifierUpdateNotification)
|
||||
{
|
||||
IdentifierUpdateNotification.Operation operation = identifierUpdateNotification.getOperation();
|
||||
|
||||
//Only process ADD updates. Ignore REMOVE to preserve the full metadata set
|
||||
if(operation == IdentifierUpdateNotification.Operation.ADD ||
|
||||
operation == IdentifierUpdateNotification.Operation.SILENT_ADD)
|
||||
{
|
||||
mIdentifierCollection.receive(identifierUpdateNotification);
|
||||
|
||||
List<Alias> aliases = mAliasList.getAliases(identifierUpdateNotification.getIdentifier());
|
||||
|
||||
for(Alias alias: aliases)
|
||||
{
|
||||
if(alias.isRecordable())
|
||||
{
|
||||
mRecordAudio.set(true);
|
||||
}
|
||||
|
||||
//Add all broadcast channels for the alias ... let the set handle duplication.
|
||||
for(BroadcastChannel broadcastChannel: alias.getBroadcastChannels())
|
||||
{
|
||||
mBroadcastChannels.add(broadcastChannel);
|
||||
}
|
||||
|
||||
//Only assign a playback priority if it is lower priority than the current setting.
|
||||
int playbackPriority = alias.getPlaybackPriority();
|
||||
|
||||
if(playbackPriority < mMonitorPriority.get())
|
||||
{
|
||||
mMonitorPriority.set(playbackPriority);
|
||||
}
|
||||
}
|
||||
|
||||
mIdentifierUpdateNotificationBroadcaster.broadcast(identifierUpdateNotification);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2020 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.AbstractReusableBuffer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class AudioSegmentBroadcaster<T extends AudioSegment> extends Broadcaster<T>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioSegmentBroadcaster.class);
|
||||
|
||||
/**
|
||||
* Increments the consumer count for the reusable complex buffer and then broadcasts the buffer to all registered
|
||||
* listeners.
|
||||
*
|
||||
* The total consumer count is established and applied to the buffer prior to dispatching. If we were to simply
|
||||
* increment the consumer count prior to sending to each consumer, there is a possibility that the consumer could
|
||||
* immediately decrement the consumer count and prematurely signal that the buffer is ready for disposal before we
|
||||
* send the buffer to all consumers.
|
||||
*/
|
||||
@Override
|
||||
public void broadcast(T audioSegment)
|
||||
{
|
||||
for(Listener<T> listener : mListeners)
|
||||
{
|
||||
audioSegment.incrementConsumerCount();
|
||||
listener.receive(audioSegment);
|
||||
}
|
||||
|
||||
//Decrement consumer counter for this broadcaster
|
||||
audioSegment.decrementConsumerCount();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.sample.ConversionUtils;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -32,18 +31,17 @@ public class AudioUtils
|
||||
/**
|
||||
* Converts the audio packets into a byte array of 16-bit, little-endian audio samples
|
||||
*/
|
||||
public static byte[] convertTo16BitSamples(List<ReusableAudioPacket> audioPackets)
|
||||
public static byte[] convertTo16BitSamples(List<float[]> buffers)
|
||||
{
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
|
||||
try
|
||||
{
|
||||
for(ReusableAudioPacket audioPacket: audioPackets)
|
||||
for(float[] audioBuffer: buffers)
|
||||
{
|
||||
//Converting from 32-bit floats to signed 16-bit samples
|
||||
ByteBuffer buffer = ConversionUtils.convertToSigned16BitSamples(audioPacket.getAudioSamples());
|
||||
ByteBuffer buffer = ConversionUtils.convertToSigned16BitSamples(audioBuffer);
|
||||
stream.write(buffer.array());
|
||||
audioPacket.decrementUserCount();
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
@@ -53,4 +51,29 @@ public class AudioUtils
|
||||
|
||||
return stream.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the audio packets into a byte array of 16-bit, little-endian audio samples
|
||||
*/
|
||||
public static byte[] convert(List<float[]> audioBuffers)
|
||||
{
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
|
||||
try
|
||||
{
|
||||
for(float[] audioBuffer: audioBuffers)
|
||||
{
|
||||
//Converting from 32-bit floats to signed 16-bit samples
|
||||
ByteBuffer buffer = ConversionUtils.convertToSigned16BitSamples(audioBuffer);
|
||||
stream.write(buffer.array());
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
mLog.error("Error writing converted PCM bytes to output stream");
|
||||
}
|
||||
|
||||
return stream.toByteArray();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
|
||||
public interface IAudioPacketListener
|
||||
{
|
||||
public Listener<ReusableAudioPacket> getAudioPacketListener();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
|
||||
public interface IAudioPacketProvider
|
||||
{
|
||||
public void setAudioPacketListener( Listener<ReusableAudioPacket> listener );
|
||||
public void removeAudioPacketListener();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2020 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
|
||||
/**
|
||||
* Interface for audio segment listener
|
||||
*/
|
||||
public interface IAudioSegmentListener
|
||||
{
|
||||
/**
|
||||
* Get the audio segment listener
|
||||
*/
|
||||
Listener<AudioSegment> getAudioSegmentListener();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2020 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.audio;
|
||||
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
|
||||
/**
|
||||
* Interface for audio segment provider
|
||||
*/
|
||||
public interface IAudioSegmentProvider
|
||||
{
|
||||
/**
|
||||
* Registers the audio segment listener
|
||||
* @param listener of audio segments
|
||||
*/
|
||||
void setAudioSegmentListener(Listener<AudioSegment> listener);
|
||||
|
||||
/**
|
||||
* De-registers the audio segment listener
|
||||
*/
|
||||
void removeAudioSegmentListener();
|
||||
}
|
||||
@@ -18,22 +18,25 @@
|
||||
******************************************************************************/
|
||||
package io.github.dsheirer.audio.broadcast;
|
||||
|
||||
import io.github.dsheirer.alias.id.broadcast.BroadcastChannel;
|
||||
import io.github.dsheirer.identifier.IdentifierCollection;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class AudioRecording implements Comparable<AudioRecording>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(StreamManager.class);
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioRecording.class);
|
||||
|
||||
private Path mPath;
|
||||
private long mStartTime;
|
||||
private long mRecordingLength;
|
||||
private AtomicInteger mPendingReplayCount = new AtomicInteger();
|
||||
private IdentifierCollection mIdentifierCollection;
|
||||
private Collection<BroadcastChannel> mBroadcastChannels;
|
||||
|
||||
/**
|
||||
* Audio recording that is ready to be streamed
|
||||
@@ -43,9 +46,11 @@ public class AudioRecording implements Comparable<AudioRecording>
|
||||
* @param start time of recording in milliseconds since epoch
|
||||
* @param recordingLength in milliseconds
|
||||
*/
|
||||
public AudioRecording(Path path, IdentifierCollection identifierCollection, long start, long recordingLength)
|
||||
public AudioRecording(Path path, Collection<BroadcastChannel> broadcastChannels,
|
||||
IdentifierCollection identifierCollection, long start, long recordingLength)
|
||||
{
|
||||
mPath = path;
|
||||
mBroadcastChannels = broadcastChannels;
|
||||
mIdentifierCollection = identifierCollection;
|
||||
mStartTime = start;
|
||||
mRecordingLength = recordingLength;
|
||||
@@ -59,6 +64,14 @@ public class AudioRecording implements Comparable<AudioRecording>
|
||||
return mPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of broadcast channels that this recording should be streamed to
|
||||
*/
|
||||
public Collection<BroadcastChannel> getBroadcastChannels()
|
||||
{
|
||||
return mBroadcastChannels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional audio metadata/identifiers for the recording.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2020 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.audio.broadcast;
|
||||
|
||||
import io.github.dsheirer.audio.AudioSegment;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.record.AudioSegmentRecorder;
|
||||
import io.github.dsheirer.record.RecordFormat;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import io.github.dsheirer.util.TimeStamp;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedTransferQueue;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Audio streaming manager monitors audio segments through completion and creates temporary streaming recordings on
|
||||
* disk and enqueues the temporary recording for streaming.
|
||||
*/
|
||||
public class AudioStreamingManager implements Listener<AudioSegment>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioStreamingManager.class);
|
||||
private LinkedTransferQueue<AudioSegment> mNewAudioSegments = new LinkedTransferQueue<>();
|
||||
private List<AudioSegment> mAudioSegments = new ArrayList<>();
|
||||
private Listener<AudioRecording> mAudioRecordingListener;
|
||||
private BroadcastFormat mBroadcastFormat;
|
||||
private UserPreferences mUserPreferences;
|
||||
private ScheduledFuture<?> mAudioSegmentProcessorFuture;
|
||||
private int mNextRecordingNumber = 1;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param listener to receive completed audio recordings
|
||||
* @param broadcastFormat for temporary recordings
|
||||
* @param userPreferences to manage recording directories
|
||||
*/
|
||||
public AudioStreamingManager(Listener<AudioRecording> listener, BroadcastFormat broadcastFormat, UserPreferences userPreferences)
|
||||
{
|
||||
mAudioRecordingListener = listener;
|
||||
mBroadcastFormat = broadcastFormat;
|
||||
mUserPreferences = userPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary receive method
|
||||
*/
|
||||
@Override
|
||||
public void receive(AudioSegment audioSegment)
|
||||
{
|
||||
mNewAudioSegments.add(audioSegment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the scheduled audio segment processor
|
||||
*/
|
||||
public void start()
|
||||
{
|
||||
if(mAudioSegmentProcessorFuture == null)
|
||||
{
|
||||
mAudioSegmentProcessorFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(new AudioSegmentProcessor(),
|
||||
0, 250, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the scheduled audio segment processor
|
||||
*/
|
||||
public void stop()
|
||||
{
|
||||
if(mAudioSegmentProcessorFuture != null)
|
||||
{
|
||||
mAudioSegmentProcessorFuture.cancel(true);
|
||||
mAudioSegmentProcessorFuture = null;
|
||||
}
|
||||
|
||||
for(AudioSegment audioSegment: mNewAudioSegments)
|
||||
{
|
||||
audioSegment.decrementConsumerCount();
|
||||
}
|
||||
|
||||
mNewAudioSegments.clear();
|
||||
|
||||
for(AudioSegment audioSegment: mAudioSegments)
|
||||
{
|
||||
audioSegment.decrementConsumerCount();
|
||||
}
|
||||
|
||||
mAudioSegments.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main processing method to process audio segments
|
||||
*/
|
||||
private void processAudioSegments()
|
||||
{
|
||||
mNewAudioSegments.drainTo(mAudioSegments);
|
||||
|
||||
Iterator<AudioSegment> it = mAudioSegments.iterator();
|
||||
AudioSegment audioSegment;
|
||||
while(it.hasNext())
|
||||
{
|
||||
audioSegment = it.next();
|
||||
|
||||
if(audioSegment.completeProperty().get())
|
||||
{
|
||||
it.remove();
|
||||
|
||||
if(mAudioRecordingListener != null && audioSegment.hasBroadcastChannels())
|
||||
{
|
||||
Path path = getTemporaryRecordingPath();
|
||||
long length = 0;
|
||||
|
||||
for(float[] audioBuffer: audioSegment.getAudioBuffers())
|
||||
{
|
||||
length += audioBuffer.length;
|
||||
}
|
||||
|
||||
length /= 8; //Sample rate is 8000 samples per second, or 8 samples per millisecond.
|
||||
|
||||
try
|
||||
{
|
||||
AudioSegmentRecorder.record(audioSegment, path, RecordFormat.MP3);
|
||||
AudioRecording audioRecording = new AudioRecording(path, audioSegment.getBroadcastChannels(),
|
||||
audioSegment.getIdentifierCollection(), audioSegment.getStartTimestamp(), length);
|
||||
mAudioRecordingListener.receive(audioRecording);
|
||||
}
|
||||
catch(IOException ioe)
|
||||
{
|
||||
mLog.error("Error recording temporary stream MP3");
|
||||
}
|
||||
}
|
||||
|
||||
audioSegment.decrementConsumerCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary streaming recording file path
|
||||
*/
|
||||
private Path getTemporaryRecordingPath()
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(BroadcastModel.TEMPORARY_STREAM_FILE_SUFFIX);
|
||||
|
||||
//Check for integer overflow and readjust negative value to 0
|
||||
if(mNextRecordingNumber < 0)
|
||||
{
|
||||
mNextRecordingNumber = 1;
|
||||
}
|
||||
|
||||
int recordingNumber = mNextRecordingNumber++;
|
||||
|
||||
sb.append(recordingNumber).append("_");
|
||||
sb.append(TimeStamp.getLongTimeStamp("_"));
|
||||
sb.append(mBroadcastFormat.getFileExtension());
|
||||
|
||||
Path temporaryRecordingPath = mUserPreferences.getDirectoryPreference().getDirectoryStreaming().resolve(sb.toString());
|
||||
|
||||
return temporaryRecordingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled runnable to process audio segments.
|
||||
*/
|
||||
public class AudioSegmentProcessor implements Runnable
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
try
|
||||
{
|
||||
processAudioSegments();
|
||||
}
|
||||
catch(Throwable t)
|
||||
{
|
||||
mLog.error("Error processing audio segments for streaming", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,13 +44,9 @@ import io.github.dsheirer.gui.editor.Editor;
|
||||
import io.github.dsheirer.gui.editor.EmptyEditor;
|
||||
import io.github.dsheirer.icon.IconManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.record.AudioRecorder;
|
||||
import io.github.dsheirer.record.mp3.MP3Recorder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class BroadcastFactory
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(BroadcastFactory.class);
|
||||
@@ -186,21 +182,6 @@ public class BroadcastFactory
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an audio recorder for the specified broadcastAudio format using the specified path output file name
|
||||
*/
|
||||
public static AudioRecorder getAudioRecorder(Path path, BroadcastFormat broadcastFormat)
|
||||
{
|
||||
switch(broadcastFormat)
|
||||
{
|
||||
case MP3:
|
||||
return new MP3Recorder(path);
|
||||
default:
|
||||
mLog.debug("Unrecognized broadcastAudio format [" + broadcastFormat + "] cannot create audio recorder");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static ISilenceGenerator getSilenceGenerator(BroadcastFormat format)
|
||||
{
|
||||
switch(format)
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.audio.broadcast;
|
||||
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.alias.AliasModel;
|
||||
import io.github.dsheirer.alias.id.broadcast.BroadcastChannel;
|
||||
import io.github.dsheirer.icon.IconManager;
|
||||
@@ -29,7 +28,6 @@ import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -51,7 +49,7 @@ import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class BroadcastModel extends AbstractTableModel implements Listener<ReusableAudioPacket>
|
||||
public class BroadcastModel extends AbstractTableModel implements Listener<AudioRecording>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(BroadcastModel.class);
|
||||
|
||||
@@ -76,7 +74,6 @@ public class BroadcastModel extends AbstractTableModel implements Listener<Reusa
|
||||
private Map<String,BroadcastConfiguration> mBroadcastConfigurationMap = new HashMap<>();
|
||||
private Map<String,AudioBroadcaster> mBroadcasterMap = new HashMap<>();
|
||||
private IconManager mIconManager;
|
||||
private StreamManager mStreamManager;
|
||||
private AliasModel mAliasModel;
|
||||
private Broadcaster<BroadcastEvent> mBroadcastEventBroadcaster = new Broadcaster<>();
|
||||
|
||||
@@ -87,8 +84,6 @@ public class BroadcastModel extends AbstractTableModel implements Listener<Reusa
|
||||
{
|
||||
mAliasModel = aliasModel;
|
||||
mIconManager = iconManager;
|
||||
mStreamManager = new StreamManager(new CompletedRecordingListener(), BroadcastFormat.MP3, userPreferences);
|
||||
mStreamManager.start();
|
||||
|
||||
//Monitor to remove temporary recording files that have been streamed by all audio broadcasters
|
||||
ThreadPool.SCHEDULED.scheduleAtFixedRate(new RecordingDeletionMonitor(), 15l, 15l, TimeUnit.SECONDS);
|
||||
@@ -281,22 +276,28 @@ public class BroadcastModel extends AbstractTableModel implements Listener<Reusa
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receive(ReusableAudioPacket audioPacket)
|
||||
public void receive(AudioRecording audioRecording)
|
||||
{
|
||||
if(audioPacket.isStreamable())
|
||||
if(audioRecording != null && !audioRecording.getBroadcastChannels().isEmpty())
|
||||
{
|
||||
for(BroadcastChannel channel: audioPacket.getBroadcastChannels())
|
||||
for(BroadcastChannel broadcastChannel : audioRecording.getBroadcastChannels())
|
||||
{
|
||||
if(mBroadcasterMap.containsKey(channel.getChannelName()))
|
||||
String channelName = broadcastChannel.getChannelName();
|
||||
|
||||
if(channelName != null)
|
||||
{
|
||||
audioPacket.incrementUserCount();
|
||||
mStreamManager.receive(audioPacket);
|
||||
return;
|
||||
AudioBroadcaster audioBroadcaster = getBroadcaster(channelName);
|
||||
|
||||
if(audioBroadcaster != null)
|
||||
{
|
||||
audioRecording.addPendingReplay();
|
||||
audioBroadcaster.receive(audioRecording);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioPacket.decrementUserCount();
|
||||
mRecordingQueue.add(audioRecording);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,19 +312,9 @@ public class BroadcastModel extends AbstractTableModel implements Listener<Reusa
|
||||
|
||||
if(audioBroadcaster != null)
|
||||
{
|
||||
audioBroadcaster.setListener(new Listener<BroadcastEvent>()
|
||||
{
|
||||
@Override
|
||||
public void receive(BroadcastEvent broadcastEvent)
|
||||
{
|
||||
process(broadcastEvent);
|
||||
}
|
||||
});
|
||||
|
||||
audioBroadcaster.setListener(broadcastEvent -> process(broadcastEvent));
|
||||
audioBroadcaster.start();
|
||||
|
||||
mBroadcasterMap.put(audioBroadcaster.getBroadcastConfiguration().getName(), audioBroadcaster);
|
||||
|
||||
int index = mBroadcastConfigurations.indexOf(audioBroadcaster.getBroadcastConfiguration());
|
||||
|
||||
if(index >= 0)
|
||||
@@ -710,43 +701,6 @@ public class BroadcastModel extends AbstractTableModel implements Listener<Reusa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes completed audio recordings and distributes them to the audio broadcasters. Adds the recording to
|
||||
* the audio recording queue to be monitored for deletion.
|
||||
*/
|
||||
public class CompletedRecordingListener implements Listener<AudioRecording>
|
||||
{
|
||||
@Override
|
||||
public void receive(AudioRecording audioRecording)
|
||||
{
|
||||
if(audioRecording != null && audioRecording.hasIdentifierCollection())
|
||||
{
|
||||
AliasList aliasList = mAliasModel.getAliasList(audioRecording.getIdentifierCollection());
|
||||
|
||||
if(aliasList != null)
|
||||
{
|
||||
for(BroadcastChannel broadcastChannel : aliasList.getBroadcastChannels(audioRecording.getIdentifierCollection()))
|
||||
{
|
||||
String channelName = broadcastChannel.getChannelName();
|
||||
|
||||
if(channelName != null)
|
||||
{
|
||||
AudioBroadcaster audioBroadcaster = getBroadcaster(channelName);
|
||||
|
||||
if(audioBroadcaster != null)
|
||||
{
|
||||
audioRecording.addPendingReplay();
|
||||
audioBroadcaster.receive(audioRecording);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mRecordingQueue.add(audioRecording);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors the recording queue and removes any recordings that have no pending replays by audio broadcasters
|
||||
*/
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/*
|
||||
*
|
||||
* * ******************************************************************************
|
||||
* * Copyright (C) 2014-2019 Dennis Sheirer
|
||||
* *
|
||||
* * This program is free software: you can redistribute it and/or modify
|
||||
* * it under the terms of the GNU General Public License as published by
|
||||
* * the Free Software Foundation, either version 3 of the License, or
|
||||
* * (at your option) any later version.
|
||||
* *
|
||||
* * This program is distributed in the hope that it will be useful,
|
||||
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* * GNU General Public License for more details.
|
||||
* *
|
||||
* * You should have received a copy of the GNU General Public License
|
||||
* * along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* * *****************************************************************************
|
||||
*
|
||||
*
|
||||
*/
|
||||
package io.github.dsheirer.audio.broadcast;
|
||||
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.record.AudioRecorder;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import io.github.dsheirer.util.TimeStamp;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class StreamManager implements Listener<ReusableAudioPacket>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(StreamManager.class);
|
||||
private static final long MAXIMUM_RECORDER_LIFESPAN_MILLIS = 30000; //30 seconds
|
||||
|
||||
private static AtomicInteger sNextRecordingNumber = new AtomicInteger();
|
||||
|
||||
private Listener<AudioRecording> mAudioRecordingListener;
|
||||
private BroadcastFormat mBroadcastFormat;
|
||||
private UserPreferences mUserPreferences;
|
||||
private Map<Integer,AudioRecorder> mStreamRecorders = new HashMap<>();
|
||||
private Runnable mRecorderMonitor;
|
||||
private ScheduledFuture<?> mRecorderMonitorFuture;
|
||||
private AtomicBoolean mRunning = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* Stream manager processes all incoming audio packets and reassembles individual audio streams, converts audio
|
||||
* to desired output format and persists each stream to disc. Each recording is capped at a maximum length to
|
||||
* ensure that recordings don't run too long before they are streamed out and to ensure that inactive recordings
|
||||
* are closed in a timely fashion.
|
||||
*
|
||||
* Completed streamable audio recordings are nominated to the output listener (for broadcast) upon completion
|
||||
*
|
||||
* @param listener to receive completed audio recordings
|
||||
* @param broadcastFormat for the stream
|
||||
* @param userPreferences to obtain the temporary streaming recording directory
|
||||
*/
|
||||
public StreamManager(Listener<AudioRecording> listener, BroadcastFormat broadcastFormat, UserPreferences userPreferences)
|
||||
{
|
||||
mAudioRecordingListener = listener;
|
||||
mBroadcastFormat = broadcastFormat;
|
||||
mUserPreferences = userPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the stream manager. Schedules a processor to monitor for inactive temporary stream recorders
|
||||
* every 2 seconds.
|
||||
*/
|
||||
public void start()
|
||||
{
|
||||
if(mRunning.compareAndSet(false, true))
|
||||
{
|
||||
if(mRecorderMonitor == null)
|
||||
{
|
||||
mRecorderMonitor = new RecorderMonitor();
|
||||
}
|
||||
|
||||
mRecorderMonitorFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(mRecorderMonitor,
|
||||
0,2, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the stream manager. Stops the inactive temporary stream recorder monitoring thread.
|
||||
*/
|
||||
public void stop()
|
||||
{
|
||||
if(mRunning.compareAndSet(true, false))
|
||||
{
|
||||
if(mRecorderMonitorFuture != null)
|
||||
{
|
||||
mRecorderMonitorFuture.cancel(true);
|
||||
}
|
||||
|
||||
synchronized(mStreamRecorders)
|
||||
{
|
||||
List<Integer> streamKeys = new ArrayList<>(mStreamRecorders.keySet());
|
||||
|
||||
for(Integer streamKey : streamKeys)
|
||||
{
|
||||
removeRecorder(streamKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receive(ReusableAudioPacket audioPacket)
|
||||
{
|
||||
if(mRunning.get() && audioPacket.hasIdentifierCollection())
|
||||
{
|
||||
synchronized(mStreamRecorders)
|
||||
{
|
||||
int channelMetadataID = audioPacket.getAudioChannelId();
|
||||
|
||||
ReusableAudioPacket.Type type = audioPacket.getType();
|
||||
|
||||
if(type == ReusableAudioPacket.Type.AUDIO)
|
||||
{
|
||||
if(mStreamRecorders.containsKey(channelMetadataID))
|
||||
{
|
||||
AudioRecorder recorder = mStreamRecorders.get(channelMetadataID);
|
||||
|
||||
if(recorder != null)
|
||||
{
|
||||
audioPacket.incrementUserCount();
|
||||
recorder.receive(audioPacket);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AudioRecorder recorder = BroadcastFactory.getAudioRecorder(getTemporaryRecordingPath(), mBroadcastFormat);
|
||||
recorder.start();
|
||||
audioPacket.incrementUserCount();
|
||||
recorder.receive(audioPacket);
|
||||
mStreamRecorders.put(channelMetadataID, recorder);
|
||||
}
|
||||
}
|
||||
else if(type == ReusableAudioPacket.Type.END)
|
||||
{
|
||||
removeRecorder(channelMetadataID);
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.info("Unrecognized Audio Packet Type: " + type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioPacket.decrementUserCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the recorder associated with the source channel ID.
|
||||
*
|
||||
* Note: this method invocation is not thread safe and must be invoked by a thread safe mechanism that protects the
|
||||
* mStreamRecorders map.
|
||||
*
|
||||
* @param sourceChannelID identifying the recorder
|
||||
*/
|
||||
private void removeRecorder(Integer sourceChannelID)
|
||||
{
|
||||
if(mStreamRecorders.containsKey(sourceChannelID))
|
||||
{
|
||||
AudioRecorder recorder = mStreamRecorders.remove(sourceChannelID);
|
||||
|
||||
recorder.close(new Listener<AudioRecorder>()
|
||||
{
|
||||
@Override
|
||||
public void receive(AudioRecorder audioRecorder)
|
||||
{
|
||||
AudioRecording audioRecording =
|
||||
new AudioRecording(audioRecorder.getPath(), audioRecorder.getIdentifierCollection(),
|
||||
audioRecorder.getTimeRecordingStart(), audioRecorder.getRecordingLength());
|
||||
|
||||
if(mAudioRecordingListener != null)
|
||||
{
|
||||
mAudioRecordingListener.receive(audioRecording);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary streaming recording file path
|
||||
*/
|
||||
private Path getTemporaryRecordingPath()
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(BroadcastModel.TEMPORARY_STREAM_FILE_SUFFIX);
|
||||
|
||||
int recordingNumber = sNextRecordingNumber.incrementAndGet();
|
||||
|
||||
//Check for integer overflow and readjust negative value to 0
|
||||
if(recordingNumber < 0)
|
||||
{
|
||||
sNextRecordingNumber.set(0);
|
||||
recordingNumber = sNextRecordingNumber.incrementAndGet();
|
||||
}
|
||||
sb.append(recordingNumber).append("_");
|
||||
sb.append(TimeStamp.getLongTimeStamp("_"));
|
||||
sb.append(mBroadcastFormat.getFileExtension());
|
||||
|
||||
Path temporaryRecordingPath = mUserPreferences.getDirectoryPreference().getDirectoryStreaming().resolve(sb.toString());
|
||||
|
||||
return temporaryRecordingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors recorders to ensure they don't exceed the maximum life-span allowed for a recording.
|
||||
*/
|
||||
public class RecorderMonitor implements Runnable
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
synchronized(mStreamRecorders)
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
mStreamRecorders.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().getTimeRecordingStart() + MAXIMUM_RECORDER_LIFESPAN_MILLIS < now)
|
||||
.forEach(entry ->
|
||||
{
|
||||
mLog.info("cycling recorder - max temporary streaming recording time limit reached [" +
|
||||
entry.getValue().getPath().toString() + "]");
|
||||
|
||||
removeRecorder(entry.getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
package io.github.dsheirer.audio.codec.mbe;
|
||||
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -32,9 +33,9 @@ public abstract class AmbeAudioModule extends JmbeAudioModule
|
||||
private static final String AMBE_CODEC = "AMBE 3600 x 2450";
|
||||
private static boolean sLibraryStatusLogged = false;
|
||||
|
||||
public AmbeAudioModule(UserPreferences userPreferences)
|
||||
public AmbeAudioModule(UserPreferences userPreferences, AliasList aliasList)
|
||||
{
|
||||
super(userPreferences);
|
||||
super(userPreferences, aliasList);
|
||||
|
||||
if(!sLibraryStatusLogged)
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
package io.github.dsheirer.audio.codec.mbe;
|
||||
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -32,9 +33,9 @@ public abstract class ImbeAudioModule extends JmbeAudioModule
|
||||
private static final String IMBE_CODEC = "IMBE";
|
||||
private static boolean sLibraryStatusLogged = false;
|
||||
|
||||
public ImbeAudioModule(UserPreferences userPreferences)
|
||||
public ImbeAudioModule(UserPreferences userPreferences, AliasList aliasList)
|
||||
{
|
||||
super(userPreferences);
|
||||
super(userPreferences, aliasList);
|
||||
|
||||
if(!sLibraryStatusLogged)
|
||||
{
|
||||
|
||||
@@ -23,14 +23,15 @@
|
||||
package io.github.dsheirer.audio.codec.mbe;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.alias.AliasList;
|
||||
import io.github.dsheirer.audio.AbstractAudioModule;
|
||||
import io.github.dsheirer.audio.squelch.ISquelchStateListener;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.message.IMessage;
|
||||
import io.github.dsheirer.message.IMessageListener;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacketQueue;
|
||||
import jmbe.iface.IAudioCodec;
|
||||
import jmbe.iface.IAudioCodecLibrary;
|
||||
import org.slf4j.Logger;
|
||||
@@ -44,17 +45,18 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class JmbeAudioModule extends AbstractAudioModule implements Listener<IMessage>, IMessageListener
|
||||
public abstract class JmbeAudioModule extends AbstractAudioModule implements Listener<IMessage>, IMessageListener,
|
||||
ISquelchStateListener
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(JmbeAudioModule.class);
|
||||
private static final String JMBE_AUDIO_LIBRARY = "JMBE";
|
||||
private static List<String> mLibraryLoadStatusLogged = new ArrayList<>();
|
||||
private IAudioCodec mAudioCodec;
|
||||
private UserPreferences mUserPreferences;
|
||||
private ReusableAudioPacketQueue mAudioPacketQueue = new ReusableAudioPacketQueue("JmbeAudioModule");
|
||||
|
||||
public JmbeAudioModule(UserPreferences userPreferences)
|
||||
public JmbeAudioModule(UserPreferences userPreferences, AliasList aliasList)
|
||||
{
|
||||
super(aliasList);
|
||||
mUserPreferences = userPreferences;
|
||||
MyEventBus.getEventBus().register(this);
|
||||
loadConverter();
|
||||
@@ -73,11 +75,6 @@ public abstract class JmbeAudioModule extends AbstractAudioModule implements Lis
|
||||
return getAudioCodec() != null;
|
||||
}
|
||||
|
||||
protected ReusableAudioPacketQueue getAudioPacketQueue()
|
||||
{
|
||||
return mAudioPacketQueue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Listener<IMessage> getMessageListener()
|
||||
{
|
||||
|
||||
@@ -22,21 +22,9 @@
|
||||
|
||||
package io.github.dsheirer.audio.codec.mbe;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.dsheirer.audio.convert.thumbdv.ThumbDv;
|
||||
import io.github.dsheirer.audio.convert.thumbdv.message.response.AmbeResponse;
|
||||
import io.github.dsheirer.module.decode.p25.audio.VoiceFrame;
|
||||
import io.github.dsheirer.record.wave.AudioPacketWaveRecorder;
|
||||
import io.github.dsheirer.record.wave.WaveMetadata;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* Utility for converting MBE call sequences (*.mbe) to PCM wave audio format
|
||||
*/
|
||||
@@ -44,64 +32,64 @@ public class MBECallSequenceConverter
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(MBECallSequenceConverter.class);
|
||||
|
||||
public static void convert(Path input, Path output) throws IOException
|
||||
{
|
||||
InputStream inputStream = Files.newInputStream(input);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
MBECallSequence sequence = mapper.readValue(inputStream, MBECallSequence.class);
|
||||
convert(sequence, output);
|
||||
}
|
||||
|
||||
public static void convert(MBECallSequence callSequence, Path outputPath)
|
||||
{
|
||||
if(callSequence == null || callSequence.isEncrypted())
|
||||
{
|
||||
throw new IllegalArgumentException("Cannot decode null or encrypted call sequence");
|
||||
}
|
||||
|
||||
if(callSequence != null && !callSequence.isEncrypted())
|
||||
{
|
||||
ThumbDv.AudioProtocol protocol = ThumbDv.AudioProtocol.P25_PHASE2;
|
||||
|
||||
AudioPacketWaveRecorder recorder = new AudioPacketWaveRecorder(outputPath);
|
||||
recorder.start();
|
||||
|
||||
long delayMillis = 0;
|
||||
|
||||
try(ThumbDv thumbDv = new ThumbDv(protocol, recorder))
|
||||
{
|
||||
thumbDv.start();
|
||||
for(VoiceFrame voiceFrame: callSequence.getVoiceFrames())
|
||||
{
|
||||
mLog.debug("Frame [" + voiceFrame.getFrame() + "] + Hex [" + AmbeResponse.toHex(voiceFrame.getFrameBytes()) + "]");
|
||||
thumbDv.decode(voiceFrame.getFrameBytes());
|
||||
delayMillis += 30;
|
||||
}
|
||||
|
||||
if(delayMillis > 0)
|
||||
{
|
||||
delayMillis += 1000;
|
||||
try
|
||||
{
|
||||
Thread.sleep(delayMillis);
|
||||
}
|
||||
catch(InterruptedException ie)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException ioe)
|
||||
{
|
||||
mLog.error("Error", ioe);
|
||||
}
|
||||
|
||||
recorder.stop(Paths.get(outputPath.toString().replace(".tmp", ".wav")), new WaveMetadata());
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args)
|
||||
{
|
||||
// public static void convert(Path input, Path output) throws IOException
|
||||
// {
|
||||
// InputStream inputStream = Files.newInputStream(input);
|
||||
// ObjectMapper mapper = new ObjectMapper();
|
||||
// MBECallSequence sequence = mapper.readValue(inputStream, MBECallSequence.class);
|
||||
// convert(sequence, output);
|
||||
// }
|
||||
//
|
||||
// public static void convert(MBECallSequence callSequence, Path outputPath)
|
||||
// {
|
||||
// if(callSequence == null || callSequence.isEncrypted())
|
||||
// {
|
||||
// throw new IllegalArgumentException("Cannot decode null or encrypted call sequence");
|
||||
// }
|
||||
//
|
||||
// if(callSequence != null && !callSequence.isEncrypted())
|
||||
// {
|
||||
// ThumbDv.AudioProtocol protocol = ThumbDv.AudioProtocol.P25_PHASE2;
|
||||
//
|
||||
// AudioPacketWaveRecorder recorder = new AudioPacketWaveRecorder(outputPath);
|
||||
// recorder.start();
|
||||
//
|
||||
// long delayMillis = 0;
|
||||
//
|
||||
// try(ThumbDv thumbDv = new ThumbDv(protocol, recorder))
|
||||
// {
|
||||
// thumbDv.start();
|
||||
// for(VoiceFrame voiceFrame: callSequence.getVoiceFrames())
|
||||
// {
|
||||
// mLog.debug("Frame [" + voiceFrame.getFrame() + "] + Hex [" + AmbeResponse.toHex(voiceFrame.getFrameBytes()) + "]");
|
||||
// thumbDv.decode(voiceFrame.getFrameBytes());
|
||||
// delayMillis += 30;
|
||||
// }
|
||||
//
|
||||
// if(delayMillis > 0)
|
||||
// {
|
||||
// delayMillis += 1000;
|
||||
// try
|
||||
// {
|
||||
// Thread.sleep(delayMillis);
|
||||
// }
|
||||
// catch(InterruptedException ie)
|
||||
// {
|
||||
//
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// catch(IOException ioe)
|
||||
// {
|
||||
// mLog.error("Error", ioe);
|
||||
// }
|
||||
//
|
||||
// recorder.stop(Paths.get(outputPath.toString().replace(".tmp", ".wav")), new WaveMetadata());
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public static void main(String[] args)
|
||||
// {
|
||||
// String mbe = "/home/denny/SDRTrunk/recordings/20190331085324_154250000_3_TS0_65084_6591007.mbe";
|
||||
// String mbe = "/home/denny/SDRTrunk/recordings/20190331085324_154250000_2_TS1_65035.mbe";
|
||||
//
|
||||
@@ -118,5 +106,5 @@ public class MBECallSequenceConverter
|
||||
// {
|
||||
// mLog.error("Error", ioe);
|
||||
// }
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
******************************************************************************/
|
||||
package io.github.dsheirer.audio.convert;
|
||||
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface IAudioConverter
|
||||
@@ -27,7 +25,7 @@ public interface IAudioConverter
|
||||
/**
|
||||
* Converts the PCM audio packets to converted audio format. May produce partial audio frame data.
|
||||
*/
|
||||
public byte[] convert(List<ReusableAudioPacket> audioPackets);
|
||||
public byte[] convert(List<float[]> audioBuffers);
|
||||
|
||||
/**
|
||||
* Finalizes audio conversion by fully converting any partial frames left in the buffer and returning the
|
||||
|
||||
@@ -20,7 +20,6 @@ package io.github.dsheirer.audio.convert;
|
||||
|
||||
import io.github.dsheirer.audio.AudioFormats;
|
||||
import io.github.dsheirer.audio.AudioUtils;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import net.sourceforge.lame.lowlevel.LameEncoder;
|
||||
import net.sourceforge.lame.mp3.Lame;
|
||||
import net.sourceforge.lame.mp3.MPEGMode;
|
||||
@@ -54,7 +53,7 @@ public class MP3AudioConverter implements IAudioConverter
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] convert(List<ReusableAudioPacket> audioPackets)
|
||||
public byte[] convert(List<float[]> audioPackets)
|
||||
{
|
||||
mMP3Stream.reset();
|
||||
|
||||
@@ -84,6 +83,36 @@ public class MP3AudioConverter implements IAudioConverter
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] convertAudio(List<float[]> audioBuffers)
|
||||
{
|
||||
mMP3Stream.reset();
|
||||
|
||||
byte[] pcmBytes = AudioUtils.convert(audioBuffers);
|
||||
|
||||
int pcmBufferSize = Math.min(mMP3Buffer.length, pcmBytes.length);
|
||||
|
||||
int mp3BufferSize = 0;
|
||||
|
||||
int pcmBytesPosition = 0;
|
||||
|
||||
try
|
||||
{
|
||||
while (0 < (mp3BufferSize = mEncoder.encodeBuffer(pcmBytes, pcmBytesPosition, pcmBufferSize, mMP3Buffer)))
|
||||
{
|
||||
pcmBytesPosition += pcmBufferSize;
|
||||
pcmBufferSize = Math.min(mMP3Buffer.length, pcmBytes.length - pcmBytesPosition);
|
||||
mMP3Stream.write(mMP3Buffer, 0, mp3BufferSize);
|
||||
}
|
||||
|
||||
return mMP3Stream.toByteArray();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
mLog.error("There was an error converting audio to MP3: " + e.getMessage());
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] flush()
|
||||
{
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
******************************************************************************/
|
||||
package io.github.dsheirer.audio.convert;
|
||||
|
||||
import io.github.dsheirer.record.mp3.MP3Recorder;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacketQueue;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -31,10 +28,11 @@ import java.util.List;
|
||||
public class MP3SilenceGenerator implements ISilenceGenerator
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(MP3SilenceGenerator.class);
|
||||
public static final int MP3_BIT_RATE = 16;
|
||||
public static final boolean CONSTANT_BIT_RATE = false;
|
||||
|
||||
private MP3AudioConverter mGenerator = new MP3AudioConverter(MP3Recorder.MP3_BIT_RATE, MP3Recorder.CONSTANT_BIT_RATE);
|
||||
private MP3AudioConverter mGenerator = new MP3AudioConverter(MP3_BIT_RATE, CONSTANT_BIT_RATE);
|
||||
private byte[] mPreviousPartialFrameData;
|
||||
private ReusableAudioPacketQueue mAudioPacketQueue = new ReusableAudioPacketQueue("MP3 Silence Generator");
|
||||
|
||||
/**
|
||||
* Generates MP3 audio silence frames
|
||||
@@ -46,13 +44,10 @@ public class MP3SilenceGenerator implements ISilenceGenerator
|
||||
public byte[] generate(long duration)
|
||||
{
|
||||
int length = (int)(duration * 8); //8000 Hz sample rate
|
||||
ReusableAudioPacket silencePacket = mAudioPacketQueue.getBuffer(length);
|
||||
Arrays.fill(silencePacket.getAudioSamples(), 0.0f);
|
||||
|
||||
List<ReusableAudioPacket> silencePackets = new ArrayList<>();
|
||||
silencePackets.add(silencePacket);
|
||||
|
||||
byte[] frameData = mGenerator.convert(silencePackets);
|
||||
List<float[]> silenceBuffers = new ArrayList<>();
|
||||
silenceBuffers.add(new float[length]);
|
||||
byte[] frameData = mGenerator.convert(silenceBuffers);
|
||||
|
||||
frameData = merge(mPreviousPartialFrameData, frameData);
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ import io.github.dsheirer.audio.convert.thumbdv.message.response.DecodeSpeechRes
|
||||
import io.github.dsheirer.audio.convert.thumbdv.message.response.ReadyResponse;
|
||||
import io.github.dsheirer.audio.convert.thumbdv.message.response.SetVocoderParameterResponse;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacketQueue;
|
||||
import io.github.dsheirer.sample.buffer.ReusableBufferQueue;
|
||||
import io.github.dsheirer.sample.buffer.ReusableFloatBuffer;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -82,14 +82,14 @@ public class ThumbDv implements AutoCloseable
|
||||
private ScheduledFuture mAudioDecodeProcessorHandle;
|
||||
private LinkedTransferQueue<DecodeSpeechRequest> mDecodeSpeechRequests = new LinkedTransferQueue<>();
|
||||
private AudioProtocol mAudioProtocol;
|
||||
private Listener<ReusableAudioPacket> mAudioPacketListener;
|
||||
private ReusableAudioPacketQueue mReusableAudioPacketQueue = new ReusableAudioPacketQueue("ThumbDv");
|
||||
private Listener<ReusableFloatBuffer> mAudioBufferListener;
|
||||
private ReusableBufferQueue mReusableBufferQueue = new ReusableBufferQueue("ThumbDv");
|
||||
private boolean mStarted;
|
||||
|
||||
public ThumbDv(AudioProtocol audioProtocol, Listener<ReusableAudioPacket> listener)
|
||||
public ThumbDv(AudioProtocol audioProtocol, Listener<ReusableFloatBuffer> listener)
|
||||
{
|
||||
mAudioProtocol = audioProtocol;
|
||||
mAudioPacketListener = listener;
|
||||
mAudioBufferListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,12 +206,11 @@ public class ThumbDv implements AutoCloseable
|
||||
{
|
||||
AmbeMessage message = AmbeMessageFactory.getMessage(bytes);
|
||||
|
||||
if(message instanceof DecodeSpeechResponse && mAudioPacketListener != null)
|
||||
if(message instanceof DecodeSpeechResponse && mAudioBufferListener != null)
|
||||
{
|
||||
float[] samples = ((DecodeSpeechResponse)message).getSamples();
|
||||
ReusableAudioPacket audioPacket = mReusableAudioPacketQueue.getBuffer(samples.length);
|
||||
audioPacket.loadAudioFrom(samples);
|
||||
mAudioPacketListener.receive(audioPacket);
|
||||
ReusableFloatBuffer audioBuffer = mReusableBufferQueue.getBuffer(samples, System.currentTimeMillis());
|
||||
mAudioBufferListener.receive(audioBuffer);
|
||||
}
|
||||
else if(message instanceof ReadyResponse && mAudioDecodeProcessorHandle == null)
|
||||
{
|
||||
@@ -457,7 +456,7 @@ public class ThumbDv implements AutoCloseable
|
||||
|
||||
mLog.debug("Starting thumb dv thread(s)");
|
||||
|
||||
final Listener<ReusableAudioPacket> listener = reusableAudioPacket -> {
|
||||
final Listener<ReusableFloatBuffer> listener = reusableAudioPacket -> {
|
||||
mLog.info("Got an audio packet!");
|
||||
reusableAudioPacket.decrementUserCount();
|
||||
};
|
||||
|
||||
@@ -127,6 +127,12 @@ public class AudioChannelPanel extends JPanel implements Listener<AudioEvent>, S
|
||||
{
|
||||
//Deregister from receiving preference update notifications
|
||||
MyEventBus.getEventBus().unregister(this);
|
||||
|
||||
if(mAudioOutput != null)
|
||||
{
|
||||
mAudioOutput.removeAudioEventListener(this);
|
||||
mAudioOutput.removeAudioMetadataListener();
|
||||
}
|
||||
}
|
||||
|
||||
private void init()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
*
|
||||
* * ******************************************************************************
|
||||
* * Copyright (C) 2014-2019 Dennis Sheirer
|
||||
* * Copyright (C) 2014-2020 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
|
||||
@@ -21,13 +21,23 @@
|
||||
*/
|
||||
package io.github.dsheirer.audio.playback;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.alias.id.priority.Priority;
|
||||
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 io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.source.mixer.MixerChannel;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -42,42 +52,39 @@ import javax.sound.sampled.LineUnavailableException;
|
||||
import javax.sound.sampled.Mixer;
|
||||
import javax.sound.sampled.SourceDataLine;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedTransferQueue;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
public abstract class AudioOutput implements Listener<ReusableAudioPacket>, LineListener
|
||||
/**
|
||||
* Audio output/playback channel for a single audio mixer channel. Providers support for playback of audio segments
|
||||
* and broadcasts audio segment metadata to registered listeners (ie gui components).
|
||||
*/
|
||||
public abstract class AudioOutput implements LineListener, Listener<IdentifierUpdateNotification>
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AudioOutput.class);
|
||||
|
||||
private LinkedTransferQueue<ReusableAudioPacket> mBuffer = new LinkedTransferQueue<>();
|
||||
private int mBufferStartThreshold;
|
||||
private int mBufferStopThreshold;
|
||||
private static IdentifierCollection EMPTY_IDENTIFIER_COLLECTION = new IdentifierCollection(0);
|
||||
static
|
||||
{
|
||||
EMPTY_IDENTIFIER_COLLECTION.setUpdated(true);
|
||||
}
|
||||
|
||||
private Listener<IdentifierCollection> mIdentifierCollectionListener;
|
||||
private Broadcaster<AudioEvent> mAudioEventBroadcaster = new Broadcaster<>();
|
||||
|
||||
private ScheduledFuture<?> mProcessorTask;
|
||||
|
||||
private ScheduledFuture<?> mProcessorFuture;
|
||||
private SourceDataLine mOutput;
|
||||
private Mixer mMixer;
|
||||
private MixerChannel mMixerChannel;
|
||||
private FloatControl mGainControl;
|
||||
private BooleanControl mMuteControl;
|
||||
|
||||
private AudioEvent mAudioStartEvent;
|
||||
private AudioEvent mAudioStopEvent;
|
||||
|
||||
private boolean mCanProcessAudio = false;
|
||||
private long mLastActivity = System.currentTimeMillis();
|
||||
private AudioSegment mCurrentAudioSegment;
|
||||
private AudioSegment mNextAudioSegment;
|
||||
private ReentrantLock mLock = new ReentrantLock();
|
||||
private int mCurrentBufferIndex = 0;
|
||||
private UserPreferences mUserPreferences;
|
||||
private BooleanProperty mEmptyProperty = new SimpleBooleanProperty(true);
|
||||
private IntegerProperty mAudioPriority = new SimpleIntegerProperty(Priority.DEFAULT_PRIORITY);
|
||||
private ByteBuffer mAudioSegmentStartTone;
|
||||
private ByteBuffer mAudioSegmentPreemptTone;
|
||||
|
||||
/**
|
||||
* Single audio channel playback with automatic starting and stopping of the
|
||||
@@ -94,10 +101,11 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
* @param requestedBufferSize of approximately 1 second of audio
|
||||
*/
|
||||
public AudioOutput(Mixer mixer, MixerChannel mixerChannel, AudioFormat audioFormat, Line.Info lineInfo,
|
||||
int requestedBufferSize)
|
||||
int requestedBufferSize, UserPreferences userPreferences)
|
||||
{
|
||||
mMixer = mixer;
|
||||
mMixerChannel = mixerChannel;
|
||||
mUserPreferences = userPreferences;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -139,46 +147,362 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
mixer.getMixerInfo().getName() + " | " + getChannelName() + "]");
|
||||
}
|
||||
|
||||
/* Run the queue processor task every 40 milliseconds or 25 times a second */
|
||||
mProcessorTask = ThreadPool.SCHEDULED.scheduleAtFixedRate(new BufferProcessor(),
|
||||
0, 40, TimeUnit.MILLISECONDS);
|
||||
//Run the queue processor task every 250 milliseconds or 4 times a second
|
||||
mProcessorFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(new AudioSegmentProcessor(),
|
||||
0, 250, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
mAudioStartEvent = new AudioEvent(AudioEvent.Type.AUDIO_STARTED,
|
||||
getChannelName());
|
||||
mAudioStopEvent = new AudioEvent(AudioEvent.Type.AUDIO_STOPPED,
|
||||
getChannelName());
|
||||
|
||||
mAudioStartEvent = new AudioEvent(AudioEvent.Type.AUDIO_STARTED, getChannelName());
|
||||
mAudioStopEvent = new AudioEvent(AudioEvent.Type.AUDIO_STOPPED, getChannelName());
|
||||
mCanProcessAudio = true;
|
||||
}
|
||||
}
|
||||
catch(LineUnavailableException e)
|
||||
{
|
||||
mLog.error("Couldn't obtain audio source data line for "
|
||||
+ "audio output - mixer [" + mMixer.getMixerInfo().getName() + "]");
|
||||
mLog.error("Couldn't obtain audio source data line for audio output - mixer [" +
|
||||
mMixer.getMixerInfo().getName() + "]");
|
||||
}
|
||||
|
||||
updateToneInsertionAudioClips();
|
||||
|
||||
//Register to receive directory preference update notifications so we can update the preference items
|
||||
MyEventBus.getEventBus().register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean property that indicates if this audio output has an audio segment queued or in process of playback.
|
||||
*/
|
||||
public BooleanProperty emptyProperty()
|
||||
{
|
||||
return mEmptyProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio playback priority of the current audio segment or default audio priority if nothing is currently loaded.
|
||||
*/
|
||||
public IntegerProperty audioPriorityProperty()
|
||||
{
|
||||
return mAudioPriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the audio segment for playback.
|
||||
*
|
||||
* Note: if a segment is currently playing and another audio segment is already queued for playback, invoking
|
||||
* this method will overwrite the queued segment with the argument.
|
||||
*
|
||||
* @param audioSegment to schedule for playback.
|
||||
*/
|
||||
public void play(AudioSegment audioSegment)
|
||||
{
|
||||
if(audioSegment != null)
|
||||
{
|
||||
mLock.lock();
|
||||
|
||||
if(mNextAudioSegment != null)
|
||||
{
|
||||
mNextAudioSegment.decrementConsumerCount();
|
||||
mNextAudioSegment = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
mNextAudioSegment = audioSegment;
|
||||
mEmptyProperty.set(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
mLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void reset()
|
||||
/**
|
||||
* Indicates if the audio segment is linked to the current playback audio segment
|
||||
*/
|
||||
public boolean isLinkedTo(AudioSegment audioSegment)
|
||||
{
|
||||
broadcast(new AudioEvent(AudioEvent.Type.AUDIO_STOPPED, getChannelName()));
|
||||
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)
|
||||
{
|
||||
broadcast(identifierCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates audio segment start and preempt tone insertion clips
|
||||
*/
|
||||
private void updateToneInsertionAudioClips()
|
||||
{
|
||||
mAudioSegmentStartTone = null;
|
||||
mAudioSegmentPreemptTone = null;
|
||||
|
||||
float[] start = mUserPreferences.getPlaybackPreference().getStartTone();
|
||||
|
||||
if(start != null)
|
||||
{
|
||||
mAudioSegmentStartTone = convert(start);
|
||||
}
|
||||
|
||||
float[] preempt = mUserPreferences.getPlaybackPreference().getPreemptTone();
|
||||
|
||||
if(start != null)
|
||||
{
|
||||
mAudioSegmentPreemptTone = convert(preempt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of a processed audio segment and notifies the originator that processing of the segment is complete
|
||||
* by decrementing the consumer count.
|
||||
* @param audioSegment to dispose
|
||||
*/
|
||||
private void dispose(AudioSegment audioSegment)
|
||||
{
|
||||
mAudioPriority.unbind();
|
||||
|
||||
if(audioSegment != null)
|
||||
{
|
||||
audioSegment.decrementConsumerCount();
|
||||
audioSegment.removeIdentifierUpdateNotificationListener(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a tone indicating that a new audio segment is starting
|
||||
*/
|
||||
private ByteBuffer getAudioSegmentStartTone()
|
||||
{
|
||||
return mAudioSegmentStartTone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a tone indicating that the current audio segment playback has been preempted for a higher priority
|
||||
* audio segment that is now starting.
|
||||
*/
|
||||
private ByteBuffer getAudioSegmentPreemptionTone()
|
||||
{
|
||||
return mAudioSegmentPreemptTone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(buffer != null)
|
||||
{
|
||||
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())
|
||||
{
|
||||
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);
|
||||
|
||||
checkStart();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a newly
|
||||
* assigned audio segment. It also handles starting and stopping the playback source data line to avoid audio
|
||||
* discontinuities due to buffer underruns.
|
||||
*/
|
||||
private void processAudio()
|
||||
{
|
||||
if(mNextAudioSegment != null)
|
||||
{
|
||||
mLock.lock();
|
||||
|
||||
try
|
||||
{
|
||||
//For linked audio segments, allow the linked segment to complete first before assigning the next
|
||||
if(mNextAudioSegment.isLinked())
|
||||
{
|
||||
if(mCurrentAudioSegment == null)
|
||||
{
|
||||
mCurrentAudioSegment = mNextAudioSegment;
|
||||
mNextAudioSegment = null;
|
||||
mCurrentBufferIndex = 0;
|
||||
|
||||
if(mCurrentAudioSegment != null)
|
||||
{
|
||||
mAudioPriority.bind(mCurrentAudioSegment.monitorPriorityProperty());
|
||||
mCurrentAudioSegment.addIdentifierUpdateNotificationListener(this);
|
||||
broadcast(mCurrentAudioSegment.getIdentifierCollection());
|
||||
}
|
||||
else
|
||||
{
|
||||
mAudioPriority.setValue(Priority.DEFAULT_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//Insert audio segment start or audio priority preemption bonk tone
|
||||
if(mCurrentAudioSegment == null)
|
||||
{
|
||||
playAudio(getAudioSegmentStartTone());
|
||||
}
|
||||
else if(mCurrentBufferIndex > 0 &&
|
||||
(!mCurrentAudioSegment.completeProperty().get() ||
|
||||
mCurrentBufferIndex < mCurrentAudioSegment.getAudioBufferCount()))
|
||||
{
|
||||
playAudio(getAudioSegmentPreemptionTone());
|
||||
}
|
||||
else
|
||||
{
|
||||
playAudio(getAudioSegmentStartTone());
|
||||
}
|
||||
|
||||
//Close current audio segment
|
||||
dispose(mCurrentAudioSegment);
|
||||
mCurrentAudioSegment = mNextAudioSegment;
|
||||
mNextAudioSegment = null;
|
||||
mCurrentBufferIndex = 0;
|
||||
|
||||
if(mCurrentAudioSegment != null)
|
||||
{
|
||||
mAudioPriority.bind(mCurrentAudioSegment.monitorPriorityProperty());
|
||||
mCurrentAudioSegment.addIdentifierUpdateNotificationListener(this);
|
||||
broadcast(mCurrentAudioSegment.getIdentifierCollection());
|
||||
}
|
||||
else
|
||||
{
|
||||
mAudioPriority.setValue(Priority.DEFAULT_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
mLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
if(mCurrentAudioSegment != null)
|
||||
{
|
||||
//Check for completed audio segment
|
||||
if(mCurrentAudioSegment.completeProperty().get() &&
|
||||
mCurrentBufferIndex >= mCurrentAudioSegment.getAudioBufferCount())
|
||||
{
|
||||
dispose(mCurrentAudioSegment);
|
||||
mCurrentAudioSegment = null;
|
||||
|
||||
mLock.lock();
|
||||
|
||||
try
|
||||
{
|
||||
if(mNextAudioSegment == null)
|
||||
{
|
||||
mEmptyProperty.set(true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
mLock.unlock();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
//Process any new buffers that have been added to the audio segment. If a next audio segment gets assigned
|
||||
//while processing, exit the loop so that we can evaluate the next for higher priority preempt. If the next
|
||||
//segment is a linked segment, ignore it so that we can close out the current segment.
|
||||
while((mNextAudioSegment == null || mNextAudioSegment.isLinked()) &&
|
||||
mCurrentBufferIndex < mCurrentAudioSegment.getAudioBufferCount())
|
||||
{
|
||||
float[] audioBuffer = mCurrentAudioSegment.getAudioBuffers().get(mCurrentBufferIndex++);
|
||||
|
||||
if(audioBuffer != null)
|
||||
{
|
||||
ByteBuffer audio = convert(audioBuffer);
|
||||
playAudio(audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkStop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares this audio output for disposal.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
mCanProcessAudio = false;
|
||||
|
||||
if(mProcessorTask != null)
|
||||
if(mProcessorFuture != null)
|
||||
{
|
||||
mProcessorTask.cancel(true);
|
||||
mProcessorFuture.cancel(true);
|
||||
}
|
||||
|
||||
mProcessorTask = null;
|
||||
mProcessorFuture = null;
|
||||
|
||||
mBuffer.clear();
|
||||
mLock.lock();
|
||||
|
||||
mAudioEventBroadcaster.dispose();
|
||||
mAudioEventBroadcaster = null;
|
||||
try
|
||||
{
|
||||
if(mNextAudioSegment != null)
|
||||
{
|
||||
mNextAudioSegment.decrementConsumerCount();
|
||||
mNextAudioSegment = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
mLock.unlock();
|
||||
}
|
||||
|
||||
dispose(mCurrentAudioSegment);
|
||||
mCurrentAudioSegment = null;
|
||||
|
||||
mAudioEventBroadcaster.clear();
|
||||
mIdentifierCollectionListener = null;
|
||||
|
||||
if(mOutput != null)
|
||||
@@ -192,10 +516,9 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the audio packet data into a byte buffer format appropriate for
|
||||
* the underlying source data line.
|
||||
* Converts the audio buffer data into a byte buffer format appropriate for the underlying source data line.
|
||||
*/
|
||||
protected abstract ByteBuffer convert(ReusableAudioPacket packet);
|
||||
protected abstract ByteBuffer convert(float[] buffer);
|
||||
|
||||
/**
|
||||
* Audio output channel name
|
||||
@@ -229,7 +552,7 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
/**
|
||||
* Broadcasts an audio event to the registered listener
|
||||
*/
|
||||
private void broadcast(AudioEvent audioEvent)
|
||||
private void broadcastAudioEvent(AudioEvent audioEvent)
|
||||
{
|
||||
mAudioEventBroadcaster.broadcast(audioEvent);
|
||||
}
|
||||
@@ -243,6 +566,9 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
mIdentifierCollectionListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the current audio metadata listener
|
||||
*/
|
||||
public void removeAudioMetadataListener()
|
||||
{
|
||||
mIdentifierCollectionListener = null;
|
||||
@@ -260,139 +586,31 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of either last buffer received or last buffer processed
|
||||
* 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
|
||||
*/
|
||||
public long getLastActivityTimestamp()
|
||||
private void checkStart()
|
||||
{
|
||||
return mLastActivity;
|
||||
if(mCanProcessAudio && !mOutput.isRunning() && mOutput.available() <= mBufferStartThreshold)
|
||||
{
|
||||
mOutput.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last activity timestamp to current system time
|
||||
* 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
|
||||
*/
|
||||
public void updateTimestamp()
|
||||
private void checkStop()
|
||||
{
|
||||
mLastActivity = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receive(ReusableAudioPacket packet)
|
||||
{
|
||||
if(mCanProcessAudio)
|
||||
if(mCanProcessAudio && mOutput.isRunning() && mOutput.available() >= mBufferStopThreshold)
|
||||
{
|
||||
//Update the activity timestamp so that this audio output doesn't
|
||||
//get disconnected before it starts processing the audio stream
|
||||
updateTimestamp();
|
||||
|
||||
mBuffer.add(packet);
|
||||
}
|
||||
else
|
||||
{
|
||||
packet.decrementUserCount();
|
||||
}
|
||||
}
|
||||
|
||||
public class BufferProcessor implements Runnable
|
||||
{
|
||||
private AtomicBoolean mProcessing = new AtomicBoolean();
|
||||
private List<ReusableAudioPacket> mAudioPackets = new ArrayList<ReusableAudioPacket>();
|
||||
|
||||
|
||||
public BufferProcessor()
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
try
|
||||
{
|
||||
/* The processing flag ensures that only one instance of the
|
||||
* processor can run at any given time */
|
||||
if(mProcessing.compareAndSet(false, true))
|
||||
{
|
||||
mBuffer.drainTo(mAudioPackets);
|
||||
|
||||
for(ReusableAudioPacket packet : mAudioPackets)
|
||||
{
|
||||
if(packet.getType() == ReusableAudioPacket.Type.AUDIO)
|
||||
{
|
||||
broadcast(packet.getIdentifierCollection());
|
||||
|
||||
ByteBuffer buffer = convert(packet);
|
||||
|
||||
int wrote = 0;
|
||||
|
||||
if(!mOutput.isRunning())
|
||||
{
|
||||
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);
|
||||
|
||||
checkStart();
|
||||
}
|
||||
|
||||
if(mOutput.isRunning() && wrote < buffer.array().length)
|
||||
{
|
||||
//Blocking write
|
||||
wrote += mOutput.write(buffer.array(), wrote,
|
||||
buffer.array().length - wrote);
|
||||
}
|
||||
|
||||
updateTimestamp();
|
||||
}
|
||||
else
|
||||
{
|
||||
packet.decrementUserCount();
|
||||
}
|
||||
}
|
||||
|
||||
mAudioPackets.clear();
|
||||
|
||||
checkStop();
|
||||
|
||||
mProcessing.set(false);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
mLog.error("Error while processing audio buffers", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts audio playback once audio buffer is almost full and remaining
|
||||
* capacity falls below the start threshold.
|
||||
*/
|
||||
private void checkStart()
|
||||
{
|
||||
if(mCanProcessAudio &&
|
||||
!mOutput.isRunning() &&
|
||||
mOutput.available() <= mBufferStartThreshold)
|
||||
{
|
||||
mOutput.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private void checkStop()
|
||||
{
|
||||
if(mCanProcessAudio && mOutput.isRunning() && mOutput.available() >= mBufferStopThreshold)
|
||||
{
|
||||
mOutput.drain();
|
||||
mOutput.stop();
|
||||
broadcast(EMPTY_IDENTIFIER_COLLECTION);
|
||||
}
|
||||
mOutput.drain();
|
||||
mOutput.stop();
|
||||
broadcast(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,9 +622,7 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
if(mMuteControl != null)
|
||||
{
|
||||
mMuteControl.setValue(muted);
|
||||
|
||||
broadcast(new AudioEvent(muted ? AudioEvent.Type.AUDIO_MUTED :
|
||||
AudioEvent.Type.AUDIO_UNMUTED, getChannelName()));
|
||||
broadcastAudioEvent(new AudioEvent(muted ? AudioEvent.Type.AUDIO_MUTED : AudioEvent.Type.AUDIO_UNMUTED, getChannelName()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,14 +647,17 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
return mGainControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this audio output has a gain control available
|
||||
*/
|
||||
public boolean hasGainControl()
|
||||
{
|
||||
return mGainControl != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors the source data line playback state and broadcasts audio events
|
||||
* to the registered listener as the state changes
|
||||
* Monitors the source data line playback state and broadcasts audio events to the registered listener as the
|
||||
* state changes
|
||||
*/
|
||||
@Override
|
||||
public void update(LineEvent event)
|
||||
@@ -454,4 +673,23 @@ public abstract class AudioOutput implements Listener<ReusableAudioPacket>, Line
|
||||
mAudioEventBroadcaster.broadcast(mAudioStopEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runnable audio segment processor
|
||||
*/
|
||||
public class AudioSegmentProcessor implements Runnable
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
try
|
||||
{
|
||||
processAudio();
|
||||
}
|
||||
catch(Throwable t)
|
||||
{
|
||||
mLog.error("Error while processing audio buffers", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,24 @@ import io.github.dsheirer.alias.AliasModel;
|
||||
import io.github.dsheirer.audio.AudioEvent;
|
||||
import io.github.dsheirer.audio.AudioException;
|
||||
import io.github.dsheirer.audio.IAudioController;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.gui.preference.PreferenceEditorType;
|
||||
import io.github.dsheirer.gui.preference.PreferenceEditorViewRequest;
|
||||
import io.github.dsheirer.icon.IconManager;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import io.github.dsheirer.source.SourceManager;
|
||||
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
|
||||
import io.github.dsheirer.source.mixer.MixerManager;
|
||||
import jiconfont.icons.font_awesome.FontAwesome;
|
||||
import jiconfont.swing.IconFontSwing;
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.sound.sampled.FloatControl;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JMenu;
|
||||
@@ -51,6 +58,7 @@ import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseListener;
|
||||
import java.util.List;
|
||||
|
||||
public class AudioPanel extends JPanel implements Listener<AudioEvent>
|
||||
{
|
||||
@@ -169,34 +177,19 @@ public class AudioPanel extends JPanel implements Listener<AudioEvent>
|
||||
JPopupMenu popup = new JPopupMenu();
|
||||
|
||||
/* Audio mixer/output selection menus */
|
||||
JMenu outputMenu = new JMenu("Audio Output");
|
||||
|
||||
MixerChannelConfiguration[] mixerConfigurations =
|
||||
mSourceManager.getMixerManager().getOutputMixers();
|
||||
|
||||
for(MixerChannelConfiguration mixerConfig : mixerConfigurations)
|
||||
JMenuItem outputMenu = new JMenuItem("Configure ...");
|
||||
Icon icon = IconFontSwing.buildIcon(FontAwesome.COG, 14);
|
||||
outputMenu.setIcon(icon);
|
||||
outputMenu.addActionListener(new ActionListener()
|
||||
{
|
||||
MixerSelectionItem mixerItem = new MixerSelectionItem(mixerConfig);
|
||||
|
||||
try
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e)
|
||||
{
|
||||
MixerChannelConfiguration current = mController.getMixerChannelConfiguration();
|
||||
|
||||
if(current != null && current.equals(mixerConfig))
|
||||
{
|
||||
mixerItem.setSelected(true);
|
||||
}
|
||||
MyEventBus.getEventBus().post(new PreferenceEditorViewRequest(PreferenceEditorType.AUDIO_PLAYBACK));
|
||||
}
|
||||
catch(AudioException e)
|
||||
{
|
||||
mLog.error("Error while detecting current mixer "
|
||||
+ "channel configuration", e);
|
||||
}
|
||||
|
||||
outputMenu.add(mixerItem);
|
||||
}
|
||||
|
||||
});
|
||||
popup.add(outputMenu);
|
||||
popup.add(new JPopupMenu.Separator());
|
||||
|
||||
/* Audio output mute and volume control */
|
||||
for(AudioOutput output : mController.getAudioOutputs())
|
||||
|
||||
@@ -19,13 +19,17 @@
|
||||
*/
|
||||
package io.github.dsheirer.audio.playback;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.audio.AudioEvent;
|
||||
import io.github.dsheirer.audio.AudioException;
|
||||
import io.github.dsheirer.audio.AudioSegment;
|
||||
import io.github.dsheirer.audio.IAudioController;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.sample.Broadcaster;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.source.mixer.MixerChannel;
|
||||
import io.github.dsheirer.source.mixer.MixerChannelConfiguration;
|
||||
import io.github.dsheirer.source.mixer.MixerManager;
|
||||
@@ -38,113 +42,157 @@ import javax.sound.sampled.Mixer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.LinkedTransferQueue;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class AudioPlaybackManager implements Listener<ReusableAudioPacket>, IAudioController
|
||||
/**
|
||||
* Manages scheduling and playback of audio segments to the local users audio system.
|
||||
*/
|
||||
public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioController
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AudioPlaybackManager.class);
|
||||
|
||||
public static final int AUDIO_TIMEOUT = 1000; //1 second
|
||||
|
||||
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(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_STARTED, null);
|
||||
|
||||
public static final AudioEvent CONFIGURATION_CHANGE_COMPLETE =
|
||||
new AudioEvent(AudioEvent.Type.AUDIO_CONFIGURATION_CHANGE_COMPLETE, null);
|
||||
|
||||
private LinkedTransferQueue<ReusableAudioPacket> mAudioPacketQueue = new LinkedTransferQueue<>();
|
||||
private Map<Integer,AudioOutputConnection> mChannelConnectionMap = new HashMap<>();
|
||||
private List<AudioOutputConnection> mAudioOutputConnections = new ArrayList<>();
|
||||
private AudioOutputConnection mLowestPriorityConnection;
|
||||
private int mAvailableConnectionCount;
|
||||
|
||||
private Map<String,AudioOutput> mAudioOutputMap = new HashMap<>();
|
||||
|
||||
private Broadcaster<AudioEvent> mControllerBroadcaster = new Broadcaster<>();
|
||||
|
||||
private ScheduledFuture<?> mProcessingTask;
|
||||
private MixerManager mMixerManager;
|
||||
private UserPreferences mUserPreferences;
|
||||
private MixerChannelConfiguration mMixerChannelConfiguration;
|
||||
|
||||
private List<AudioOutput> mAudioOutputs = new ArrayList<>();
|
||||
private List<AudioSegment> mAudioSegments = new ArrayList<>();
|
||||
private LinkedTransferQueue<AudioSegment> mNewAudioSegmentQueue = new LinkedTransferQueue<>();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Constructs an instance.
|
||||
*
|
||||
* @param userPreferences for audio playback preferences
|
||||
*/
|
||||
public AudioPlaybackManager(MixerManager mixerManager)
|
||||
public AudioPlaybackManager(UserPreferences userPreferences)
|
||||
{
|
||||
mMixerManager = mixerManager;
|
||||
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the saved mixer configuration or a default configuration for audio playback.
|
||||
*/
|
||||
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 = mMixerManager.getOutputMixers();
|
||||
|
||||
for(MixerChannelConfiguration mixerConfig : mixerConfigurations)
|
||||
{
|
||||
if(mixerConfig.matches(mixer, channels))
|
||||
{
|
||||
configuration = mixerConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if(configuration == null)
|
||||
{
|
||||
configuration = getDefaultConfiguration();
|
||||
}
|
||||
mUserPreferences = userPreferences;
|
||||
MyEventBus.getEventBus().register(this);
|
||||
|
||||
MixerChannelConfiguration configuration = mUserPreferences.getPlaybackPreference().getMixerChannelConfiguration();
|
||||
try
|
||||
{
|
||||
setMixerChannelConfiguration(configuration, false);
|
||||
setMixerChannelConfiguration(configuration);
|
||||
}
|
||||
catch(Exception e)
|
||||
catch(AudioException ae)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default audio playback configuration with a mono audio playback channel.
|
||||
* Receives audio segments from channel audio modules.
|
||||
* @param audioSegment
|
||||
*/
|
||||
private MixerChannelConfiguration getDefaultConfiguration()
|
||||
@Override
|
||||
public void receive(AudioSegment audioSegment)
|
||||
{
|
||||
/* Use the system default mixer and mono channel as default startup */
|
||||
Mixer defaultMixer = AudioSystem.getMixer(null);
|
||||
mNewAudioSegmentQueue.add(audioSegment);
|
||||
}
|
||||
|
||||
return new MixerChannelConfiguration(defaultMixer, MixerChannel.MONO);
|
||||
/**
|
||||
* Processes new audio segments and automatically assigns them to audio outputs.
|
||||
*
|
||||
* Note: this method is intended to be repeatedly invoked by a scheduled processing thread.
|
||||
*/
|
||||
private void processAudioSegments()
|
||||
{
|
||||
//Transfer new audio segments out of the concurrent queue and into the managed segments list.
|
||||
mNewAudioSegmentQueue.drainTo(mAudioSegments);
|
||||
|
||||
if(!mAudioSegments.isEmpty())
|
||||
{
|
||||
Iterator<AudioSegment> it = mAudioSegments.iterator();
|
||||
AudioSegment audioSegment;
|
||||
|
||||
//Remove any audio segments flagged as do not monitor. Don't remove completed segments yet, because
|
||||
//we want to give them a brief chance at playback. Automatically assign linked audio segments to the
|
||||
//current audio output for audio continutity
|
||||
while(it.hasNext())
|
||||
{
|
||||
audioSegment = it.next();
|
||||
|
||||
if(audioSegment.isDoNotMonitor())
|
||||
{
|
||||
audioSegment.decrementConsumerCount();
|
||||
it.remove();
|
||||
}
|
||||
else if(audioSegment.isLinked())
|
||||
{
|
||||
for(AudioOutput audioOutput: mAudioOutputs)
|
||||
{
|
||||
if(audioOutput.isLinkedTo(audioSegment))
|
||||
{
|
||||
audioOutput.play(audioSegment);
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Assign audio segments to empty audio outputs
|
||||
if(!mAudioSegments.isEmpty())
|
||||
{
|
||||
Collections.sort(mAudioSegments, Comparator.comparingInt(o -> o.monitorPriorityProperty().get()));
|
||||
|
||||
//Assign empty audio outputs first
|
||||
for(AudioOutput audioOutput: mAudioOutputs)
|
||||
{
|
||||
if(audioOutput.emptyProperty().get())
|
||||
{
|
||||
audioOutput.play(mAudioSegments.remove(0));
|
||||
|
||||
if(mAudioSegments.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Preempt ongoing audio segment playback when higher priority audio segments are available
|
||||
if(!mAudioSegments.isEmpty())
|
||||
{
|
||||
//Assign empty audio outputs first
|
||||
for(AudioOutput audioOutput: mAudioOutputs)
|
||||
{
|
||||
if(mAudioSegments.get(0).monitorPriorityProperty().get() < audioOutput.audioPriorityProperty().get())
|
||||
{
|
||||
audioOutput.play(mAudioSegments.remove(0));
|
||||
|
||||
if(mAudioSegments.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Remove any audio segments marked as completed
|
||||
it = mAudioSegments.iterator(); //reset the iterator
|
||||
while(it.hasNext())
|
||||
{
|
||||
audioSegment = it.next();
|
||||
|
||||
if(audioSegment.completeProperty().get())
|
||||
{
|
||||
it.remove();
|
||||
audioSegment.decrementConsumerCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void dispose()
|
||||
@@ -152,68 +200,34 @@ public class AudioPlaybackManager implements Listener<ReusableAudioPacket>, IAud
|
||||
if(mProcessingTask != null)
|
||||
{
|
||||
mProcessingTask.cancel(true);
|
||||
mProcessingTask = null;
|
||||
}
|
||||
|
||||
mAudioPacketQueue.clear();
|
||||
|
||||
mProcessingTask = null;
|
||||
|
||||
mChannelConnectionMap.clear();
|
||||
|
||||
for(AudioOutputConnection connection : mAudioOutputConnections)
|
||||
{
|
||||
connection.dispose();
|
||||
}
|
||||
|
||||
mAudioOutputConnections.clear();
|
||||
mNewAudioSegmentQueue.clear();
|
||||
mAudioSegments.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary ingest point for audio produced by all decoding channels, for distribution to audio playback devices.
|
||||
* Receive user preference update notifications so that we can detect when the user changes the audio output
|
||||
* device in the user preferences editor.
|
||||
*/
|
||||
@Override
|
||||
public synchronized void receive(ReusableAudioPacket packet)
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
{
|
||||
mAudioPacketQueue.add(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks each audio channel assignment and disconnects any inactive connections
|
||||
*/
|
||||
private void disconnectInactiveChannelAssignments()
|
||||
{
|
||||
boolean changed = false;
|
||||
|
||||
for(AudioOutputConnection connection : mAudioOutputConnections)
|
||||
if(preferenceType == PreferenceType.PLAYBACK)
|
||||
{
|
||||
if(connection.isInactive() && mChannelConnectionMap.containsKey(connection.getAudioChannelId()))
|
||||
MixerChannelConfiguration configuration = mUserPreferences.getPlaybackPreference().getMixerChannelConfiguration();
|
||||
|
||||
if(configuration != null && !configuration.equals(mMixerChannelConfiguration))
|
||||
{
|
||||
mChannelConnectionMap.remove(connection.getAudioChannelId());
|
||||
connection.disconnect();
|
||||
mAvailableConnectionCount++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(changed)
|
||||
{
|
||||
updateLowestPriorityAssignment();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the lowest priority channel connection where the a higher value indicates a lower priority.
|
||||
*/
|
||||
private void updateLowestPriorityAssignment()
|
||||
{
|
||||
mLowestPriorityConnection = null;
|
||||
|
||||
for(AudioOutputConnection connection : mAudioOutputConnections)
|
||||
{
|
||||
if(connection.isConnected() &&
|
||||
(mLowestPriorityConnection == null || mLowestPriorityConnection.getPriority() < connection.getPriority()))
|
||||
{
|
||||
mLowestPriorityConnection = connection;
|
||||
try
|
||||
{
|
||||
setMixerChannelConfiguration(configuration);
|
||||
}
|
||||
catch(AudioException ae)
|
||||
{
|
||||
mLog.error("Error changing audio output to [" + configuration.toString() + "]", ae);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,19 +241,7 @@ public class AudioPlaybackManager implements Listener<ReusableAudioPacket>, IAud
|
||||
@Override
|
||||
public void setMixerChannelConfiguration(MixerChannelConfiguration entry) throws AudioException
|
||||
{
|
||||
setMixerChannelConfiguration(entry, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures audio playback to use the configuration specified in the entry argument.
|
||||
*
|
||||
* @param entry to use in configuring the audio playback setup.
|
||||
* @param saveSettings to save the audio playback configuration settings in the properties file.
|
||||
* @throws AudioException if there is an error
|
||||
*/
|
||||
public void setMixerChannelConfiguration(MixerChannelConfiguration entry, boolean saveSettings) throws AudioException
|
||||
{
|
||||
if(entry != null && (entry.getMixerChannel() == MixerChannel.MONO || entry.getMixerChannel() == MixerChannel.STEREO))
|
||||
if(entry != null)
|
||||
{
|
||||
mControllerBroadcaster.broadcast(CONFIGURATION_CHANGE_STARTED);
|
||||
|
||||
@@ -248,68 +250,37 @@ public class AudioPlaybackManager implements Listener<ReusableAudioPacket>, IAud
|
||||
mProcessingTask.cancel(true);
|
||||
}
|
||||
|
||||
disposeCurrentConfiguration();
|
||||
for(AudioOutput audioOutput: mAudioOutputs)
|
||||
{
|
||||
audioOutput.dispose();
|
||||
}
|
||||
|
||||
mAudioOutputs.clear();
|
||||
|
||||
switch(entry.getMixerChannel())
|
||||
{
|
||||
case MONO:
|
||||
AudioOutput mono = new MonoAudioOutput(entry.getMixer());
|
||||
mAudioOutputConnections.add(new AudioOutputConnection(mono));
|
||||
mAvailableConnectionCount++;
|
||||
mAudioOutputMap.put(mono.getChannelName(), mono);
|
||||
AudioOutput mono = new MonoAudioOutput(entry.getMixer(), mUserPreferences);
|
||||
mAudioOutputs.add(mono);
|
||||
break;
|
||||
case STEREO:
|
||||
AudioOutput left = new StereoAudioOutput(entry.getMixer(), MixerChannel.LEFT);
|
||||
mAudioOutputConnections.add(new AudioOutputConnection(left));
|
||||
mAvailableConnectionCount++;
|
||||
mAudioOutputMap.put(left.getChannelName(), left);
|
||||
AudioOutput left = new StereoAudioOutput(entry.getMixer(), MixerChannel.LEFT, mUserPreferences);
|
||||
mAudioOutputs.add(left);
|
||||
|
||||
AudioOutput right = new StereoAudioOutput(entry.getMixer(), MixerChannel.RIGHT);
|
||||
mAudioOutputConnections.add(new AudioOutputConnection(right));
|
||||
mAvailableConnectionCount++;
|
||||
mAudioOutputMap.put(right.getChannelName(), right);
|
||||
AudioOutput right = new StereoAudioOutput(entry.getMixer(), MixerChannel.RIGHT, mUserPreferences);
|
||||
mAudioOutputs.add(right);
|
||||
break;
|
||||
default:
|
||||
throw new AudioException("Unsupported mixer channel "
|
||||
+ "configuration: " + entry.getMixerChannel());
|
||||
throw new AudioException("Unsupported mixer channel configuration: " + entry.getMixerChannel());
|
||||
}
|
||||
|
||||
mProcessingTask = ThreadPool.SCHEDULED.scheduleAtFixedRate(new AudioPacketProcessor(),
|
||||
0, 15, TimeUnit.MILLISECONDS);
|
||||
|
||||
mProcessingTask = ThreadPool.SCHEDULED.scheduleAtFixedRate(new AudioSegmentProcessor(),
|
||||
0, 250, TimeUnit.MILLISECONDS);
|
||||
mControllerBroadcaster.broadcast(CONFIGURATION_CHANGE_COMPLETE);
|
||||
|
||||
if(saveSettings)
|
||||
{
|
||||
SystemProperties properties = SystemProperties.getInstance();
|
||||
properties.set(AUDIO_MIXER_PROPERTY, entry.getMixer().getMixerInfo().getName());
|
||||
properties.set(AUDIO_CHANNELS_PROPERTY, entry.getMixerChannel().name());
|
||||
}
|
||||
mMixerChannelConfiguration = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current audio playback mixer channel configuration setting.
|
||||
*/
|
||||
@@ -320,12 +291,12 @@ public class AudioPlaybackManager implements Listener<ReusableAudioPacket>, IAud
|
||||
}
|
||||
|
||||
/**
|
||||
* List of audio outputs available for the current mixer channel configuration
|
||||
* List of sorted audio outputs available for the current mixer channel configuration
|
||||
*/
|
||||
@Override
|
||||
public List<AudioOutput> getAudioOutputs()
|
||||
{
|
||||
List<AudioOutput> outputs = new ArrayList<>(mAudioOutputMap.values());
|
||||
List<AudioOutput> outputs = new ArrayList<>(mAudioOutputs);
|
||||
|
||||
Collections.sort(outputs, new Comparator<AudioOutput>()
|
||||
{
|
||||
@@ -358,228 +329,21 @@ public class AudioPlaybackManager implements Listener<ReusableAudioPacket>, IAud
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 audioPacket from a decoding channel source
|
||||
* @return an audio output connection or null
|
||||
* Scheduled runnable to process incoming audio segments
|
||||
*/
|
||||
private AudioOutputConnection getConnection(ReusableAudioPacket audioPacket)
|
||||
public class AudioSegmentProcessor implements Runnable
|
||||
{
|
||||
int audioChannelId = audioPacket.getAudioChannelId();
|
||||
|
||||
//Use an existing connection
|
||||
if(mChannelConnectionMap.containsKey(audioChannelId))
|
||||
{
|
||||
return mChannelConnectionMap.get(audioChannelId);
|
||||
}
|
||||
|
||||
//Connect to an unused, available connection
|
||||
if(mAvailableConnectionCount > 0)
|
||||
{
|
||||
for(AudioOutputConnection connection : mAudioOutputConnections)
|
||||
{
|
||||
if(connection.isDisconnected())
|
||||
{
|
||||
connection.connect(audioChannelId, audioPacket.getMonitoringPriority());
|
||||
mChannelConnectionMap.put(audioChannelId, connection);
|
||||
mAvailableConnectionCount--;
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
}
|
||||
//Preempt an existing lower priority connection and connect when this is a higher priority packet
|
||||
else
|
||||
{
|
||||
int priority = audioPacket.getMonitoringPriority();
|
||||
|
||||
AudioOutputConnection connection = mLowestPriorityConnection;
|
||||
|
||||
if(connection != null && priority < connection.getPriority())
|
||||
{
|
||||
mChannelConnectionMap.remove(connection.getAudioChannelId());
|
||||
|
||||
connection.connect(audioChannelId, priority);
|
||||
|
||||
mChannelConnectionMap.put(audioChannelId, connection);
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public class AudioPacketProcessor implements Runnable
|
||||
{
|
||||
private List<ReusableAudioPacket> mPackets = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
try
|
||||
{
|
||||
disconnectInactiveChannelAssignments();
|
||||
|
||||
if(mAudioPacketQueue != null)
|
||||
{
|
||||
mAudioPacketQueue.drainTo(mPackets);
|
||||
|
||||
for(ReusableAudioPacket packet : mPackets)
|
||||
{
|
||||
/* Don't process any packet's marked as do not monitor */
|
||||
if(!packet.isDoNotMonitor() && packet.getType() == ReusableAudioPacket.Type.AUDIO)
|
||||
{
|
||||
AudioOutputConnection connection = getConnection(packet);
|
||||
|
||||
if(connection != null)
|
||||
{
|
||||
connection.receive(packet);
|
||||
}
|
||||
else
|
||||
{
|
||||
packet.decrementUserCount();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
packet.decrementUserCount();
|
||||
}
|
||||
}
|
||||
|
||||
mPackets.clear();
|
||||
}
|
||||
processAudioSegments();
|
||||
}
|
||||
catch(Exception e)
|
||||
catch(Throwable t)
|
||||
{
|
||||
mLog.error("Encountered error while processing audio packets", e);
|
||||
|
||||
while(mPackets.size() > 0)
|
||||
{
|
||||
ReusableAudioPacket audioPacket = mPackets.remove(0);
|
||||
audioPacket.decrementUserCount();
|
||||
}
|
||||
mLog.error("Encountered error while processing audio segments", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio output connection manages a connection between a source and an audio
|
||||
* output and maintains current state information about the audio activity
|
||||
* received form the source.
|
||||
*/
|
||||
public class AudioOutputConnection
|
||||
{
|
||||
private static final int DISCONNECTED = Integer.MIN_VALUE;
|
||||
|
||||
private AudioOutput mAudioOutput;
|
||||
private int mPriority = 0;
|
||||
private int mAudioChannelId = DISCONNECTED;
|
||||
|
||||
public AudioOutputConnection(AudioOutput audioOutput)
|
||||
{
|
||||
mAudioOutput = audioOutput;
|
||||
}
|
||||
|
||||
public void receive(ReusableAudioPacket packet)
|
||||
{
|
||||
if(packet.hasIdentifierCollection() && packet.getAudioChannelId() == mAudioChannelId)
|
||||
{
|
||||
int priority = packet.getMonitoringPriority();
|
||||
|
||||
if(mPriority != priority)
|
||||
{
|
||||
mPriority = priority;
|
||||
updateLowestPriorityAssignment();
|
||||
}
|
||||
|
||||
if(mAudioOutput != null)
|
||||
{
|
||||
mAudioOutput.receive(packet);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(packet.hasIdentifierCollection())
|
||||
{
|
||||
mLog.error("Received audio packet from channel metadata [" + packet.getAudioChannelId() +
|
||||
"] however this assignment is currently connected to metadata [" + mAudioChannelId + "]");
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.error("Received audio packet with no metadata - cannot route audio packet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 mAudioChannelId == 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, int priority)
|
||||
{
|
||||
mAudioChannelId = source;
|
||||
mPriority = priority;
|
||||
updateLowestPriorityAssignment();
|
||||
|
||||
mAudioOutput.updateTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently connected source or -1 if disconnected
|
||||
*/
|
||||
public int getAudioChannelId()
|
||||
{
|
||||
return mAudioChannelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
mAudioChannelId = DISCONNECTED;
|
||||
mPriority = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if audio output is current inactive, meaning that the
|
||||
* audio output hasn't recently processed any audio packets.
|
||||
*/
|
||||
public boolean isInactive()
|
||||
{
|
||||
return (mAudioOutput.getLastActivityTimestamp() + AUDIO_TIMEOUT) < System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public int getPriority()
|
||||
{
|
||||
return mPriority;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
package io.github.dsheirer.audio.playback;
|
||||
|
||||
import io.github.dsheirer.audio.AudioFormats;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.source.mixer.MixerChannel;
|
||||
|
||||
import javax.sound.sampled.Mixer;
|
||||
@@ -34,23 +34,21 @@ public class MonoAudioOutput extends AudioOutput
|
||||
{
|
||||
private final static int BUFFER_SIZE = 8000;
|
||||
|
||||
public MonoAudioOutput(Mixer mixer)
|
||||
public MonoAudioOutput(Mixer mixer, UserPreferences userPreferences)
|
||||
{
|
||||
super(mixer, MixerChannel.MONO, AudioFormats.PCM_SIGNED_8KHZ_16BITS_MONO,
|
||||
AudioFormats.MONO_SOURCE_DATALINE_INFO, BUFFER_SIZE);
|
||||
AudioFormats.MONO_SOURCE_DATALINE_INFO, BUFFER_SIZE, userPreferences);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the audio packet data into mono audio frames.
|
||||
*/
|
||||
protected ByteBuffer convert(ReusableAudioPacket packet)
|
||||
protected ByteBuffer convert(float[] samples)
|
||||
{
|
||||
ByteBuffer buffer = null;
|
||||
|
||||
if(packet.hasAudioSamples())
|
||||
if(samples.length > 0)
|
||||
{
|
||||
float[] samples = packet.getAudioSamples();
|
||||
|
||||
/* Little-endian byte buffer */
|
||||
buffer = ByteBuffer.allocate(samples.length * 2).order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
@@ -62,8 +60,6 @@ public class MonoAudioOutput extends AudioOutput
|
||||
}
|
||||
}
|
||||
|
||||
packet.decrementUserCount();
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
package io.github.dsheirer.audio.playback;
|
||||
|
||||
import io.github.dsheirer.audio.AudioFormats;
|
||||
import io.github.dsheirer.sample.buffer.ReusableAudioPacket;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.source.mixer.MixerChannel;
|
||||
|
||||
import javax.sound.sampled.Mixer;
|
||||
@@ -34,10 +34,10 @@ public class StereoAudioOutput extends AudioOutput
|
||||
{
|
||||
private final static int BUFFER_SIZE = 16000;
|
||||
|
||||
public StereoAudioOutput(Mixer mixer, MixerChannel channel)
|
||||
public StereoAudioOutput(Mixer mixer, MixerChannel channel, UserPreferences userPreferences)
|
||||
{
|
||||
super(mixer, channel, AudioFormats.PCM_SIGNED_8KHZ_16BITS_STEREO, AudioFormats.STEREO_SOURCE_DATALINE_INFO,
|
||||
BUFFER_SIZE);
|
||||
BUFFER_SIZE, userPreferences);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,14 +45,12 @@ public class StereoAudioOutput extends AudioOutput
|
||||
* channel containing the audio and the other channel containing zero
|
||||
* valued (silent) samples.
|
||||
*/
|
||||
protected ByteBuffer convert(ReusableAudioPacket packet)
|
||||
protected ByteBuffer convert(float[] samples)
|
||||
{
|
||||
ByteBuffer buffer = null;
|
||||
|
||||
if(packet.hasAudioSamples())
|
||||
if(samples.length > 0)
|
||||
{
|
||||
float[] samples = packet.getAudioSamples();
|
||||
|
||||
/* Little-endian byte buffer */
|
||||
buffer = ByteBuffer.allocate(samples.length * 4).order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
@@ -76,8 +74,6 @@ public class StereoAudioOutput extends AudioOutput
|
||||
}
|
||||
}
|
||||
|
||||
packet.decrementUserCount();
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user