#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

@@ -19,7 +19,6 @@
package audio.broadcast;
import alias.AliasModel;
import audio.AudioPacket;
import audio.broadcast.broadcastify.BroadcastifyConfiguration;
import audio.broadcast.broadcastify.BroadcastifyConfigurationEditor;
import audio.broadcast.icecast.IcecastHTTPAudioBroadcaster;
@@ -30,6 +29,7 @@ import audio.broadcast.icecast.IcecastTCPConfiguration;
import audio.broadcast.icecast.IcecastTCPConfigurationEditor;
import audio.broadcast.shoutcast.v1.ShoutcastV1AudioBroadcaster;
import audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
import audio.broadcast.shoutcast.v1.ShoutcastV1ConfigurationEditor;
import audio.broadcast.shoutcast.v2.ShoutcastV2AudioBroadcaster;
import audio.broadcast.shoutcast.v2.ShoutcastV2Configuration;
import audio.convert.IAudioConverter;
@@ -46,8 +46,6 @@ import record.mp3.MP3Recorder;
import settings.SettingsManager;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class BroadcastFactory
{
@@ -55,20 +53,6 @@ public class BroadcastFactory
public static final int MP3_MONO_16_KHZ_BITRATE = 16;
public static final boolean MP3_CONSTANT_BITRATE = false;
private static MP3AudioConverter MP3_SILENT_FRAME_GENERATOR;
static
{
MP3_SILENT_FRAME_GENERATOR = new MP3AudioConverter(MP3Recorder.MP3_BIT_RATE, MP3Recorder.CONSTANT_BIT_RATE);
//We have to charge the converter pipeline with 1200 milliseconds to get 1 second of audio the first time
float[] silence = new float[9600]; //1200 milliseconds at 8000 kHz
AudioPacket silencePacket = new AudioPacket(silence, null);
List<AudioPacket> silencePackets = new ArrayList<>();
silencePackets.add(silencePacket);
MP3_SILENT_FRAME_GENERATOR.convert(silencePackets);
}
/**
* Creates an audio streaming broadcaster for the configuration
@@ -185,6 +169,9 @@ public class BroadcastFactory
case ICECAST_HTTP:
editor = new IcecastHTTPConfigurationEditor(broadcastModel, aliasModel, settingsManager);
break;
case SHOUTCAST_V1:
editor = new ShoutcastV1ConfigurationEditor(broadcastModel, aliasModel, settingsManager);
break;
default:
editor = new EmptyEditor<BroadcastConfiguration>();
break;

View File

@@ -97,6 +97,7 @@ public class BroadcastPanel extends JPanel implements ActionListener, ListSelect
menu.add(new AddBroadcastConfigurationItem(BroadcastServerType.BROADCASTIFY));
menu.add(new AddBroadcastConfigurationItem(BroadcastServerType.ICECAST_TCP));
menu.add(new AddBroadcastConfigurationItem(BroadcastServerType.ICECAST_HTTP));
menu.add(new AddBroadcastConfigurationItem(BroadcastServerType.SHOUTCAST_V1));
menu.show(e.getComponent(), e.getX(), e.getY());
}

View File

@@ -170,12 +170,23 @@ public class StreamManager implements Listener<AudioPacket>
if(mStreamRecorders.containsKey(sourceChannelID))
{
AudioRecorder recorder = mStreamRecorders.remove(sourceChannelID);
recorder.stop();
StreamableAudioRecording streamableAudioRecording = new StreamableAudioRecording(recorder.getPath(),
recorder.getMetadata(), recorder.getTimeRecordingStart(), recorder.getRecordingLength());
recorder.close(new Listener<AudioRecorder>()
{
@Override
public void receive(AudioRecorder audioRecorder)
{
StreamableAudioRecording streamableAudioRecording =
new StreamableAudioRecording(audioRecorder.getPath(), audioRecorder.getMetadata(),
audioRecorder.getTimeRecordingStart(), audioRecorder.getRecordingLength());
if(mAudioBroadcaster != null)
{
mAudioBroadcaster.receive(streamableAudioRecording);
}
}
});
mAudioBroadcaster.receive(streamableAudioRecording);
}
}

View File

@@ -0,0 +1,275 @@
/*******************************************************************************
* 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 audio.broadcast.shoutcast;
import audio.broadcast.icecast.IcecastConfiguration;
import audio.broadcast.icecast.IcecastHeader;
import audio.broadcast.shoutcast.v1.ShoutcastMetadata;
import audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
import audio.metadata.AudioMetadata;
import audio.metadata.Metadata;
import audio.metadata.MetadataType;
import controller.ThreadPoolManager;
import org.apache.mina.core.future.ConnectFuture;
import org.apache.mina.core.service.IoHandlerAdapter;
import org.apache.mina.core.session.IoSession;
import org.apache.mina.filter.codec.ProtocolCodecFilter;
import org.apache.mina.filter.codec.textline.TextLineCodecFactory;
import org.apache.mina.filter.logging.LoggingFilter;
import org.apache.mina.http.HttpClientCodec;
import org.apache.mina.http.HttpRequestImpl;
import org.apache.mina.http.api.HttpMethod;
import org.apache.mina.http.api.HttpRequest;
import org.apache.mina.http.api.HttpVersion;
import org.apache.mina.transport.socket.nio.NioSocketConnector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import properties.SystemProperties;
import java.io.UnsupportedEncodingException;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class ShoutcastMetadataUpdater
{
private final static Logger mLog = LoggerFactory.getLogger(ShoutcastMetadataUpdater.class);
private final static String UTF8 = "UTF-8";
private ThreadPoolManager mThreadPoolManager;
private ShoutcastV1Configuration mShoutcastV1Configuration;
private NioSocketConnector mSocketConnector;
private AtomicBoolean mUpdating = new AtomicBoolean();
private Queue<String> mMetadataQueue = new LinkedTransferQueue<>();
private Map<String, String> mHTTPHeaders;
private boolean mStackTraceLoggingSuppressed;
/**
* Shoutcast song metadata updater. Each metadata update is processed in the order received and the dispatch of
* the HTTP update request is processed in a separate thread (runnable) to avoid delaying streaming of this
* broadcaster. When multiple metadata updates are received prior to completion of the current ongoing update
* sequence, those updates will be queued and processed in the order received.
*/
public ShoutcastMetadataUpdater(ThreadPoolManager threadPoolManager, ShoutcastV1Configuration shoutcastV1Configuration)
{
mThreadPoolManager = threadPoolManager;
mShoutcastV1Configuration = shoutcastV1Configuration;
}
/**
* Socket connector - lazy constructor.
*/
private NioSocketConnector getSocketConnector()
{
if (mSocketConnector == null)
{
mSocketConnector = new NioSocketConnector();
// mSocketConnector.getFilterChain().addLast("logger",
// new LoggingFilter(ShoutcastMetadataUpdater.class));
mSocketConnector.getFilterChain().addLast("http_client_codec", new HttpClientCodec());
//Each metadata update session is single-use, so we'll shut it down upon success or failure
mSocketConnector.setHandler(new IoHandlerAdapter()
{
@Override
public void exceptionCaught(IoSession session, Throwable cause) throws Exception
{
//Single-use session - close it after we receive an error
session.closeNow();
}
@Override
public void messageReceived(IoSession session, Object message) throws Exception
{
//Single-use session - close it after we receive a response
session.closeNow();
}
});
}
return mSocketConnector;
}
/**
* Sends a song metadata update to the remote server. Additional metadata updates received while the updater
* is in the process of sending an existing metadata update will be queued and processed in received order.
*
* Note: due to thread timing, there is a slight chance that a concurrent update request will not be processed
* and will remain in the update queue until the next metadata update is requested. However, this is a design
* trade-off to avoid having a scheduled runnable repeatedly processing the update queue.
*/
public void update(AudioMetadata audioMetadata)
{
mMetadataQueue.offer(getSong(audioMetadata));
if (mUpdating.compareAndSet(false, true))
{
String song = mMetadataQueue.poll();
while (song != null)
{
HttpRequest updateRequest = createUpdateRequest(song);
if (updateRequest != null)
{
mThreadPoolManager.scheduleOnce(new Runnable()
{
@Override
public void run()
{
try
{
ConnectFuture connectFuture = getSocketConnector()
.connect(new InetSocketAddress(mShoutcastV1Configuration.getHost(),
mShoutcastV1Configuration.getPort()));
connectFuture.awaitUninterruptibly();
IoSession session = connectFuture.getSession();
if (session != null)
{
session.write(updateRequest);
}
}
catch (Exception e)
{
Throwable throwableCause = e.getCause();
if (throwableCause instanceof ConnectException)
{
//Do nothing, the server is unavailable
}
else
{
if (!mStackTraceLoggingSuppressed)
{
mLog.error("Error sending metadata update. Future errors will " +
"be suppressed", e);
mStackTraceLoggingSuppressed = true;
}
}
}
}
}, 0l, TimeUnit.SECONDS);
}
//Fetch next metadata update to send
song = mMetadataQueue.poll();
}
mUpdating.set(false);
}
}
/**
* Creates the song information for a metadata update
*/
private static String getSong(AudioMetadata audioMetadata)
{
StringBuilder sb = new StringBuilder();
if(audioMetadata != null)
{
Metadata to = audioMetadata.getMetadata(MetadataType.TO);
sb.append("TO:");
if(to != null)
{
if(to.hasAlias())
{
sb.append(to.getAlias().getName());
}
else
{
sb.append(to.getValue());
}
}
else
{
sb.append("UNKNOWN");
}
Metadata from = audioMetadata.getMetadata(MetadataType.FROM);
sb.append(" FROM:");
if(from != null)
{
if(from.hasAlias())
{
sb.append(from.getAlias().getName());
}
else
{
sb.append(from.getValue());
}
}
else
{
sb.append("UNKNOWN");
}
}
else
{
sb.append("Scanning ....");
}
return sb.toString();
}
/**
* Creates an HTTP GET request to update the metadata on the remote server
*/
private HttpRequest createUpdateRequest(String song)
{
try
{
StringBuilder sb = new StringBuilder();
sb.append("pass=").append(mShoutcastV1Configuration.getPassword());
sb.append("&mode=updinfo");
sb.append("&song=").append(URLEncoder.encode(song, UTF8));
Map<String,String> headers = new HashMap<>();
// headers.put("User-Agent", SystemProperties.getInstance().getApplicationName() + " (Mozilla compatible)");
HttpRequestImpl request = new HttpRequestImpl(HttpVersion.HTTP_1_0, HttpMethod.GET, "/admin.cgi",
sb.toString(), headers);
return request;
}
catch (UnsupportedEncodingException e)
{
//This should never happen
mLog.error("UTF-8 encoding is not supported - can't update song metadata");
}
return null;
}
}

