#30 - adds support for Shoutcast V1 streaming. Resolves issue where temporary MP3 recording files were not being flushed at the end, causing partial frames to be streamed resulting in repeated remote server connection resets.

Note: Shoutcast V1 streaming to a Shoutcast V2.5.x server seems to be problematic.  The newer server tracks MP3 boundaries in order to insert advertising and this appears to have problems keeping sync with the MP3 stream.  I spent significant time eliminating MP3 framing errors in my code.  You can see these 'Bad frame' errors in the sc_serv.log and the shoutcast server will eventually reset the connection after a period of time or after a number of framing errors occur.  I anticipate that streaming to a Shoutcast V2 server using the ultravox (ie V2.x) protocol may resolve this issue.
This commit is contained in:
Denny
2016-12-29 05:59:14 -05:00
parent 0cebe7e5d9
commit a4e3c53ba8
16 changed files with 1114 additions and 384 deletions

View File

@@ -54,6 +54,7 @@ public abstract class AudioRecorder extends Module implements Listener<AudioPack
protected long mTimeLastPacketReceived;
private BufferProcessor mBufferProcessor;
private ScheduledFuture<?> mProcessorHandle;
private Listener<AudioRecorder> mRecordingClosedListener;
private long mSampleCount;
@@ -152,42 +153,23 @@ public abstract class AudioRecorder extends Module implements Listener<AudioPack
}
/**
* Stops the recorder and closes out the recording file
* Stops the recorder and flags the recording to be closed. Use this method if you do not need any details about
* the final recording. Otherwise, use the close() method and register a closing listener.
*/
public void stop()
{
if(mRunning.compareAndSet(true, false))
{
if (mProcessorHandle != null)
{
mProcessorHandle.cancel(true);
}
mProcessorHandle = null;
processAudioPacketQueue();
close();
}
close(null);
}
/**
* Closes the recording file.
* Closes the recording file. Upon successful closing of the recording file, the listener is notified that the
* audio recorder is closed.
*/
private void close()
public void close(Listener<AudioRecorder> listener)
{
if (mFileOutputStream != null)
{
try
{
mFileOutputStream.flush();
mFileOutputStream.close();
}
catch (IOException e)
{
mLog.error("Error closing output stream", e);
}
}
mRecordingClosedListener = listener;
mRunning.set(false);
}
@@ -276,6 +258,14 @@ public abstract class AudioRecorder extends Module implements Listener<AudioPack
{
}
/**
* Flushes any remaining audio to the output file. This method should be implemented by subclasses where an audio
* converter may contain residual frame data that should be flushed to disk before closing the audio file.
*/
protected void flush()
{
}
/**
* Drains the audio packet queue and records the audio packets to file
*/
@@ -289,6 +279,38 @@ public abstract class AudioRecorder extends Module implements Listener<AudioPack
{
processAudioPacketQueue();
//If we've been stopped or closed, finish the queue, close the recording, notify the listener, and
// cancel the future
if(!mRunning.get())
{
//Allow sub-classes to flush remaining audio frame data to disk.
flush();
if (mFileOutputStream != null)
{
try
{
mFileOutputStream.flush();
mFileOutputStream.close();
}
catch (IOException e)
{
mLog.error("Error closing output stream", e);
}
}
if(mRecordingClosedListener != null)
{
mRecordingClosedListener.receive(AudioRecorder.this);
}
if (mProcessorHandle != null)
{
mProcessorHandle.cancel(false);
mProcessorHandle = null;
}
}
mProcessing.set(false);
}
}

View File

