diff --git a/src/audio/broadcast/BroadcastFactory.java b/src/audio/broadcast/BroadcastFactory.java index 2677e9cd..5f8b47fa 100644 --- a/src/audio/broadcast/BroadcastFactory.java +++ b/src/audio/broadcast/BroadcastFactory.java @@ -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 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(); break; diff --git a/src/audio/broadcast/BroadcastPanel.java b/src/audio/broadcast/BroadcastPanel.java index 90ce247e..eee6270b 100644 --- a/src/audio/broadcast/BroadcastPanel.java +++ b/src/audio/broadcast/BroadcastPanel.java @@ -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()); } diff --git a/src/audio/broadcast/StreamManager.java b/src/audio/broadcast/StreamManager.java index 49732f3d..5ba0c97c 100644 --- a/src/audio/broadcast/StreamManager.java +++ b/src/audio/broadcast/StreamManager.java @@ -170,12 +170,23 @@ public class StreamManager implements Listener 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() + { + @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); } } diff --git a/src/audio/broadcast/shoutcast/ShoutcastMetadataUpdater.java b/src/audio/broadcast/shoutcast/ShoutcastMetadataUpdater.java new file mode 100644 index 00000000..e107a367 --- /dev/null +++ b/src/audio/broadcast/shoutcast/ShoutcastMetadataUpdater.java @@ -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 + * + ******************************************************************************/ +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 mMetadataQueue = new LinkedTransferQueue<>(); + private Map 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 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; + } +} diff --git a/src/audio/broadcast/shoutcast/v1/ShoutcastAudioBroadcaster.java b/src/audio/broadcast/shoutcast/v1/ShoutcastAudioBroadcaster.java new file mode 100644 index 00000000..92689e2d --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastAudioBroadcaster.java @@ -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 + * + ******************************************************************************/ +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); + } +} diff --git a/src/audio/broadcast/BroadcastMetadata.java b/src/audio/broadcast/shoutcast/v1/ShoutcastMetadata.java similarity index 94% rename from src/audio/broadcast/BroadcastMetadata.java rename to src/audio/broadcast/shoutcast/v1/ShoutcastMetadata.java index aa83c94b..b62eb032 100644 --- a/src/audio/broadcast/BroadcastMetadata.java +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastMetadata.java @@ -16,9 +16,11 @@ * along with this program. If not, see * ******************************************************************************/ -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; } diff --git a/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java b/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java index 39aadf79..c5795aa2 100644 --- a/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java @@ -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 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 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()); } } } diff --git a/src/audio/broadcast/shoutcast/v1/ShoutcastV1Configuration.java b/src/audio/broadcast/shoutcast/v1/ShoutcastV1Configuration.java index 8b3c1e1a..ee1133fa 100644 --- a/src/audio/broadcast/shoutcast/v1/ShoutcastV1Configuration.java +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1Configuration.java @@ -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(); } } diff --git a/src/audio/broadcast/shoutcast/v1/ShoutcastV1ConfigurationEditor.java b/src/audio/broadcast/shoutcast/v1/ShoutcastV1ConfigurationEditor.java new file mode 100644 index 00000000..696a3252 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1ConfigurationEditor.java @@ -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 + * + ******************************************************************************/ +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; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java b/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java index b973edb4..ef7be179 100644 --- a/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java +++ b/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java @@ -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; diff --git a/src/audio/convert/IAudioConverter.java b/src/audio/convert/IAudioConverter.java index 86cdd98f..92ccb5dc 100644 --- a/src/audio/convert/IAudioConverter.java +++ b/src/audio/convert/IAudioConverter.java @@ -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 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(); } diff --git a/src/audio/convert/MP3AudioConverter.java b/src/audio/convert/MP3AudioConverter.java index 06ac3ae1..ea813f1c 100644 --- a/src/audio/convert/MP3AudioConverter.java +++ b/src/audio/convert/MP3AudioConverter.java @@ -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); + } } diff --git a/src/audio/convert/MP3SilenceGenerator.java b/src/audio/convert/MP3SilenceGenerator.java index 7b088506..87a308d1 100644 --- a/src/audio/convert/MP3SilenceGenerator.java +++ b/src/audio/convert/MP3SilenceGenerator.java @@ -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"); diff --git a/src/record/AudioRecorder.java b/src/record/AudioRecorder.java index f310f734..51568016 100644 --- a/src/record/AudioRecorder.java +++ b/src/record/AudioRecorder.java @@ -54,6 +54,7 @@ public abstract class AudioRecorder extends Module implements Listener mProcessorHandle; + private Listener mRecordingClosedListener; private long mSampleCount; @@ -152,42 +153,23 @@ public abstract class AudioRecorder extends Module implements Listener 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 + * + ******************************************************************************/ +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 directoryStream = Files.newDirectoryStream(path, sb.toString())) + { + directoryStream.forEach(new Consumer() + { + @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(); + } +} diff --git a/src/record/mp3/MP3Recorder.java b/src/record/mp3/MP3Recorder.java index da88ee81..747496ce 100644 --- a/src/record/mp3/MP3Recorder.java +++ b/src/record/mp3/MP3Recorder.java @@ -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"); - } }