#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:
Denny Sheirer
2020-03-19 10:35:05 +00:00
committed by GitHub
parent de249c448b
commit fa16db129b
88 changed files with 4446 additions and 4183 deletions

View File

@@ -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);
}
}
}
}