#30 WIP - adds stream manager to receive multiple audio stream's packets and convert the audio and persist to disk. Broadcaster will now receive converted audio files to stream out to the server.

This commit is contained in:
Denny
2016-11-13 04:33:45 -05:00
parent 357979a5dd
commit ecfac83eb4
10 changed files with 939 additions and 514 deletions

View File

@@ -10,6 +10,7 @@
<w>polyphase</w> <w>polyphase</w>
<w>samplerate</w> <w>samplerate</w>
<w>shoutcast</w> <w>shoutcast</w>
<w>streamable</w>
<w>talkgroup</w> <w>talkgroup</w>
<w>tgid</w> <w>tgid</w>
<w>ultravox</w> <w>ultravox</w>

BIN
images/broadcastify.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -285,23 +285,24 @@ public class Alias
} }
} }
/** /**
* Inspects the alias for any broadcast channels; * Inspects the alias for broadcast channel/streamable alias ids. Default is false;
*/ */
public boolean hasBroadcastChannels() @XmlTransient
{ public boolean isStreamable()
for( AliasID id: getId() ) {
{ for( AliasID id: getId() )
if( id.getType() == AliasIDType.BROADCAST_CHANNEL) {
{ if( id.getType() == AliasIDType.BROADCAST_CHANNEL)
return true; {
} return true;
} }
}
return false; return false;
} }
/** /**
* List of broadcast channels specified for this alias. * List of broadcast channels specified for this alias.
*/ */
@XmlTransient @XmlTransient

View File

