From ecfac83eb454793a1cf972ce2c407b43c3c2b16f Mon Sep 17 00:00:00 2001 From: Denny Date: Sun, 13 Nov 2016 04:33:45 -0500 Subject: [PATCH] #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. --- .idea/dictionaries/denny.xml | 1 + images/broadcastify.png | Bin 0 -> 31499 bytes src/alias/Alias.java | 33 +- src/audio/broadcast/BroadcastFactory.java | 19 + src/audio/broadcast/BroadcastFormat.java | 10 +- src/audio/broadcast/StreamManager.java | 253 ++++++++ .../broadcast/StreamableAudioRecording.java | 51 ++ src/audio/metadata/AudioMetadata.java | 549 +++++++++--------- src/record/AudioRecorder.java | 278 +++++++++ src/record/mp3/MP3Recorder.java | 259 +-------- 10 files changed, 939 insertions(+), 514 deletions(-) create mode 100644 images/broadcastify.png create mode 100644 src/audio/broadcast/StreamManager.java create mode 100644 src/audio/broadcast/StreamableAudioRecording.java create mode 100644 src/record/AudioRecorder.java 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 0000000000000000000000000000000000000000..6671ff74c4d78b17fcdf2147f3ed1a70a949bcc7 GIT binary patch literal 31499 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

+J3zBZqhDqYbZks=I>YBQ*C#(ho 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 + * + ******************************************************************************/ +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();