View File

@@ -0,0 +1,47 @@
/*******************************************************************************
* 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 audio.broadcast.shoutcast.v1;
import audio.broadcast.AudioBroadcaster;
import audio.broadcast.BroadcastConfiguration;
import audio.broadcast.shoutcast.ShoutcastMetadataUpdater;
import audio.metadata.AudioMetadata;
import controller.ThreadPoolManager;
public abstract class ShoutcastAudioBroadcaster extends AudioBroadcaster
{
private ShoutcastMetadataUpdater mMetadataUpdater;
public ShoutcastAudioBroadcaster(ThreadPoolManager threadPoolManager, BroadcastConfiguration broadcastConfiguration)
{
super(threadPoolManager, broadcastConfiguration);
}
@Override
protected void broadcastMetadata(AudioMetadata metadata)
{
if(mMetadataUpdater == null)
{
mMetadataUpdater = new ShoutcastMetadataUpdater(getThreadPoolManager(),
(ShoutcastV1Configuration)getBroadcastConfiguration());
}
mMetadataUpdater.update(metadata);
}
}

View File

@@ -16,9 +16,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
******************************************************************************/
package audio.broadcast;
package audio.broadcast.shoutcast.v1;
public enum BroadcastMetadata
import audio.broadcast.BroadcastFormat;
public enum ShoutcastMetadata
{
AIM("icy-aim:"),
AUDIO_BIT_RATE("icy-br:"),
@@ -43,7 +45,7 @@ public enum BroadcastMetadata
private String mTag;
private BroadcastMetadata(String tag)
private ShoutcastMetadata(String tag)
{
mTag = tag;
}

View File

@@ -18,46 +18,45 @@
******************************************************************************/
package audio.broadcast.shoutcast.v1;
import audio.AudioPacket;
import audio.broadcast.AudioBroadcaster;
import audio.broadcast.BroadcastFactory;
import audio.broadcast.BroadcastFormat;
import audio.broadcast.BroadcastMetadata;
import audio.broadcast.BroadcastState;
import audio.metadata.AudioMetadata;
import controller.ThreadPoolManager;
import org.apache.mina.core.RuntimeIoException;
import org.apache.mina.core.buffer.IoBuffer;
import org.apache.mina.core.future.ConnectFuture;
import org.apache.mina.core.service.IoHandlerAdapter;
import org.apache.mina.core.session.IoSession;
import org.apache.mina.filter.codec.ProtocolCodecFilter;
import org.apache.mina.filter.codec.textline.TextLineCodecFactory;
import org.apache.mina.transport.socket.nio.NioSocketConnector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import record.wave.AudioPacketMonoWaveReader;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.UnknownHostException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class ShoutcastV1AudioBroadcaster extends AudioBroadcaster
public class ShoutcastV1AudioBroadcaster extends ShoutcastAudioBroadcaster
{
private final static Logger mLog = LoggerFactory.getLogger( ShoutcastV1AudioBroadcaster.class );
private static final long RECONNECT_INTERVAL_MILLISECONDS = 30000; //30 seconds
private NioSocketConnector mSocketConnector;
private IoSession mStreamingSession = null;
private static final long RECONNECT_INTERVAL_MILLISECONDS = 15000; //15 seconds
private long mLastConnectionAttempt = 0;
private Socket mSocket;
private DataOutputStream mOutputStream;
private DataInputStream mInputStream;
private List<AudioPacket> mPacketsToBroadcast = new ArrayList<>();
private static final int mBufferSize = 2000;
private AtomicBoolean mConnecting = new AtomicBoolean();
/**
* Creates a Shoutcast Version 1 broadcaster.
* @param configuration
* Creates a Shoutcast v1 compatible broadcaster using TCP protocol.
*
* Note: use @see ShoutcastV2AudioBroadcaster for Shoutcast version 2.x and newer.
*
* This broadcaster uses the Apache Mina library for the streaming socket connection and for metadata updates. The
* ShoutcastV1IOHandler manages all interaction with the Shoutcast server and manages the overall broadcast state.
*
* @param configuration for the Shoutcast stream
*/
public ShoutcastV1AudioBroadcaster(ThreadPoolManager threadPoolManager, ShoutcastV1Configuration configuration)
{
@@ -65,57 +64,102 @@ public class ShoutcastV1AudioBroadcaster extends AudioBroadcaster
}
/**
* Shoutcast V1 Configuration information
* Shoutcast V1 broadcast configuration
*/
private ShoutcastV1Configuration getShoutcastConfiguration()
private ShoutcastV1Configuration getConfiguration()
{
return (ShoutcastV1Configuration)getBroadcastConfiguration();
}
@Override
protected void disconnect()
{
//TODO: implement a disconnect
}
@Override
protected void broadcastMetadata(AudioMetadata metadata)
{
mLog.debug("Request to send audio metadata to shoutcast v1 server - needs code");
}
/**
* Process audio packets using Shoutcast V1 Protocol
* Broadcasts the audio frame or sequence
*/
@Override
protected void broadcastAudio(byte[] audio)
{
//If we're connected, send the audio, otherwise discard it
if(connect())
if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected())
{
try
{
send(audio);
}
catch (IOException e)
{
mLog.error("Error while dispatching audio", e);
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
}
IoBuffer buf = IoBuffer.allocate(audio.length).setAutoExpand(false);
buf.put(audio);
buf.flip();
mStreamingSession.write(buf);
}
}
/**
* Indicates if the audio handler is currently connected to the remote server and capable of streaming audio.
* (Re)Connects the broadcaster to the remote server if it currently is disconnected and indicates if the broadcaster
* is currently connected to the remote server following any connection attempts.
*
* Attempts to connect via this method when the broadcast state indicates an error condition will be ignored.
*
* This method will attempt to establish a connection or reconnect to the streaming server.
* @return true if the audio handler can stream audio
*/
private boolean connect()
{
if(!connected())
if(!connected() && canConnect() &&
(mLastConnectionAttempt + RECONNECT_INTERVAL_MILLISECONDS < System.currentTimeMillis()) &&
mConnecting.compareAndSet(false, true))
{
createConnection();
mLastConnectionAttempt = System.currentTimeMillis();
if(mSocketConnector == null)
{
mSocketConnector = new NioSocketConnector();
mSocketConnector.setConnectTimeoutCheckInterval(10000);
// mSocketConnector.getFilterChain().addLast("logger",
// new LoggingFilter(ShoutcastV1AudioBroadcaster.class));
mSocketConnector.getFilterChain().addLast("codec",
new ProtocolCodecFilter(new TextLineCodecFactory()));
mSocketConnector.setHandler(new ShoutcastIOHandler());
}
mStreamingSession = null;
Runnable runnable = new Runnable()
{
@Override
public void run()
{
setBroadcastState(BroadcastState.CONNECTING);
try
{
ConnectFuture future = mSocketConnector
.connect(new InetSocketAddress(getBroadcastConfiguration().getHost(),
getBroadcastConfiguration().getPort()));
future.awaitUninterruptibly();
mStreamingSession = future.getSession();
}
catch(RuntimeIoException rie)
{
Throwable throwableCause = rie.getCause();
if(throwableCause instanceof ConnectException)
{
setBroadcastState(BroadcastState.NO_SERVER);
}
else if(throwableCause != null)
{
setBroadcastState(BroadcastState.ERROR);
mLog.debug("Failed to connect", rie);
}
else
{
setBroadcastState(BroadcastState.ERROR);
mLog.debug("Failed to connect - no exception is available");
}
disconnect();
}
}
};
getThreadPoolManager().scheduleOnce(runnable, 0l, TimeUnit.SECONDS);
}
return connected();
@@ -123,211 +167,113 @@ public class ShoutcastV1AudioBroadcaster extends AudioBroadcaster
/**
* Creates a connnection to the remote server using the shoutcast configuration information. Once disconnected
* following a successful connection, attempts to reestablish a connection on a set interval
* Disconnect from the remote broadcast server and cleanup input/output streams and socket connection
*/
private void createConnection()
{
if(!connected() && System.currentTimeMillis() - mLastConnectionAttempt >= RECONNECT_INTERVAL_MILLISECONDS)
{
mLastConnectionAttempt = System.currentTimeMillis();
createSocket();
if(mSocket.isConnected())
{
StringBuilder sb = new StringBuilder();
//Password
sb.append(getShoutcastConfiguration().getPassword()).append(BroadcastMetadata.COMMAND_TERMINATOR);
//Metadata
sb.append(BroadcastMetadata.STREAM_NAME.encode(getShoutcastConfiguration().getStreamName()));
sb.append(BroadcastMetadata.URL.encode(getShoutcastConfiguration().getURL()));
sb.append(BroadcastMetadata.PUBLIC.encode(getShoutcastConfiguration().isPublic()));
sb.append(BroadcastMetadata.GENRE.encode(getShoutcastConfiguration().getGenre()));
sb.append(BroadcastMetadata.AUDIO_BIT_RATE.encode(getShoutcastConfiguration().getBitRate()));
//End of connection string
sb.append(BroadcastMetadata.COMMAND_TERMINATOR);
try
{
send(sb.toString());
if(isInvalidResponse())
{
mLog.error("Invalid response after sending metadata");
return;
}
else
{
setBroadcastState(BroadcastState.CONNECTED);
}
}
catch(IOException ioe)
{
mLog.debug("Error while connecting to server", ioe);
setBroadcastState(BroadcastState.ERROR);
}
mLog.debug("Connection established");
}
}
}
/**
* Sends the string data to the remote server
* @param data to send
* @throws IOException if there is an error communicating with the remote server
*/
private void send(String data) throws IOException
{
if(data != null && !data.isEmpty() && mOutputStream != null)
{
mOutputStream.writeBytes(data);
}
}
/**
* Sends the byte data to the remote server
* @param data to send
* @throws IOException if there is an error communicating with the remote server
*/
private void send(byte[] data) throws IOException
public void disconnect()
{
if(connected())
{
int sent = 0;
while(sent < data.length)
if(mStreamingSession != null)
{
int available = data.length - sent;
int sending = available <mBufferSize ? available : mBufferSize;
mOutputStream.write(data, sent, sending);
sent += sending;
mStreamingSession.closeNow();
}
}
}
private String getResponse() throws IOException
{
if(mInputStream != null)
{
int bytesAvailable = mInputStream.available();
if(bytesAvailable > 0)
{
byte[] responseBuffer = new byte[bytesAvailable];
int bytesRead = 0;
while(bytesRead < bytesAvailable)
{
bytesRead += mInputStream.read(responseBuffer, bytesRead, bytesAvailable - bytesRead);
}
return new String(responseBuffer);
}
}
return null;
}
private boolean isInvalidResponse()
{
String response;
try
{
response = getResponse();
}
catch(IOException e)
{
mLog.error("Error while retrieving server response message", e);
setBroadcastState(BroadcastState.ERROR);
return false;
}
if(response != null && !response.isEmpty())
{
if(response.startsWith("OK"))
{
return false;
}
else if(response.startsWith("Invalid Password"))
{
setBroadcastState(BroadcastState.INVALID_CREDENTIALS);
return true;
}
mLog.debug("Unrecognized server response:" + response);
return true;
}
return false;
}
/**
* Creates a socket connection to the remote server and sets the state to CONNECTING.
* IO Handler for managing Icecast TCP connection and credentials
*/
private void createSocket()
public class ShoutcastIOHandler extends IoHandlerAdapter
{
if(mSocket == null)
/**
* Sends stream configuration and user credentials upon connecting to remote server
*/
@Override
public void sessionOpened(IoSession session) throws Exception
{
mLog.debug("Creating socket");
try
{
mSocket = new Socket(getShoutcastConfiguration().getHost(),
getShoutcastConfiguration().getPort());
mOutputStream = new DataOutputStream(mSocket.getOutputStream());
mInputStream = new DataInputStream(mSocket.getInputStream());
}
catch(UnknownHostException uhe)
{
setBroadcastState(BroadcastState.NO_SERVER);
mLog.error("Unknown host or port. Unable to create connection to streaming server host[" +
getShoutcastConfiguration().getHost() + "] and port[" +
getShoutcastConfiguration().getPort() + "] - will reattempt connection periodically");
return;
}
catch(IOException ioe)
{
setBroadcastState(BroadcastState.ERROR);
StringBuilder sb = new StringBuilder();
mLog.error("Error connecting to streaming server host[" +
getShoutcastConfiguration().getHost() + "] and port[" +
getShoutcastConfiguration().getPort() + "]", ioe);
return;
}
//Password
sb.append(getConfiguration().getPassword()).append(ShoutcastMetadata.COMMAND_TERMINATOR);
//Metadata
sb.append(ShoutcastMetadata.STREAM_NAME.encode(getConfiguration().getName()));
sb.append(ShoutcastMetadata.PUBLIC.encode(getConfiguration().isPublic()));
sb.append(ShoutcastMetadata.GENRE.encode(getConfiguration().getGenre()));
sb.append(ShoutcastMetadata.DESCRIPTION.encode(getConfiguration().getDescription()));
sb.append(ShoutcastMetadata.AUDIO_BIT_RATE.encode(getConfiguration().getBitRate()));
//End of connection string
sb.append(ShoutcastMetadata.COMMAND_TERMINATOR);
session.write(sb.toString());
}
if(mSocket.isConnected())
@Override
public void sessionClosed(IoSession session) throws Exception
{
setBroadcastState(BroadcastState.CONNECTING);
}
else
{
try
//If there is already an error state, don't override it. Otherwise, set state to disconnected
if(!getBroadcastState().isErrorState())
{
SocketAddress address = getShoutcastConfiguration().getAddress();
mSocket.connect(address);
setBroadcastState(BroadcastState.CONNECTING);
setBroadcastState(BroadcastState.DISCONNECTED);
}
catch(UnknownHostException uhe)
{
setBroadcastState(BroadcastState.NO_SERVER);
}
catch(IOException e)
{
setBroadcastState(BroadcastState.ERROR);
mLog.error("Error connecting to streaming server host[" +
getShoutcastConfiguration().getHost() + "] and port[" +
getShoutcastConfiguration().getPort() + "]", e);
mSocketConnector.dispose();
mStreamingSession = null;
mSocketConnector = null;
mConnecting.set(false);
super.sessionClosed(session);
}
@Override
public void exceptionCaught(IoSession session, Throwable cause) throws Exception
{
if(cause instanceof IOException && ((IOException)cause).getMessage().startsWith("Connection reset"))
{
mLog.info("Streaming connection reset by remote server - reestablishing connection");
disconnect();
connect();
}
else
{
mLog.error("Broadcast error", cause);
setBroadcastState(BroadcastState.ERROR);
disconnect();
}
mConnecting.set(false);
}
@Override
public void messageReceived(IoSession session, Object object) throws Exception
{
mLog.debug("Message Received:" + object.toString());
if(object instanceof String)
{
String message = (String)object;
if(message != null && !message.trim().isEmpty())
{
if(message.startsWith("OK"))
{
setBroadcastState(BroadcastState.CONNECTED);
}
else if(message.startsWith("icy-caps:"))
{
//TODO: what does icy-caps:11 tell us?
}
else
{
mLog.error("Unrecognized server response:" + message);
setBroadcastState(BroadcastState.ERROR);
}
}
}
else
{
mLog.error("Icecast TCP broadcaster - unrecognized message [ " + object.getClass() +
"] received:" + object.toString());
}
}
}

View File

@@ -21,16 +21,16 @@ package audio.broadcast.shoutcast.v1;
import audio.broadcast.BroadcastConfiguration;
import audio.broadcast.BroadcastFormat;
import audio.broadcast.BroadcastServerType;
import audio.broadcast.icecast.IcecastTCPConfiguration;
import javax.xml.bind.annotation.XmlAttribute;
public class ShoutcastV1Configuration extends BroadcastConfiguration
{
private String mStreamName;
private String mGenre;
private String mDescription;
private boolean mPublic;
private int mChannels;
private int mBitRate;
private String mURL;
private int mChannels = 1;
private int mBitRate = 16;
public ShoutcastV1Configuration()
{
@@ -56,12 +56,10 @@ public class ShoutcastV1Configuration extends BroadcastConfiguration
copy.setEnabled(false);
//Icecast Configuration Parameters
copy.setStreamName(getStreamName());
copy.setGenre(getGenre());
copy.setPublic(isPublic());
copy.setChannels(getChannels());
copy.setBitRate(getBitRate());
copy.setURL(getURL());
return copy;
}
@@ -72,26 +70,10 @@ public class ShoutcastV1Configuration extends BroadcastConfiguration
return BroadcastServerType.SHOUTCAST_V1;
}
/**
* Stream name
*/
public String getStreamName()
{
return mStreamName;
}
/**
* Sets the stream name
* @param name
*/
public void setStreamName(String name)
{
mStreamName = name;
}
/**
* Stream genre
*/
@XmlAttribute(name="genre")
public String getGenre()
{
return mGenre;
@@ -106,9 +88,28 @@ public class ShoutcastV1Configuration extends BroadcastConfiguration
mGenre = genre;
}
/**
* Stream description
*/
@XmlAttribute(name="description")
public String getDescription()
{
return mDescription;
}
/**
* Stream description
* @param description
*/
public void setDescription(String description)
{
mDescription = description;
}
/**
* Public visibility of the broadcastAudio
*/
@XmlAttribute(name="public")
public boolean isPublic()
{
return mPublic;
@@ -126,6 +127,7 @@ public class ShoutcastV1Configuration extends BroadcastConfiguration
/**
* Number of audio channels in the broadcastAudio
*/
@XmlAttribute(name="channels")
public int getChannels()
{
return mChannels;
@@ -142,6 +144,7 @@ public class ShoutcastV1Configuration extends BroadcastConfiguration
/**
* Bit rate in bits per second
*/
@XmlAttribute(name="bitrate")
public int getBitRate()
{
return mBitRate;
@@ -156,20 +159,12 @@ public class ShoutcastV1Configuration extends BroadcastConfiguration
mBitRate = bitRate;
}
/**
* URL associated with the broadcastAudio where users can find additional details.
*/
public String getURL()
@Override
public boolean isValid()
{
return mURL;
}
/**
* URL associated with the broadcastAudio where users can find additional details.
* @param url
*/
public void setURL(String url)
{
mURL = url;
return getName() != null && !getName().isEmpty() &&
getHost() != null && !getHost().isEmpty() &&
getPort() > 0 && getPort() < 65535 &&
getPassword() != null && !getPassword().isEmpty();
}
}

View File

@@ -0,0 +1,320 @@
/*******************************************************************************
* 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 audio.broadcast.shoutcast.v1;
import alias.AliasModel;
import audio.broadcast.BroadcastConfiguration;
import audio.broadcast.BroadcastConfigurationEditor;
import audio.broadcast.BroadcastEvent;
import audio.broadcast.BroadcastModel;
import audio.broadcast.BroadcastServerType;
import net.miginfocom.swing.MigLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import settings.SettingsManager;
import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class ShoutcastV1ConfigurationEditor extends BroadcastConfigurationEditor
{
private final static Logger mLog = LoggerFactory.getLogger( ShoutcastV1ConfigurationEditor.class );
private static final int ONE_MINUTE_MS = 60000;
private JTextField mName;
private JTextField mServer;
private JTextField mPort;
private JTextField mPassword;
private JTextField mGenre;
private JTextField mDescription;
private JCheckBox mPublic;
private JSlider mDelay;
private JLabel mDelayValue;
private JCheckBox mEnabled;
private JButton mSaveButton;
private JButton mResetButton;
public ShoutcastV1ConfigurationEditor(BroadcastModel broadcastModel, AliasModel aliasModel,
SettingsManager settingsManager)
{
super(broadcastModel, aliasModel, settingsManager);
init();
}
private void init()
{
setLayout( new MigLayout( "fill,wrap 2", "[align right][grow,fill]",
"[][][][][][][][][][][][][][grow,fill]" ) );
setPreferredSize(new Dimension(150,400));
JLabel channelLabel = new JLabel("Shoutcast (v1.x) Stream");
ImageIcon icon = mSettingsManager.getImageIcon(BroadcastServerType.SHOUTCAST_V1.getIconName(), 25);
channelLabel.setIcon(icon);
add(channelLabel, "span, align center");
add(new JLabel("Name:"));
mName = new JTextField();
mName.getDocument().addDocumentListener(this);
mName.setToolTipText("Unique name for this stream");
add(mName);
add(new JLabel("Server:"));
mServer = new JTextField();
mServer.getDocument().addDocumentListener(this);
mServer.setToolTipText("Server address (e.g. audio1.shoutcast.com or localhost)");
add(mServer);
add(new JLabel("Port:"));
mPort = new JTextField();
mPort.getDocument().addDocumentListener(this);
mPort.setToolTipText("Server port number (e.g. 80)");
add(mPort);
add(new JLabel("Password:"));
mPassword = new JTextField();
mPassword.getDocument().addDocumentListener(this);
mPassword.setToolTipText("Password for the stream");
add(mPassword);
add(new JLabel("Genre:"));
mGenre = new JTextField();
mGenre.getDocument().addDocumentListener(this);
mGenre.setToolTipText("Genre (e.g. public safety)");
add(mGenre);
add(new JLabel("Description:"));
mDescription = new JTextField();
mDescription.getDocument().addDocumentListener(this);
mDescription.setToolTipText("Description of the stream");
add(mDescription);
add(new JLabel("Public:"));
mPublic = new JCheckBox();
mPublic.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
setModified(true);
}
});
mPublic.setToolTipText("Designate stream as public (checked) or private");
add(mPublic);
add(new JLabel());
mDelayValue = new JLabel("0 Minutes");
add(mDelayValue);
add(new JLabel("Delay:"));
mDelay = new JSlider(0,60,0);
mDelay.setMajorTickSpacing(10);
mDelay.setMinorTickSpacing(5);
mDelay.addChangeListener(new ChangeListener()
{
@Override
public void stateChanged(ChangeEvent e)
{
mDelayValue.setText(mDelay.getValue() + " Minutes");
setModified(true);
}
});
mDelay.setToolTipText("Audio broadcast delay in minutes");
add(mDelay);
add(new JLabel("Enabled:")); //Empty
mEnabled = new JCheckBox();
mEnabled.setToolTipText("Enable (checked) or disable this stream");
mEnabled.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
setModified(true);
}
});
add(mEnabled);
mSaveButton = new JButton("Save");
mSaveButton.setEnabled(false);
mSaveButton.setToolTipText("Click to save configuration information");
mSaveButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
save();
}
});
add(mSaveButton);
mResetButton = new JButton("Reset");
mResetButton.setEnabled(false);
mResetButton.setToolTipText("Click to reset any changes you've made since the last save");
mResetButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
setModified(false);
setItem(getItem());
}
});
add(mResetButton);
}
@Override
public void setModified(boolean modified)
{
super.setModified(modified);
mSaveButton.setEnabled(modified);
mResetButton.setEnabled(modified);
}
@Override
public void setItem(BroadcastConfiguration item)
{
super.setItem(item);
if(hasItem())
{
ShoutcastV1Configuration config = (ShoutcastV1Configuration)getItem();
mName.setText(config.getName());
mServer.setText(config.getHost());
mPort.setText(config.getPort() != 0 ? String.valueOf(config.getPort()) : null);
mPassword.setText(config.getPassword());
mGenre.setText(config.getGenre());
mDescription.setText(config.getDescription());
mPublic.setSelected(config.isPublic());
mDelay.setValue((int)(config.getDelay() / ONE_MINUTE_MS));
mEnabled.setSelected(config.isEnabled());
}
else
{
mName.setText(null);
mServer.setText(null);
mPort.setText(null);
mPassword.setText(null);
mGenre.setText(null);
mDescription.setText(null);
mPublic.setSelected(true);
mDelay.setValue(0);
mEnabled.setSelected(false);
}
setModified(false);
}
@Override
public void save()
{
ShoutcastV1Configuration config = (ShoutcastV1Configuration)getItem();
if(validateConfiguration())
{
updateConfigurationName(config, mName.getText());
config.setHost(mServer.getText());
config.setPort(getPort());
config.setDescription(mDescription.getText());
config.setPassword(mPassword.getText());
config.setDescription(mDescription.getText());
config.setGenre(mGenre.getText());
config.setPublic(mPublic.isSelected());
config.setDelay(mDelay.getValue() * ONE_MINUTE_MS);
config.setEnabled(mEnabled.isSelected());
setModified(false);
mBroadcastModel.process(new BroadcastEvent(getItem(), BroadcastEvent.Event.CONFIGURATION_CHANGE));
}
}
/**
* Validates the user specified values in the controls prior to saving them to the configuration
*/
private boolean validateConfiguration()
{
String name = mName.getText();
//A unique stream name is required
if(!mBroadcastModel.isUniqueName(name, getItem()))
{
String message = (name == null || name.isEmpty()) ?
"Please specify a stream name." : "Stream name " + name +
"is already in use.\nPlease choose another name.";
JOptionPane.showMessageDialog(ShoutcastV1ConfigurationEditor.this,
message, "Invalid Stream Name", JOptionPane.ERROR_MESSAGE);
mName.requestFocus();
return false;
}
//Server address is optional, but required
if(!validateTextField(mServer, "Invalid Server Address", "Please specify a server address."))
{
return true;
}
//Port is optional, but required
if(!validateIntegerTextField(mPort, "Invalid Port Number", "Please specify a port number (1 <> 65535)", 1, 65535))
{
return true;
}
//Password is optional, but required
if(!validateTextField(mPassword, "Invalid Password", "Please specify a password."))
{
return true;
}
return true;
}
/**
* Parses an Integer from the port text control or returns 0 if the control doesn't contain an integer value.
*/
private int getPort()
{
String port = mPort.getText();
if(port != null && !port.isEmpty())
{
try
{
return Integer.parseInt(port);
}
catch(Exception e)
{
//Do nothing, we couldn't parse the number value
}
}
return 0;
}
}

View File

@@ -26,7 +26,7 @@ public class ShoutcastV2Configuration extends BroadcastConfiguration
{
private int mStreamID;
private String mUserID;
private int mBitRate;
private int mBitRate = 16;
private String mStreamName;
private String mGenre;
private String mURL;

View File

@@ -24,5 +24,14 @@ import java.util.List;
public interface IAudioConverter
{
/**
* Converts the PCM audio packets to converted audio format. May produce partial audio frame data.
*/
public byte[] convert(List<AudioPacket> audioPackets);
/**
* Finalizes audio conversion by fully converting any partial frames left in the buffer and returning the
* remaining bytes to produce a full frame.
*/
public byte[] flush();
}

View File

@@ -28,6 +28,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.List;
public class MP3AudioConverter implements IAudioConverter
@@ -82,4 +83,14 @@ public class MP3AudioConverter implements IAudioConverter
return new byte[0];
}
}
@Override
public byte[] flush()
{
byte[] lastPartialFrame = new byte[mEncoder.getMP3BufferSize()];
int length = mEncoder.encodeFinish(lastPartialFrame);
return Arrays.copyOf(lastPartialFrame, length);
}
}

View File

@@ -39,6 +39,8 @@ public class MP3SilenceGenerator implements ISilenceGenerator
*/
public MP3SilenceGenerator()
{
//Prime the conversion buffer with 172 ms of audio to produce (and throw away) the first 20 byte buffer
generate(172);
}
public byte[] generate(long duration)
@@ -57,10 +59,16 @@ public class MP3SilenceGenerator implements ISilenceGenerator
MP3SilenceGenerator generator = new MP3SilenceGenerator();
for(long x = 0; x < 20; x ++)
int sampleCount = 0;
int block = 1;
for(long x = 0; x < 500; x ++)
{
byte[] silence = generator.generate(1000);
mLog.debug("Silence:" + x + " Length:" + silence.length);
byte[] silence = generator.generate(block);
sampleCount += block;
mLog.debug("Silence:" + x + " Count:" + sampleCount + " Length:" + silence.length);
}
mLog.debug("Finished");

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");
}
}