diff --git a/.idea/dictionaries/denny.xml b/.idea/dictionaries/denny.xml
index 284f2300..569c2a0d 100644
--- a/.idea/dictionaries/denny.xml
+++ b/.idea/dictionaries/denny.xml
@@ -10,6 +10,7 @@
polyphase
samplerate
shoutcast
+ streamable
talkgroup
tgid
ultravox
diff --git a/images/broadcastify.png b/images/broadcastify.png
new file mode 100644
index 00000000..6671ff74
Binary files /dev/null and b/images/broadcastify.png differ
diff --git a/src/alias/Alias.java b/src/alias/Alias.java
index 5882b479..5447cb2c 100644
--- a/src/alias/Alias.java
+++ b/src/alias/Alias.java
@@ -230,7 +230,7 @@ public class Alias
addAliasID( p );
}
}
-
+
/**
* Inspects the alias for a non-recordable alias id. Default is true;
*/
@@ -285,23 +285,24 @@ public class Alias
}
}
- /**
- * Inspects the alias for any broadcast channels;
- */
- public boolean hasBroadcastChannels()
- {
- for( AliasID id: getId() )
- {
- if( id.getType() == AliasIDType.BROADCAST_CHANNEL)
- {
- return true;
- }
- }
+ /**
+ * Inspects the alias for broadcast channel/streamable alias ids. Default is false;
+ */
+ @XmlTransient
+ public boolean isStreamable()
+ {
+ for( AliasID id: getId() )
+ {
+ if( id.getType() == AliasIDType.BROADCAST_CHANNEL)
+ {
+ return true;
+ }
+ }
- return false;
- }
+ return false;
+ }
- /**
+ /**
* List of broadcast channels specified for this alias.
*/
@XmlTransient
diff --git a/src/audio/broadcast/BroadcastFactory.java b/src/audio/broadcast/BroadcastFactory.java
index 1160984e..b2c47c90 100644
--- a/src/audio/broadcast/BroadcastFactory.java
+++ b/src/audio/broadcast/BroadcastFactory.java
@@ -35,6 +35,10 @@ import gui.editor.Editor;
import gui.editor.EmptyEditor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import record.AudioRecorder;
+import record.mp3.MP3Recorder;
+
+import java.nio.file.Path;
public class BroadcastFactory
{
@@ -157,4 +161,19 @@ public class BroadcastFactory
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;
+ }
+ }
}
diff --git a/src/audio/broadcast/BroadcastFormat.java b/src/audio/broadcast/BroadcastFormat.java
index f6aeacf2..5699085f 100644
--- a/src/audio/broadcast/BroadcastFormat.java
+++ b/src/audio/broadcast/BroadcastFormat.java
@@ -20,11 +20,12 @@ package audio.broadcast;
public enum BroadcastFormat
{
- MP3("audio/mpeg");
+ MP3("audio/mpeg", ".mp3");
private String mValue;
+ private String mFileExtension;
- private BroadcastFormat(String value)
+ private BroadcastFormat(String value, String fileExtension)
{
mValue = value;
}
@@ -33,4 +34,9 @@ public enum BroadcastFormat
{
return mValue;
}
+
+ public String getFileExtension()
+ {
+ return mFileExtension;
+ }
}
diff --git a/src/audio/broadcast/StreamManager.java b/src/audio/broadcast/StreamManager.java
new file mode 100644
index 00000000..801d4ccc
--- /dev/null
+++ b/src/audio/broadcast/StreamManager.java
@@ -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
+ *
+ ******************************************************************************/
+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
+{
+ 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 mOutputListener;
+ private long mDelay;
+ private Path mTempDirectory;
+ private Map 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 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());
+ });
+ }
+ }
+ }
+}
diff --git a/src/audio/broadcast/StreamableAudioRecording.java b/src/audio/broadcast/StreamableAudioRecording.java
new file mode 100644
index 00000000..34cb5c0f
--- /dev/null
+++ b/src/audio/broadcast/StreamableAudioRecording.java
@@ -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
+ *
+ ******************************************************************************/
+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;
+ }
+}
diff --git a/src/audio/metadata/AudioMetadata.java b/src/audio/metadata/AudioMetadata.java
index 521ffa79..81e3a9ad 100644
--- a/src/audio/metadata/AudioMetadata.java
+++ b/src/audio/metadata/AudioMetadata.java
@@ -1,17 +1,17 @@
/*******************************************************************************
* SDR Trunk
* Copyright (C) 2014-2016 Dennis Sheirer
- *
+ *
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
- *
+ *
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
- *
+ *
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
******************************************************************************/
@@ -32,288 +32,305 @@ import alias.id.priority.Priority;
public class AudioMetadata implements Listener
{
- protected static final Logger mLog = LoggerFactory.getLogger( AudioMetadata.class );
+ protected static final Logger mLog = LoggerFactory.getLogger(AudioMetadata.class);
- private int mSource;
- private Map mMetadata = new HashMap<>();
- private boolean mUpdated = false;
+ private int mSource;
+ private Map mMetadata = new HashMap<>();
+ private boolean mUpdated = false;
- /* Channel Selected == highest priority */
- private boolean mSelected = false;
+ /* Channel Selected == highest priority */
+ private boolean mSelected = false;
- /* Call Priority */
- private int mPriority = Priority.DEFAULT_PRIORITY;
+ /* Call Priority */
+ private int mPriority = Priority.DEFAULT_PRIORITY;
- /* Indicates if the overall source is recordable */
- private boolean mSourceRecordable = false;
-
- /* Indicates the current recordable state */
- private boolean mRecordable = false;
-
- /* A unique identifier for this metadata */
- private String mIdentifier;
-
- public AudioMetadata( int source, boolean recordable )
- {
- mSource = source;
-
- setIdentifier( mSource );
-
- mSourceRecordable = recordable;
-
- setRecordable( mSourceRecordable );
- }
+ /* Indicates if the overall source is recordable */
+ private boolean mSourceRecordable = false;
- /**
- * 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 )
- {
- setIdentifier( source, null );
- }
-
- public void setIdentifier( int source, String id )
- {
- mIdentifier = "SRC:" + source + " ID:" + ( id == null ? "UNKNOWN" : id );
- mUpdated = true;
- }
-
- /**
- * Returns a copy of the current audio metadata.
- */
- public AudioMetadata copyOf()
- {
- AudioMetadata copy = new AudioMetadata( mSource, mSourceRecordable );
-
- copy.mPriority = mPriority;
- copy.mSelected = mSelected;
- copy.mRecordable = mRecordable;
+ /* Indicates the current recordable state */
+ private boolean mRecordable = false;
- copy.mMetadata.putAll( mMetadata );
- copy.mUpdated = mUpdated;
- copy.mIdentifier = new String( mIdentifier );
-
- mUpdated = false;
-
- return copy;
- }
+ /* Indicates the current streamable state */
+ private boolean mStreamable = false;
- /**
- * 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 toRemove = new ArrayList<>();
+ /* A unique identifier for this metadata */
+ private String mIdentifier;
- for( MetadataType type: mMetadata.keySet() )
- {
- Metadata metadata = mMetadata.get( type );
+ public AudioMetadata(int source, boolean recordable)
+ {
+ mSource = source;
- if( metadata != null )
- {
- if( metadata.isTemporal() )
- {
- toRemove.add( type );
- }
- else
- {
- processMetadata( metadata );
- }
- }
- }
-
- for( MetadataType type: toRemove )
- {
- mMetadata.remove( type );
- }
+ setIdentifier(mSource);
- mUpdated = true;
- }
-
- private void processMetadata( Metadata metadata )
- {
- if( metadata.hasAlias() )
- {
- Alias alias = metadata.getAlias();
-
- if( metadata.getMetadataType() == MetadataType.FROM ||
- metadata.getMetadataType() == MetadataType.TO )
- {
- if( !alias.isRecordable() )
- {
- mRecordable = false;
- }
- }
+ mSourceRecordable = recordable;
- if( metadata.getMetadataType() == MetadataType.TO )
- {
- setIdentifier( mSource, metadata.getValue() );
- }
+ setRecordable(mSourceRecordable);
+ }
- if( alias != null && alias.getCallPriority() < mPriority )
- {
- mPriority = alias.getCallPriority();
- }
- }
- }
-
- /**
- * 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()
- * method invocation.
- */
- public boolean isUpdated()
- {
- return mUpdated;
- }
-
- public void setUpdated( boolean updated )
- {
- mUpdated = updated;
- }
+ /**
+ * Returns a unique identifier for this audio metadata that contains the
+ * source id and optionally the TO metadata string.
+ */
+ public String getIdentifier()
+ {
+ return mIdentifier;
+ }
- /**
- * 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() );
+ public void setIdentifier(int source)
+ {
+ setIdentifier(source, null);
+ }
- if( existing == null || !existing.equals( metadata ) )
- {
- mMetadata.put( metadata.getMetadataType(), metadata );
- processMetadata( metadata );
- setUpdated( true );
- }
- }
-
- mUpdated = true;
- }
-
- /**
- * Returns the complete set of metadata
- */
- public Map getMetadata()
- {
- return mMetadata;
- }
+ public void setIdentifier(int source, String id)
+ {
+ mIdentifier = "SRC:" + source + " ID:" + (id == null ? "UNKNOWN" : id);
+ mUpdated = true;
+ }
- /**
- * Returns the metadata object matching the type or null
- */
- public Metadata getMetadata( MetadataType type )
- {
- return mMetadata.get( type );
- }
+ /**
+ * Returns a copy of the current audio metadata.
+ */
+ public AudioMetadata copyOf()
+ {
+ AudioMetadata copy = new AudioMetadata(mSource, mSourceRecordable);
- /**
- * Source channel ID
- */
- public void setSource( int source )
- {
- mSource = source;
-
- mUpdated = true;
- }
-
- public int getSource()
- {
- return mSource;
- }
+ copy.mPriority = mPriority;
+ copy.mSelected = mSelected;
+ copy.mRecordable = mRecordable;
+ copy.mStreamable = mStreamable;
- /**
- * Sets the priority of this audio packet within the defined min/max priority range */
- public void setPriority( int priority )
- {
- mPriority = priority;
-
- mUpdated = true;
- }
+ copy.mMetadata.putAll(mMetadata);
+ copy.mUpdated = mUpdated;
+ copy.mIdentifier = new String(mIdentifier);
- /**
- * Returns the priority of this audio metadata
- *
- * @return - priority of audio packet where lower numbers are higher priority
- */
- public int getPriority()
- {
- return mPriority;
- }
-
- public boolean isDoNotMonitor()
- {
- return mPriority == Priority.DO_NOT_MONITOR;
- }
+ mUpdated = false;
- /**
- * Selected audio channel. A selected channel will override the priority
- * level so that the audio can be listened to immediately.
- */
- public void setSelected( boolean selected )
- {
- mSelected = selected;
-
- mUpdated = true;
- }
+ return copy;
+ }
- public boolean isSelected()
- {
- return mSelected;
- }
-
- /**
- * Indicates if audio associated with this metadata is recordable.
- */
- public boolean isRecordable()
- {
- return mRecordable;
- }
-
- public void setRecordable( boolean recordable )
- {
- mRecordable = recordable;
- mUpdated = true;
- }
-
- 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 entry: getMetadata().entrySet() )
- {
- sb.append( entry.getValue().getKey() );
- sb.append( ":" );
- sb.append( entry.getValue().getValue() );
- sb.append( " " );
- }
+ /**
+ * 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;
- return sb.toString();
- }
+ List toRemove = new ArrayList<>();
+
+ for (MetadataType type : mMetadata.keySet())
+ {
+ Metadata metadata = mMetadata.get(type);
+
+ if (metadata != null)
+ {
+ if (metadata.isTemporal())
+ {
+ toRemove.add(type);
+ }
+ else
+ {
+ processMetadata(metadata);
+ }
+ }
+ }
+
+ for (MetadataType type : toRemove)
+ {
+ mMetadata.remove(type);
+ }
+
+ mUpdated = true;
+ }
+
+ private void processMetadata(Metadata metadata)
+ {
+ if (metadata.hasAlias())
+ {
+ Alias alias = metadata.getAlias();
+
+ if (metadata.getMetadataType() == MetadataType.FROM || metadata.getMetadataType() == MetadataType.TO)
+ {
+ if (!alias.isRecordable())
+ {
+ mRecordable = false;
+ }
+
+ if (alias.isStreamable())
+ {
+ mStreamable = true;
+ }
+ }
+
+ if (metadata.getMetadataType() == MetadataType.TO)
+ {
+ setIdentifier(mSource, metadata.getValue());
+ }
+
+ if (alias != null && alias.getCallPriority() < mPriority)
+ {
+ mPriority = alias.getCallPriority();
+ }
+ }
+ }
+
+ /**
+ * 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()
+ * method invocation.
+ */
+ public boolean isUpdated()
+ {
+ return mUpdated;
+ }
+
+ public void setUpdated(boolean updated)
+ {
+ mUpdated = updated;
+ }
+
+ /**
+ * 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))
+ {
+ mMetadata.put(metadata.getMetadataType(), metadata);
+ processMetadata(metadata);
+ setUpdated(true);
+ }
+ }
+
+ mUpdated = true;
+ }
+
+ /**
+ * Returns the complete set of metadata
+ */
+ public Map getMetadata()
+ {
+ return mMetadata;
+ }
+
+ /**
+ * Returns the metadata object matching the type or null
+ */
+ public Metadata getMetadata(MetadataType type)
+ {
+ return mMetadata.get(type);
+ }
+
+ /**
+ * Source channel ID
+ */
+ public void setSource(int source)
+ {
+ mSource = source;
+
+ mUpdated = true;
+ }
+
+ public int getSource()
+ {
+ return mSource;
+ }
+
+ /**
+ * Sets the priority of this audio packet within the defined min/max priority range
+ */
+ public void setPriority(int priority)
+ {
+ mPriority = priority;
+
+ mUpdated = true;
+ }
+
+ /**
+ * Returns the priority of this audio metadata
+ *
+ * @return - priority of audio packet where lower numbers are higher priority
+ */
+ public int getPriority()
+ {
+ return mPriority;
+ }
+
+ public boolean isDoNotMonitor()
+ {
+ return mPriority == Priority.DO_NOT_MONITOR;
+ }
+
+ /**
+ * Selected audio channel. A selected channel will override the priority
+ * level so that the audio can be listened to immediately.
+ */
+ public void setSelected(boolean selected)
+ {
+ mSelected = selected;
+
+ mUpdated = true;
+ }
+
+ public boolean isSelected()
+ {
+ return mSelected;
+ }
+
+ /**
+ * Indicates if audio associated with this metadata is recordable.
+ */
+ public boolean isRecordable()
+ {
+ return mRecordable;
+ }
+
+ public void setRecordable(boolean recordable)
+ {
+ mRecordable = recordable;
+ mUpdated = true;
+ }
+
+ /**
+ * Indicates if the audio associated with this metadata is streamable
+ */
+ public boolean isStreamable()
+ {
+ return mStreamable;
+ }
+
+ 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 entry : getMetadata().entrySet())
+ {
+ sb.append(entry.getValue().getKey());
+ sb.append(":");
+ sb.append(entry.getValue().getValue());
+ sb.append(" ");
+ }
+
+ return sb.toString();
+ }
}
diff --git a/src/record/AudioRecorder.java b/src/record/AudioRecorder.java
new file mode 100644
index 00000000..84d6270d
--- /dev/null
+++ b/src/record/AudioRecorder.java
@@ -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
+ *
+ ******************************************************************************/
+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, IAudioPacketListener
+{
+ private final static Logger mLog = LoggerFactory.getLogger(AudioRecorder.class);
+
+ private LinkedBlockingQueue mAudioPacketQueue = new LinkedBlockingQueue<>(500);
+ private List 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 interface.
+ * This is necessary since you can't have multiple methods with the same erasure (ie Listener) in the
+ * parent module class.
+ */
+ @Override
+ public Listener 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 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);
+ }
+ }
+ }
+}
diff --git a/src/record/mp3/MP3Recorder.java b/src/record/mp3/MP3Recorder.java
index b9513c7d..71db8e12 100644
--- a/src/record/mp3/MP3Recorder.java
+++ b/src/record/mp3/MP3Recorder.java
@@ -24,12 +24,14 @@ import audio.convert.MP3AudioConverter;
import module.Module;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import record.AudioRecorder;
import record.wave.AudioPacketMonoWaveReader;
import sample.Listener;
import util.TimeStamp;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.OutputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
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.
*/
-public class MP3Recorder extends Module implements Listener, IAudioPacketListener
+public class MP3Recorder extends AudioRecorder
{
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 FileOutputStream mFileOutputStream;
- private String mFilePrefix;
- private Path mPath;
-
- private BufferProcessor mBufferProcessor;
- private ScheduledFuture> mProcessorHandle;
- private LinkedBlockingQueue 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
* 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))
- {
- if (mBufferProcessor == null)
- {
- mBufferProcessor = new BufferProcessor();
- }
+ super(path);
- try
- {
- 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);
- }
- }
+ mMP3Converter = new MP3AudioConverter(MP3_BIT_RATE, CONSTANT_BIT_RATE);
}
- /**
- * 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
- public void receive(AudioPacket audioPacket)
+ protected void record(List audioPackets) throws IOException
{
- if (mRunning.get())
+ OutputStream outputStream = getOutputStream();
+
+ if(outputStream != null)
{
- boolean success = mAudioPacketQueue.offer(audioPacket);
+ processMetadata(audioPackets);
- if (!success)
- {
- mLog.error("recorder buffer overflow - purging [" + mPath.toFile().getAbsolutePath() + "]");
+ byte[] mp3Audio = mMP3Converter.convert(audioPackets);
- mAudioPacketQueue.clear();
- }
-
- mLastBufferReceived = System.currentTimeMillis();
+ outputStream.write(mp3Audio);
}
}
/**
- * 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
- public Listener getAudioPacketListener()
+ private void processMetadata(List audioPackets)
{
- return this;
- }
-
- /**
- * 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 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 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 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);
- }
- }
+ //TODO: detect metadata changes and write out ID3 tags to the MP3 stream
}
public static void main(String[] args)
{
- Path path = Paths.get("/home/denny/Music/PCM.wav");
- mLog.debug("Opening: " + path.toString());
+ Path inputPath = Paths.get("/home/denny/Music/PCM.wav");
+ 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());
- try (AudioPacketMonoWaveReader reader = new AudioPacketMonoWaveReader(path, true))
+ try (AudioPacketMonoWaveReader reader = new AudioPacketMonoWaveReader(inputPath, true))
{
reader.setListener(recorder);
reader.read();