@@ -0,0 +1,119 @@
/*******************************************************************************
* sdrtrunk
* Copyright (C) 2014-2016 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package record.mp3;
import audio.broadcast.BroadcastModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import properties.SystemProperties;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.function.Consumer;
public class MP3FileInspector
{
private final static Logger mLog = LoggerFactory.getLogger(MP3FileInspector.class);
private Path mPath;
public MP3FileInspector(Path path)
{
mPath = path;
}
public void inspect()
{
try
{
byte[] audio = Files.readAllBytes(mPath);
mLog.debug("File:" + mPath.toAbsolutePath().toString() + " Length:" + audio.length + " Residual:" + audio.length % 144);
// mLog.debug(format(Arrays.copyOfRange(audio, 0, 20)));
// for(int x = 0; x < audio.length - 3; x++)
// {
// if(audio[x] == (byte)0xFF && ((audio[x + 1] & 0xE0) == 0xE0))
// {
// mLog.debug("\tFrame: " + x + "\t" + format(Arrays.copyOfRange(audio, x, x + 4)));
//
// x += 143;
// }
// }
}
catch(IOException ioe)
{
mLog.error("Error reading temporary audio stream recording [" + mPath.toString() +
"] - skipping recording");
}
}
public static void main(String[] args)
{
mLog.debug("Starting ...");
Path path = SystemProperties.getInstance().getApplicationFolder(BroadcastModel.TEMPORARY_STREAM_DIRECTORY);
if(path != null && Files.isDirectory(path))
{
StringBuilder sb = new StringBuilder();
sb.append(BroadcastModel.TEMPORARY_STREAM_FILE_SUFFIX);
sb.append("*.*");
try(DirectoryStream<Path> directoryStream = Files.newDirectoryStream(path, sb.toString()))
{
directoryStream.forEach(new Consumer<Path>()
{
@Override
public void accept(Path path)
{
MP3FileInspector inspector = new MP3FileInspector(path);
inspector.inspect();
}
});
}
catch(IOException ioe)
{
mLog.error("Error discovering orphaned temporary stream recording files", ioe);
}
}
mLog.debug("Finished");
}
private String format(byte[] bytes)
{
StringBuilder sb = new StringBuilder();
for(byte b: bytes)
{
sb.append(String.format("%02X", b));
}
return sb.toString();
}
}

View File

@@ -20,21 +20,28 @@ package record.mp3;
import audio.AudioPacket;
import audio.IAudioPacketListener;
import audio.broadcast.BroadcastModel;
import audio.convert.MP3AudioConverter;
import module.Module;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import properties.SystemProperties;
import record.AudioRecorder;
import record.wave.AudioPacketMonoWaveReader;
import sample.Listener;
import util.TimeStamp;
import java.io.ByteArrayInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
@@ -42,6 +49,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* MP3 recorder for converting 8 kHz PCM audio packets to MP3 and writing to .mp3 file.
@@ -83,6 +91,24 @@ public class MP3Recorder extends AudioRecorder
}
}
@Override
protected void flush()
{
byte[] partialFrame = mMP3Converter.flush();
if(partialFrame != null && partialFrame.length > 0)
{
try
{
getOutputStream().write(partialFrame);
}
catch(IOException ioe)
{
mLog.error("Error writing final audio frame data to file", ioe);
}
}
}
/**
* 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.
@@ -92,53 +118,4 @@ public class MP3Recorder extends AudioRecorder
{
//TODO: detect metadata changes and write out ID3 tags to the MP3 stream
}
public static void main(String[] args)
{
mLog.debug("Starting ...");
// Path inputPath = Paths.get("/home/denny/Music/PCM.wav");
// Path outputPath = Paths.get("/home/denny/Music/denny_test/PCM.mp3");
//
// 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(inputPath, true))
// {
// reader.setListener(recorder);
// reader.read();
// recorder.stop();
// }
// catch (IOException e)
// {
// mLog.error("Error", e);
// }
//
Path outputPath = Paths.get("/home/denny/Music/Silence_test_10_seconds.mp3");
mLog.debug("Writing: " + outputPath.toString());
final MP3Recorder recorder = new MP3Recorder(outputPath);
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
recorder.start(executor);
long duration = 1165;
int length = (int)(duration * 8); //8000 Hz sample rate
float[] silence = new float[length];
AudioPacket silencePacket = new AudioPacket(silence, null);
for(int x = 0; x < 10; x++)
{
recorder.receive(silencePacket);
}
recorder.stop();
executor.shutdown();
mLog.debug("Finished");
}
}