/*******************************************************************************
* 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.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
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 AudioBroadcaster mAudioBroadcaster;
private Path mTempDirectory;
private Map mStreamRecorders = new HashMap<>();
private Runnable mRecorderMonitor;
private ScheduledFuture> mRecorderMonitorFuture;
private AtomicBoolean mRunning = new AtomicBoolean();
/**
* 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 broadcast) upon completion
*
* @param threadPoolManager for scheduling runnables
* @param audioBroadcaster to receive completed streamable audio recordings
* @param tempDirectory where to store temporary audio recordings
*/
public StreamManager(ThreadPoolManager threadPoolManager, AudioBroadcaster audioBroadcaster, Path tempDirectory)
{
assert(tempDirectory != null && Files.isDirectory(tempDirectory));
assert(mThreadPoolManager != null);
mThreadPoolManager = threadPoolManager;
mAudioBroadcaster = audioBroadcaster;
mTempDirectory = tempDirectory;
}
/**
* Starts the stream manager. Schedules a processor to monitor for inactive temporary stream recorders
* every 2 seconds.
*/
public void start()
{
if(mRunning.compareAndSet(false, true))
{
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(mRunning.compareAndSet(true, false))
{
if(mRecorderMonitorFuture != null)
{
mRecorderMonitorFuture.cancel(true);
}
synchronized (mStreamRecorders)
{
List streamKeys = new ArrayList<>(mStreamRecorders.keySet());
for(Integer streamKey: streamKeys)
{
removeRecorder(streamKey);
}
}
}
}
@Override
public void receive(AudioPacket audioPacket)
{
if(mRunning.get() && 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(),
mAudioBroadcaster.getBroadcastConfiguration().getBroadcastFormat());
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(), recorder.getTimeRecordingStart(), recorder.getRecordingLength());
mAudioBroadcaster.receive(streamableAudioRecording);
}
}
/**
* Creates a temporary streaming recording file path
*/
private Path getTemporaryRecordingPath()
{
StringBuilder sb = new StringBuilder();
sb.append(BroadcastModel.TEMPORARY_STREAM_FILE_SUFFIX);
sb.append( TimeStamp.getLongTimeStamp( "_" ) );
sb.append( mAudioBroadcaster.getBroadcastConfiguration().getBroadcastFormat().getFileExtension() );
Path temporaryRecordingPath = mTempDirectory.resolve(sb.toString());
return temporaryRecordingPath;
}
/**
* Monitors recorders to ensure they don't exceed the maximum life-span allowed for a 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());
});
}
}
}
}