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();