@@ -35,6 +35,10 @@ import gui.editor.Editor;
import gui.editor.EmptyEditor; import gui.editor.EmptyEditor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import record.AudioRecorder;
import record.mp3.MP3Recorder;
import java.nio.file.Path;
public class BroadcastFactory public class BroadcastFactory
{ {
@@ -157,4 +161,19 @@ public class BroadcastFactory
return editor; return editor;
} }
/**
* Creates an audio recorder for the specified broadcast 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 broadcast format [" + broadcastFormat + "] cannot create audio recorder");
return null;
}
}
} }

View File

@@ -20,11 +20,12 @@ package audio.broadcast;
public enum BroadcastFormat public enum BroadcastFormat
{ {
MP3("audio/mpeg"); MP3("audio/mpeg", ".mp3");
private String mValue; private String mValue;
private String mFileExtension;
private BroadcastFormat(String value) private BroadcastFormat(String value, String fileExtension)
{ {
mValue = value; mValue = value;
} }
@@ -33,4 +34,9 @@ public enum BroadcastFormat
{ {
return mValue; return mValue;
} }
public String getFileExtension()
{
return mFileExtension;
}
} }

View File

@@ -0,0 +1,253 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2016 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package audio.broadcast;
import audio.AudioPacket;
import controller.ThreadPoolManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import record.AudioRecorder;
import sample.Listener;
import util.TimeStamp;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public class StreamManager implements Listener<AudioPacket>
{
private final static Logger mLog = LoggerFactory.getLogger(StreamManager.class);
private static final long MAXIMUM_RECORDER_LIFESPAN_MILLIS = 30000; //30 seconds
private ThreadPoolManager mThreadPoolManager;
private BroadcastFormat mBroadcastFormat;
private Listener<StreamableAudioRecording> mOutputListener;
private long mDelay;
private Path mTempDirectory;
private Map<Integer, AudioRecorder> mStreamRecorders = new HashMap<>();
private Runnable mRecorderMonitor;
private ScheduledFuture<?> mRecorderMonitorFuture;
/**
* 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 streaming once a specified delay
* has elapsed.
*
* @param threadPoolManager for scheduling runnables
* @param broadcastFormat defines the output recording format
* @param outputListener to receive completed streamable audio recordings
* @param delay from recording start until audio recording is nominated to output listener
* @param tempDirectory where to store temporary audio recordings
*/
public StreamManager(ThreadPoolManager threadPoolManager,
BroadcastFormat broadcastFormat,
Listener<StreamableAudioRecording> outputListener,
long delay,
Path tempDirectory)
{
assert(tempDirectory != null && Files.isDirectory(tempDirectory));
assert(mThreadPoolManager != null);
assert(mBroadcastFormat != null);
mThreadPoolManager = threadPoolManager;
mBroadcastFormat = broadcastFormat;
mOutputListener = outputListener;
mDelay = delay;
mTempDirectory = tempDirectory;
}
/**
* Starts the stream manager. Schedules a processor to monitor for inactive temporary stream recorders
* every 2 seconds.
*/
public void start()
{
if(mRecorderMonitorFuture == null)
{
if(mRecorderMonitor == null)
{
mRecorderMonitor = new RecorderMonitor();
}
mRecorderMonitorFuture = mThreadPoolManager
.scheduleFixedRate(ThreadPoolManager.ThreadType.AUDIO_PROCESSING, mRecorderMonitor, 2,
TimeUnit.SECONDS);
}
}
/**
* Stops the stream manager. Stops the inactive temporary stream recorder monitoring thread.
*/
public void stop()
{
if(mRecorderMonitorFuture != null)
{
mRecorderMonitorFuture.cancel(true);
}
synchronized (mStreamRecorders)
{
mStreamRecorders.entrySet().stream().forEach(entry ->
{
removeRecorder(entry.getKey());
});
}
}
@Override
public void receive(AudioPacket audioPacket)
{
if(audioPacket.hasAudioMetadata())
{
synchronized (mStreamRecorders)
{
int sourceChannelID = audioPacket.getAudioMetadata().getSource();
AudioPacket.Type type = audioPacket.getType();
if(type == AudioPacket.Type.AUDIO)
{
if(mStreamRecorders.containsKey(sourceChannelID))
{
AudioRecorder recorder = mStreamRecorders.get(sourceChannelID);
if(recorder != null)
{
recorder.receive(audioPacket);
}
}
else
{
AudioRecorder recorder = BroadcastFactory.getAudioRecorder(getTemporaryRecordingPath(),
mBroadcastFormat);
recorder.start(mThreadPoolManager.getScheduledExecutorService());
recorder.receive(audioPacket);
mStreamRecorders.put(sourceChannelID, recorder);
}
}
else if(type == AudioPacket.Type.END)
{
removeRecorder(sourceChannelID);
}
else
{
mLog.info("Unrecognized Audio Packet Type: " + type);
}
}
}
}
/**
* 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.stop();
StreamableAudioRecording streamableAudioRecording = new StreamableAudioRecording(recorder.getPath(),
recorder.getMetadata());
if(mDelay <= 0)
{
mOutputListener.receive(streamableAudioRecording);
}
else
{
long waitTimeMillis = mDelay - (System.currentTimeMillis() - recorder.getTimeRecordingStart());
mThreadPoolManager.scheduleOnce(new RecordingDispatcher(streamableAudioRecording),
waitTimeMillis, TimeUnit.MILLISECONDS);
}
}
}
/**
* Creates a temporary streaming recording file path
*/
private Path getTemporaryRecordingPath()
{
StringBuilder sb = new StringBuilder();
sb.append("temporary_streaming_file_");
sb.append( TimeStamp.getLongTimeStamp( "_" ) );
sb.append( mBroadcastFormat.getFileExtension() );
Path temporaryRecordingPath = mTempDirectory.resolve(sb.toString());
return temporaryRecordingPath;
}
/**
* Dispatches the streamable audio recording to the output listener.
*/
public class RecordingDispatcher implements Runnable
{
StreamableAudioRecording mStreamableAudioRecording;
public RecordingDispatcher(StreamableAudioRecording recording)
{
mStreamableAudioRecording = recording;
}
@Override
public void run()
{
if(mOutputListener != null)
{
mOutputListener.receive(mStreamableAudioRecording);
}
}
}
/**
* Monitors recorders to ensure they don't exceed the maximum life-span allowed for the 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 ->
{
removeRecorder(entry.getKey());
});
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2016 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package audio.broadcast;
import audio.metadata.AudioMetadata;
import java.nio.file.Path;
public class StreamableAudioRecording
{
private Path mPath;
private AudioMetadata mAudioMetadata;
public StreamableAudioRecording(Path path, AudioMetadata audioMetadata)
{
mPath = path;
mAudioMetadata = audioMetadata;
}
/**
* Path to the completed audio recording
*/
public Path getPath()
{
return mPath;
}
/**
* Optional audio metadata for the recording.
*/
public AudioMetadata getAudioMetadata()
{
return mAudioMetadata;
}
}

View File

@@ -32,288 +32,305 @@ import alias.id.priority.Priority;
public class AudioMetadata implements Listener<Metadata> public class AudioMetadata implements Listener<Metadata>
{ {
protected static final Logger mLog = LoggerFactory.getLogger( AudioMetadata.class ); protected static final Logger mLog = LoggerFactory.getLogger(AudioMetadata.class);
private int mSource; private int mSource;
private Map<MetadataType,Metadata> mMetadata = new HashMap<>(); private Map<MetadataType, Metadata> mMetadata = new HashMap<>();
private boolean mUpdated = false; private boolean mUpdated = false;
/* Channel Selected == highest priority */ /* Channel Selected == highest priority */
private boolean mSelected = false; private boolean mSelected = false;
/* Call Priority */ /* Call Priority */
private int mPriority = Priority.DEFAULT_PRIORITY; private int mPriority = Priority.DEFAULT_PRIORITY;
/* Indicates if the overall source is recordable */ /* Indicates if the overall source is recordable */
private boolean mSourceRecordable = false; private boolean mSourceRecordable = false;
/* Indicates the current recordable state */ /* Indicates the current recordable state */
private boolean mRecordable = false; private boolean mRecordable = false;
/* A unique identifier for this metadata */ /* Indicates the current streamable state */
private String mIdentifier; private boolean mStreamable = false;
public AudioMetadata( int source, boolean recordable ) /* A unique identifier for this metadata */
{ private String mIdentifier;
mSource = source;
setIdentifier( mSource ); public AudioMetadata(int source, boolean recordable)
{
mSource = source;
mSourceRecordable = recordable; setIdentifier(mSource);
setRecordable( mSourceRecordable ); mSourceRecordable = recordable;
}
/** setRecordable(mSourceRecordable);
* Returns a unique identifier for this audio metadata that contains the }
* source id and optionally the TO metadata string.
*/
public String getIdentifier()
{
return mIdentifier;
}
public void setIdentifier( int source ) /**
{ * Returns a unique identifier for this audio metadata that contains the
setIdentifier( source, null ); * source id and optionally the TO metadata string.
} */
public String getIdentifier()
{
return mIdentifier;
}
public void setIdentifier( int source, String id ) public void setIdentifier(int source)
{ {
mIdentifier = "SRC:" + source + " ID:" + ( id == null ? "UNKNOWN" : id ); setIdentifier(source, null);
mUpdated = true; }
}
/** public void setIdentifier(int source, String id)
* Returns a copy of the current audio metadata. {
*/ mIdentifier = "SRC:" + source + " ID:" + (id == null ? "UNKNOWN" : id);
public AudioMetadata copyOf() mUpdated = true;
{ }
AudioMetadata copy = new AudioMetadata( mSource, mSourceRecordable );
copy.mPriority = mPriority; /**
copy.mSelected = mSelected; * Returns a copy of the current audio metadata.
copy.mRecordable = mRecordable; */
public AudioMetadata copyOf()
{
AudioMetadata copy = new AudioMetadata(mSource, mSourceRecordable);
copy.mMetadata.putAll( mMetadata ); copy.mPriority = mPriority;
copy.mUpdated = mUpdated; copy.mSelected = mSelected;
copy.mIdentifier = new String( mIdentifier ); copy.mRecordable = mRecordable;
copy.mStreamable = mStreamable;
mUpdated = false; copy.mMetadata.putAll(mMetadata);
copy.mUpdated = mUpdated;
copy.mIdentifier = new String(mIdentifier);
return copy; mUpdated = false;
}
/** return copy;
* Removes any accumulated temporal metadata and returns to a default set. }
*/
public void reset()
{
/* Reset recordable state, identifier and audio call priority levels
* and reprocess the metadata */
mRecordable = mSourceRecordable;
setIdentifier( mSource );
mPriority = Priority.DEFAULT_PRIORITY;
List<MetadataType> toRemove = new ArrayList<>(); /**
* Removes any accumulated temporal metadata and returns to a default set.
*/
public void reset()
{
//Reset recordable state, identifier and audio call priority levels and reprocess the metadata
mRecordable = mSourceRecordable;
mStreamable = false;
setIdentifier(mSource);
mPriority = Priority.DEFAULT_PRIORITY;
for( MetadataType type: mMetadata.keySet() ) List<MetadataType> toRemove = new ArrayList<>();
{
Metadata metadata = mMetadata.get( type );
if( metadata != null ) for (MetadataType type : mMetadata.keySet())
{ {
if( metadata.isTemporal() ) Metadata metadata = mMetadata.get(type);
{
toRemove.add( type );
}
else
{
processMetadata( metadata );
}
}
}
for( MetadataType type: toRemove ) if (metadata != null)
{ {
mMetadata.remove( type ); if (metadata.isTemporal())
} {
toRemove.add(type);
}
else
{
processMetadata(metadata);
}
}
}
mUpdated = true; for (MetadataType type : toRemove)
} {
mMetadata.remove(type);
}
private void processMetadata( Metadata metadata ) mUpdated = true;
{ }
if( metadata.hasAlias() )
{
Alias alias = metadata.getAlias();
if( metadata.getMetadataType() == MetadataType.FROM || private void processMetadata(Metadata metadata)
metadata.getMetadataType() == MetadataType.TO ) {
{ if (metadata.hasAlias())
if( !alias.isRecordable() ) {
{ Alias alias = metadata.getAlias();
mRecordable = false;
}
}
if( metadata.getMetadataType() == MetadataType.TO ) if (metadata.getMetadataType() == MetadataType.FROM || metadata.getMetadataType() == MetadataType.TO)
{ {
setIdentifier( mSource, metadata.getValue() ); if (!alias.isRecordable())
} {
mRecordable = false;
}
if( alias != null && alias.getCallPriority() < mPriority ) if (alias.isStreamable())
{ {
mPriority = alias.getCallPriority(); mStreamable = true;
} }
} }
}
/** if (metadata.getMetadataType() == MetadataType.TO)
* Indicates if this audio metadata contains updated information. The flag {
* will only be set when new data is added and will be reset by a copyOf() setIdentifier(mSource, metadata.getValue());
* method invocation. }
*/
public boolean isUpdated()
{
return mUpdated;
}
public void setUpdated( boolean updated ) if (alias != null && alias.getCallPriority() < mPriority)
{ {
mUpdated = updated; mPriority = alias.getCallPriority();
} }
}
}
/** /**
* Adds the metadata. Any temporal metadata will be removed when the * Indicates if this audio metadata contains updated information. The flag
* dispose() method is invoked. * will only be set when new data is added and will be reset by a copyOf()
*/ * method invocation.
@Override */
public void receive( Metadata metadata ) public boolean isUpdated()
{ {
if( metadata.isReset() ) return mUpdated;
{ }
reset();
}
else
{
Metadata existing = mMetadata.get( metadata.getMetadataType() );
if( existing == null || !existing.equals( metadata ) ) public void setUpdated(boolean updated)
{ {
mMetadata.put( metadata.getMetadataType(), metadata ); mUpdated = updated;
processMetadata( metadata ); }
setUpdated( true );
}
}
mUpdated = true; /**
} * Adds the metadata. Any temporal metadata will be removed when the
* dispose() method is invoked.
*/
@Override
public void receive(Metadata metadata)
{
if (metadata.isReset())
{
reset();
}
else
{
Metadata existing = mMetadata.get(metadata.getMetadataType());
/** if (existing == null || !existing.equals(metadata))
* Returns the complete set of metadata {
*/ mMetadata.put(metadata.getMetadataType(), metadata);
public Map<MetadataType,Metadata> getMetadata() processMetadata(metadata);
{ setUpdated(true);
return mMetadata; }
} }
/** mUpdated = true;
* Returns the metadata object matching the type or null }
*/
public Metadata getMetadata( MetadataType type )
{
return mMetadata.get( type );
}
/** /**
* Source channel ID * Returns the complete set of metadata
*/ */
public void setSource( int source ) public Map<MetadataType, Metadata> getMetadata()
{ {
mSource = source; return mMetadata;
}
mUpdated = true; /**
} * Returns the metadata object matching the type or null
*/
public Metadata getMetadata(MetadataType type)
{
return mMetadata.get(type);
}
public int getSource() /**
{ * Source channel ID
return mSource; */
} public void setSource(int source)
{
mSource = source;
/** mUpdated = true;
* Sets the priority of this audio packet within the defined min/max priority range */ }
public void setPriority( int priority )
{
mPriority = priority;
mUpdated = true; public int getSource()
} {
return mSource;
}
/** /**
* Returns the priority of this audio metadata * Sets the priority of this audio packet within the defined min/max priority range
* */
* @return - priority of audio packet where lower numbers are higher priority public void setPriority(int priority)
*/ {
public int getPriority() mPriority = priority;
{
return mPriority;
}
public boolean isDoNotMonitor() mUpdated = true;
{ }
return mPriority == Priority.DO_NOT_MONITOR;
}
/** /**
* Selected audio channel. A selected channel will override the priority * Returns the priority of this audio metadata
* level so that the audio can be listened to immediately. *
*/ * @return - priority of audio packet where lower numbers are higher priority
public void setSelected( boolean selected ) */
{ public int getPriority()
mSelected = selected; {
return mPriority;
}
mUpdated = true; public boolean isDoNotMonitor()
} {
return mPriority == Priority.DO_NOT_MONITOR;
}
public boolean isSelected() /**
{ * Selected audio channel. A selected channel will override the priority
return mSelected; * level so that the audio can be listened to immediately.
} */
public void setSelected(boolean selected)
{
mSelected = selected;
/** mUpdated = true;
* Indicates if audio associated with this metadata is recordable. }
*/
public boolean isRecordable()
{
return mRecordable;
}
public void setRecordable( boolean recordable ) public boolean isSelected()
{ {
mRecordable = recordable; return mSelected;
mUpdated = true; }
}
public String toString() /**
{ * Indicates if audio associated with this metadata is recordable.
StringBuilder sb = new StringBuilder(); */
public boolean isRecordable()
{
return mRecordable;
}
sb.append( "Audio Metadata - source:" ); public void setRecordable(boolean recordable)
sb.append( getSource() ); {
sb.append( " priority:" ); mRecordable = recordable;
sb.append( getPriority() ); mUpdated = true;
sb.append( " updated:" + isUpdated() ); }
sb.append( " " );
for( Entry<MetadataType,Metadata> entry: getMetadata().entrySet() ) /**
{ * Indicates if the audio associated with this metadata is streamable
sb.append( entry.getValue().getKey() ); */
sb.append( ":" ); public boolean isStreamable()
sb.append( entry.getValue().getValue() ); {
sb.append( " " ); return mStreamable;
} }
return sb.toString(); public String toString()
} {
StringBuilder sb = new StringBuilder();
sb.append("Audio Metadata - source:");
sb.append(getSource());
sb.append(" priority:");
sb.append(getPriority());
sb.append(" updated:" + isUpdated());
sb.append(" ");
for (Entry<MetadataType, Metadata> entry : getMetadata().entrySet())
{
sb.append(entry.getValue().getKey());
sb.append(":");
sb.append(entry.getValue().getValue());
sb.append(" ");
}
return sb.toString();
}
} }

View File

@@ -0,0 +1,278 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2016 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package record;
import audio.AudioPacket;
import audio.IAudioPacketListener;
import audio.metadata.AudioMetadata;
import module.Module;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sample.Listener;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public abstract class AudioRecorder extends Module implements Listener<AudioPacket>, IAudioPacketListener
{
private final static Logger mLog = LoggerFactory.getLogger(AudioRecorder.class);
private LinkedBlockingQueue<AudioPacket> mAudioPacketQueue = new LinkedBlockingQueue<>(500);
private List<AudioPacket> mPacketsToProcess = new ArrayList<>();
private FileOutputStream mFileOutputStream;
private AtomicBoolean mRunning = new AtomicBoolean();
protected Path mPath;
protected AudioMetadata mAudioMetadata;
protected long mTimeRecordingStart;
protected long mTimeLastPacketReceived;
private BufferProcessor mBufferProcessor;
private ScheduledFuture<?> mProcessorHandle;
/**
* Abstract audio recorder that implements audio packet queueing and threaded audio conversion/writing to a file
*
* @param path for the output recording
*/
public AudioRecorder(Path path)
{
mPath = path;
}
/**
* Path for the audio recording file
*/
public Path getPath()
{
return mPath;
}
/**
* Latest audio metadata received for this recording
*/
public AudioMetadata getMetadata()
{
return mAudioMetadata;
}
/**
* Timestamp of the last buffer received by this recorder - allows this recorder to be monitored for automatic
* closure after a time period has elapsed.
*/
public long getTimeLastPacketReceived()
{
return mTimeLastPacketReceived;
}
public long getTimeRecordingStart()
{
return mTimeRecordingStart;
}
/**
* Implements the IAudioPacketListener interface and simply redirects to the Listener<AudioPacket> interface.
* This is necessary since you can't have multiple methods with the same erasure (ie Listener<xxx>) in the
* parent module class.
*/
@Override
public Listener<AudioPacket> getAudioPacketListener()
{
return this;
}
/**
* Processes the audio packet and captures the latest AudioMetadata for the recording for easy access.
*/
@Override
public void receive(AudioPacket audioPacket)
{
if(mRunning.get())
{
mTimeRecordingStart = System.currentTimeMillis();
mTimeLastPacketReceived = mTimeRecordingStart;
if(audioPacket.hasAudioMetadata())
{
mAudioMetadata = audioPacket.getAudioMetadata();
}
boolean success = mAudioPacketQueue.offer(audioPacket);
if (!success)
{
mLog.error("recorder buffer overflow - stopping recorder [" + getPath().toString() + "]");
stop();
}
}
}
/**
* File output stream for the current recording. Intended to allow sub-classes to write binary data to the file.
*/
protected OutputStream getOutputStream()
{
return null;
}
/**
* Stops the recorder and closes out the recording file
*/
public void stop()
{
if(mRunning.compareAndSet(true, false))
{
if (mProcessorHandle != null)
{
mProcessorHandle.cancel(true);
}
mProcessorHandle = null;
processAudioPacketQueue();
close();
}
}
/**
* Closes the recording file.
*/
private void close()
{
if (mFileOutputStream != null)
{
try
{
mFileOutputStream.close();
}
catch (IOException e)
{
mLog.error("Error closing output stream", e);
}
}
}
/**
* Records the list of audio packets in the sub-class specific audio format.
*/
protected abstract void record(List<AudioPacket> audioPackets) throws IOException;
/**
* Starts this recorder as a scheduled thread running under the executor argument
*
* @param executor to use in scheduling audio conversion and file writes.
*/
public void start(ScheduledExecutorService executor)
{
if (mRunning.compareAndSet(false, true))
{
mTimeLastPacketReceived = System.currentTimeMillis();
if (mBufferProcessor == null)
{
mBufferProcessor = new BufferProcessor();
}
try
{
mFileOutputStream = new FileOutputStream(mPath.toFile());
mLog.info("Created Audio Recording [" + getPath().toString() + "]");
/* Schedule the handler to run every half second */
mProcessorHandle = executor.scheduleAtFixedRate(mBufferProcessor, 0, 500, TimeUnit.MILLISECONDS);
}
catch (IOException io)
{
mLog.error("Error starting audio recorder [" + getPath().toString() + "]", io);
mRunning.set(false);
}
}
}
/**
* Processes the audio packet queue.
*/
private void processAudioPacketQueue()
{
mAudioPacketQueue.drainTo(mPacketsToProcess);
if (!mPacketsToProcess.isEmpty())
{
try
{
record(mPacketsToProcess);
}
catch(IOException ioe)
{
mLog.debug("Error while recording audio to [" + getPath().toString() + "] - stopping recorder");
stop();
}
mPacketsToProcess.clear();
}
}
/**
* Disposes this audio recorder and prepares it for reclamation
*/
@Override
public void dispose()
{
stop();
}
/**
* Not implemented. Recorder modules are not appropriate for reset and reuse.
*/
@Override
public void reset()
{
}
/**
* Drains the audio packet queue and records the audio packets to file
*/
public class BufferProcessor implements Runnable
{
private AtomicBoolean mProcessing = new AtomicBoolean();
public void run()
{
if (mProcessing.compareAndSet(false, true))
{
processAudioPacketQueue();
mProcessing.set(false);
}
}
}
}

View File

@@ -24,12 +24,14 @@ import audio.convert.MP3AudioConverter;
import module.Module; import module.Module;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import record.AudioRecorder;
import record.wave.AudioPacketMonoWaveReader; import record.wave.AudioPacketMonoWaveReader;
import sample.Listener; import sample.Listener;
import util.TimeStamp; import util.TimeStamp;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
@@ -44,268 +46,65 @@ import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* MP3 recorder for converting 8 kHz PCM audio packets to MP3 and writing to .mp3 file. * MP3 recorder for converting 8 kHz PCM audio packets to MP3 and writing to .mp3 file.
*/ */
public class MP3Recorder extends Module implements Listener<AudioPacket>, IAudioPacketListener public class MP3Recorder extends AudioRecorder
{ {
private final static Logger mLog = LoggerFactory.getLogger(MP3Recorder.class); private final static Logger mLog = LoggerFactory.getLogger(MP3Recorder.class);
public static final int MP3_BIT_RATE = 16;
public static final boolean CONSTANT_BIT_RATE = false;
private MP3AudioConverter mMP3Converter; private MP3AudioConverter mMP3Converter;
private FileOutputStream mFileOutputStream;
private String mFilePrefix;
private Path mPath;
private BufferProcessor mBufferProcessor;
private ScheduledFuture<?> mProcessorHandle;
private LinkedBlockingQueue<AudioPacket> mAudioPacketQueue = new LinkedBlockingQueue<>(500);
private long mLastBufferReceived;
private AtomicBoolean mRunning = new AtomicBoolean();
/** /**
* MP3 audio recorder module for converting audio packets to 16 kHz constant bit rate MP3 format and * MP3 audio recorder module for converting audio packets to 16 kHz constant bit rate MP3 format and
* recording to a file. * recording to a file.
*/
public MP3Recorder(String filePrefix)
{
mFilePrefix = filePrefix;
int mp3BitRate = 16; //16 kHz
boolean variableBitRate = false; //CBR
mMP3Converter = new MP3AudioConverter(mp3BitRate, variableBitRate);
}
/**
* Timestamp of when the latest buffer was received by this recorder
*/
public long getLastBufferReceived()
{
return mLastBufferReceived;
}
public Path getPath()
{
return mPath;
}
/**
* Starts this recorder using as a scheduled thread broadcast running under the executor argument
* *
* @param executor to use in scheduling MP3 conversion and file writes. * @param path to the output file. File name should include the .mp3 file extension.
*/ */
public void start(ScheduledExecutorService executor) public MP3Recorder(Path path)
{ {
if (mRunning.compareAndSet(false, true)) super(path);
{
if (mBufferProcessor == null)
{
mBufferProcessor = new BufferProcessor();
}
try mMP3Converter = new MP3AudioConverter(MP3_BIT_RATE, CONSTANT_BIT_RATE);
{
StringBuilder sb = new StringBuilder();
sb.append(mFilePrefix);
sb.append("_");
sb.append(TimeStamp.getLongTimeStamp("_"));
sb.append(".mp3");
mPath = Paths.get(sb.toString());
mFileOutputStream = new FileOutputStream(mPath.toFile());
mLog.info("Created MP3 Recording [" + sb.toString() + "]");
/* Schedule the handler to run every second */
mProcessorHandle = executor.scheduleAtFixedRate(mBufferProcessor, 0, 200, TimeUnit.MILLISECONDS);
}
catch (IOException io)
{
mLog.error("Error starting real buffer recorder", io);
}
}
} }
/**
* Stops the recorder.
*/
public void stop()
{
mRunning.set(false);
if (mProcessorHandle != null)
{
mProcessorHandle.cancel(true);
}
mProcessorHandle = null;
processAudioPacketQueue();
close();
}
/**
* Closes the recording file.
*/
private void close()
{
if (mFileOutputStream != null)
{
try
{
mFileOutputStream.close();
}
catch (IOException e)
{
mLog.error("Error closing output stream", e);
}
}
}
/**
* Primary data insert method designed to accept a stream of audio packets and a final ending audio packet.
*
* Audio packets received before the recorder is started or after the recorder is stopped will be ignored.
*/
@Override @Override
public void receive(AudioPacket audioPacket) protected void record(List<AudioPacket> audioPackets) throws IOException
{ {
if (mRunning.get()) OutputStream outputStream = getOutputStream();
if(outputStream != null)
{ {
boolean success = mAudioPacketQueue.offer(audioPacket); processMetadata(audioPackets);
if (!success) byte[] mp3Audio = mMP3Converter.convert(audioPackets);
{
mLog.error("recorder buffer overflow - purging [" + mPath.toFile().getAbsolutePath() + "]");
mAudioPacketQueue.clear(); outputStream.write(mp3Audio);
}
mLastBufferReceived = System.currentTimeMillis();
} }
} }
/** /**
* IAudioPacketListener interface method. * Processes audio metadata contained in the audio packets and converts the metadata to MP3 ID3 metadata tags and
* writes the ID3 tags to the output stream.
* @param audioPackets
*/ */
@Override private void processMetadata(List<AudioPacket> audioPackets)
public Listener<AudioPacket> getAudioPacketListener()
{ {
return this; //TODO: detect metadata changes and write out ID3 tags to the MP3 stream
}
/**
* Disposes this audio recorder and prepares it for reclamation
*/
@Override
public void dispose()
{
stop();
}
/**
* Not implemented. Recorder modules are not appropriate for reset and reuse.
*/
@Override
public void reset()
{
}
/**
* Processes the audio packet queue.
*/
private void processAudioPacketQueue()
{
try
{
List<AudioPacket> mPacketsToProcess = new ArrayList<>();
mAudioPacketQueue.drainTo(mPacketsToProcess);
if (!mPacketsToProcess.isEmpty())
{
boolean stopRequested = false;
int stopPacketIndex = 0;
//An END audio packet is a request to stop the recorder
for (AudioPacket packet : mPacketsToProcess)
{
if (packet.getType() == AudioPacket.Type.END)
{
stopRequested = true;
stopPacketIndex = mPacketsToProcess.indexOf(packet);
continue;
}
}
if (stopRequested)
{
mRunning.set(false);
mAudioPacketQueue.clear();
if (stopPacketIndex > 0)
{
List<AudioPacket> remainingPacketsToProcess = new ArrayList<>();
for (int x = 0; x < stopPacketIndex; x++)
{
remainingPacketsToProcess.add(mPacketsToProcess.get(x));
}
write(remainingPacketsToProcess);
remainingPacketsToProcess.clear();
}
stop();
}
else
{
write(mPacketsToProcess);
}
mPacketsToProcess.clear();
}
}
catch (IOException ioe)
{
mAudioPacketQueue.clear();
mLog.error("IOException while processing audio packet queue for MP3 conversion and storage", ioe);
}
}
/**
* Converts the PCM audio packets to MP3 format and writes the converted audio to the output stream.
*/
private void write(List<AudioPacket> audioPackets) throws IOException
{
byte[] mp3Audio = mMP3Converter.convert(audioPackets);
mFileOutputStream.write(mp3Audio);
}
public class BufferProcessor implements Runnable
{
private AtomicBoolean mProcessing = new AtomicBoolean();
public void run()
{
if (mProcessing.compareAndSet(false, true))
{
processAudioPacketQueue();
mProcessing.set(false);
}
}
} }
public static void main(String[] args) public static void main(String[] args)
{ {
Path path = Paths.get("/home/denny/Music/PCM.wav"); Path inputPath = Paths.get("/home/denny/Music/PCM.wav");
mLog.debug("Opening: " + path.toString()); Path outputPath = Paths.get("/home/denny/Music/denny_test/PCM.mp3");
final MP3Recorder recorder = new MP3Recorder("/home/denny/Music/denny_test"); mLog.debug("Reading: " + inputPath.toString());
mLog.debug("Writing: " + outputPath.toString());
final MP3Recorder recorder = new MP3Recorder(outputPath);
recorder.start(Executors.newSingleThreadScheduledExecutor()); recorder.start(Executors.newSingleThreadScheduledExecutor());
try (AudioPacketMonoWaveReader reader = new AudioPacketMonoWaveReader(path, true)) try (AudioPacketMonoWaveReader reader = new AudioPacketMonoWaveReader(inputPath, true))
{ {
reader.setListener(recorder); reader.setListener(recorder);
reader.read(); reader.read();