diff --git a/.idea/dictionaries/denny.xml b/.idea/dictionaries/denny.xml index 31ec5bc1..408050c7 100644 --- a/.idea/dictionaries/denny.xml +++ b/.idea/dictionaries/denny.xml @@ -1,12 +1,22 @@ + bahroo + broadcastify esnid fleetsync + icecast ident polyphase + samplerate + sdrtrunk + sheirer + shoutcast + streamable talkgroup tgid + ultravox + xtea \ No newline at end of file diff --git a/.idea/libraries/imports.xml b/.idea/libraries/imports.xml index 8ac29dbb..c2d94bbd 100644 --- a/.idea/libraries/imports.xml +++ b/.idea/libraries/imports.xml @@ -8,7 +8,6 @@ - @@ -21,11 +20,46 @@ - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/jmbe_interface_0_3_3.xml b/.idea/libraries/jmbe_interface_0_3_3.xml deleted file mode 100644 index 1b73f0a9..00000000 --- a/.idea/libraries/jmbe_interface_0_3_3.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/libraries/org_asynchttpclient_async_http_client_2_0_18.xml b/.idea/libraries/org_asynchttpclient_async_http_client_2_0_18.xml new file mode 100644 index 00000000..884ea3f9 --- /dev/null +++ b/.idea/libraries/org_asynchttpclient_async_http_client_2_0_18.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 18d93cc0..2c55e21a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -38,20 +38,7 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 00000000..e96534fb --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG index 198a6063..83a91896 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,10 @@ -0.3.0 2016xxxx (work in progress) - - Requires Java 8 for compatibility with eventual changeover to JavaFX +0.3.0 2017xxxx (work in progress) + - FEATURE: audio streaming - adds capability to stream to multiple remote broadcast + servers (Broadcastify, Icecast v2.3 and v2.4+, and Shoutcast V1 and V2). Each + stream can be independently configured with a delay. Audio calls are directed + to one or more streams by attaching one or more stream identifiers to each alias. + - Requires Java 8 (or newer) for compatibility with eventual changeover to JavaFX - Extensive GUI updates for tuner, channel and alias management. - Updated alias list management of alias id values and wildcards. Weighting is now applied to each wildcard so that they can be ordered from most specific to least @@ -14,10 +18,10 @@ decoders and filters - New audio playback interface with global mute and support for one or two audio channels - - Audio playback is prioritized using a new Call Priority alias action + - Audio playback is prioritized using a new Call Priority name action with selected channels having the highest priority and any audio without - an alias identifier having the lowest priority. - - Updated Audio recording per call with new 'Non-Recordable' alias action + an name identifier having the lowest priority. + - Updated Audio recording per call with new 'Non-Recordable' name action - Updated Baseband (I/Q) recording at 48 kHz - Added Airspy tuner support - New traffic channel pool manager for trunked call following diff --git a/config/logback.xml b/config/logback.xml index 631e8aa5..9fe5f368 100644 --- a/config/logback.xml +++ b/config/logback.xml @@ -27,4 +27,7 @@ + + + \ No newline at end of file diff --git a/images/broadcastify.png b/images/broadcastify.png new file mode 100644 index 00000000..6671ff74 Binary files /dev/null and b/images/broadcastify.png differ diff --git a/images/icecast.png b/images/icecast.png new file mode 100644 index 00000000..e57643f2 Binary files /dev/null and b/images/icecast.png differ diff --git a/images/shoutcast.png b/images/shoutcast.png new file mode 100644 index 00000000..083f2caf Binary files /dev/null and b/images/shoutcast.png differ diff --git a/imports/activation-1.1.jar b/imports/activation-1.1.jar new file mode 100644 index 00000000..53f82a1c Binary files /dev/null and b/imports/activation-1.1.jar differ diff --git a/imports/axis.jar b/imports/axis.jar new file mode 100644 index 00000000..20b09a59 Binary files /dev/null and b/imports/axis.jar differ diff --git a/imports/commons-codec-1.9.jar b/imports/commons-codec-1.9.jar new file mode 100644 index 00000000..ef35f1c5 Binary files /dev/null and b/imports/commons-codec-1.9.jar differ diff --git a/imports/commons-discovery-0.2.jar b/imports/commons-discovery-0.2.jar new file mode 100644 index 00000000..b8855484 Binary files /dev/null and b/imports/commons-discovery-0.2.jar differ diff --git a/imports/commons-logging-1.2.jar b/imports/commons-logging-1.2.jar new file mode 100644 index 00000000..93a3b9f6 Binary files /dev/null and b/imports/commons-logging-1.2.jar differ diff --git a/imports/java-lame-3.98.4.jar b/imports/java-lame-3.98.4.jar new file mode 100644 index 00000000..026d96eb Binary files /dev/null and b/imports/java-lame-3.98.4.jar differ diff --git a/imports/java-mail-1.4.jar b/imports/java-mail-1.4.jar new file mode 100644 index 00000000..e6f70832 Binary files /dev/null and b/imports/java-mail-1.4.jar differ diff --git a/imports/javassist-3.20.0-GA.jar b/imports/javassist-3.20.0-GA.jar new file mode 100644 index 00000000..0f1ff725 Binary files /dev/null and b/imports/javassist-3.20.0-GA.jar differ diff --git a/imports/jaxrpc.jar b/imports/jaxrpc.jar new file mode 100644 index 00000000..a2c13d9a Binary files /dev/null and b/imports/jaxrpc.jar differ diff --git a/imports/mina-core-2.0.16.jar b/imports/mina-core-2.0.16.jar new file mode 100644 index 00000000..0e6d9883 Binary files /dev/null and b/imports/mina-core-2.0.16.jar differ diff --git a/imports/mina-http-2.0.16.jar b/imports/mina-http-2.0.16.jar new file mode 100644 index 00000000..62a5efcc Binary files /dev/null and b/imports/mina-http-2.0.16.jar differ diff --git a/imports/radioreference-14.jar b/imports/radioreference-14.jar new file mode 100644 index 00000000..2c7a5d90 Binary files /dev/null and b/imports/radioreference-14.jar differ diff --git a/imports/slf4j-api-1.7.21.jar b/imports/slf4j-api-1.7.21.jar new file mode 100644 index 00000000..2a5c33ec Binary files /dev/null and b/imports/slf4j-api-1.7.21.jar differ diff --git a/imports/slf4j-api-1.7.7.jar b/imports/slf4j-api-1.7.7.jar deleted file mode 100644 index b28e220b..00000000 Binary files a/imports/slf4j-api-1.7.7.jar and /dev/null differ diff --git a/imports/wsdl4j-1.5.1.jar b/imports/wsdl4j-1.5.1.jar new file mode 100644 index 00000000..c6254ee6 Binary files /dev/null and b/imports/wsdl4j-1.5.1.jar differ diff --git a/sdrtrunk.iml b/sdrtrunk.iml index 4ceeed69..04cc0636 100644 --- a/sdrtrunk.iml +++ b/sdrtrunk.iml @@ -20,6 +20,6 @@ - + \ No newline at end of file diff --git a/src/alias/Alias.java b/src/alias/Alias.java index b17a92b9..3770b014 100644 --- a/src/alias/Alias.java +++ b/src/alias/Alias.java @@ -19,7 +19,10 @@ package alias; import java.awt.Color; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; +import java.util.Set; +import java.util.TreeSet; import javax.xml.bind.annotation.XmlAttribute; import javax.xml.bind.annotation.XmlElement; @@ -30,6 +33,7 @@ import javax.xml.bind.annotation.XmlTransient; import alias.action.AliasAction; import alias.id.AliasID; import alias.id.AliasIDType; +import alias.id.broadcast.BroadcastChannel; import alias.id.nonrecordable.NonRecordable; import alias.id.priority.Priority; @@ -175,7 +179,34 @@ public class Alias { return !mAliasActions.isEmpty(); } - + + /** + * Perform any validation/cleanup actions on this alias. + */ + public void validate() + { + //Check for duplicate broadcast channels + List broadcastChannels = getBroadcastChannels(); + + if(broadcastChannels.size() > 0) + { + Set channelNames = new TreeSet<>(); + + for(BroadcastChannel broadcastChannel: broadcastChannels) + { + String channelName = broadcastChannel.getChannelName(); + + if(channelName == null || channelName.isEmpty() || channelNames.contains(channelName)) + { + removeAliasID(broadcastChannel); + } + else + { + channelNames.add(channelName); + } + } + } + } /** * Returns the priority level of this alias, if defined, or the default priority */ @@ -184,7 +215,7 @@ public class Alias { for( AliasID id: mAliasIDs ) { - if( id.getType() == AliasIDType.Priority ) + if( id.getType() == AliasIDType.PRIORITY) { return ((Priority)id).getPriority(); } @@ -197,7 +228,7 @@ public class Alias { for( AliasID id: mAliasIDs ) { - if( id.getType() == AliasIDType.Priority ) + if( id.getType() == AliasIDType.PRIORITY) { return true; } @@ -216,7 +247,7 @@ public class Alias { for( AliasID id: mAliasIDs ) { - if( id.getType() == AliasIDType.Priority ) + if( id.getType() == AliasIDType.PRIORITY) { ((Priority)id).setPriority( priority ); return; @@ -229,7 +260,7 @@ public class Alias addAliasID( p ); } } - + /** * Inspects the alias for a non-recordable alias id. Default is true; */ @@ -238,7 +269,7 @@ public class Alias { for( AliasID id: getId() ) { - if( id.getType() == AliasIDType.NonRecordable ) + if( id.getType() == AliasIDType.NON_RECORDABLE) { return false; } @@ -258,7 +289,7 @@ public class Alias for( AliasID id: getId() ) { - if( id.getType() == AliasIDType.NonRecordable ) + if( id.getType() == AliasIDType.NON_RECORDABLE) { toRemove = id; break; @@ -274,7 +305,7 @@ public class Alias { for( AliasID id: getId() ) { - if( id.getType() == AliasIDType.NonRecordable ) + if( id.getType() == AliasIDType.NON_RECORDABLE) { return; } @@ -283,4 +314,62 @@ public class Alias addAliasID( new NonRecordable() ); } } + + /** + * Inspects the alias for broadcast channel/streamable alias ids. Default is false; + */ + @XmlTransient + public boolean isStreamable() + { + for( AliasID id: getId() ) + { + if( id.getType() == AliasIDType.BROADCAST_CHANNEL) + { + return true; + } + } + + return false; + } + + /** + * List of broadcast channels specified for this alias. + */ + @XmlTransient + public List getBroadcastChannels() + { + List broadcastChannels = new ArrayList<>(); + + for(AliasID id: getId()) + { + if(id.getType() == AliasIDType.BROADCAST_CHANNEL) + { + broadcastChannels.add((BroadcastChannel)id); + } + } + + return broadcastChannels; + } + + /** + * Indicates if this alias contains a broadcast channel alias id with the channel name. + */ + public boolean hasBroadcastChannel(String channel) + { + if(channel == null || channel.isEmpty()) + { + return false; + } + + for(AliasID id: getId()) + { + if(id.getType() == AliasIDType.BROADCAST_CHANNEL && + ((BroadcastChannel)id).getChannelName().contentEquals(channel)) + { + return true; + } + } + + return false; + } } diff --git a/src/alias/AliasController.java b/src/alias/AliasController.java index 60696bc6..0619672c 100644 --- a/src/alias/AliasController.java +++ b/src/alias/AliasController.java @@ -1,5 +1,6 @@ package alias; +import audio.broadcast.BroadcastModel; import gui.editor.Editor; import java.awt.Color; @@ -52,12 +53,12 @@ public class AliasController extends JPanel private IconCellRenderer mIconCellRenderer; - public AliasController( AliasModel aliasModel, SettingsManager settingsManager ) + public AliasController(AliasModel aliasModel, BroadcastModel broadcastModel, SettingsManager settingsManager ) { mAliasModel = aliasModel; - mAliasEditor = new AliasEditor( mAliasModel, settingsManager ); - mMultipleAliasEditor = new MultipleAliasEditor( mAliasModel, settingsManager ); + mAliasEditor = new AliasEditor( mAliasModel, broadcastModel, settingsManager ); + mMultipleAliasEditor = new MultipleAliasEditor( mAliasModel, broadcastModel, settingsManager ); mIconCellRenderer = new IconCellRenderer( settingsManager ); diff --git a/src/alias/AliasEditor.java b/src/alias/AliasEditor.java index b642df01..9cdd4632 100644 --- a/src/alias/AliasEditor.java +++ b/src/alias/AliasEditor.java @@ -17,6 +17,7 @@ ******************************************************************************/ package alias; +import audio.broadcast.BroadcastModel; import gui.editor.Editor; import java.awt.Color; @@ -49,11 +50,11 @@ public class AliasEditor extends Editor private AliasIdentifierEditor mAliasIdentifierEditor; private AliasActionEditor mAliasActionEditor; - public AliasEditor( AliasModel aliasModel, SettingsManager settingsManager ) + public AliasEditor(AliasModel aliasModel, BroadcastModel broadcastModel, SettingsManager settingsManager ) { mAliasModel = aliasModel; mAliasNameEditor = new AliasNameEditor( mAliasModel, settingsManager ); - mAliasIdentifierEditor = new AliasIdentifierEditor( aliasModel ); + mAliasIdentifierEditor = new AliasIdentifierEditor( aliasModel, broadcastModel ); mAliasActionEditor = new AliasActionEditor( aliasModel ); mAliasModel.addListener( this ); diff --git a/src/alias/AliasFactory.java b/src/alias/AliasFactory.java index 3f91ccbe..7acb920e 100644 --- a/src/alias/AliasFactory.java +++ b/src/alias/AliasFactory.java @@ -17,8 +17,6 @@ ******************************************************************************/ package alias; -import gui.editor.Editor; -import gui.editor.EmptyEditor; import alias.action.AliasAction; import alias.action.AliasActionType; import alias.action.beep.BeepAction; @@ -29,6 +27,8 @@ import alias.action.script.ScriptAction; import alias.action.script.ScriptActionEditor; import alias.id.AliasID; import alias.id.AliasIDType; +import alias.id.broadcast.BroadcastChannel; +import alias.id.broadcast.BroadcastChannelEditor; import alias.id.esn.ESNEditor; import alias.id.esn.Esn; import alias.id.fleetsync.FleetsyncID; @@ -53,6 +53,9 @@ import alias.id.talkgroup.TalkgroupID; import alias.id.talkgroup.TalkgroupIDEditor; import alias.id.uniqueID.UniqueID; import alias.id.uniqueID.UniqueIDEditor; +import audio.broadcast.BroadcastModel; +import gui.editor.Editor; +import gui.editor.EmptyEditor; public class AliasFactory { @@ -60,22 +63,27 @@ public class AliasFactory { switch( id.getType() ) { + case BROADCAST_CHANNEL: + BroadcastChannel originalBroadcast = (BroadcastChannel)id; + BroadcastChannel copyBroadcast = new BroadcastChannel(); + copyBroadcast.setChannelName(originalBroadcast.getChannelName()); + return copyBroadcast; case ESN: Esn originalESN = (Esn)id; Esn copyESN = new Esn(); copyESN.setEsn( originalESN.getEsn() ); return copyESN; - case Fleetsync: + case FLEETSYNC: FleetsyncID originalFleetsyncID = (FleetsyncID)id; FleetsyncID copyFleetsyncID = new FleetsyncID(); copyFleetsyncID.setIdent( originalFleetsyncID.getIdent() ); return copyFleetsyncID; - case LTRNetUID: + case LTR_NET_UID: UniqueID originalUniqueID = (UniqueID)id; UniqueID copyUniqueID = new UniqueID(); copyUniqueID.setUid( originalUniqueID.getUid() ); return copyUniqueID; - case LoJack: + case LOJACK: LoJackFunctionAndID originalLoJackFunctionAndID = (LoJackFunctionAndID)id; LoJackFunctionAndID copyLoJackFunctionAndID = new LoJackFunctionAndID(); copyLoJackFunctionAndID.setFunction( originalLoJackFunctionAndID.getFunction() ); @@ -96,24 +104,24 @@ public class AliasFactory MPT1327ID copyMPT1327ID = new MPT1327ID(); copyMPT1327ID.setIdent( originalMPT1327ID.getIdent() ); return copyMPT1327ID; - case NonRecordable: + case NON_RECORDABLE: return new NonRecordable(); - case Priority: + case PRIORITY: Priority originalPriority = (Priority)id; Priority copyPriority = new Priority(); copyPriority.setPriority( originalPriority.getPriority() ); return copyPriority; - case Site: + case SITE: SiteID originalSiteID = (SiteID)id; SiteID copySiteID = new SiteID(); copySiteID.setSite( originalSiteID.getSite() ); return copySiteID; - case Talkgroup: + case TALKGROUP: TalkgroupID originalTalkgroupID = (TalkgroupID)id; TalkgroupID copyTalkgroupID = new TalkgroupID(); copyTalkgroupID.setTalkgroup( originalTalkgroupID.getTalkgroup() ); return copyTalkgroupID; - case Status: + case STATUS: StatusID originalStatusID = (StatusID)id; StatusID copyStatusID = new StatusID(); copyStatusID.setStatus( originalStatusID.getStatus() ); @@ -183,19 +191,21 @@ public class AliasFactory return copy; } - public static Editor getEditor( AliasID aliasID ) + public static Editor getEditor( AliasID aliasID, BroadcastModel broadcastModel ) { if( aliasID != null ) { switch( aliasID.getType() ) { + case BROADCAST_CHANNEL: + return new BroadcastChannelEditor(aliasID, broadcastModel); case ESN: return new ESNEditor( aliasID ); - case Fleetsync: + case FLEETSYNC: return new FleetsyncIDEditor( aliasID ); - case LTRNetUID: + case LTR_NET_UID: return new UniqueIDEditor( aliasID ); - case LoJack: + case LOJACK: return new LoJackIDEditor( aliasID ); case MDC1200: return new MDC1200IDEditor( aliasID ); @@ -203,15 +213,15 @@ public class AliasFactory return new MINEditor( aliasID ); case MPT1327: return new MPT1327IDEditor( aliasID ); - case NonRecordable: + case NON_RECORDABLE: return new NonRecordableEditor( aliasID ); - case Priority: + case PRIORITY: return new PriorityEditor( aliasID ); - case Site: + case SITE: return new SiteIDEditor( aliasID ); - case Status: + case STATUS: return new StatusIDEditor( aliasID ); - case Talkgroup: + case TALKGROUP: return new TalkgroupIDEditor( aliasID ); default: break; @@ -245,13 +255,15 @@ public class AliasFactory { switch( type ) { + case BROADCAST_CHANNEL: + return new BroadcastChannel(); case ESN: return new Esn(); - case Fleetsync: + case FLEETSYNC: return new FleetsyncID(); - case LTRNetUID: + case LTR_NET_UID: return new UniqueID(); - case LoJack: + case LOJACK: return new LoJackFunctionAndID(); case MDC1200: return new MDC1200ID(); @@ -259,15 +271,15 @@ public class AliasFactory return new Min(); case MPT1327: return new MPT1327ID(); - case NonRecordable: + case NON_RECORDABLE: return new NonRecordable(); - case Priority: + case PRIORITY: return new Priority(); - case Site: + case SITE: return new SiteID(); - case Status: + case STATUS: return new StatusID(); - case Talkgroup: + case TALKGROUP: return new TalkgroupID(); default: throw new IllegalArgumentException( "Unrecognized Alias ID type: " + type ); diff --git a/src/alias/AliasList.java b/src/alias/AliasList.java index 0d5e64e1..8b5a6ff8 100644 --- a/src/alias/AliasList.java +++ b/src/alias/AliasList.java @@ -41,7 +41,6 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.regex.Pattern; public class AliasList implements Listener { @@ -120,7 +119,7 @@ public class AliasList implements Listener mESN.put(esn, alias); } break; - case Fleetsync: + case FLEETSYNC: String fleetsync = ((FleetsyncID)id).getIdent(); if(fleetsync != null) @@ -134,7 +133,7 @@ public class AliasList implements Listener mFleetsync.put(fleetsync, alias); } break; - case LoJack: + case LOJACK: mLoJack.put((LoJackFunctionAndID) id, alias); break; case MDC1200: @@ -179,10 +178,10 @@ public class AliasList implements Listener mMobileID.put(min, alias); } break; - case LTRNetUID: + case LTR_NET_UID: mUniqueID.put(((UniqueID)id).getUid(), alias); break; - case Site: + case SITE: String siteID = ((SiteID)id).getSite(); if(siteID != null) @@ -196,10 +195,10 @@ public class AliasList implements Listener mSiteID.put(siteID, alias); } break; - case Status: + case STATUS: mStatus.put(((StatusID) id).getStatus(), alias); break; - case Talkgroup: + case TALKGROUP: String tgid = ((TalkgroupID)id).getTalkgroup(); if(tgid != null) @@ -213,8 +212,9 @@ public class AliasList implements Listener mTalkgroup.put(tgid, alias); } break; - case NonRecordable: - case Priority: + case BROADCAST_CHANNEL: + case NON_RECORDABLE: + case PRIORITY: //We don't maintain lookups for these items break; default: @@ -279,7 +279,7 @@ public class AliasList implements Listener mESN.remove(esn); break; - case Fleetsync: + case FLEETSYNC: String fleetsync = ((FleetsyncID)id).getIdent(); if(fleetsync != null) @@ -292,7 +292,7 @@ public class AliasList implements Listener mFleetsync.remove(fleetsync); } break; - case LoJack: + case LOJACK: mLoJack.remove((LoJackFunctionAndID) id); break; case MDC1200: @@ -334,16 +334,16 @@ public class AliasList implements Listener mMobileID.remove(min); } break; - case LTRNetUID: + case LTR_NET_UID: mUniqueID.remove(((UniqueID) id).getUid()); break; - case Site: + case SITE: mSiteID.remove(((SiteID) id).getSite()); break; - case Status: + case STATUS: mStatus.remove(((StatusID) id).getStatus()); break; - case Talkgroup: + case TALKGROUP: String tgid = ((TalkgroupID)id).getTalkgroup(); if(tgid != null) @@ -356,8 +356,8 @@ public class AliasList implements Listener mTalkgroup.remove(tgid); } break; - case NonRecordable: - case Priority: + case NON_RECORDABLE: + case PRIORITY: //We don't maintain lookups for these items break; default: diff --git a/src/alias/AliasModel.java b/src/alias/AliasModel.java index a051a0d0..a46bd532 100644 --- a/src/alias/AliasModel.java +++ b/src/alias/AliasModel.java @@ -26,6 +26,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import javax.swing.table.AbstractTableModel; +import alias.id.broadcast.BroadcastChannel; import sample.Broadcaster; import sample.Listener; import alias.AliasEvent.Event; @@ -210,6 +211,33 @@ public class AliasModel extends AbstractTableModel } } + /** + * Renames any broadcast channels that have the previous name. + * @param previousName to rename + * @param newName to assign to the broadcast channel + */ + public void renameBroadcastChannel(String previousName, String newName) + { + if(previousName == null || previousName.isEmpty() || newName == null || newName.isEmpty()) + { + return; + } + + for(Alias alias: mAliases) + { + if(alias.hasBroadcastChannel(previousName)) + { + for(BroadcastChannel broadcastChannel: alias.getBroadcastChannels()) + { + if(broadcastChannel.getChannelName().contentEquals(previousName)) + { + broadcastChannel.setChannelName(newName); + } + } + } + } + } + @Override public int getRowCount() { @@ -289,6 +317,14 @@ public class AliasModel extends AbstractTableModel public void broadcast( AliasEvent event ) { + Alias alias = event.getAlias(); + + //Validate the alias following a user action that changed the alias or any alias IDs + if(alias != null) + { + alias.validate(); + } + if( event.getEvent() == Event.CHANGE ) { int index = mAliases.indexOf( event.getAlias() ); diff --git a/src/alias/MultipleAliasEditor.java b/src/alias/MultipleAliasEditor.java index e22e0b1b..ba6861d1 100644 --- a/src/alias/MultipleAliasEditor.java +++ b/src/alias/MultipleAliasEditor.java @@ -1,5 +1,8 @@ package alias; +import alias.id.AliasIDType; +import alias.id.broadcast.BroadcastChannel; +import audio.broadcast.BroadcastModel; import gui.editor.Editor; import java.awt.Color; @@ -7,6 +10,7 @@ import java.awt.Dimension; import java.awt.EventQueue; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.util.Arrays; import java.util.List; import javax.swing.DefaultComboBoxModel; @@ -33,385 +37,429 @@ import settings.SettingsManager; import alias.AliasEvent.Event; import alias.id.priority.Priority; -public class MultipleAliasEditor extends Editor> - implements Listener +public class MultipleAliasEditor extends Editor> + implements Listener { - private static final long serialVersionUID = 1L; - private final static Logger mLog = LoggerFactory.getLogger( MultipleAliasEditor.class ); - - private static String HELP_TEXT = - "Select attributes below to change for all selected aliases"; + private static final long serialVersionUID = 1L; + private final static Logger mLog = LoggerFactory.getLogger(MultipleAliasEditor.class); - private JLabel mAliasCountLabel; - private JLabel mAliasCount; + private static String HELP_TEXT = + "Select attributes below to change for all selected aliases"; - private JCheckBox mListCheckBox = new JCheckBox( "List" ); - private JComboBox mListCombo = new JComboBox<>(); - private JCheckBox mGroupCheckBox = new JCheckBox( "Group" ); - private JComboBox mGroupCombo = new JComboBox<>(); - private JCheckBox mIconCheckBox = new JCheckBox( "Icon" ); + private JLabel mAliasCountLabel; + private JLabel mAliasCount; + + private JCheckBox mListCheckBox = new JCheckBox("List"); + private JComboBox mListCombo = new JComboBox<>(); + private JCheckBox mGroupCheckBox = new JCheckBox("Group"); + private JComboBox mGroupCombo = new JComboBox<>(); + private JCheckBox mIconCheckBox = new JCheckBox("Icon"); private JComboBox mMapIconCombo; - private JCheckBox mColorCheckBox = new JCheckBox( "Color" ); + private JCheckBox mColorCheckBox = new JCheckBox("Color"); private JButton mButtonColor; private JButton mBtnIconManager; - private JCheckBox mRecordCheckBox = new JCheckBox( "Record" ); - private JComboBox mRecordActionCombo = new JComboBox<>( Record.values() ); - private JCheckBox mPriorityCheckBox = new JCheckBox( "Priority" ); + private JCheckBox mRecordCheckBox = new JCheckBox("Record"); + private JComboBox mRecordActionCombo = new JComboBox<>(Record.values()); + private JCheckBox mPriorityCheckBox = new JCheckBox("Priority"); private JSlider mPrioritySlider; private JLabel mPrioritySliderLabel; - - private AliasModel mAliasModel; - private SettingsManager mSettingsManager; - - public MultipleAliasEditor( AliasModel aliasModel, SettingsManager settingsManager ) - { - mAliasModel = aliasModel; - mAliasModel.addListener( this ); - - mSettingsManager = settingsManager; - - init(); - } - - public List getAliases() - { - if( hasItem() ) - { - return (List)getItem(); - } - - return null; - } - - public void init() - { - setLayout( new MigLayout( "fill,wrap 2", "[grow,fill][grow,fill]", - "[][][][][][][][][][][][][grow,fill]" ) ); - - mAliasCountLabel = new JLabel( "Alias:" ); - add( mAliasCountLabel ); - - mAliasCount = new JLabel( "Multiple" ); - add( mAliasCount, "span" ); - - add( new JSeparator(), "span" ); - - add( new JLabel( HELP_TEXT ), "span" ); - - add( mListCheckBox ); + private JCheckBox mStreamCheckBox = new JCheckBox("Stream"); + private JComboBox mStreamCombo; - mListCombo.setEditable( true ); - add( mListCombo, "wrap" ); + private AliasModel mAliasModel; + private BroadcastModel mBroadcastModel; + private SettingsManager mSettingsManager; - add( mGroupCheckBox ); + public MultipleAliasEditor(AliasModel aliasModel, BroadcastModel broadcastModel, SettingsManager settingsManager) + { + mAliasModel = aliasModel; + mBroadcastModel = broadcastModel; + mSettingsManager = settingsManager; - mGroupCombo.setEditable( true ); - add( mGroupCombo, "wrap" ); + mAliasModel.addListener(this); - add( mColorCheckBox ); + init(); + } - mButtonColor = new JButton( "Select ..." ); - mButtonColor.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) + public List getAliases() + { + if (hasItem()) + { + return (List) getItem(); + } + + return null; + } + + public void init() + { + setLayout(new MigLayout("fill,wrap 2", "[grow,fill][grow,fill]", + "[][][][][][][][][][][][][][grow,fill]")); + + mAliasCountLabel = new JLabel("Alias:"); + add(mAliasCountLabel); + + mAliasCount = new JLabel("Multiple"); + add(mAliasCount, "span"); + + add(new JSeparator(), "span"); + + add(new JLabel(HELP_TEXT), "span"); + + add(mListCheckBox); + + mListCombo.setEditable(true); + add(mListCombo, "wrap"); + + add(mGroupCheckBox); + + mGroupCombo.setEditable(true); + add(mGroupCombo, "wrap"); + + add(mColorCheckBox); + + mButtonColor = new JButton("Select ..."); + mButtonColor.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) { - Color newColor = JColorChooser.showDialog( - MultipleAliasEditor.this, - "Choose color for this alias", null ); + Color newColor = JColorChooser.showDialog( + MultipleAliasEditor.this, + "Choose color for this alias", null); - if( newColor != null ) - { - mButtonColor.setForeground( newColor ); - mButtonColor.setBackground( newColor ); - } + if (newColor != null) + { + mButtonColor.setForeground(newColor); + mButtonColor.setBackground(newColor); + } } - } ); - add( mButtonColor, "wrap" ); - - add( mIconCheckBox ); - - mMapIconCombo = new JComboBox( mSettingsManager.getMapIcons() ); + }); + add(mButtonColor, "wrap"); - MapIconListCellRenderer renderer = new MapIconListCellRenderer(); - renderer.setPreferredSize( new Dimension( 200, 30 ) ); - mMapIconCombo.setRenderer( renderer ); - add( mMapIconCombo, "wrap" ); + add(mIconCheckBox); - //Dummy place holder - add( new JLabel() ); - - mBtnIconManager = new JButton( "Icon Manager" ); - mBtnIconManager.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent arg0 ) + mMapIconCombo = new JComboBox(mSettingsManager.getMapIcons()); + + MapIconListCellRenderer renderer = new MapIconListCellRenderer(); + renderer.setPreferredSize(new Dimension(200, 30)); + mMapIconCombo.setRenderer(renderer); + add(mMapIconCombo, "wrap"); + + //Dummy place holder + add(new JLabel()); + + mBtnIconManager = new JButton("Icon Manager"); + mBtnIconManager.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent arg0) { - final IconManager iconManager = - new IconManager( mSettingsManager, MultipleAliasEditor.this ); - - EventQueue.invokeLater( new Runnable() - { - @Override - public void run() - { - iconManager.setVisible( true ); - } - } ); + final IconManager iconManager = + new IconManager(mSettingsManager, MultipleAliasEditor.this); + + EventQueue.invokeLater(new Runnable() + { + @Override + public void run() + { + iconManager.setVisible(true); + } + }); } - } ); - - add( mBtnIconManager, "span,wrap" ); - - add( mRecordCheckBox ); - add( mRecordActionCombo ); + }); - //Placeholder - add( new JLabel( " " ) ); - mPrioritySliderLabel = new JLabel( "Priority: " + Priority.MIN_PRIORITY ); - add( mPrioritySliderLabel ); - - add( mPriorityCheckBox ); - - mPrioritySlider = new JSlider( JSlider.HORIZONTAL, - Priority.MIN_PRIORITY, - Priority.MAX_PRIORITY + 1, - Priority.MIN_PRIORITY ); + add(mBtnIconManager, "span,wrap"); - mPrioritySlider.setMajorTickSpacing( 20 ); - mPrioritySlider.setMinorTickSpacing( 5 ); - mPrioritySlider.setPaintTicks( true ); - mPrioritySlider.setLabelTable( mPrioritySlider.createStandardLabels( 25, 25 ) ); - mPrioritySlider.setPaintLabels( true ); - mPrioritySlider.addChangeListener( new ChangeListener() - { - @Override - public void stateChanged( ChangeEvent e ) - { - int priority = mPrioritySlider.getValue(); + add(mRecordCheckBox); + add(mRecordActionCombo); - if( priority > Priority.MAX_PRIORITY ) - { - mPrioritySliderLabel.setText( "Priority: Do Not Monitor" ); - } - else - { - mPrioritySliderLabel.setText( "Priority: " + mPrioritySlider.getValue() ); - } - } - } ); - mPrioritySlider.setToolTipText( HELP_TEXT ); - - add( mPrioritySlider ); - - JButton saveButton = new JButton( "Save" ); - saveButton.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) - { - save(); - } - } ); - add( saveButton ); + //Placeholder + add(new JLabel(" ")); + mPrioritySliderLabel = new JLabel("Audio Priority: " + Priority.MIN_PRIORITY); + add(mPrioritySliderLabel); - JButton resetButton = new JButton( "Reset" ); - resetButton.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent e ) - { - reset(); - } - } ); - add( resetButton ); + add(mPriorityCheckBox); - setModified( false ); - } - - @Override - public void setItem( List item ) - { - super.setItem( item ); + mPrioritySlider = new JSlider(JSlider.HORIZONTAL, + Priority.MIN_PRIORITY, + Priority.MAX_PRIORITY + 1, + Priority.MIN_PRIORITY); - StringBuilder sb = new StringBuilder(); - sb.append( "Multiple Selected [ " ); - - if( hasItem() ) - { - sb.append( String.valueOf( getAliases().size() ) ); - } - else - { - sb.append( "0" ); - } + mPrioritySlider.setMajorTickSpacing(20); + mPrioritySlider.setMinorTickSpacing(5); + mPrioritySlider.setPaintTicks(true); + mPrioritySlider.setLabelTable(mPrioritySlider.createStandardLabels(25, 25)); + mPrioritySlider.setPaintLabels(true); + mPrioritySlider.addChangeListener(new ChangeListener() + { + @Override + public void stateChanged(ChangeEvent e) + { + int priority = mPrioritySlider.getValue(); - sb.append( " ]" ); - - mAliasCount.setText( sb.toString() ); + if (priority > Priority.MAX_PRIORITY) + { + mPrioritySliderLabel.setText("Audio Priority: Do Not Monitor"); + } + else + { + mPrioritySliderLabel.setText("Audio Priority: " + mPrioritySlider.getValue()); + } + } + }); + mPrioritySlider.setToolTipText(HELP_TEXT); - List listNames = mAliasModel.getListNames(); - listNames.add( 0, "" ); - mListCombo.setModel( new DefaultComboBoxModel( - listNames.toArray( new String[ listNames.size() ] ) ) ); - - List groupNames = mAliasModel.getGroupNames(); - groupNames.add( 0, "" ); - mGroupCombo.setModel( new DefaultComboBoxModel( - groupNames.toArray( new String[ groupNames.size() ] ) ) ); + add(mPrioritySlider); - setCheckBoxesUnselected(); - - setModified( false ); - } - - private void setCheckBoxesUnselected() - { - mListCheckBox.setSelected( false ); - mGroupCheckBox.setSelected( false ); - mColorCheckBox.setSelected( false ); - mIconCheckBox.setSelected( false ); - mRecordCheckBox.setSelected( false ); - mPriorityCheckBox.setSelected( false ); - } + add(mStreamCheckBox); - @Override - public void reset() - { - List listNames = mAliasModel.getListNames(); - listNames.add( 0, "" ); - mListCombo.setModel( new DefaultComboBoxModel( - listNames.toArray( new String[ listNames.size() ] ) ) ); - - List groupNames = mAliasModel.getGroupNames(); - groupNames.add( 0, "" ); - mGroupCombo.setModel( new DefaultComboBoxModel( - groupNames.toArray( new String[ groupNames.size() ] ) ) ); - - mButtonColor.setForeground( getForeground() ); - mButtonColor.setBackground( getBackground() ); - - super.reset(); - } - - private boolean hasRequestedChanges() - { - return mListCheckBox.isSelected() || - mGroupCheckBox.isSelected() || - mColorCheckBox.isSelected() || - mIconCheckBox.isSelected() || - mRecordCheckBox.isSelected() || - mPriorityCheckBox.isSelected(); - } + mStreamCombo = new JComboBox(); + mStreamCombo.setEditable(true); + add(mStreamCombo, "wrap"); - @Override - public void save() - { - if( hasRequestedChanges() && hasItem() ) - { - List aliases = getAliases(); - - for( Alias alias: aliases ) - { - if( mListCheckBox.isSelected() ) - { - String list = null; - - if( mListCombo.getSelectedItem() != null ) - { - list = (String)mListCombo.getSelectedItem(); - } - - alias.setList( list ); - } - - if( mGroupCheckBox.isSelected() ) - { - String group = null; - - if( mGroupCombo.getSelectedItem() != null ) - { - group = (String)mGroupCombo.getSelectedItem(); - } - - alias.setGroup( group ); - } - - if( mColorCheckBox.isSelected() ) - { - alias.setColor( mButtonColor.getForeground().getRGB() ); - } - - if( mIconCheckBox.isSelected() ) - { - if( mMapIconCombo.getSelectedItem() != null ) - { - alias.setIconName( ((MapIcon)mMapIconCombo.getSelectedItem()).getName() ); - } - else - { - alias.setIconName( null ); - } - } - - if( mRecordCheckBox.isSelected() ) - { - Record action = - (Record)mRecordActionCombo.getSelectedItem(); + JButton saveButton = new JButton("Save"); + saveButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + save(); + } + }); + add(saveButton); - alias.setRecordable( action == Record.RECORDABLE ); - } - - if( mPriorityCheckBox.isSelected() ) - { - int priority = mPrioritySlider.getValue(); + JButton resetButton = new JButton("Reset"); + resetButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + reset(); + } + }); + add(resetButton); - //This is a work-around -- we use max priority + 1 in the - //gui to indicate do not monitor, and change the value here - if( priority == Priority.MAX_PRIORITY + 1 ) - { - priority = Priority.DO_NOT_MONITOR; - } - - alias.setCallPriority( priority ); - } - } + setModified(false); + } - for( Alias alias: aliases ) - { - //Broadcast an alias change event to save the updates - mAliasModel.broadcast( new AliasEvent( alias, Event.CHANGE ) ); - } - } - } + @Override + public void setItem(List item) + { + super.setItem(item); - @Override - public void receive( AliasEvent event ) - { - if( event.getEvent() == Event.DELETE && - hasItem() && - getAliases().contains( event.getAlias() ) ) - { - getAliases().remove( event.getAlias() ); - setItem( getAliases() ); - } - } - - public enum Record - { - NON_RECORDABLE( "Non-Recordable" ), - RECORDABLE( "Recordable" ); - - private String mLabel; - - private Record( String label ) - { - mLabel = label; - } - - public String toString() - { - return mLabel; - } - } + StringBuilder sb = new StringBuilder(); + sb.append("Multiple Selected [ "); + + if (hasItem()) + { + sb.append(String.valueOf(getAliases().size())); + } + else + { + sb.append("0"); + } + + sb.append(" ]"); + + mAliasCount.setText(sb.toString()); + + List listNames = mAliasModel.getListNames(); + listNames.add(0, ""); + mListCombo.setModel(new DefaultComboBoxModel( + listNames.toArray(new String[listNames.size()]))); + + List groupNames = mAliasModel.getGroupNames(); + groupNames.add(0, ""); + mGroupCombo.setModel(new DefaultComboBoxModel( + groupNames.toArray(new String[groupNames.size()]))); + + List streamNames = mBroadcastModel.getBroadcastConfigurationNames(); + mStreamCombo.setModel(new DefaultComboBoxModel(streamNames.toArray(new String[streamNames.size()]))); + + setCheckBoxesUnselected(); + + setModified(false); + } + + private void setCheckBoxesUnselected() + { + mListCheckBox.setSelected(false); + mGroupCheckBox.setSelected(false); + mColorCheckBox.setSelected(false); + mIconCheckBox.setSelected(false); + mRecordCheckBox.setSelected(false); + mPriorityCheckBox.setSelected(false); + mStreamCheckBox.setSelected(false); + } + + @Override + public void reset() + { + List listNames = mAliasModel.getListNames(); + listNames.add(0, ""); + mListCombo.setModel(new DefaultComboBoxModel( + listNames.toArray(new String[listNames.size()]))); + + List groupNames = mAliasModel.getGroupNames(); + groupNames.add(0, ""); + mGroupCombo.setModel(new DefaultComboBoxModel( + groupNames.toArray(new String[groupNames.size()]))); + + mButtonColor.setForeground(getForeground()); + mButtonColor.setBackground(getBackground()); + + super.reset(); + } + + private boolean hasRequestedChanges() + { + return mListCheckBox.isSelected() || + mGroupCheckBox.isSelected() || + mColorCheckBox.isSelected() || + mIconCheckBox.isSelected() || + mRecordCheckBox.isSelected() || + mPriorityCheckBox.isSelected() || + mStreamCheckBox.isSelected(); + } + + @Override + public void save() + { + if (hasRequestedChanges() && hasItem()) + { + List aliases = getAliases(); + + for (Alias alias : aliases) + { + if (mListCheckBox.isSelected()) + { + String list = null; + + if (mListCombo.getSelectedItem() != null) + { + list = (String) mListCombo.getSelectedItem(); + } + + alias.setList(list); + } + + if (mGroupCheckBox.isSelected()) + { + String group = null; + + if (mGroupCombo.getSelectedItem() != null) + { + group = (String) mGroupCombo.getSelectedItem(); + } + + alias.setGroup(group); + } + + if (mColorCheckBox.isSelected()) + { + alias.setColor(mButtonColor.getForeground().getRGB()); + } + + if (mIconCheckBox.isSelected()) + { + if (mMapIconCombo.getSelectedItem() != null) + { + alias.setIconName(((MapIcon) mMapIconCombo.getSelectedItem()).getName()); + } + else + { + alias.setIconName(null); + } + } + + if (mRecordCheckBox.isSelected()) + { + Record action = + (Record) mRecordActionCombo.getSelectedItem(); + + alias.setRecordable(action == Record.RECORDABLE); + } + + if (mPriorityCheckBox.isSelected()) + { + int priority = mPrioritySlider.getValue(); + + //This is a work-around -- we use max priority + 1 in the + //gui to indicate do not monitor, and change the value here + if (priority == Priority.MAX_PRIORITY + 1) + { + priority = Priority.DO_NOT_MONITOR; + } + + alias.setCallPriority(priority); + } + + if(mStreamCheckBox.isSelected()) + { + if(mStreamCombo.getSelectedItem() != null) + { + String streamName = (String)mStreamCombo.getSelectedItem(); + + boolean hasChannel = false; + + //Ensure the alias doesn't already have the channel + for(BroadcastChannel channel: alias.getBroadcastChannels()) + { + if(channel.getChannelName().equals(streamName)) + { + hasChannel = true; + continue; + } + } + + if(!hasChannel) + { + BroadcastChannel channel = (BroadcastChannel)AliasFactory + .getAliasID(AliasIDType.BROADCAST_CHANNEL); + + channel.setChannelName(streamName); + alias.addAliasID(channel); + } + } + } + } + + for (Alias alias : aliases) + { + //Broadcast an alias change event to save the updates + mAliasModel.broadcast(new AliasEvent(alias, Event.CHANGE)); + } + } + } + + @Override + public void receive(AliasEvent event) + { + if (event.getEvent() == Event.DELETE && + hasItem() && + getAliases().contains(event.getAlias())) + { + getAliases().remove(event.getAlias()); + setItem(getAliases()); + } + } + + public enum Record + { + NON_RECORDABLE("Non-Recordable"), + RECORDABLE("Recordable"); + + private String mLabel; + + private Record(String label) + { + mLabel = label; + } + + public String toString() + { + return mLabel; + } + } } diff --git a/src/alias/id/AliasID.java b/src/alias/id/AliasID.java index b310330c..25eff7d5 100644 --- a/src/alias/id/AliasID.java +++ b/src/alias/id/AliasID.java @@ -21,6 +21,7 @@ import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlSeeAlso; import javax.xml.bind.annotation.XmlTransient; +import alias.id.broadcast.BroadcastChannel; import playlist.version1.Group; import alias.id.esn.Esn; import alias.id.fleetsync.FleetsyncID; @@ -36,7 +37,7 @@ import alias.id.talkgroup.TalkgroupID; import alias.id.uniqueID.UniqueID; @SuppressWarnings( "deprecation" ) -@XmlSeeAlso( { Esn.class, FleetsyncID.class, Group.class, LoJackFunctionAndID.class, +@XmlSeeAlso( {BroadcastChannel.class, Esn.class, FleetsyncID.class, Group.class, LoJackFunctionAndID.class, MDC1200ID.class, Min.class, MPT1327ID.class, NonRecordable.class, Priority.class, SiteID.class, StatusID.class, TalkgroupID.class, UniqueID.class } ) diff --git a/src/alias/id/AliasIDType.java b/src/alias/id/AliasIDType.java index 7417b66a..6a71ff4f 100644 --- a/src/alias/id/AliasIDType.java +++ b/src/alias/id/AliasIDType.java @@ -20,20 +20,20 @@ package alias.id; public enum AliasIDType { ESN( "ESN" ), - Fleetsync( "Fleetsync" ), - LoJack( "LoJack" ), - LTRNetUID( "LTR-Net UID" ), + FLEETSYNC( "Fleetsync" ), + LOJACK( "LoJack" ), + LTR_NET_UID( "LTR-Net UID" ), MDC1200( "MDC-1200" ), MIN( "Passport MIN" ), MPT1327( "MPT-1327" ), - Site( "Site" ), - Status( "Status" ), - Talkgroup( "Talkgroup" ), + SITE( "Site" ), + STATUS( "Status" ), + TALKGROUP( "Talkgroup" ), - Invert( "Audio Inversion" ), - Priority( "Audio Priority" ), - NonRecordable( "Audio Non-Recordable" ), - Streaming( "Audio Streaming" ); + INVERT( "Audio Inversion" ), + PRIORITY( "Audio Priority" ), + NON_RECORDABLE( "Audio Non-Recordable" ), + BROADCAST_CHANNEL( "Audio Broadcast Channel" ); private String mLabel; diff --git a/src/alias/id/AliasIdentifierEditor.java b/src/alias/id/AliasIdentifierEditor.java index 935d59be..0c20517d 100644 --- a/src/alias/id/AliasIdentifierEditor.java +++ b/src/alias/id/AliasIdentifierEditor.java @@ -17,6 +17,7 @@ ******************************************************************************/ package alias.id; +import audio.broadcast.BroadcastModel; import gui.editor.Editor; import gui.editor.EmptyEditor; @@ -61,10 +62,13 @@ public class AliasIdentifierEditor extends Editor private EditorContainer mEditorContainer = new EditorContainer(); private AliasModel mAliasModel; + private BroadcastModel mBroadcastModel; - public AliasIdentifierEditor( AliasModel model ) + public AliasIdentifierEditor( AliasModel aliasModel, BroadcastModel broadcastModel ) { - mAliasModel = model; + mAliasModel = aliasModel; + mBroadcastModel = broadcastModel; + init(); } @@ -120,45 +124,35 @@ public class AliasIdentifierEditor extends Editor JPopupMenu menu = new JPopupMenu(); menu.add( new AddAliasIdentifierItem( AliasIDType.ESN ) ); - menu.add( new AddAliasIdentifierItem( AliasIDType.Fleetsync ) ); - menu.add( new AddAliasIdentifierItem( AliasIDType.LoJack ) ); - menu.add( new AddAliasIdentifierItem( AliasIDType.LTRNetUID ) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.FLEETSYNC) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.LOJACK) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.LTR_NET_UID) ); menu.add( new AddAliasIdentifierItem( AliasIDType.MDC1200 ) ); menu.add( new AddAliasIdentifierItem( AliasIDType.MPT1327 ) ); menu.add( new AddAliasIdentifierItem( AliasIDType.MIN ) ); - menu.add( new AddAliasIdentifierItem( AliasIDType.Site ) ); - menu.add( new AddAliasIdentifierItem( AliasIDType.Status ) ); - menu.add( new AddAliasIdentifierItem( AliasIDType.Talkgroup ) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.SITE) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.STATUS) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.TALKGROUP) ); + + menu.addSeparator(); + + menu.add( new AddAliasIdentifierItem( AliasIDType.BROADCAST_CHANNEL) ); - boolean separatorAdded = false; - if( hasItem() ) { Alias alias = getItem(); - - if( !alias.hasCallPriority() ) - { - if( !separatorAdded ) - { - menu.addSeparator(); - separatorAdded = true; - } - - menu.add( new AddAliasIdentifierItem( AliasIDType.Priority ) ); - } - + if( alias.isRecordable() ) { - if( !separatorAdded ) - { - menu.addSeparator(); - separatorAdded = true; - } - - menu.add( new AddAliasIdentifierItem( AliasIDType.NonRecordable ) ); + menu.add( new AddAliasIdentifierItem( AliasIDType.NON_RECORDABLE) ); + } + + if( !alias.hasCallPriority() ) + { + menu.add( new AddAliasIdentifierItem( AliasIDType.PRIORITY) ); } } - + menu.show( e.getComponent(), e.getX(), e.getY() ); } } ); @@ -327,7 +321,7 @@ public class AliasIdentifierEditor extends Editor removeAll(); //This will always give us an editor - mEditor = AliasFactory.getEditor( aliasID ); + mEditor = AliasFactory.getEditor( aliasID, mBroadcastModel ); add( mEditor ); diff --git a/src/alias/id/broadcast/BroadcastChannel.java b/src/alias/id/broadcast/BroadcastChannel.java new file mode 100644 index 00000000..f0baf630 --- /dev/null +++ b/src/alias/id/broadcast/BroadcastChannel.java @@ -0,0 +1,107 @@ +/******************************************************************************* + * 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 alias.id.broadcast; + +import alias.id.AliasID; +import alias.id.AliasIDType; + +import javax.xml.bind.annotation.XmlAttribute; + +public class BroadcastChannel extends AliasID implements Comparable +{ + private String mChannelName; + + public BroadcastChannel() + { + //JAXB Constructor + } + + @Override + public int compareTo(BroadcastChannel other) + { + if(mChannelName != null && other.getChannelName() != null) + { + return mChannelName.compareTo(other.getChannelName()); + } + else if(mChannelName != null) + { + return -1; + } + else + { + return 1; + } + } + + /** + * Creates a named broadcast channel + */ + public BroadcastChannel(String channelName) + { + mChannelName = channelName; + } + + /** + * Name of the broadcastAudio channel configuration + */ + @XmlAttribute(name="channel") + public String getChannelName() + { + return mChannelName; + } + + /** + * Sets the name of the broadcastAudio channel configuration + */ + public void setChannelName(String channel) + { + mChannelName = channel; + } + + @Override + public AliasIDType getType() + { + return AliasIDType.BROADCAST_CHANNEL; + } + + @Override + public boolean isValid() + { + return mChannelName != null; + } + + @Override + public boolean matches( AliasID id ) + { + return false; + } + + @Override + public String toString() + { + if(isValid()) + { + return "Broadcast Channel: " + mChannelName; + } + else + { + return "Broadcast Channel: None Selected"; + } + } +} diff --git a/src/alias/id/broadcast/BroadcastChannelEditor.java b/src/alias/id/broadcast/BroadcastChannelEditor.java new file mode 100644 index 00000000..f8d07810 --- /dev/null +++ b/src/alias/id/broadcast/BroadcastChannelEditor.java @@ -0,0 +1,129 @@ +/******************************************************************************* + * 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 alias.id.broadcast; + +import alias.id.AliasID; +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastModel; +import gui.editor.DocumentListenerEditor; +import net.miginfocom.swing.MigLayout; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; + +public class BroadcastChannelEditor extends DocumentListenerEditor +{ + private static final long serialVersionUID = 1L; + + private static final String HELP_TEXT = "" + + "

Audio Broadcast Channel

" + + "Name of a audio streaming broadcast configuration
" + + "to be used for streaming audio to an external
" + + "streaming server"; + + private JComboBox mBroadcastConfigurations; + private BroadcastModel mBroadcastModel; + + public BroadcastChannelEditor(AliasID aliasID, BroadcastModel broadcastModel) + { + mBroadcastModel = broadcastModel; + + initGUI(); + setItem( aliasID ); + } + + private void initGUI() + { + setLayout( new MigLayout( "fill,wrap 1", "[]", "[][]" ) ); + + add( new JLabel( "Broadcast Channel" ), "grow" ); + + List channelNames = mBroadcastModel.getBroadcastConfigurationNames(); + + mBroadcastConfigurations = new JComboBox(channelNames.toArray(new String[channelNames.size()])); + mBroadcastConfigurations.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + setModified(true); + } + }); + + add(mBroadcastConfigurations, "grow"); + + JLabel help = new JLabel( "Help ..." ); + help.setForeground( Color.BLUE.brighter() ); + help.setCursor( new Cursor( Cursor.HAND_CURSOR ) ); + help.addMouseListener( new MouseAdapter() + { + @Override + public void mouseClicked( MouseEvent e ) + { + JOptionPane.showMessageDialog( BroadcastChannelEditor.this, + HELP_TEXT, "Help", JOptionPane.INFORMATION_MESSAGE ); + } + } ); + + add( help, "align left" ); + } + + @Override + public void setItem(AliasID item) + { + super.setItem(item); + + if(item instanceof BroadcastChannel) + { + BroadcastChannel channel = (BroadcastChannel)item; + + if(channel.isValid()) + { + mBroadcastConfigurations.setSelectedItem(channel.getChannelName()); + } + } + + setModified(false); + } + + @Override + public void save() + { + String channel = (String)mBroadcastConfigurations.getSelectedItem(); + + BroadcastConfiguration selectedConfiguration = mBroadcastModel.getBroadcastConfiguration(channel); + + + if(selectedConfiguration != null) + { + ((BroadcastChannel)getItem()).setChannelName(selectedConfiguration.getName()); + } + else + { + ((BroadcastChannel)getItem()).setChannelName(null); + } + } + + +} diff --git a/src/alias/id/fleetsync/FleetsyncID.java b/src/alias/id/fleetsync/FleetsyncID.java index bf402f1b..22b00872 100644 --- a/src/alias/id/fleetsync/FleetsyncID.java +++ b/src/alias/id/fleetsync/FleetsyncID.java @@ -74,6 +74,6 @@ public class FleetsyncID extends AliasID @Override public AliasIDType getType() { - return AliasIDType.Fleetsync; + return AliasIDType.FLEETSYNC; } } diff --git a/src/alias/id/lojack/LoJackFunctionAndID.java b/src/alias/id/lojack/LoJackFunctionAndID.java index 038c1444..f2cc8c80 100644 --- a/src/alias/id/lojack/LoJackFunctionAndID.java +++ b/src/alias/id/lojack/LoJackFunctionAndID.java @@ -101,7 +101,7 @@ public class LoJackFunctionAndID extends AliasID @Override public AliasIDType getType() { - return AliasIDType.LoJack; + return AliasIDType.LOJACK; } @Override diff --git a/src/alias/id/nonrecordable/NonRecordable.java b/src/alias/id/nonrecordable/NonRecordable.java index a72d8e12..c881c360 100644 --- a/src/alias/id/nonrecordable/NonRecordable.java +++ b/src/alias/id/nonrecordable/NonRecordable.java @@ -8,7 +8,7 @@ public class NonRecordable extends AliasID @Override public AliasIDType getType() { - return AliasIDType.NonRecordable; + return AliasIDType.NON_RECORDABLE; } @Override diff --git a/src/alias/id/priority/Priority.java b/src/alias/id/priority/Priority.java index caef53b5..9243db45 100644 --- a/src/alias/id/priority/Priority.java +++ b/src/alias/id/priority/Priority.java @@ -78,6 +78,6 @@ public class Priority extends AliasID @Override public AliasIDType getType() { - return AliasIDType.Priority; + return AliasIDType.PRIORITY; } } diff --git a/src/alias/id/siteID/SiteID.java b/src/alias/id/siteID/SiteID.java index 04d6d20c..a16008e1 100644 --- a/src/alias/id/siteID/SiteID.java +++ b/src/alias/id/siteID/SiteID.java @@ -63,6 +63,6 @@ public class SiteID extends AliasID @Override public AliasIDType getType() { - return AliasIDType.Site; + return AliasIDType.SITE; } } diff --git a/src/alias/id/status/StatusID.java b/src/alias/id/status/StatusID.java index ebc8f133..71ccb5d8 100644 --- a/src/alias/id/status/StatusID.java +++ b/src/alias/id/status/StatusID.java @@ -70,6 +70,6 @@ public class StatusID extends AliasID @Override public AliasIDType getType() { - return AliasIDType.Status; + return AliasIDType.STATUS; } } diff --git a/src/alias/id/talkgroup/TalkgroupID.java b/src/alias/id/talkgroup/TalkgroupID.java index 78cae9fd..fe7b3ec3 100644 --- a/src/alias/id/talkgroup/TalkgroupID.java +++ b/src/alias/id/talkgroup/TalkgroupID.java @@ -78,6 +78,6 @@ public class TalkgroupID extends AliasID @Override public AliasIDType getType() { - return AliasIDType.Talkgroup; + return AliasIDType.TALKGROUP; } } diff --git a/src/alias/id/uniqueID/UniqueID.java b/src/alias/id/uniqueID/UniqueID.java index 29e074c6..69302b38 100644 --- a/src/alias/id/uniqueID/UniqueID.java +++ b/src/alias/id/uniqueID/UniqueID.java @@ -70,6 +70,6 @@ public class UniqueID extends AliasID @Override public AliasIDType getType() { - return AliasIDType.LTRNetUID; + return AliasIDType.LTR_NET_UID; } } diff --git a/src/audio/AudioUtils.java b/src/audio/AudioUtils.java new file mode 100644 index 00000000..fac396f9 --- /dev/null +++ b/src/audio/AudioUtils.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * 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; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import record.wave.RealBufferWaveRecorder; +import sample.ConversionUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; + +public class AudioUtils +{ + private final static Logger mLog = LoggerFactory.getLogger(AudioUtils.class); + + /** + * Converts the audio packets into a byte array of 16-bit, little-endian audio samples + */ + public static byte[] convertTo16BitSamples(List audioPackets) + { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + try + { + for(AudioPacket packet: audioPackets) + { + //Converting from 32-bit floats to signed 16-bit samples + ByteBuffer buffer = ConversionUtils.convertToSigned16BitSamples(packet.getAudioBuffer().getSamples()); + stream.write(buffer.array()); + } + } + catch(IOException e) + { + mLog.error("Error writing converted PCM bytes to output stream"); + } + + return stream.toByteArray(); + } +} diff --git a/src/audio/broadcast/AudioBroadcaster.java b/src/audio/broadcast/AudioBroadcaster.java new file mode 100644 index 00000000..7467a2b6 --- /dev/null +++ b/src/audio/broadcast/AudioBroadcaster.java @@ -0,0 +1,462 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import audio.AudioPacket; +import audio.IAudioPacketListener; +import audio.convert.ISilenceGenerator; +import audio.metadata.AudioMetadata; +import controller.ThreadPoolManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import properties.SystemProperties; +import sample.Listener; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedTransferQueue; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public abstract class AudioBroadcaster implements Listener +{ + private final static Logger mLog = LoggerFactory.getLogger( AudioBroadcaster.class ); + + public static final int PROCESSOR_RUN_INTERVAL_MS = 1000; + + private ThreadPoolManager mThreadPoolManager; + private ScheduledFuture mScheduledTask; + + private RecordingQueueProcessor mRecordingQueueProcessor = new RecordingQueueProcessor(); + private List mAudioRecordingQueue = new CopyOnWriteArrayList<>(); + + private ISilenceGenerator mSilenceGenerator; + + private Listener mBroadcastEventListener; + private BroadcastState mBroadcastState = BroadcastState.READY; + + private int mStreamedAudioCount = 0; + private BroadcastConfiguration mBroadcastConfiguration; + private long mDelay; + private AtomicBoolean mStreaming = new AtomicBoolean(); + + /** + * AudioBroadcaster for streaming audio recordings to a remote streaming audio server. Audio recordings are + * generated by an internal StreamManager that converts an inbound stream of AudioPackets into a recording of the + * desired audio format (e.g. MP3) and nominates the recording to an internal recording queue for streaming. The + * broadcaster supports receiving audio packets from multiple audio sources. Each audio packet's internal audio + * metadata source string is used to reassemble each packet stream. Recordings are capped at 30 seconds length. + * If a source audio packet stream exceeds 30 seconds in length, it will be chunked into 30 second recordings. + * + * This broadcaster supports a time delay setting for delaying broadcast of audio recordings. The delay setting is + * defined in the broadcast configuration. When this delay is greater than zero, the recording will remain in the + * audio broadcaster queue until the recording start time + delay elapses. Audio recordings are processed in a FIFO + * manner. + * + * Use the start() and stop() methods to connect to/disconnect from the remote server. Audio recordings will be + * streamed to the remote server when available. One second silence frames will be broadcast to the server when + * there are no recordings available, in order to maintain a connection with the remote server. Any audio packet + * streams received while the broadcaster is stopped will be ignored. + * + * The last audio packet's metadata is automatically attached to the closed audio recording when it is enqueued for + * broadcast. That metadata will be updated on the remote server once the audio recording is opened for streaming. + */ + public AudioBroadcaster(ThreadPoolManager threadPoolManager, BroadcastConfiguration broadcastConfiguration) + { + mThreadPoolManager = threadPoolManager; + mBroadcastConfiguration = broadcastConfiguration; + mDelay = mBroadcastConfiguration.getDelay(); + mSilenceGenerator = BroadcastFactory.getSilenceGenerator(broadcastConfiguration.getBroadcastFormat()); + } + + protected ThreadPoolManager getThreadPoolManager() + { + return mThreadPoolManager; + } + + /** + * Broadcast binary audio data frames or sequences. + */ + protected abstract void broadcastAudio(byte[] audio); + + /** + * Protocol-specific metadata updater + */ + protected abstract IBroadcastMetadataUpdater getMetadataUpdater(); + + /** + * Broadcasts the next song's audio metadata prior to streaming the next song. + * @param metadata for the next recording that will be streamed + */ + protected void broadcastMetadata(AudioMetadata metadata) + { + IBroadcastMetadataUpdater metadataUpdater = getMetadataUpdater(); + + if(metadataUpdater != null) + { + metadataUpdater.update(metadata); + } + } + + /** + * Disconnects the broadcaster from the remote server for a reset or final stop. + */ + protected abstract void disconnect(); + + /** + * Connects to the remote server specified by the broadcast configuration and starts audio streaming. + */ + public void start() + { + if(mStreaming.compareAndSet(false, true)) + { + if(mScheduledTask == null) + { + if(mThreadPoolManager != null) + { + mScheduledTask = mThreadPoolManager.scheduleFixedRate(ThreadPoolManager.ThreadType.AUDIO_PROCESSING, + mRecordingQueueProcessor, PROCESSOR_RUN_INTERVAL_MS, TimeUnit.MILLISECONDS ); + } + } + } + } + + /** + * Disconnects from the remote server. + */ + public void stop() + { + if(mStreaming.compareAndSet(true, false)) + { + if(mThreadPoolManager != null && mScheduledTask != null) + { + mThreadPoolManager.cancel(mScheduledTask); + } + + disconnect(); + } + } + + /** + * Stream name for the broadcast configuration for this broadcaster + * @return stream name or null + */ + public String getStreamName() + { + BroadcastConfiguration config = getBroadcastConfiguration(); + + if(config != null) + { + return config.getName(); + } + + return null; + } + + /** + * Size of recording queue for recordings awaiting streaming + */ + public int getQueueSize() + { + return mAudioRecordingQueue.size(); + } + + /** + * Number of audio recordings streamed to remote server + */ + public int getStreamedAudioCount() + { + return mStreamedAudioCount; + } + + /** + * Primary insert method for the stream manager to nominate completed audio recordings for broadcast. + * + * @param recording to queue for broadcasting + */ + public void receive(AudioRecording recording) + { + if(connected()) + { + mAudioRecordingQueue.add(recording); + broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE)); + } + } + + /** + * Broadcast configuration used by this broadcaster + */ + public BroadcastConfiguration getBroadcastConfiguration() + { + return mBroadcastConfiguration; + } + + /** + * Registers the listener to receive broadcast events/state changes + */ + public void setListener(Listener listener) + { + mBroadcastEventListener = listener; + } + + /** + * Removes the listener from receiving broadcast events/state changes + */ + public void removeListener() + { + mBroadcastEventListener = null; + } + + /** + * Broadcasts the event to any registered listener + */ + public void broadcast(BroadcastEvent event) + { + if(mBroadcastEventListener != null) + { + mBroadcastEventListener.receive(event); + } + } + + /** + * Sets the state of the broadcastAudio connection + */ + protected void setBroadcastState(BroadcastState state) + { + if(mBroadcastState != state) + { + mLog.info("[" + getStreamName() + "] status: " + state); + mBroadcastState = state; + + broadcast(new BroadcastEvent(this, BroadcastEvent.Event.BROADCASTER_STATE_CHANGE)); + + if(mBroadcastState.isErrorState()) + { + stop(); + } + + if(!connected()) + { + //Remove all pending audio recordings + while(!mAudioRecordingQueue.isEmpty()) + { + AudioRecording recording = mAudioRecordingQueue.get(0); + recording.removePendingReplay(); + } + } + } + } + + /** + * Current state of the broadcastAudio connection + */ + public BroadcastState getBroadcastState() + { + return mBroadcastState; + } + + /** + * Indicates if the broadcaster is currently connected to the remote server + */ + protected boolean connected() + { + return getBroadcastState() == BroadcastState.CONNECTED; + } + + /** + * Indicates if this broadcaster can connect and is not currently in an error state or a connected state. + */ + public boolean canConnect() + { + BroadcastState state = getBroadcastState(); + + return state != BroadcastState.CONNECTED && !state.isErrorState(); + } + + /** + * Indicates if the current broadcast state is an error state, meaning that it cannot recover or connect using the + * current configuration. + */ + protected boolean isErrorState() + { + return getBroadcastState().isErrorState(); + } + + + /** + * Audio recording queue processor. Fetches recordings from the queue and chunks the recording byte content + * to subclass implementations for broadcast in the appropriate manner. + */ + public class RecordingQueueProcessor implements Runnable + { + private AtomicBoolean mProcessing = new AtomicBoolean(); + private ByteArrayInputStream mInputStream; + private long mFinalSilencePadding = 0; + private int mBytesStreamedActual = 0; + private int mBytesStreamedRequired = 0; + + @Override + public void run() + { + if(mProcessing.compareAndSet(false, true)) + { + try + { + if(mInputStream == null || mInputStream.available() <= 0) + { + if(mFinalSilencePadding > 0) + { + broadcastAudio(mSilenceGenerator.generate(mFinalSilencePadding)); + mFinalSilencePadding = 0; + } + + nextRecording(); + } + + if(mInputStream != null) + { + //We need to stream at 13.888 fps (144 byte frame) to achieve 2000 Bps or 16 kbps + mBytesStreamedRequired += 2000; //2000 bytes per second for 16 kbps data rate + int bytesToStream = mBytesStreamedRequired - mBytesStreamedActual; + + //Trim length to whole-frame intervals (144 byte frame) + bytesToStream -= (bytesToStream % 144); + + int length = Math.min(bytesToStream, mInputStream.available()); + + byte[] audio = new byte[length]; + + try + { + mBytesStreamedActual += mInputStream.read(audio); + + broadcastAudio(audio); + } + catch(IOException ioe) + { + mLog.error("Error reading from in-memory audio recording input stream", ioe); + } + } + else + { + broadcastAudio(mSilenceGenerator.generate(PROCESSOR_RUN_INTERVAL_MS)); + } + } + catch(Exception e) + { + mLog.error("Error while processing audio streaming queue", e); + } + + mProcessing.set(false); + } + } + + /** + * Loads the next recording for broadcast + */ + private void nextRecording() + { + mBytesStreamedActual = 0; + mBytesStreamedRequired = 0; + + boolean metadataUpdateRequired = false; + + if(mInputStream != null) + { + mStreamedAudioCount++; + broadcast(new BroadcastEvent(AudioBroadcaster.this, + BroadcastEvent.Event.BROADCASTER_STREAMED_COUNT_CHANGE)); + metadataUpdateRequired = true; + } + + mInputStream = null; + + AudioRecording nextRecording = null; + + //Find the recording with the earliest start time + Iterator it = mAudioRecordingQueue.iterator(); + + while(it.hasNext()) + { + AudioRecording recording = it.next(); + + if(nextRecording == null || (recording.getStartTime() < nextRecording.getStartTime())) + { + nextRecording = recording; + } + } + + if(nextRecording != null && nextRecording.getStartTime() + mDelay <= System.currentTimeMillis()) + { + mAudioRecordingQueue.remove(nextRecording); + + try + { + if(Files.exists(nextRecording.getPath())) + { + byte[] audio = Files.readAllBytes(nextRecording.getPath()); + + if(audio != null && audio.length > 0) + { + mInputStream = new ByteArrayInputStream(audio); + + mFinalSilencePadding = PROCESSOR_RUN_INTERVAL_MS - + (nextRecording.getRecordingLength() % PROCESSOR_RUN_INTERVAL_MS); + + while(mFinalSilencePadding >= PROCESSOR_RUN_INTERVAL_MS) + { + mFinalSilencePadding -= PROCESSOR_RUN_INTERVAL_MS; + } + + if(connected()) + { + broadcastMetadata(nextRecording.getAudioMetadata()); + } + + metadataUpdateRequired = false; + } + } + } + catch(IOException ioe) + { + mLog.error("Stream [" + getBroadcastConfiguration().getName() + "] error reading temporary audio " + + "stream recording [" + nextRecording.getPath().toString() + "] - skipping recording - ", ioe); + + mInputStream = null; + metadataUpdateRequired = false; + } + + nextRecording.removePendingReplay(); + + broadcast(new BroadcastEvent(AudioBroadcaster.this, BroadcastEvent.Event.BROADCASTER_QUEUE_CHANGE)); + } + + //If we closed out a recording and don't have a new/next recording, send an empty metadata update + if(metadataUpdateRequired && connected()) + { + broadcastMetadata(null); + } + } + } +} diff --git a/src/audio/broadcast/AudioRecording.java b/src/audio/broadcast/AudioRecording.java new file mode 100644 index 00000000..1d4c3f16 --- /dev/null +++ b/src/audio/broadcast/AudioRecording.java @@ -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 + * + ******************************************************************************/ +package audio.broadcast; + +import audio.metadata.AudioMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; + +public class AudioRecording implements Comparable +{ + private final static Logger mLog = LoggerFactory.getLogger(StreamManager.class); + + private Path mPath; + private AudioMetadata mAudioMetadata; + private long mStartTime; + private long mRecordingLength; + private AtomicInteger mPendingReplayCount = new AtomicInteger(); + + /** + * Audio recording that is ready to be streamed + * @param path to the audio recording file + * @param audioMetadata associated with the recording + * @param start time of recording in milliseconds since epoch + * @param recordingLength in milliseconds + */ + public AudioRecording(Path path, AudioMetadata audioMetadata, long start, long recordingLength) + { + mPath = path; + mAudioMetadata = audioMetadata; + mStartTime = start; + mRecordingLength = recordingLength; + } + + /** + * Path to the completed audio recording + */ + public Path getPath() + { + return mPath; + } + + /** + * Optional audio metadata for the recording. + */ + public AudioMetadata getAudioMetadata() + { + return mAudioMetadata; + } + + /** + * Recording start time in milliseconds since epoch + */ + public long getStartTime() + { + return mStartTime; + } + + /** + * Recording length in milliseconds + */ + public long getRecordingLength() + { + return mRecordingLength; + } + + + /** + * Implements comparable for sorting recordings based on start time in ascending order + */ + @Override + public int compareTo(AudioRecording otherRecording) + { + return Long.compare(getStartTime(), otherRecording.getStartTime()); + } + + /** + * Increments the count of pending replays. + */ + public void addPendingReplay() + { + mPendingReplayCount.incrementAndGet(); + } + + /** + * Decrements the count of pending replays. + */ + public void removePendingReplay() + { + mPendingReplayCount.decrementAndGet(); + } + + /** + * Indicates if there are any remaining pending replays. Once the pending replay count is less than or equal to + * zero, the recording can be deleted. + */ + public boolean hasPendingReplays() + { + return mPendingReplayCount.get() > 0; + } +} diff --git a/src/audio/broadcast/BroadcastConfiguration.java b/src/audio/broadcast/BroadcastConfiguration.java new file mode 100644 index 00000000..312dca96 --- /dev/null +++ b/src/audio/broadcast/BroadcastConfiguration.java @@ -0,0 +1,279 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import audio.broadcast.broadcastify.BroadcastifyConfiguration; +import audio.broadcast.icecast.IcecastHTTPConfiguration; +import audio.broadcast.icecast.IcecastTCPConfiguration; +import audio.broadcast.shoutcast.v1.ShoutcastV1Configuration; +import audio.broadcast.shoutcast.v2.ShoutcastV2Configuration; + +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlSeeAlso; +import javax.xml.bind.annotation.XmlTransient; +import java.net.InetSocketAddress; +import java.net.SocketAddress; + +@XmlSeeAlso({BroadcastifyConfiguration.class, + IcecastTCPConfiguration.class, + IcecastHTTPConfiguration.class, + ShoutcastV1Configuration.class, + ShoutcastV2Configuration.class}) +@XmlRootElement(name = "stream") +public abstract class BroadcastConfiguration +{ + private BroadcastFormat mBroadcastFormat = BroadcastFormat.MP3; + private String mName; + private String mHost; + private int mPort; + private String mPassword; + private long mDelay; + private boolean mEnabled = true; + + public BroadcastConfiguration() + { + //No-arg constructor required for JAXB + } + + /** + * Creates a copy/clone of the configuration + */ + public abstract BroadcastConfiguration copyOf(); + + /** + * Broadcast audio streaming configuration. Describes the streaming server and configuration details. + */ + public BroadcastConfiguration(BroadcastFormat format) + { + mBroadcastFormat = format; + } + + /** + * Broadcast server type + */ + @XmlAttribute(name = "type") + public abstract BroadcastServerType getBroadcastServerType(); + + /** + * Name identifying this broadcastAudio configuration. + */ + @XmlAttribute(name = "name") + public String getName() + { + return mName; + } + + /** + * Sets the name name that identifies/describes this configuration + * + * @param name + */ + public void setName(String name) + { + mName = name; + } + + /** + * Indicates if this configuration has a name value + */ + public boolean hasName() + { + return mName != null; + } + + /** + * BROADCAST server host name. + */ + @XmlAttribute(name = "host") + public String getHost() + { + return mHost; + } + + /** + * Host name or IP address of the streaming server + * + * @param host + */ + public void setHost(String host) + { + mHost = host; + } + + /** + * Indicates if this configuration has a host value + */ + public boolean hasHost() + { + return mHost != null; + } + + /** + * BROADCAST server port number; + */ + @XmlAttribute(name = "port") + public int getPort() + { + return mPort; + } + + /** + * Port number of the streaming server to where the broadcastAudio will be directed. + * + * @param port + */ + public void setPort(int port) + { + mPort = port; + } + + /** + * Indicates if this configuration contains a port value + */ + public boolean hasPort() + { + return mPort > 0; + } + + public SocketAddress getAddress() + { + return new InetSocketAddress(getHost(), getPort()); + } + + /** + * Password to authenticate with the streaming server. + */ + @XmlAttribute(name = "password") + public String getPassword() + { + return mPassword; + } + + /** + * Sets the password used to authenticate the broadcastAudio broadcastAudio user to the streaming server + * + * @param password + */ + public void setPassword(String password) + { + mPassword = password; + } + + /** + * Indicates if this configuration has a password value + */ + public boolean hasPassword() + { + return mPassword != null; + } + + /** + * Audio broadcastAudio content type (e.g. mp3) + */ + @XmlAttribute(name = "format") + public BroadcastFormat getBroadcastFormat() + { + return mBroadcastFormat; + } + + public void setBroadcastFormat(BroadcastFormat format) + { + mBroadcastFormat = format; + } + + /** + * Audio broadcastAudio delay from recording start until the audio file is broadcastAudio to the server. + */ + @XmlAttribute(name = "delay") + public long getDelay() + { + return mDelay; + } + + /** + * User specified delay in milli-seconds from audio call start until when the audio is broadcastAudio to the server. + * @param delay in milliseconds + */ + public void setDelay(long delay) + { + mDelay = delay; + } + + /** + * Indicates if this broadcaster is enable, meaning that it will automatically connect on startup. + */ + @XmlAttribute(name = "enabled") + public boolean isEnabled() + { + return mEnabled; + } + + public void setEnabled(boolean enabled) + { + mEnabled = enabled; + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + + if (!(o instanceof BroadcastConfiguration)) + { + return false; + } + + BroadcastConfiguration that = (BroadcastConfiguration) o; + + if (getBroadcastFormat() != that.getBroadcastFormat()) + { + return false; + } + + if (getName() != null ? !getName().equals(that.getName()) : that.getName() != null) + { + return false; + } + + return getBroadcastServerType() == that.getBroadcastServerType(); + } + + @Override + public int hashCode() + { + int result = getBroadcastFormat().hashCode(); + result = 31 * result + (getName() != null ? getName().hashCode() : 0); + result = 31 * result + getBroadcastServerType().hashCode(); + return result; + } + + /** + * Indicates if this configuration is valid. A minimal check will ensure that it contains at least a hostname and + * port number. + */ + @XmlTransient + public boolean isValid() + { + return mHost != null && mPort > 0; + } +} diff --git a/src/audio/broadcast/BroadcastConfigurationEditor.java b/src/audio/broadcast/BroadcastConfigurationEditor.java new file mode 100644 index 00000000..678ba383 --- /dev/null +++ b/src/audio/broadcast/BroadcastConfigurationEditor.java @@ -0,0 +1,128 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import alias.AliasModel; +import audio.broadcast.broadcastify.BroadcastifyConfiguration; +import audio.broadcast.broadcastify.BroadcastifyConfigurationEditor; +import gui.editor.DocumentListenerEditor; +import settings.SettingsManager; + +import javax.swing.*; + +public abstract class BroadcastConfigurationEditor extends DocumentListenerEditor +{ + protected SettingsManager mSettingsManager; + protected BroadcastModel mBroadcastModel; + protected AliasModel mAliasModel; + + public BroadcastConfigurationEditor(BroadcastModel broadcastModel, AliasModel aliasModel, SettingsManager settingsManager) + { + mBroadcastModel = broadcastModel; + mAliasModel = aliasModel; + mSettingsManager = settingsManager; + } + + /** + * Updates the configuration with the new name and prompts the user to update any aliases that have a broadcast + * channel alias id with the old name to update to the new name. + * + * @param broadcastConfiguration to assign a new channel name + * @param newName to assign to the broadcast configuration. Note: assumes that validateConfiguration() has been + * invoked to verify that newName is non-null and non-empty + */ + protected void updateConfigurationName(BroadcastConfiguration broadcastConfiguration, String newName) + { + String previousName = broadcastConfiguration.getName(); + + if(previousName == null || previousName.isEmpty()) + { + broadcastConfiguration.setName(newName); + return; + } + + if(!previousName.equals(newName)) + { + mAliasModel.renameBroadcastChannel(previousName, newName); + broadcastConfiguration.setName(newName); + } + } + + /** + * Validates a text field control for a non-null, non-empty value + * @param field to validate + * @param title to use for error dialog + * @param message to use for error dialog + * @return true if field contains a non-null, non-empty value + */ + protected boolean validateTextField(JTextField field, String title, String message) + { + String text = field.getText(); + + if(text == null || text.isEmpty()) + { + JOptionPane.showMessageDialog(BroadcastConfigurationEditor.this, message, title, + JOptionPane.ERROR_MESSAGE); + + field.requestFocus(); + return false; + } + + return true; + } + + /** + * Validates the text field control that contains an integer value for non-null, non-empty and within the + * specified min/max valid range. + * @param field to validate + * @param title to use for error dialog + * @param message to use for error dialog + * @param minValid value + * @param maxValid value + * @return true if field contains a non-null, non-empty value within the valid min/max range + */ + protected boolean validateIntegerTextField(JTextField field, String title, String message, int minValid, int maxValid) + { + if(validateTextField(field, title, message)) + { + String text = field.getText(); + + try + { + int value = Integer.parseInt(text); + + if(minValid <= value && value <= maxValid) + { + return true; + } + } + catch(Exception e) + { + //Do nothing, we couldn't parse the number value + } + + JOptionPane.showMessageDialog(BroadcastConfigurationEditor.this, message, title, + JOptionPane.ERROR_MESSAGE); + + field.requestFocus(); + } + + return false; + } +} diff --git a/src/audio/broadcast/BroadcastEvent.java b/src/audio/broadcast/BroadcastEvent.java new file mode 100644 index 00000000..6a48bd5e --- /dev/null +++ b/src/audio/broadcast/BroadcastEvent.java @@ -0,0 +1,90 @@ +/******************************************************************************* + * 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; + +public class BroadcastEvent +{ + private BroadcastConfiguration mBroadcastConfiguration; + private AudioBroadcaster mAudioBroadcaster; + private Event mEvent; + + /** + * AliasEvent - event describing any changes to an alias + * @param configuration - alias that changed + * @param event - change event + */ + public BroadcastEvent(BroadcastConfiguration configuration, Event event ) + { + mBroadcastConfiguration = configuration; + mEvent = event; + } + + public BroadcastEvent(AudioBroadcaster audioBroadcaster, Event event ) + { + mAudioBroadcaster = audioBroadcaster; + mEvent = event; + } + + public BroadcastConfiguration getBroadcastConfiguration() + { + return mBroadcastConfiguration; + } + + public AudioBroadcaster getAudioBroadcaster() + { + return mAudioBroadcaster; + } + + /** + * Indicates if this event pertains to an audio broadcaster + */ + public boolean isAudioBroadcasterEvent() + { + return mAudioBroadcaster != null; + } + + /** + * Indicates if this event pertains to a broadcast configuration + */ + public boolean isBroadcastConfigurationEvent() + { + return mBroadcastConfiguration != null; + } + + public Event getEvent() + { + return mEvent; + } + + /** + * Channel events to describe the specific event + */ + public enum Event + { + BROADCASTER_ADD, + BROADCASTER_QUEUE_CHANGE, + BROADCASTER_STATE_CHANGE, + BROADCASTER_STREAMED_COUNT_CHANGE, + BROADCASTER_DELETE, + + CONFIGURATION_ADD, + CONFIGURATION_CHANGE, + CONFIGURATION_DELETE; + } +} diff --git a/src/audio/broadcast/BroadcastFactory.java b/src/audio/broadcast/BroadcastFactory.java new file mode 100644 index 00000000..3fc95768 --- /dev/null +++ b/src/audio/broadcast/BroadcastFactory.java @@ -0,0 +1,215 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import alias.AliasModel; +import audio.broadcast.broadcastify.BroadcastifyConfiguration; +import audio.broadcast.broadcastify.BroadcastifyConfigurationEditor; +import audio.broadcast.icecast.IcecastHTTPAudioBroadcaster; +import audio.broadcast.icecast.IcecastHTTPConfiguration; +import audio.broadcast.icecast.IcecastHTTPConfigurationEditor; +import audio.broadcast.icecast.IcecastTCPAudioBroadcaster; +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.broadcast.shoutcast.v2.ShoutcastV2ConfigurationEditor; +import audio.convert.IAudioConverter; +import audio.convert.ISilenceGenerator; +import audio.convert.MP3AudioConverter; +import audio.convert.MP3SilenceGenerator; +import controller.ThreadPoolManager; +import gui.editor.Editor; +import gui.editor.EmptyEditor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import record.AudioRecorder; +import record.mp3.MP3Recorder; +import settings.SettingsManager; + +import java.nio.file.Path; + +public class BroadcastFactory +{ + private final static Logger mLog = LoggerFactory.getLogger( BroadcastFactory.class ); + + public static final int MP3_MONO_16_KHZ_BITRATE = 16; + public static final boolean MP3_CONSTANT_BITRATE = false; + + /** + * Creates an audio streaming broadcaster for the configuration + * @param threadPoolManager for creating an audio broadcasting connection thread + * @param configuration describing the server and audio types + * @return configured broadcaster or null + */ + public static AudioBroadcaster getBroadcaster(ThreadPoolManager threadPoolManager, + BroadcastConfiguration configuration) + { + if(configuration != null) + { + IAudioConverter converter = getAudioConverter(configuration); + + if(converter != null) + { + switch(configuration.getBroadcastServerType()) + { + case BROADCASTIFY: + return new IcecastTCPAudioBroadcaster(threadPoolManager, + (BroadcastifyConfiguration)configuration); + case ICECAST_TCP: + return new IcecastTCPAudioBroadcaster(threadPoolManager, + (IcecastTCPConfiguration)configuration); + case ICECAST_HTTP: + return new IcecastHTTPAudioBroadcaster(threadPoolManager, + (IcecastHTTPConfiguration)configuration); + case SHOUTCAST_V1: + return new ShoutcastV1AudioBroadcaster(threadPoolManager, + (ShoutcastV1Configuration)configuration); + case SHOUTCAST_V2: + return new ShoutcastV2AudioBroadcaster(threadPoolManager, + (ShoutcastV2Configuration)configuration); + case UNKNOWN: + default: + mLog.info("Unrecognized broadcastAudio configuration: " + configuration.getBroadcastFormat().name()); + break; + } + } + } + + return null; + } + + /** + * Creates an audio convert to convert from 8 kHz PCM audio to the specified format + * @param configuration containing the requested output audio format + * @return audio convert or null + */ + public static IAudioConverter getAudioConverter(BroadcastConfiguration configuration) + { + switch(configuration.getBroadcastFormat()) + { + case MP3: + return new MP3AudioConverter(MP3_MONO_16_KHZ_BITRATE, MP3_CONSTANT_BITRATE); + default: + mLog.info("Unrecognized broadcastAudio format: " + configuration.getBroadcastFormat().name()); + } + + return null; + } + + /** + * Creates a broadcastAudio configuration for the specified server type and format + * + * @param serverType for the configuration + * @param format for the output audio format + * @return constructed (empty) configuration + */ + public static BroadcastConfiguration getConfiguration(BroadcastServerType serverType, BroadcastFormat format) + { + switch(serverType) + { + case BROADCASTIFY: + return new BroadcastifyConfiguration(format); + case ICECAST_HTTP: + return new IcecastHTTPConfiguration(format); + case ICECAST_TCP: + return new IcecastTCPConfiguration(format); + case SHOUTCAST_V1: + return new ShoutcastV1Configuration(format); + case SHOUTCAST_V2: + return new ShoutcastV2Configuration(format); + case UNKNOWN: + default: + mLog.info("Unrecognized broadcastAudio server type: " + serverType.name()); + break; + } + + return null; + } + + /** + * Constructs an editor for the specified broadcastAudio configuration + * @param configuration to modify or view + * @param broadcastModel model for broadcastAudio configurations + * @return an editor for the specified broadcastAudio configuration + */ + public static Editor getEditor(BroadcastConfiguration configuration, + BroadcastModel broadcastModel, + AliasModel aliasModel, + SettingsManager settingsManager) + { + Editor editor; + + switch (configuration.getBroadcastServerType()) + { + case BROADCASTIFY: + editor = new BroadcastifyConfigurationEditor(broadcastModel, aliasModel, settingsManager); + break; + case ICECAST_TCP: + editor = new IcecastTCPConfigurationEditor(broadcastModel, aliasModel, settingsManager); + break; + case ICECAST_HTTP: + editor = new IcecastHTTPConfigurationEditor(broadcastModel, aliasModel, settingsManager); + break; + case SHOUTCAST_V1: + editor = new ShoutcastV1ConfigurationEditor(broadcastModel, aliasModel, settingsManager); + break; + case SHOUTCAST_V2: + editor = new ShoutcastV2ConfigurationEditor(broadcastModel, aliasModel, settingsManager); + break; + default: + editor = new EmptyEditor(); + break; + } + + editor.setItem(configuration); + + return editor; + } + + /** + * Creates an audio recorder for the specified broadcastAudio format using the specified path output file name + */ + public static AudioRecorder getAudioRecorder(Path path, BroadcastFormat broadcastFormat) + { + switch (broadcastFormat) + { + case MP3: + return new MP3Recorder(path); + default: + mLog.debug("Unrecognized broadcastAudio format [" + broadcastFormat + "] cannot create audio recorder"); + return null; + } + } + + public static ISilenceGenerator getSilenceGenerator(BroadcastFormat format) + { + switch (format) + { + case MP3: + return new MP3SilenceGenerator(); + default: + throw new IllegalArgumentException("Unrecognized broadcast format [" + format + + "] can't create silence generator"); + } + } +} diff --git a/src/audio/broadcast/BroadcastFormat.java b/src/audio/broadcast/BroadcastFormat.java new file mode 100644 index 00000000..134423bd --- /dev/null +++ b/src/audio/broadcast/BroadcastFormat.java @@ -0,0 +1,43 @@ +/******************************************************************************* + * 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; + +public enum BroadcastFormat +{ + MP3("audio/mpeg", ".mp3"); + + private String mValue; + private String mFileExtension; + + private BroadcastFormat(String value, String fileExtension) + { + mValue = value; + mFileExtension = fileExtension; + } + + public String getValue() + { + return mValue; + } + + public String getFileExtension() + { + return mFileExtension; + } +} diff --git a/src/audio/broadcast/BroadcastModel.java b/src/audio/broadcast/BroadcastModel.java new file mode 100644 index 00000000..703c350c --- /dev/null +++ b/src/audio/broadcast/BroadcastModel.java @@ -0,0 +1,755 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import alias.id.broadcast.BroadcastChannel; +import audio.AudioPacket; +import audio.metadata.AudioMetadata; +import controller.ThreadPoolManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import properties.SystemProperties; +import sample.Broadcaster; +import sample.Listener; +import settings.SettingsManager; + +import javax.swing.*; +import javax.swing.table.AbstractTableModel; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class BroadcastModel extends AbstractTableModel implements Listener +{ + private final static Logger mLog = LoggerFactory.getLogger( BroadcastModel.class ); + + public static final String TEMPORARY_STREAM_DIRECTORY = "streaming"; + public static final String TEMPORARY_STREAM_FILE_SUFFIX = "temporary_streaming_file_"; + + private static final String UNIQUE_NAME_REGEX = "(.*)\\((\\d*)\\)"; + + public static final int COLUMN_SERVER_ICON = 0; + public static final int COLUMN_STREAM_NAME = 1; + public static final int COLUMN_BROADCASTER_STATUS = 2; + public static final int COLUMN_BROADCASTER_QUEUE_SIZE = 3; + public static final int COLUMN_BROADCASTER_STREAMED_COUNT = 4; + + private List mBroadcastConfigurations = new CopyOnWriteArrayList<>(); + private List mRecordingQueue = new CopyOnWriteArrayList<>(); + + private Map mBroadcastConfigurationMap = new HashMap<>(); + private Map mBroadcasterMap = new HashMap<>(); + private ThreadPoolManager mThreadPoolManager; + private SettingsManager mSettingsManager; + private StreamManager mStreamManager; + private Broadcaster mBroadcastEventBroadcaster = new Broadcaster<>(); + + /** + * Model for managing Broadcast configurations and any associated broadcaster instances. + */ + public BroadcastModel(ThreadPoolManager threadPoolManager, SettingsManager settingsManager) + { + mThreadPoolManager = threadPoolManager; + mSettingsManager = settingsManager; + mStreamManager = new StreamManager(threadPoolManager, new CompletedRecordingListener(), BroadcastFormat.MP3, + SystemProperties.getInstance().getApplicationFolder(TEMPORARY_STREAM_DIRECTORY)); + mStreamManager.start(); + + //Monitor to remove temporary recording files that have been streamed by all audio broadcasters + mThreadPoolManager.scheduleFixedRate(ThreadPoolManager.ThreadType.AUDIO_RECORDING, + new RecordingDeletionMonitor(), 15l, TimeUnit.SECONDS ); + + removeOrphanedTemporaryRecordings(); + } + + /** + * List of broadcastAudio configuration names + */ + public List getBroadcastConfigurationNames() + { + List names = new ArrayList<>(); + + for(BroadcastConfiguration configuration: mBroadcastConfigurations) + { + names.add(configuration.getName()); + } + + return names; + } + + /** + * Current list of broadcastAudio configurations + */ + public List getBroadcastConfigurations() + { + return mBroadcastConfigurations; + } + + /** + * Adds the list of broadcastAudio configurations to this model + */ + public void addBroadcastConfigurations(List configurations) + { + for(BroadcastConfiguration configuration: configurations) + { + addBroadcastConfiguration(configuration); + } + } + + /** + * Adds the broadcastAudio configuration to this model + */ + public void addBroadcastConfiguration(BroadcastConfiguration configuration) + { + if(configuration != null) + { + ensureUniqueName(configuration); + + if(!mBroadcastConfigurations.contains(configuration)) + { + mBroadcastConfigurations.add(configuration); + + int index = mBroadcastConfigurations.size() - 1; + + fireTableRowsInserted( index, index ); + + mBroadcastConfigurationMap.put(configuration.getName(), configuration); + + process( new BroadcastEvent( configuration, BroadcastEvent.Event.CONFIGURATION_ADD) ); + } + } + } + + /** + * Clones the configuration and adds it this model with a unique configuration name + */ + public BroadcastConfiguration cloneBroadcastConfiguration(BroadcastConfiguration configuration) + { + if(configuration != null) + { + BroadcastConfiguration clone = configuration.copyOf(); + addBroadcastConfiguration(clone); + return clone; + } + + return null; + } + + /** + * Updates the configuration's name so that it is unique among all other broadcast configurations + * @param configuration + */ + private void ensureUniqueName(BroadcastConfiguration configuration) + { + if(configuration.getName() == null || configuration.getName().isEmpty()) + { + configuration.setName("New Configuration"); + } + + while(!isUniqueName(configuration.getName(), configuration)) + { + String currentName = configuration.getName(); + + if(currentName.matches(UNIQUE_NAME_REGEX)) + { + int currentVersion = 1; + + StringBuilder sb = new StringBuilder(); + Matcher m = Pattern.compile(UNIQUE_NAME_REGEX).matcher(currentName); + + if(m.find()) + { + String version = m.group(2); + + try + { + currentVersion = Integer.parseInt(version); + } + catch(Exception e) + { + //Couldn't parse the version number -- keep incrementing until we find a winner + } + + currentVersion++; + + sb.append(m.group(1)).append("(").append(currentVersion).append(")"); + } + else + { + sb.append(configuration.getName()).append("(").append(currentVersion).append(")"); + } + + configuration.setName(sb.toString()); + } + else + { + StringBuilder sb = new StringBuilder(); + sb.append(configuration.getName()).append("(2)"); + configuration.setName(sb.toString()); + } + } + + } + + /** + * Indicates if the name is unique among all of the broadcast configurations. Checks each of the configurations + * in this model for a name collision. Assumes that the name argument will be assigned to the configuration + * argument if the name is unique, therefore, the name will not be checked against the configuration argument for + * a collision. + * + * @param name to check for uniqueness + * @param configuration to ignore when checking the name for uniqueness + * @return true if the name is not null and unique among all configurations managed by this model + */ + public boolean isUniqueName(String name, BroadcastConfiguration configuration) + { + if(name == null || name.isEmpty()) + { + return false; + } + + for(BroadcastConfiguration configurationToCompare: mBroadcastConfigurations) + { + if(configurationToCompare != configuration && + configurationToCompare.getName() != null && + configurationToCompare.getName().equals(name)) + { + return false; + } + } + + return true; + } + + public void removeBroadcastConfiguration(BroadcastConfiguration broadcastConfiguration) + { + if(broadcastConfiguration != null && mBroadcastConfigurations.contains(broadcastConfiguration)) + { + int index = mBroadcastConfigurations.indexOf(broadcastConfiguration); + + mBroadcastConfigurations.remove(broadcastConfiguration); + + mBroadcastConfigurationMap.remove(broadcastConfiguration.getName()); + + process(new BroadcastEvent( broadcastConfiguration, BroadcastEvent.Event.CONFIGURATION_DELETE)); + + fireTableRowsDeleted( index, index ); + } + } + + /** + * Returns the broadcaster associated with the stream name or null if there is no broadcaster setup for the name. + */ + public AudioBroadcaster getBroadcaster(String streamName) + { + return mBroadcasterMap.get(streamName); + } + + @Override + public void receive(AudioPacket audioPacket) + { + if (audioPacket.hasAudioMetadata() && + audioPacket.getAudioMetadata().isStreamable()) + { + mStreamManager.receive(audioPacket); + } + } + + /** + * Creates a new broadcaster for the broadcast configuration and adds it to the model + */ + private void createBroadcaster(BroadcastConfiguration broadcastConfiguration) + { + if (broadcastConfiguration != null && + broadcastConfiguration.isEnabled() && + broadcastConfiguration.isValid() && + !mBroadcasterMap.containsKey(broadcastConfiguration.getName())) + { + AudioBroadcaster audioBroadcaster = BroadcastFactory.getBroadcaster(mThreadPoolManager, + broadcastConfiguration); + + if (audioBroadcaster != null) + { + audioBroadcaster.setListener(new Listener() + { + @Override + public void receive(BroadcastEvent broadcastEvent) + { + process(broadcastEvent); + } + }); + + audioBroadcaster.start(); + + mBroadcasterMap.put(audioBroadcaster.getBroadcastConfiguration().getName(), audioBroadcaster); + + int index = mBroadcastConfigurations.indexOf(audioBroadcaster.getBroadcastConfiguration()); + + if(index >= 0) + { + fireTableRowsUpdated(index, index); + } + + broadcast(new BroadcastEvent(audioBroadcaster, BroadcastEvent.Event.BROADCASTER_ADD)); + } + } + } + + /** + * Shut down a broadcaster created from the configuration and remove it from this model + */ + private void deleteBroadcaster(String name) + { + if(name != null && mBroadcasterMap.containsKey(name)) + { + AudioBroadcaster audioBroadcaster = mBroadcasterMap.remove(name); + + if(audioBroadcaster != null) + { + audioBroadcaster.stop(); + audioBroadcaster.removeListener(); + + int index = mBroadcastConfigurations.indexOf(audioBroadcaster.getBroadcastConfiguration()); + + if(index >= 0) + { + fireTableRowsUpdated(index, index); + } + + broadcast(new BroadcastEvent(audioBroadcaster, BroadcastEvent.Event.BROADCASTER_DELETE)); + } + } + } + + /** + * Returns the broadcast configuration identified by the stream name + */ + public BroadcastConfiguration getBroadcastConfiguration(String streamName) + { + return mBroadcastConfigurationMap.get(streamName); + } + + /** + * Registers the listener to receive broadcastAudio configuration events + */ + public void addListener(Listener listener) + { + mBroadcastEventBroadcaster.addListener(listener); + } + + /** + * Removes the listener from receiving broadcastAudio configuration events + */ + public void removeListener(Listener listener) + { + mBroadcastEventBroadcaster.removeListener(listener); + } + + /** + * Broadcasts the broadcastAudio configuration change event + */ + private void broadcast(BroadcastEvent event) + { + mBroadcastEventBroadcaster.broadcast( event ); + } + + /** + * Process a broadcast event from one of the broadcasters managed by this model + */ + public void process(BroadcastEvent broadcastEvent) + { + if(broadcastEvent.isBroadcastConfigurationEvent()) + { + switch(broadcastEvent.getEvent()) + { + case CONFIGURATION_ADD: + createBroadcaster(broadcastEvent.getBroadcastConfiguration()); + break; + case CONFIGURATION_CHANGE: + BroadcastConfiguration broadcastConfiguration = broadcastEvent.getBroadcastConfiguration(); + int index = mBroadcastConfigurations.indexOf(broadcastConfiguration); + fireTableRowsUpdated( index, index ); + + //Delete and recreate the broadcaster for any broadcast configuration changes + String previousChannelName = cleanupMapAssociations(broadcastConfiguration); + deleteBroadcaster(previousChannelName); + + //Delay restarting the broadcaster to allow remote server time to cleanup + mThreadPoolManager.scheduleOnce(new DelayedBroadcasterStartup(broadcastConfiguration), 1, TimeUnit.SECONDS); + break; + case CONFIGURATION_DELETE: + deleteBroadcaster(broadcastEvent.getBroadcastConfiguration().getName()); + break; + } + } + else if(broadcastEvent.isAudioBroadcasterEvent()) + { + int row = mBroadcastConfigurations.indexOf(broadcastEvent.getAudioBroadcaster().getBroadcastConfiguration()); + + switch(broadcastEvent.getEvent()) + { + case BROADCASTER_QUEUE_CHANGE: + if(row >= 0) + { + fireTableCellUpdated(row, COLUMN_BROADCASTER_QUEUE_SIZE); + } + break; + case BROADCASTER_STATE_CHANGE: + if(row >= 0) + { + fireTableCellUpdated(row, COLUMN_BROADCASTER_STATUS); + } + break; + case BROADCASTER_STREAMED_COUNT_CHANGE: + if(row >= 0) + { + fireTableCellUpdated(row, COLUMN_BROADCASTER_STREAMED_COUNT); + } + break; + } + } + + //Rebroadcast the event to any listeners of this model + broadcast(broadcastEvent); + } + + /** + * When the name of a broadcast configuration changes, remove the old map associations so that they can be replaced + * with the new map associations. Remove any broadcaster that is associated with the old configuration name. + * + * @param broadcastConfiguration + * @return previous or current channel name that has been replaced with the current configuration channel name so + * that anything else associated with the previous name can be cleaned up. + */ + private String cleanupMapAssociations(BroadcastConfiguration broadcastConfiguration) + { + String oldName = null; + + for(Map.Entry entry: mBroadcastConfigurationMap.entrySet()) + { + if(entry.getValue() == broadcastConfiguration) + { + oldName = entry.getKey(); + continue; + } + } + + if(oldName != null) + { + mBroadcastConfigurationMap.remove(oldName); + } + + mBroadcastConfigurationMap.put(broadcastConfiguration.getName(), broadcastConfiguration); + + return oldName; + } + + @Override + public int getRowCount() + { + return mBroadcastConfigurations.size(); + } + + @Override + public int getColumnCount() + { + return 5; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) + { + try + { + if( rowIndex <= mBroadcastConfigurations.size()) + { + BroadcastConfiguration configuration = mBroadcastConfigurations.get(rowIndex); + + if(configuration != null) + { + switch(columnIndex) + { + case COLUMN_SERVER_ICON: + String iconName = configuration.getBroadcastServerType().getIconName(); + + if(iconName != null && mSettingsManager != null) + { + return mSettingsManager.getImageIcon(iconName, 14); + } + break; + case COLUMN_STREAM_NAME: + return configuration.getName(); + case COLUMN_BROADCASTER_STATUS: + AudioBroadcaster audioBroadcasterA = mBroadcasterMap.get(configuration.getName()); + + if(audioBroadcasterA != null) + { + return audioBroadcasterA.getBroadcastState(); + } + else if(!configuration.isEnabled()) + { + return BroadcastState.DISABLED; + } + else if(!configuration.isValid()) + { + return BroadcastState.INVALID_SETTINGS; + } + case COLUMN_BROADCASTER_QUEUE_SIZE: + AudioBroadcaster audioBroadcasterB = mBroadcasterMap.get(configuration.getName()); + + if(audioBroadcasterB != null) + { + return audioBroadcasterB.getQueueSize(); + } + break; + case COLUMN_BROADCASTER_STREAMED_COUNT: + AudioBroadcaster audioBroadcasterC = mBroadcasterMap.get(configuration.getName()); + + if(audioBroadcasterC != null) + { + return audioBroadcasterC.getStreamedAudioCount(); + } + break; + default: + break; + } + } + } + } + catch(Exception e) + { + mLog.error("Error accessing data in broadcast model", e); + } + + return null; + } + + @Override + public Class getColumnClass(int columnIndex) + { + if(columnIndex == COLUMN_SERVER_ICON) + { + return ImageIcon.class; + } + + return String.class; + } + + /** + * Broadcast configuration at the specified model row + */ + public BroadcastConfiguration getConfigurationAt(int rowIndex) + { + return mBroadcastConfigurations.get(rowIndex); + } + + /** + * Model row number for the specified configuration + */ + public int getRowForConfiguration(BroadcastConfiguration configuration) + { + return mBroadcastConfigurations.indexOf(configuration); + } + + @Override + public String getColumnName(int column) + { + switch(column) + { + case COLUMN_SERVER_ICON: + return null; + case COLUMN_STREAM_NAME: + return "Name"; + case COLUMN_BROADCASTER_STATUS: + return "Status"; + case COLUMN_BROADCASTER_QUEUE_SIZE: + return "Queued"; + case COLUMN_BROADCASTER_STREAMED_COUNT: + return "Streamed"; + } + + return null; + } + + /** + * Cleanup method to remove a temporary recording file from disk. + * + * @param recording to remove + */ + private void removeRecording(AudioRecording recording) + { + try + { + Files.delete(recording.getPath()); + } + catch(IOException ioe) + { + mLog.error("Error deleting temporary internet recording file: " + recording.getPath().toString() + " - " + + ioe.getMessage()); + } + } + + + /** + * Removes any temporary stream recordings left-over from the previous application run. + * + * This should only be invoked on startup. + */ + private void removeOrphanedTemporaryRecordings() + { + Path path = SystemProperties.getInstance().getApplicationFolder(BroadcastModel.TEMPORARY_STREAM_DIRECTORY); + + if(path != null && Files.isDirectory(path)) + { + StringBuilder sb = new StringBuilder(); + sb.append(TEMPORARY_STREAM_FILE_SUFFIX); + sb.append("*.*"); + + try(DirectoryStream directoryStream = Files.newDirectoryStream(path, sb.toString())) + { + directoryStream.forEach(new Consumer() + { + @Override + public void accept(Path path) + { + try + { + Files.delete(path); + } + catch(IOException ioe) + { + mLog.error("Couldn't delete orphaned temporary recording: " + path.toString(), ioe); + } + } + }); + } + catch(IOException ioe) + { + mLog.error("Error discovering orphaned temporary stream recording files", ioe); + } + } + } + + /** + * Provides delayed startup of a broadcaster to allow for the remote server to complete a disconnection. + */ + public class DelayedBroadcasterStartup implements Runnable + { + private BroadcastConfiguration mBroadcastConfiguration; + + public DelayedBroadcasterStartup(BroadcastConfiguration configuration) + { + mBroadcastConfiguration = configuration; + } + + + @Override + public void run() + { + createBroadcaster(mBroadcastConfiguration); + } + } + + /** + * Processes completed audio recordings and distributes them to the audio broadcasters. Adds the recording to + * the audio recording queue to be monitored for deletion. + */ + public class CompletedRecordingListener implements Listener + { + @Override + public void receive(AudioRecording audioRecording) + { + AudioMetadata metadata = audioRecording.getAudioMetadata(); + + if(metadata != null && metadata.isStreamable()) + { + for (BroadcastChannel broadcastChannel : metadata.getBroadcastChannels()) + { + String channelName = broadcastChannel.getChannelName(); + + if (channelName != null) + { + AudioBroadcaster audioBroadcaster = getBroadcaster(channelName); + + if (audioBroadcaster != null) + { + audioRecording.addPendingReplay(); + audioBroadcaster.receive(audioRecording); + } + } + } + } + + mRecordingQueue.add(audioRecording); + } + } + + /** + * Monitors the recording queue and removes any recordings that have no pending replays by audio broadcasters + */ + public class RecordingDeletionMonitor implements Runnable + { + @Override + public void run() + { + try + { + List recordingsToDelete = new ArrayList<>(); + + Iterator it = mRecordingQueue.iterator(); + + AudioRecording recording; + + while(it.hasNext()) + { + recording = it.next(); + + if(!recording.hasPendingReplays()) + { + recordingsToDelete.add(recording); + } + } + + if(!recordingsToDelete.isEmpty()) + { + for(AudioRecording recordingToDelete: recordingsToDelete) + { + mRecordingQueue.remove(recordingToDelete); + removeRecording(recordingToDelete); + } + } + } + catch(Exception e) + { + mLog.error("Error while checking audio recording queue for recordings to delete", e); + } + } + } +} diff --git a/src/audio/broadcast/BroadcastPanel.java b/src/audio/broadcast/BroadcastPanel.java new file mode 100644 index 00000000..7136e83b --- /dev/null +++ b/src/audio/broadcast/BroadcastPanel.java @@ -0,0 +1,332 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import alias.AliasModel; +import com.jidesoft.swing.JideSplitPane; +import gui.editor.Editor; +import gui.editor.EmptyEditor; +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.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; +import javax.swing.event.TableColumnModelEvent; +import javax.swing.event.TableColumnModelListener; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.TableCellRenderer; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +public class BroadcastPanel extends JPanel implements ActionListener, ListSelectionListener +{ + private final static Logger mLog = LoggerFactory.getLogger( BroadcastPanel.class ); + + private static final String NEW_BROADCAST_CONFIGURATION = "New"; + private static final String COPY_BROADCAST_CONFIGURATION = "Copy"; + private static final String DELETE_BROADCAST_CONFIGURATION = "Delete"; + + private BroadcastModel mBroadcastModel; + private AliasModel mAliasModel; + private SettingsManager mSettingsManager; + + private JTable mBroadcastConfigurationTable; + private JideSplitPane mSplitPane; + private Editor mEmptyEditor = new EmptyEditor<>("Configuration:"); + private Editor mEditor; + private JButton mNewButton = new JButton(NEW_BROADCAST_CONFIGURATION); + private JButton mCopyButton = new JButton(COPY_BROADCAST_CONFIGURATION); + private JButton mDeleteButton = new JButton(DELETE_BROADCAST_CONFIGURATION); + + public BroadcastPanel(BroadcastModel broadcastModel, AliasModel aliasModel, SettingsManager settingManager) + { + mBroadcastModel = broadcastModel; + mAliasModel = aliasModel; + mSettingsManager = settingManager; + mEditor = mEmptyEditor; + + init(); + } + + private void init() + { + setLayout(new MigLayout("insets 0 0 0 0 ", "[grow,fill]", "[grow,fill]")); + + mBroadcastConfigurationTable = new JTable(mBroadcastModel); + mBroadcastConfigurationTable.setAutoCreateRowSorter(true); + mBroadcastConfigurationTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + mBroadcastConfigurationTable.getSelectionModel().addListSelectionListener(BroadcastPanel.this); + + mBroadcastConfigurationTable.getColumnModel().getColumn(BroadcastModel.COLUMN_SERVER_ICON).setPreferredWidth(1); + mBroadcastConfigurationTable.getColumnModel().getColumn(BroadcastModel.COLUMN_STREAM_NAME).setPreferredWidth(25); + mBroadcastConfigurationTable.getColumnModel().getColumn(BroadcastModel.COLUMN_BROADCASTER_STATUS) + .setCellRenderer(new StatusCellRenderer()); + + JScrollPane scroller = new JScrollPane(mBroadcastConfigurationTable); + + JPanel buttonsPanel = new JPanel(); + buttonsPanel.setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill][grow,fill][grow,fill]", "[]")); + + mNewButton = new JButton("New ..."); + mNewButton.setToolTipText("Create a new broadcast audio configuration"); + mNewButton.addMouseListener(new MouseAdapter() + { + @Override + public void mouseClicked(MouseEvent e) + { + JPopupMenu menu = new JPopupMenu(); + + 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.add(new AddBroadcastConfigurationItem(BroadcastServerType.SHOUTCAST_V2)); + + menu.show(e.getComponent(), e.getX(), e.getY()); + } + }); + + buttonsPanel.add(mNewButton); + + mCopyButton.addActionListener(this); + mCopyButton.setEnabled(false); + mCopyButton.setToolTipText("Creates a copy of the currently selected streaming configuration and adds it"); + buttonsPanel.add(mCopyButton); + + mDeleteButton.addActionListener(this); + mDeleteButton.setEnabled(false); + mDeleteButton.setToolTipText("Deletes the currently selected streaming configuration"); + buttonsPanel.add(mDeleteButton); + + JPanel listAndButtonsPanel = new JPanel(); + + listAndButtonsPanel.setLayout(new MigLayout("insets 0 0 0 0", "[grow,fill]", "[grow,fill][]")); + + listAndButtonsPanel.add(scroller, "wrap"); + listAndButtonsPanel.add(buttonsPanel); + + mSplitPane = new JideSplitPane(JideSplitPane.HORIZONTAL_SPLIT); + mSplitPane.setDividerSize(5); + mSplitPane.setShowGripper(true); + mSplitPane.add(listAndButtonsPanel); + + mSplitPane.add(mEditor); + + add(mSplitPane); + } + + @Override + public void actionPerformed(ActionEvent e) + { + switch(e.getActionCommand()) + { + case COPY_BROADCAST_CONFIGURATION: + BroadcastConfiguration copySelected = getSelectedBroadcastConfiguration(); + mBroadcastModel.cloneBroadcastConfiguration(copySelected); + break; + case DELETE_BROADCAST_CONFIGURATION: + BroadcastConfiguration deleteSelected = getSelectedBroadcastConfiguration(); + + if(deleteSelected != null) + { + int choice = JOptionPane.showConfirmDialog(BroadcastPanel.this, "Do you want to delete broadcast\n" + + " configuration " + deleteSelected.getName() + "?", "Delete Broadcast Configuration?", + JOptionPane.YES_NO_OPTION); + + if(choice == JOptionPane.YES_OPTION) + { + mBroadcastModel.removeBroadcastConfiguration(deleteSelected); + } + } + break; + } + + } + + /** + * Returns the selected configuration + */ + private BroadcastConfiguration getSelectedBroadcastConfiguration() + { + int viewRow = mBroadcastConfigurationTable.getSelectedRow(); + + if(viewRow >= 0) + { + int modelRow = mBroadcastConfigurationTable.convertRowIndexToModel(viewRow); + + if(modelRow >= 0) + { + return mBroadcastModel.getConfigurationAt(modelRow); + } + } + + return null; + } + + + @Override + public void valueChanged(ListSelectionEvent e) + { + if (!e.getValueIsAdjusting()) + { + BroadcastConfiguration selectedConfiguration = getSelectedBroadcastConfiguration(); + + //Enable buttons accordingly + mCopyButton.setEnabled(selectedConfiguration != null); + mDeleteButton.setEnabled(selectedConfiguration != null); + + if (mEditor.isModified()) + { + int option = JOptionPane.showConfirmDialog( + BroadcastPanel.this, + "Configuration settings have changed. Do you want to save these changes?", + "Save Changes?", + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE); + + if (option == JOptionPane.YES_OPTION) + { + mEditor.save(); + } + } + + mSplitPane.removePane(mEditor); + + if (selectedConfiguration != null) + { + mEditor = BroadcastFactory.getEditor(selectedConfiguration, mBroadcastModel, mAliasModel, + mSettingsManager); + } + else + { + mEditor = mEmptyEditor; + } + + mSplitPane.add(mEditor); + + revalidate(); + } + } + + public class AddBroadcastConfigurationItem extends JMenuItem + { + private static final long serialVersionUID = 1L; + + private BroadcastServerType mBroadcastServerType; + + public AddBroadcastConfigurationItem(BroadcastServerType type) + { + super(type.toString(), mSettingsManager.getImageIcon(type.getIconName(), 14)); + + mBroadcastServerType = type; + + setToolTipText("Add a new " + type.toString() + " audio stream configuration"); + + addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + final BroadcastConfiguration configuration = + BroadcastFactory.getConfiguration(mBroadcastServerType, BroadcastFormat.MP3); + + if(configuration != null) + { + mBroadcastModel.addBroadcastConfiguration(configuration); + + int modelRow = mBroadcastModel.getRowForConfiguration(configuration); + int tableRow = mBroadcastConfigurationTable.convertRowIndexToView(modelRow); + mBroadcastConfigurationTable.changeSelection(tableRow, 0, false, false); + } + } + }); + } + } + + /** + * Custom cell renderer for the broadcast state column. + */ + public class StatusCellRenderer extends DefaultTableCellRenderer + { + public StatusCellRenderer() + { + setOpaque(true); + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, + boolean hasFocus, int row, int column) + { + JLabel component = (JLabel)super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + + if(isSelected) + { + setBackground(table.getSelectionBackground()); + setForeground(table.getSelectionForeground()); + } + else + { + if(value instanceof BroadcastState) + { + BroadcastState state = (BroadcastState)value; + + if(state == BroadcastState.CONNECTED) + { + setBackground(Color.GREEN); + setForeground(table.getForeground()); + } + else if(state == BroadcastState.DISABLED) + { + setBackground(table.getBackground()); + setForeground(Color.LIGHT_GRAY); + } + else if(state == BroadcastState.INVALID_SETTINGS) + { + setBackground(Color.YELLOW); + setForeground(table.getForeground()); + } + else if(state.isErrorState()) + { + setBackground(Color.RED); + setForeground(table.getForeground()); + } + else + { + setBackground(table.getBackground()); + setForeground(table.getForeground()); + } + } + else + { + setForeground(table.getForeground()); + setBackground(table.getBackground()); + } + } + + return this; + } + } +} diff --git a/src/audio/broadcast/BroadcastServerType.java b/src/audio/broadcast/BroadcastServerType.java new file mode 100644 index 00000000..25d2ca7c --- /dev/null +++ b/src/audio/broadcast/BroadcastServerType.java @@ -0,0 +1,48 @@ +/******************************************************************************* + * 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; + +public enum BroadcastServerType +{ + BROADCASTIFY("Broadcastify", "images/broadcastify.png"), //Icecast Server 2.3.2 + ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"), + ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"), + SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"), + SHOUTCAST_V2("Shoutcast v2.x", "images/shoutcast.png"), + UNKNOWN("Unknown", null); + + private String mLabel; + private String mIconName; + + BroadcastServerType(String label, String iconName) + { + mLabel = label; + mIconName = iconName; + } + + public String toString() + { + return mLabel; + } + + public String getIconName() + { + return mIconName; + } +} diff --git a/src/audio/broadcast/BroadcastState.java b/src/audio/broadcast/BroadcastState.java new file mode 100644 index 00000000..5e6d95ec --- /dev/null +++ b/src/audio/broadcast/BroadcastState.java @@ -0,0 +1,116 @@ +/******************************************************************************* + * 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; + +public enum BroadcastState +{ + /** + * General error in configuration that causes the remote server to reject the connection + */ + CONFIGURATION_ERROR("Configuration Error", true), + + /** + * Connected to the broadcastAudio server and capable of streaming + */ + CONNECTED("Connected", false), + + /** + * Connection interrupted, attempting to reconnect. + */ + CONNECTING("Connecting", false), + + /** + * Indicates the configuration is disabled + */ + DISABLED("Disabled", true), + + /** + * Disconnected from the broadcastAudio server + */ + DISCONNECTED("Disconnected", false), + + /** + * Invalid credentials - user name or password + */ + INVALID_CREDENTIALS("Invalid User Name/Password", true), + + /** + * Invalid mount point + */ + INVALID_MOUNT_POINT("Invalid Mount/Stream ID", true), + + /** + * Invalid configuration settings + */ + INVALID_SETTINGS("Invalid Settings", true), + + /** + * Remote server max sources has been exceeded + */ + MAX_SOURCES_EXCEEDED("Max Sources Exceeded", true), + + /** + * Specified mount point is already in use + */ + MOUNT_POINT_IN_USE("Mount Point In Use", true), + + /** + * Server is not known or reachable + */ + NO_SERVER("No Server", false), + + /** + * Initial state with no connection attempted. + */ + READY("Ready", false), + + /** + * Error while broadcasting stream data. Temporary error state to allow connection to be reset. + */ + TEMPORARY_BROADCAST_ERROR("Temporary Broadcast Error", false), + + /** + * Unsupported audio format + */ + UNSUPPORTED_AUDIO_FORMAT("Unsupported Audio Type", true), + + /** + * Unspecified error + */ + ERROR("Error", true); + + private String mLabel; + private boolean mErrorState; + + private BroadcastState(String label, boolean connected) + { + mLabel = label; + mErrorState = connected; + } + + public String toString() + { + return mLabel; + } + + public boolean isErrorState() + { + return mErrorState; + } +} diff --git a/src/audio/broadcast/IBroadcastMetadataUpdater.java b/src/audio/broadcast/IBroadcastMetadataUpdater.java new file mode 100644 index 00000000..04abb236 --- /dev/null +++ b/src/audio/broadcast/IBroadcastMetadataUpdater.java @@ -0,0 +1,31 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import audio.metadata.AudioMetadata; + +public interface IBroadcastMetadataUpdater +{ + /** + * Updates broadcast audio metadata + * + * @param metadata containing metadata attributes + */ + void update(AudioMetadata metadata); +} diff --git a/src/audio/broadcast/StreamManager.java b/src/audio/broadcast/StreamManager.java new file mode 100644 index 00000000..b658df7f --- /dev/null +++ b/src/audio/broadcast/StreamManager.java @@ -0,0 +1,238 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package audio.broadcast; + +import audio.AudioPacket; +import controller.ThreadPoolManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import record.AudioRecorder; +import sample.Listener; +import util.TimeStamp; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class StreamManager implements Listener +{ + private final static Logger mLog = LoggerFactory.getLogger(StreamManager.class); + private static final long MAXIMUM_RECORDER_LIFESPAN_MILLIS = 30000; //30 seconds + + private static AtomicInteger sNextRecordingNumber = new AtomicInteger(); + + private ThreadPoolManager mThreadPoolManager; + private Listener mAudioRecordingListener; + private BroadcastFormat mBroadcastFormat; + private Path mTempDirectory; + private Map mStreamRecorders = new HashMap<>(); + private Runnable mRecorderMonitor; + private ScheduledFuture mRecorderMonitorFuture; + private AtomicBoolean mRunning = new AtomicBoolean(); + + /** + * Stream manager processes all incoming audio packets and reassembles individual audio streams, converts audio + * to desired output format and persists each stream to disc. Each recording is capped at a maximum length to + * ensure that recordings don't run too long before they are streamed out and to ensure that inactive recordings + * are closed in a timely fashion. + * + * Completed streamable audio recordings are nominated to the output listener (for broadcast) upon completion + * + * @param threadPoolManager for scheduling runnables + * @param listener to receive completed audio recordings + * @param tempDirectory where to store temporary audio recordings + */ + public StreamManager(ThreadPoolManager threadPoolManager, Listener listener, + BroadcastFormat broadcastFormat, Path tempDirectory) + { + assert(tempDirectory != null && Files.isDirectory(tempDirectory)); + assert(mThreadPoolManager != null); + + mThreadPoolManager = threadPoolManager; + mAudioRecordingListener = listener; + mBroadcastFormat = broadcastFormat; + mTempDirectory = tempDirectory; + } + + /** + * Starts the stream manager. Schedules a processor to monitor for inactive temporary stream recorders + * every 2 seconds. + */ + public void start() + { + if(mRunning.compareAndSet(false, true)) + { + if(mRecorderMonitor == null) + { + mRecorderMonitor = new RecorderMonitor(); + } + + mRecorderMonitorFuture = mThreadPoolManager.scheduleFixedRate(ThreadPoolManager.ThreadType.AUDIO_PROCESSING, + mRecorderMonitor, 2, TimeUnit.SECONDS); + } + } + + /** + * Stops the stream manager. Stops the inactive temporary stream recorder monitoring thread. + */ + public void stop() + { + if(mRunning.compareAndSet(true, false)) + { + if(mRecorderMonitorFuture != null) + { + mRecorderMonitorFuture.cancel(true); + } + + synchronized (mStreamRecorders) + { + List streamKeys = new ArrayList<>(mStreamRecorders.keySet()); + + for(Integer streamKey: streamKeys) + { + removeRecorder(streamKey); + } + } + } + } + + @Override + public void receive(AudioPacket audioPacket) + { + if(mRunning.get() && audioPacket.hasAudioMetadata()) + { + synchronized (mStreamRecorders) + { + int sourceChannelID = audioPacket.getAudioMetadata().getSource(); + + AudioPacket.Type type = audioPacket.getType(); + + if(type == AudioPacket.Type.AUDIO) + { + if(mStreamRecorders.containsKey(sourceChannelID)) + { + AudioRecorder recorder = mStreamRecorders.get(sourceChannelID); + + if(recorder != null) + { + recorder.receive(audioPacket); + } + } + else + { + AudioRecorder recorder = BroadcastFactory.getAudioRecorder(getTemporaryRecordingPath(), + mBroadcastFormat); + recorder.start(mThreadPoolManager.getScheduledExecutorService()); + recorder.receive(audioPacket); + mStreamRecorders.put(sourceChannelID, recorder); + } + } + else if(type == AudioPacket.Type.END) + { + removeRecorder(sourceChannelID); + } + else + { + mLog.info("Unrecognized Audio Packet Type: " + type); + } + } + } + } + + /** + * Removes the recorder associated with the source channel ID. + * + * Note: this method invocation is not thread safe and must be invoked by a thread safe mechanism that protects the + * mStreamRecorders map. + * + * @param sourceChannelID identifying the recorder + */ + private void removeRecorder(Integer sourceChannelID) + { + if(mStreamRecorders.containsKey(sourceChannelID)) + { + AudioRecorder recorder = mStreamRecorders.remove(sourceChannelID); + + recorder.close(new Listener() + { + @Override + public void receive(AudioRecorder audioRecorder) + { + AudioRecording audioRecording = + new AudioRecording(audioRecorder.getPath(), audioRecorder.getMetadata(), + audioRecorder.getTimeRecordingStart(), audioRecorder.getRecordingLength()); + + if(mAudioRecordingListener != null) + { + mAudioRecordingListener.receive(audioRecording); + } + } + }); + + } + } + + /** + * Creates a temporary streaming recording file path + */ + private Path getTemporaryRecordingPath() + { + StringBuilder sb = new StringBuilder(); + sb.append(BroadcastModel.TEMPORARY_STREAM_FILE_SUFFIX); + sb.append(sNextRecordingNumber.incrementAndGet()).append("_"); + sb.append( TimeStamp.getLongTimeStamp( "_" ) ); + sb.append( mBroadcastFormat.getFileExtension() ); + + Path temporaryRecordingPath = mTempDirectory.resolve(sb.toString()); + + return temporaryRecordingPath; + } + + /** + * Monitors recorders to ensure they don't exceed the maximum life-span allowed for a recording. + */ + public class RecorderMonitor implements Runnable + { + @Override + public void run() + { + synchronized (mStreamRecorders) + { + long now = System.currentTimeMillis(); + + mStreamRecorders.entrySet().stream() + .filter(entry -> entry.getValue().getTimeRecordingStart() + MAXIMUM_RECORDER_LIFESPAN_MILLIS < now) + .forEach(entry -> + { + mLog.debug("Maximum recording time limit reached - removing recorder: " + + entry.getValue().getPath().toString()); + + removeRecorder(entry.getKey()); + }); + } + } + } +} diff --git a/src/audio/broadcast/broadcastify/BroadcastifyConfiguration.java b/src/audio/broadcast/broadcastify/BroadcastifyConfiguration.java new file mode 100644 index 00000000..bc10af3e --- /dev/null +++ b/src/audio/broadcast/broadcastify/BroadcastifyConfiguration.java @@ -0,0 +1,96 @@ +/******************************************************************************* + * 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.broadcastify; + +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 BroadcastifyConfiguration extends IcecastTCPConfiguration +{ + private int mFeedID; + + public BroadcastifyConfiguration() + { + //No-arg constructor for JAXB + this(BroadcastFormat.MP3); + } + + /** + * Broadcastify configuration for Icecast 2.3.2 compatible servers + * + * @param format of output audio (MP3) + */ + public BroadcastifyConfiguration(BroadcastFormat format) + { + super(format); + + setBitRate(16); + setChannels(1); + setSampleRate(8000); + } + + @Override + public BroadcastConfiguration copyOf() + { + BroadcastifyConfiguration copy = new BroadcastifyConfiguration(getBroadcastFormat()); + + //Broadcast Configuration Parameters + copy.setName(getName()); + copy.setHost(getHost()); + copy.setPort(getPort()); + copy.setPassword(getPassword()); + copy.setDelay(getDelay()); + + copy.setEnabled(false); + + //Icecast Configuration Parameters + copy.setUserName(getUserName()); + copy.setMountPoint(getMountPoint()); + copy.setDescription(getDescription()); + copy.setGenre(getGenre()); + copy.setPublic(isPublic()); + copy.setURL(getURL()); + + //Broadcastify Configuration Parameters + copy.setFeedID(getFeedID()); + + return copy; + } + + @Override + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.BROADCASTIFY; + } + + @XmlAttribute(name="feed_id") + public int getFeedID() + { + return mFeedID; + } + + public void setFeedID(int feedID) + { + mFeedID = feedID; + } +} diff --git a/src/audio/broadcast/broadcastify/BroadcastifyConfigurationEditor.java b/src/audio/broadcast/broadcastify/BroadcastifyConfigurationEditor.java new file mode 100644 index 00000000..28603438 --- /dev/null +++ b/src/audio/broadcast/broadcastify/BroadcastifyConfigurationEditor.java @@ -0,0 +1,369 @@ +/******************************************************************************* + * 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.broadcastify; + +import alias.AliasModel; +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastConfigurationEditor; +import audio.broadcast.BroadcastEvent; +import audio.broadcast.BroadcastModel; +import audio.broadcast.BroadcastServerType; +import com.radioreference.api.soap2.UserFeedBroadcast; +import gui.editor.DocumentListenerEditor; +import net.miginfocom.swing.MigLayout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sample.Listener; +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 BroadcastifyConfigurationEditor extends BroadcastConfigurationEditor implements Listener +{ + private final static Logger mLog = LoggerFactory.getLogger( BroadcastifyConfigurationEditor.class ); + + private static final int ONE_MINUTE_MS = 60000; + + private JTextField mName; + private JTextField mServer; + private JTextField mPort; + private JTextField mMountPoint; + private JTextField mPassword; + private JTextField mFeedID; + private JSlider mDelay; + private JLabel mDelayValue; + private JCheckBox mEnabled; + private JButton mSaveButton; + private JButton mResetButton; + + public BroadcastifyConfigurationEditor(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("Broadcastify Stream"); + + ImageIcon broadcastifyIcon = mSettingsManager.getImageIcon(BroadcastServerType.BROADCASTIFY.getIconName(), 25); + channelLabel.setIcon(broadcastifyIcon); + add(channelLabel, "span, align center"); + + JButton lookupButton = new JButton("Lookup ..."); + lookupButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + final UserFeedSelectionDialog dialog = new UserFeedSelectionDialog(BroadcastifyConfigurationEditor.this); + + EventQueue.invokeLater(new Runnable() + { + @Override + public void run() + { + dialog.setLocationRelativeTo(BroadcastifyConfigurationEditor.this); + dialog.setVisible(true); + } + }); + } + }); + + add(new JLabel("")); + add(lookupButton, "grow,wrap"); + + 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. audio3.broadcastify.com). See MyBCFY feed details, Technicals tab"); + add(mServer); + + add(new JLabel("Port:")); + mPort = new JTextField(); + mPort.getDocument().addDocumentListener(this); + mPort.setToolTipText("Server port number (e.g. 80). See MyBCFY feed details, Technicals tab"); + add(mPort); + + add(new JLabel("Mount:")); + mMountPoint = new JTextField(); + mMountPoint.getDocument().addDocumentListener(this); + mMountPoint.setToolTipText("Mount point (e.g. \\awlkejrlkjsd). See MyBCFY feed details, Technicals tab"); + add(mMountPoint); + + add(new JLabel("Password:")); + mPassword = new JTextField(); + mPassword.getDocument().addDocumentListener(this); + mPassword.setToolTipText("Password. See MyBCFY feed details, Technicals tab"); + add(mPassword); + + add(new JLabel("Feed ID:")); + mFeedID = new JTextField(); + mFeedID.getDocument().addDocumentListener(this); + mFeedID.setToolTipText("Feed ID (optional). See MyBCFY feed details"); + add(mFeedID); + + + 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); + + mEnabled = new JCheckBox("Enabled"); + mEnabled.setToolTipText("Enable (checked) or disable this stream"); + mEnabled.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + setModified(true); + } + }); + add(new JLabel()); + 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\n 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()) + { + BroadcastifyConfiguration config = (BroadcastifyConfiguration)getItem(); + + mName.setText(config.getName()); + mServer.setText(config.getHost()); + mPort.setText(config.getPort() != 0 ? String.valueOf(config.getPort()) : null); + mMountPoint.setText(config.getMountPoint()); + mPassword.setText(config.getPassword()); + mFeedID.setText(String.valueOf(config.getFeedID())); + mDelay.setValue((int)(config.getDelay() / ONE_MINUTE_MS)); + mEnabled.setSelected(config.isEnabled()); + } + else + { + mName.setText(null); + mServer.setText(null); + mPort.setText(null); + mMountPoint.setText(null); + mPassword.setText(null); + mFeedID.setText(null); + mDelay.setValue(0); + mEnabled.setSelected(false); + } + + setModified(false); + } + + @Override + public void save() + { + BroadcastifyConfiguration config = (BroadcastifyConfiguration)getItem(); + + if(validateConfiguration()) + { + updateConfigurationName(config, mName.getText()); + config.setHost(mServer.getText()); + config.setPort(getPort()); + config.setMountPoint(mMountPoint.getText()); + config.setPassword(mPassword.getText()); + config.setFeedID(getFeedID()); + 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(BroadcastifyConfigurationEditor.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; + } + + //Mount Point is optional, but required + if(!validateTextField(mMountPoint, "Invalid Mount Point", "Please specify a mount point.")) + { + 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; + } + + /** + * Parses an Integer from the port text control or returns 0 if the control doesn't contain an integer value. + */ + private int getFeedID() + { + String feedID = mFeedID.getText(); + + if(feedID != null && !feedID.isEmpty()) + { + try + { + return Integer.parseInt(feedID); + } + catch(Exception e) + { + //Do nothing, we couldn't parse the number value + } + } + + return 0; + } + + @Override + public void receive(UserFeedBroadcast userFeedBroadcast) + { + mName.setText(userFeedBroadcast.getDescr()); + mServer.setText(userFeedBroadcast.getHostname()); + mPort.setText(userFeedBroadcast.getPort()); + mMountPoint.setText(userFeedBroadcast.getMount()); + mPassword.setText(userFeedBroadcast.getPassword()); + mFeedID.setText(String.valueOf(userFeedBroadcast.getFeedId())); + } +} diff --git a/src/audio/broadcast/broadcastify/UserFeedSelectionDialog.java b/src/audio/broadcast/broadcastify/UserFeedSelectionDialog.java new file mode 100644 index 00000000..d9fb63e3 --- /dev/null +++ b/src/audio/broadcast/broadcastify/UserFeedSelectionDialog.java @@ -0,0 +1,247 @@ +/******************************************************************************* + * 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.broadcastify; + +import com.radioreference.api.soap2.UserFeedBroadcast; +import external.radioreference.RadioReferenceService; +import net.miginfocom.swing.MigLayout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sample.Listener; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; +import javax.xml.rpc.ServiceException; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.net.UnknownHostException; +import java.rmi.RemoteException; + +public class UserFeedSelectionDialog extends JDialog implements DocumentListener +{ + private final static Logger mLog = LoggerFactory.getLogger( UserFeedSelectionDialog.class ); + + private Listener mListener; + private JTextField mUserNameText; + private JTextField mPasswordText; + private JButton mLookupButton; + private JList mUserFeedBroadcastList; + private JButton mOKButton; + + public UserFeedSelectionDialog(Listener listener) + { + mListener = listener; + init(); + } + + private void init() + { + JPanel panel = new JPanel(); + panel.setLayout(new MigLayout("","[align right,grow,fill][align left,grow,fill][]","[][][][][grow][]")); + + panel.add(new JLabel("Please enter your credentials"), "align left,span"); + + panel.add(new JLabel("User Name:")); + mUserNameText = new JTextField(); + mUserNameText.getDocument().addDocumentListener(this); + panel.add(mUserNameText, "wrap"); + + panel.add(new JLabel("Password:")); + mPasswordText = new JTextField(); + mPasswordText.getDocument().addDocumentListener(this); + panel.add(mPasswordText, "wrap"); + + mLookupButton = new JButton("Get Feeds"); + mLookupButton.setEnabled(false); + mLookupButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + mLookupButton.setEnabled(false); + + EventQueue.invokeLater(new Runnable() + { + @Override + public void run() + { + try + { + RadioReferenceService service = + new RadioReferenceService(mUserNameText.getText(), mPasswordText.getText()); + + UserFeedBroadcast[] feeds = service.getUserFeedBroadcasts(); + + mUserFeedBroadcastList.setListData(feeds); + } + catch(ServiceException se) + { + JOptionPane.showMessageDialog(UserFeedSelectionDialog.this, + "Unable to create radio reference web service client. See log file.", + "Error", JOptionPane.ERROR_MESSAGE); + + mLog.error("Error creating the radio reference web service client", se); + } + catch(RemoteException re) + { + Throwable cause = re.getCause(); + + if(cause == null) + { + JOptionPane.showMessageDialog(UserFeedSelectionDialog.this, + re.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + } + else if(cause instanceof UnknownHostException) + { + JOptionPane.showMessageDialog(UserFeedSelectionDialog.this, + "Broadcastify server is unavailable", "No Server", + JOptionPane.ERROR_MESSAGE); + } + else + { + JOptionPane.showMessageDialog(UserFeedSelectionDialog.this, + "An unknown error has occurred. Please see log file.", "" + + "Unknown Error", JOptionPane.ERROR_MESSAGE); + + mLog.error("Error retrieving user broadcast feed configurations from radio reference " + + "service: ", re); + } + } + + mLookupButton.setEnabled(true); + } + }); + } + }); + panel.add(mLookupButton,"grow,span"); + + mUserFeedBroadcastList = new JList(); + mUserFeedBroadcastList.setVisibleRowCount(3); + mUserFeedBroadcastList.setLayoutOrientation(JList.VERTICAL); + mUserFeedBroadcastList.setCellRenderer(new CellRenderer()); + mUserFeedBroadcastList.addListSelectionListener(new ListSelectionListener() + { + @Override + public void valueChanged(ListSelectionEvent e) + { + mOKButton.setEnabled(mUserFeedBroadcastList.getSelectedValue() != null); + } + }); + + JScrollPane listScrollPane = new JScrollPane(mUserFeedBroadcastList); + panel.add(listScrollPane, "grow,span"); + + mOKButton = new JButton("Ok"); + mOKButton.setEnabled(false); + mOKButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + if(mListener != null) + { + mListener.receive((UserFeedBroadcast)mUserFeedBroadcastList.getSelectedValue()); + } + + dispose(); + } + }); + panel.add(mOKButton,"grow"); + + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent e) + { + dispose(); + } + }); + panel.add(cancelButton, "wrap"); + + setTitle("Broadcastify Feed Lookup"); + setLayout(new BorderLayout()); + add(panel, BorderLayout.CENTER); + setSize(300,250); + setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + setModalityType(ModalityType.APPLICATION_MODAL); + } + + private void updateButtons() + { + String username = mUserNameText.getText(); + String password = mPasswordText.getText(); + + mLookupButton.setEnabled(username != null && !username.isEmpty() && password != null && !password.isEmpty()); + } + + @Override + public void insertUpdate(DocumentEvent e) + { + updateButtons(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + updateButtons(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + updateButtons(); + } + + private class CellRenderer extends JLabel implements ListCellRenderer + { + public CellRenderer() + { + setOpaque(true); + setHorizontalAlignment(LEFT); + setVerticalAlignment(CENTER); + } + + @Override + public Component getListCellRendererComponent(JList list, + UserFeedBroadcast value, + int index, + boolean isSelected, + boolean cellHasFocus) + { + if (isSelected) + { + setBackground(list.getSelectionBackground()); + setForeground(list.getSelectionForeground()); + } + else + { + setBackground(list.getBackground()); + setForeground(list.getForeground()); + } + + setText(value.getDescr()); + + return this; } + } +} diff --git a/src/audio/broadcast/icecast/IcecastAudioBroadcaster.java b/src/audio/broadcast/icecast/IcecastAudioBroadcaster.java new file mode 100644 index 00000000..e7d7ed85 --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastAudioBroadcaster.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.AudioBroadcaster; +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.IBroadcastMetadataUpdater; +import controller.ThreadPoolManager; + +public abstract class IcecastAudioBroadcaster extends AudioBroadcaster +{ + private IBroadcastMetadataUpdater mMetadataUpdater; + + public IcecastAudioBroadcaster(ThreadPoolManager threadPoolManager, BroadcastConfiguration broadcastConfiguration) + { + super(threadPoolManager, broadcastConfiguration); + } + + /** + * Icecast broadcast configuration + */ + protected IcecastConfiguration getConfiguration() + { + return (IcecastConfiguration)getBroadcastConfiguration(); + } + + @Override + protected IBroadcastMetadataUpdater getMetadataUpdater() + { + if(mMetadataUpdater == null) + { + mMetadataUpdater = new IcecastBroadcastMetadataUpdater(getThreadPoolManager(), getConfiguration()); + } + + return mMetadataUpdater; + } +} diff --git a/src/audio/broadcast/icecast/IcecastBroadcastMetadataUpdater.java b/src/audio/broadcast/icecast/IcecastBroadcastMetadataUpdater.java new file mode 100644 index 00000000..14b9867f --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastBroadcastMetadataUpdater.java @@ -0,0 +1,281 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.IBroadcastMetadataUpdater; +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.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 IcecastBroadcastMetadataUpdater implements IBroadcastMetadataUpdater +{ + private final static Logger mLog = LoggerFactory.getLogger(IcecastBroadcastMetadataUpdater.class); + private final static String UTF8 = "UTF-8"; + + private ThreadPoolManager mThreadPoolManager; + private IcecastConfiguration mIcecastConfiguration; + + private NioSocketConnector mSocketConnector; + private AtomicBoolean mUpdating = new AtomicBoolean(); + private Queue mMetadataQueue = new LinkedTransferQueue<>(); + private Map mHTTPHeaders; + private boolean mStackTraceLoggingSuppressed; + + /** + * Icecast 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 IcecastBroadcastMetadataUpdater(ThreadPoolManager threadPoolManager, IcecastConfiguration icecastConfiguration) + { + mThreadPoolManager = threadPoolManager; + mIcecastConfiguration = icecastConfiguration; + } + + /** + * Socket connector - lazy constructor. + */ + private NioSocketConnector getSocketConnector() + { + if (mSocketConnector == null) + { + mSocketConnector = new NioSocketConnector(); +// mSocketConnector.getFilterChain().addLast("logger", new LoggingFilter(IcecastBroadcastMetadataUpdater.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(); + + mLog.error("Metadata update failed"); + } + + @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(mIcecastConfiguration.getHost(), + mIcecastConfiguration.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("mode=updinfo"); + sb.append("&mount=").append(mIcecastConfiguration.getMountPoint()); + sb.append("&charset=UTF%2d8"); + sb.append("&song=").append(URLEncoder.encode(song, UTF8)); + + HttpRequestImpl request = new HttpRequestImpl(HttpVersion.HTTP_1_1, HttpMethod.GET, "/admin/metadata", + sb.toString(), getHTTPHeaders()); + + return request; + } + catch (UnsupportedEncodingException e) + { + //This should never happen + mLog.error("UTF-8 encoding is not supported - can't update song metadata"); + } + + return null; + } + + /** + * HTTP headers to use for a metadata update request. + */ + private Map getHTTPHeaders() + { + if(mHTTPHeaders == null) + { + mHTTPHeaders = new HashMap<>(); + mHTTPHeaders.put(IcecastHeader.USER_AGENT.getValue(), SystemProperties.getInstance().getApplicationName()); + mHTTPHeaders.put(IcecastHeader.AUTHORIZATION.getValue(), mIcecastConfiguration.getBase64EncodedCredentials()); + } + + return mHTTPHeaders; + } +} diff --git a/src/audio/broadcast/icecast/IcecastConfiguration.java b/src/audio/broadcast/icecast/IcecastConfiguration.java new file mode 100644 index 00000000..e70f6075 --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastConfiguration.java @@ -0,0 +1,292 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastFormat; + +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlTransient; +import java.util.Base64; + +public abstract class IcecastConfiguration extends BroadcastConfiguration +{ + protected String mUserName = "source"; + private String mMountPoint = "/stream"; + private String mDescription; + private String mGenre; + private boolean mPublic; + private int mBitRate = 16; + private int mChannels = 1; + private int mSampleRate = 8000; + private String mURL; + + public IcecastConfiguration(BroadcastFormat format) + { + super(format); + } + + public IcecastConfiguration() + { + } + + /** + * Base64 encoded version of the username and password with prepended 'Basic ' tag. + */ + @XmlTransient + public String getBase64EncodedCredentials() + { + StringBuilder sb = new StringBuilder(); + + if(hasUserName()) + { + sb.append(getUserName()).append(":").append(getPassword()); + } + else + { + sb.append(getPassword()); + } + + String base64 = Base64.getEncoder().encodeToString(sb.toString().getBytes()); + + return "Basic " + base64; + } + + @XmlAttribute( name="user_name" ) + public String getUserName() + { + return mUserName; + } + + public void setUserName(String userName) + { + mUserName = userName; + } + + public boolean hasUserName() + { + return mUserName != null; + } + + /** + * Mount point or path to the stream + * @return mount point + */ + @XmlAttribute( name="mount_point" ) + public String getMountPoint() + { + return mMountPoint; + } + + /** + * Sets the mount point (path) for the stream + * @param mountPoint + */ + public void setMountPoint(String mountPoint) + { + if(mountPoint != null && !mountPoint.isEmpty()) + { + if(mountPoint.startsWith("/")) + { + mMountPoint = mountPoint; + } + else + { + mMountPoint = "/" + mountPoint; + } + } + } + + public boolean hasMountPoint() + { + return mMountPoint != null; + } + + @XmlAttribute( name="description" ) + public String getDescription() + { + return mDescription; + } + + public void setDescription(String description) + { + mDescription = description; + } + + public boolean hasDescription() + { + return mDescription != null; + } + + /** + * Stream genre + */ + @XmlAttribute( name="genre" ) + public String getGenre() + { + return mGenre; + } + + /** + * Stream genre + * @param genre + */ + public void setGenre(String genre) + { + mGenre = genre; + } + + public boolean hasGenre() + { + return mGenre != null; + } + + /** + * Public visibility of the broadcastAudio + */ + @XmlAttribute( name="public" ) + public boolean isPublic() + { + return mPublic; + } + + /** + * Sets public visibility of the broadcastAudio + * @param isPublic indicates if the broadcastAudio should be visible to the public + */ + public void setPublic(boolean isPublic) + { + mPublic = isPublic; + } + + /** + * Number of audio channels in the broadcastAudio + */ + @XmlAttribute( name="channels" ) + public int getChannels() + { + return mChannels; + } + + /** + * Sets the number of audio channels in the broadcastAudio + */ + public void setChannels(int channels) + { + mChannels = channels; + } + + public boolean hasChannels() + { + return mChannels > 0; + } + + /** + * Bit rate in bits per second + */ + @XmlAttribute( name="bitrate" ) + public int getBitRate() + { + return mBitRate; + } + + /** + * Bit rate + * @param bitRate in samples per second + */ + public void setBitRate(int bitRate) + { + mBitRate = bitRate; + } + + public boolean hasBitRate() + { + return mBitRate > 0; + } + + @XmlAttribute( name="sample_rate" ) + public int getSampleRate() + { + return mSampleRate; + } + + public void setSampleRate(int sampleRate) + { + mSampleRate = sampleRate; + } + + public boolean hasSampleRate() + { + return mSampleRate > 0; + } + + /** + * URL associated with the broadcastAudio where users can find additional details. + */ + @XmlAttribute( name="url" ) + public String getURL() + { + return mURL; + } + + /** + * URL associated with the broadcastAudio where users can find additional details. + * @param url + */ + public void setURL(String url) + { + mURL = url; + } + + public boolean hasURL() + { + return mURL != null; + } + + @Override + public boolean isValid() + { + if(!super.isValid()) + { + return false; + } + else if(getUserName() == null) + { + return false; + } + else if(getMountPoint() == null) + { + return false; + } + else if(getChannels() != 1) + { + return false; + } + else if(getSampleRate() <= 0) + { + return false; + } + else if(getBitRate() <= 0) + { + return false; + } + + return true; + } +} diff --git a/src/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java b/src/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java new file mode 100644 index 00000000..d40f9e32 --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java @@ -0,0 +1,329 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.BroadcastState; +import audio.convert.MP3AudioConverter; +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.ProtocolDecoderException; +import org.apache.mina.http.HttpClientCodec; +import org.apache.mina.http.HttpException; +import org.apache.mina.http.HttpRequestImpl; +import org.apache.mina.http.api.DefaultHttpResponse; +import org.apache.mina.http.api.HttpMethod; +import org.apache.mina.http.api.HttpStatus; +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.net.ConnectException; +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class IcecastHTTPAudioBroadcaster extends IcecastAudioBroadcaster +{ + private static final Logger mLog = LoggerFactory.getLogger( IcecastHTTPAudioBroadcaster.class ); + private static final long RECONNECT_INTERVAL_MILLISECONDS = 30000; //30 seconds + + private NioSocketConnector mSocketConnector; + private IoSession mStreamingSession = null; + private Map mHTTPHeaders; + + private long mLastConnectionAttempt = 0; + private AtomicBoolean mConnecting = new AtomicBoolean(); + + /** + * Creates an Icecast 2.3.2 compatible broadcaster using TCP and a pseudo HTTP 1.0 protocol. This broadcaster is + * compatible with Icecast version 2.3.2 and older versions of the server software. + * + * Note: use @see IcecastHTTPAudioBroadcaster for Icecast version 2.4.x and newer. + * + * This broadcaster uses the Apache Mina library for the streaming socket connection and for metadata updates. The + * IcecastHTTPIOHandler manages all interaction with the Icecast server and manages the overall broadcast state. + * + * @param configuration for the Icecast stream + */ + public IcecastHTTPAudioBroadcaster(ThreadPoolManager threadPoolManager, IcecastHTTPConfiguration configuration) + { + super(threadPoolManager, configuration); + } + + /** + * Broadcasts the audio frame or sequence + */ + @Override + protected void broadcastAudio(byte[] audio) + { + if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) + { + IoBuffer buffer = IoBuffer.allocate(audio.length); + buffer.put(audio); + buffer.flip(); + + mStreamingSession.write(buffer); + } + } + + /** + * (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. + * + * @return true if the audio handler can stream audio + */ + private boolean connect() + { + if(!connected() && canConnect() && + (mLastConnectionAttempt + RECONNECT_INTERVAL_MILLISECONDS < System.currentTimeMillis()) && + mConnecting.compareAndSet(false, true)) + { + mLastConnectionAttempt = System.currentTimeMillis(); + + if(mSocketConnector == null) + { + mSocketConnector = new NioSocketConnector(); + mSocketConnector.setConnectTimeoutCheckInterval(10000); + +// mSocketConnector.getFilterChain().addLast("logger", +// new LoggingFilter(IcecastTCPAudioBroadcaster.class)); + + mSocketConnector.getFilterChain().addLast("codec", new HttpClientCodec()); + mSocketConnector.setHandler(new IcecastHTTPIOHandler()); + } + + 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(); + } + + + /** + * Disconnect from the remote broadcast server and cleanup input/output streams and socket connection + */ + public void disconnect() + { + if(mStreamingSession != null) + { + mStreamingSession.closeNow(); + } + + mConnecting.set(false); + } + + /** + * IO Handler for managing Icecast HTTP connection and credentials + */ + public class IcecastHTTPIOHandler extends IoHandlerAdapter + { + @Override + public void sessionOpened(IoSession session) throws Exception + { + //Send stream configuration and user credentials upon connecting to remote server + HttpRequestImpl request = new HttpRequestImpl(HttpVersion.HTTP_1_1, HttpMethod.PUT, + getConfiguration().getMountPoint(), "", getHTTPHeaders()); + + session.write(request); + } + + private Map getHTTPHeaders() + { + if(mHTTPHeaders == null) + { + mHTTPHeaders = new HashMap<>(); + mHTTPHeaders.put(IcecastHeader.ACCEPT.getValue(), "*/*"); + mHTTPHeaders.put(IcecastHeader.CONTENT_TYPE.getValue(), getConfiguration().getBroadcastFormat().getValue()); + mHTTPHeaders.put(IcecastHeader.USER_AGENT.getValue(), SystemProperties.getInstance().getApplicationName()); + mHTTPHeaders.put(IcecastHeader.AUTHORIZATION.getValue(), getConfiguration().getBase64EncodedCredentials()); + mHTTPHeaders.put(IcecastHeader.PUBLIC.getValue(), getConfiguration().isPublic() ? "1" : "0"); + + StringBuilder sb = new StringBuilder(); + sb.append("samplerate=").append(getConfiguration().getSampleRate()).append(";"); + sb.append("quality=").append(MP3AudioConverter.AUDIO_QUALITY).append(";"); + sb.append("channels=").append(getConfiguration().getChannels()); + mHTTPHeaders.put(IcecastHeader.AUDIO_INFO.getValue(), sb.toString()); + + if(getConfiguration().hasName()) + { + mHTTPHeaders.put(IcecastHeader.NAME.getValue(), getConfiguration().getName()); + } + + if(getConfiguration().hasDescription()) + { + mHTTPHeaders.put(IcecastHeader.DESCRIPTION.getValue(), getConfiguration().getDescription()); + } + + if(getConfiguration().hasURL()) + { + mHTTPHeaders.put(IcecastHeader.URL.getValue(), getConfiguration().getURL()); + } + + if(getConfiguration().hasGenre()) + { + mHTTPHeaders.put(IcecastHeader.GENRE.getValue(), getConfiguration().getGenre()); + } + + + if(getConfiguration().hasBitRate()) + { + mHTTPHeaders.put(IcecastHeader.BITRATE.getValue(), String.valueOf(getConfiguration().getBitRate())); + } + } + + return mHTTPHeaders; + } + + @Override + public void sessionClosed(IoSession session) throws Exception + { + //If there is already an error state, don't override it. Otherwise, set state to disconnected + if(!getBroadcastState().isErrorState()) + { + setBroadcastState(BroadcastState.DISCONNECTED); + } + + mSocketConnector.dispose(); + mStreamingSession = null; + mSocketConnector = null; + + mConnecting.set(false); + super.sessionClosed(session); + } + + @Override + public void exceptionCaught(IoSession session, Throwable throwable) throws Exception + { + if(throwable instanceof ProtocolDecoderException) + { + Throwable cause = ((ProtocolDecoderException)throwable).getCause(); + + if(cause instanceof HttpException && + ((HttpException)cause).getStatusCode() == HttpStatus.CLIENT_ERROR_LENGTH_REQUIRED.code()) + { + //Ignore - Icecast 2.4 sometimes doesn't send any headers with their HTTP responses. Mina expects a + //content-length for HTTP responses. + } + else + { + mLog.error("HTTP protocol decoder error", throwable); + setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR); + disconnect(); + } + } + else + { + mLog.error("Broadcast error", throwable); + setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR); + disconnect(); + } + + mConnecting.set(false); + } + + @Override + public void messageReceived(IoSession session, Object object) throws Exception + { + if(object instanceof DefaultHttpResponse) + { + DefaultHttpResponse response = (DefaultHttpResponse)object; + + switch(response.getStatus()) + { + case INFORMATIONAL_CONTINUE: + break; + case SUCCESS_OK: + setBroadcastState(BroadcastState.CONNECTED); + mConnecting.set(false); + break; + case CLIENT_ERROR_UNAUTHORIZED: + setBroadcastState(BroadcastState.INVALID_CREDENTIALS); + disconnect(); + break; + case CLIENT_ERROR_FORBIDDEN: + setBroadcastState(BroadcastState.CONFIGURATION_ERROR); + disconnect(); + break; + default: + setBroadcastState(BroadcastState.ERROR); + disconnect(); + mLog.debug("Unspecified error: " + response.toString() + " Class:" + object.getClass()); + break; + } + } + else + { + mLog.error("Icecast HTTP broadcaster - unrecognized message [ " + object.getClass() + + "] received:" + object.toString()); + } + } + } +} diff --git a/src/audio/broadcast/icecast/IcecastHTTPConfiguration.java b/src/audio/broadcast/icecast/IcecastHTTPConfiguration.java new file mode 100644 index 00000000..ee0be59b --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastHTTPConfiguration.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastFormat; +import audio.broadcast.BroadcastServerType; + +import java.util.Base64; + +public class IcecastHTTPConfiguration extends IcecastConfiguration +{ + public IcecastHTTPConfiguration() + { + //no-arg JAXB constructor + this(BroadcastFormat.MP3); + } + + /** + * Icecast 2.4.x+ compatible configuration + */ + public IcecastHTTPConfiguration(BroadcastFormat format) + { + super(format); + setPassword("change me!"); + } + + @Override + public BroadcastConfiguration copyOf() + { + IcecastHTTPConfiguration copy = new IcecastHTTPConfiguration(getBroadcastFormat()); + + //Broadcast Configuration Parameters + copy.setName(getName()); + copy.setHost(getHost()); + copy.setPort(getPort()); + copy.setPassword(getPassword()); + copy.setDelay(getDelay()); + copy.setEnabled(false); + + //Icecast Configuration Parameters + copy.setUserName(getUserName()); + copy.setMountPoint(getMountPoint()); + copy.setDescription(getDescription()); + copy.setGenre(getGenre()); + copy.setPublic(isPublic()); + copy.setBitRate(getBitRate()); + copy.setChannels(getChannels()); + copy.setSampleRate(getSampleRate()); + copy.setURL(getURL()); + + return copy; + } + + @Override + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.ICECAST_HTTP; + } + + +} diff --git a/src/audio/broadcast/icecast/IcecastHTTPConfigurationEditor.java b/src/audio/broadcast/icecast/IcecastHTTPConfigurationEditor.java new file mode 100644 index 00000000..2352e46b --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastHTTPConfigurationEditor.java @@ -0,0 +1,351 @@ +/******************************************************************************* + * 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.icecast; + +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 IcecastHTTPConfigurationEditor extends BroadcastConfigurationEditor +{ + private final static Logger mLog = LoggerFactory.getLogger( IcecastHTTPConfigurationEditor.class ); + + private static final int ONE_MINUTE_MS = 60000; + + private JTextField mName; + private JTextField mServer; + private JTextField mPort; + private JTextField mMountPoint; + private JTextField mUsername; + private JTextField mPassword; + private JTextField mDescription; + private JTextField mGenre; + private JCheckBox mPublic; + private JSlider mDelay; + private JLabel mDelayValue; + private JCheckBox mEnabled; + private JButton mSaveButton; + private JButton mResetButton; + + public IcecastHTTPConfigurationEditor(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("Icecast 2 (v2.4+) Stream"); + + ImageIcon icon = mSettingsManager.getImageIcon(BroadcastServerType.ICECAST_HTTP.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.icecast.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("Mount:")); + mMountPoint = new JTextField(); + mMountPoint.getDocument().addDocumentListener(this); + mMountPoint.setToolTipText("Mount point (e.g. /12345)"); + add(mMountPoint); + + add(new JLabel("User Name:")); + mUsername = new JTextField(); + mUsername.getDocument().addDocumentListener(this); + mUsername.setToolTipText("User name for the stream. Default: source"); + add(mUsername); + + add(new JLabel("Password:")); + mPassword = new JTextField(); + mPassword.getDocument().addDocumentListener(this); + mPassword.setToolTipText("Password for the stream"); + add(mPassword); + + add(new JLabel("Description:")); + mDescription = new JTextField(); + mDescription.getDocument().addDocumentListener(this); + mDescription.setToolTipText("Description of the stream contents"); + add(mDescription); + + add(new JLabel("Genre:")); + mGenre = new JTextField(); + mGenre.getDocument().addDocumentListener(this); + mGenre.setToolTipText("Genre (e.g. public safety)"); + add(mGenre); + + 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:")); + 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()) + { + IcecastHTTPConfiguration config = (IcecastHTTPConfiguration)getItem(); + + mName.setText(config.getName()); + mServer.setText(config.getHost()); + mPort.setText(config.getPort() != 0 ? String.valueOf(config.getPort()) : null); + mMountPoint.setText(config.getMountPoint()); + mUsername.setText(config.getUserName()); + mPassword.setText(config.getPassword()); + mDescription.setText(config.getDescription()); + mGenre.setText(config.getGenre()); + 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); + mMountPoint.setText(null); + mUsername.setText(null); + mPassword.setText(null); + mDescription.setText(null); + mGenre.setText(null); + mPublic.setSelected(true); + mDelay.setValue(0); + mEnabled.setSelected(false); + } + + setModified(false); + } + + @Override + public void save() + { + IcecastHTTPConfiguration config = (IcecastHTTPConfiguration)getItem(); + + if(validateConfiguration()) + { + updateConfigurationName(config, mName.getText()); + config.setHost(mServer.getText()); + config.setPort(getPort()); + config.setMountPoint(mMountPoint.getText()); + config.setUserName(mUsername.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(IcecastHTTPConfigurationEditor.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; + } + + //Mount Point is required + if(!validateTextField(mMountPoint, "Invalid Mount Point", "Please specify a mount point.")) + { + return false; + } + + //User name is required + if(!validateTextField(mUsername, "Invalid User Name", "Please specify a user name.")) + { + return false; + } + + //Password is required + if(!validateTextField(mPassword, "Invalid Password", "Please specify a password.")) + { + return false; + } + + 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/icecast/IcecastHeader.java b/src/audio/broadcast/icecast/IcecastHeader.java new file mode 100644 index 00000000..b9cd07c7 --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastHeader.java @@ -0,0 +1,50 @@ +/******************************************************************************* + * 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.icecast; + +public enum IcecastHeader +{ + ACCEPT("Accept"), + AUDIO_INFO("ice-audio-info"), + AUTHORIZATION("Authorization"), + BITRATE("ice-bitrate"), + CONTENT_TYPE("Content-Type"), + DESCRIPTION("ice-description"), + EXPECT("Expect"), + GENRE("ice-genre"), + HOST("Host"), + NAME("ice-name"), + PUBLIC("ice-public"), + URL("ice-url"), + USER_AGENT("User-Agent"), + + UNKNOWN("unknown"); + + private String mValue; + + private IcecastHeader(String value) + { + mValue = value; + } + + public String getValue() + { + return mValue; + } +} diff --git a/src/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java b/src/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java new file mode 100644 index 00000000..69d49f1c --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java @@ -0,0 +1,296 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.BroadcastState; +import audio.broadcast.icecast.codec.IcecastCodecFactory; +import audio.convert.MP3AudioConverter; +import controller.ThreadPoolManager; +import org.apache.mina.core.RuntimeIoException; +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.transport.socket.nio.NioSocketConnector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import properties.SystemProperties; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class IcecastTCPAudioBroadcaster extends IcecastAudioBroadcaster +{ + private final static Logger mLog = LoggerFactory.getLogger( IcecastTCPAudioBroadcaster.class ); + private final static String TERMINATOR = "\r\n"; + private final static String SEPARATOR = ":"; + private static final long RECONNECT_INTERVAL_MILLISECONDS = 30000; //30 seconds + + private NioSocketConnector mSocketConnector; + private IoSession mStreamingSession = null; + + private long mLastConnectionAttempt = 0; + private AtomicBoolean mConnecting = new AtomicBoolean(); + + /** + * Creates an Icecast 2.3.2 compatible broadcaster using TCP and a pseudo HTTP 1.0 protocol. This broadcaster is + * compatible with Icecast version 2.3.2 and older versions of the server software. + * + * Note: use @see IcecastHTTPAudioBroadcaster for Icecast version 2.4.x and newer. + * + * This broadcaster uses the Apache Mina library for the streaming socket connection and for metadata updates. The + * ShoutcastV2IOHandler manages all interaction with the Icecast server and manages the overall broadcast state. + * + * @param configuration for the Icecast stream + */ + public IcecastTCPAudioBroadcaster(ThreadPoolManager threadPoolManager, IcecastTCPConfiguration configuration) + { + super(threadPoolManager, configuration); + } + + /** + * Broadcasts the audio frame or sequence + */ + @Override + protected void broadcastAudio(byte[] audio) + { + if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) + { + mStreamingSession.write(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. + * + * @return true if the audio handler can stream audio + */ + private boolean connect() + { + if(!connected() && canConnect() && + (mLastConnectionAttempt + RECONNECT_INTERVAL_MILLISECONDS < System.currentTimeMillis()) && + mConnecting.compareAndSet(false, true)) + { + mLastConnectionAttempt = System.currentTimeMillis(); + + if(mSocketConnector == null) + { + mSocketConnector = new NioSocketConnector(); + mSocketConnector.setConnectTimeoutCheckInterval(10000); + +// mSocketConnector.getFilterChain().addLast("logger", +// new LoggingFilter(IcecastTCPAudioBroadcaster.class)); + + mSocketConnector.getFilterChain().addLast("codec", + new ProtocolCodecFilter(new IcecastCodecFactory())); + mSocketConnector.setHandler(new IcecastTCPIOHandler()); + } + + 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(); + } + + + /** + * Disconnect from the remote broadcast server and cleanup input/output streams and socket connection + */ + public void disconnect() + { + if(connected()) + { + if(mStreamingSession != null) + { + mStreamingSession.closeNow(); + } + } + } + + /** + * IO Handler for managing Icecast TCP connection and credentials + */ + public class IcecastTCPIOHandler extends IoHandlerAdapter + { + /** + * Sends stream configuration and user credentials upon connecting to remote server + */ + @Override + public void sessionOpened(IoSession session) throws Exception + { + StringBuilder sb = new StringBuilder(); + sb.append("SOURCE ").append(getConfiguration().getMountPoint()); + sb.append(" HTTP/1.0").append(TERMINATOR); + + sb.append("Authorization: ").append(getConfiguration().getBase64EncodedCredentials()).append(TERMINATOR); + sb.append(IcecastHeader.USER_AGENT.getValue()).append(SEPARATOR) + .append(SystemProperties.getInstance().getApplicationName()).append(TERMINATOR); + sb.append(IcecastHeader.CONTENT_TYPE.getValue()).append(SEPARATOR) + .append(getConfiguration().getBroadcastFormat().getValue()).append(TERMINATOR); + sb.append(IcecastHeader.PUBLIC.getValue()).append(SEPARATOR) + .append(getConfiguration().isPublic() ? "1" : "0").append(TERMINATOR); + + sb.append(IcecastHeader.AUDIO_INFO.getValue()).append(SEPARATOR); + sb.append("samplerate=").append(getConfiguration().getSampleRate()).append(";"); + sb.append("quality=").append(MP3AudioConverter.AUDIO_QUALITY).append(";"); + sb.append("channels=").append(getConfiguration().getChannels()).append(TERMINATOR); + + if(getConfiguration().hasName()) + { + sb.append(IcecastHeader.NAME.getValue()).append(SEPARATOR).append(getConfiguration().getName()).append(TERMINATOR); + } + + if(getConfiguration().hasGenre()) + { + sb.append(IcecastHeader.GENRE.getValue()).append(SEPARATOR) + .append(getConfiguration().getGenre()).append(TERMINATOR); + } + + if(getConfiguration().hasDescription()) + { + sb.append(IcecastHeader.DESCRIPTION.getValue()).append(SEPARATOR) + .append(getConfiguration().getDescription()).append(TERMINATOR); + } + + sb.append(TERMINATOR).append(TERMINATOR); + + session.write(sb.toString()); + } + + @Override + public void sessionClosed(IoSession session) throws Exception + { + //If there is already an error state, don't override it. Otherwise, set state to disconnected + if(!getBroadcastState().isErrorState()) + { + setBroadcastState(BroadcastState.DISCONNECTED); + } + + 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 + { + if(object instanceof String) + { + String message = (String)object; + + if(message != null && !message.trim().isEmpty()) + { + if(message.startsWith("HTTP/1.0 200 OK")) + { + setBroadcastState(BroadcastState.CONNECTED); + } + else if(message.startsWith("HTTP/1.0 403 Mountpoint in use")) + { + setBroadcastState(BroadcastState.MOUNT_POINT_IN_USE); + } + else if(message.contains("Invalid Password") || + message.contains("Authentication Required")) + { + setBroadcastState(BroadcastState.INVALID_CREDENTIALS); + } + 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/icecast/IcecastTCPConfiguration.java b/src/audio/broadcast/icecast/IcecastTCPConfiguration.java new file mode 100644 index 00000000..413f0a32 --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastTCPConfiguration.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * 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.icecast; + +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastFormat; +import audio.broadcast.BroadcastServerType; +import audio.broadcast.broadcastify.BroadcastifyConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.bind.annotation.XmlSeeAlso; + +@XmlSeeAlso( {BroadcastifyConfiguration.class } ) +public class IcecastTCPConfiguration extends IcecastConfiguration +{ + private final static Logger mLog = LoggerFactory.getLogger( IcecastTCPConfiguration.class ); + + public IcecastTCPConfiguration() + { + this(BroadcastFormat.MP3); + } + + /** + * Icecast 2.3.x and 2.4.x compatible configuration + * @param format of audio + */ + public IcecastTCPConfiguration(BroadcastFormat format) + { + super(format); + } + + @Override + public BroadcastConfiguration copyOf() + { + IcecastTCPConfiguration copy = new IcecastTCPConfiguration(getBroadcastFormat()); + + //Broadcast Configuration Parameters + copy.setName(getName()); + copy.setHost(getHost()); + copy.setPort(getPort()); + copy.setPassword(getPassword()); + copy.setDelay(getDelay()); + copy.setEnabled(false); + + //Icecast Configuration Parameters + copy.setUserName(getUserName()); + copy.setMountPoint(getMountPoint()); + copy.setDescription(getDescription()); + copy.setGenre(getGenre()); + copy.setPublic(isPublic()); + copy.setURL(getURL()); + + return copy; + } + + @Override + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.ICECAST_TCP; + } +} diff --git a/src/audio/broadcast/icecast/IcecastTCPConfigurationEditor.java b/src/audio/broadcast/icecast/IcecastTCPConfigurationEditor.java new file mode 100644 index 00000000..34dc16f7 --- /dev/null +++ b/src/audio/broadcast/icecast/IcecastTCPConfigurationEditor.java @@ -0,0 +1,337 @@ +/******************************************************************************* + * 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.icecast; + +import alias.AliasModel; +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastConfigurationEditor; +import audio.broadcast.BroadcastEvent; +import audio.broadcast.BroadcastModel; +import audio.broadcast.BroadcastServerType; +import audio.broadcast.broadcastify.BroadcastifyConfiguration; +import gui.editor.DocumentListenerEditor; +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 IcecastTCPConfigurationEditor extends BroadcastConfigurationEditor +{ + private final static Logger mLog = LoggerFactory.getLogger( IcecastTCPConfigurationEditor.class ); + + private static final int ONE_MINUTE_MS = 60000; + + private JTextField mName; + private JTextField mServer; + private JTextField mPort; + private JTextField mMountPoint; + private JTextField mPassword; + private JTextField mDescription; + private JTextField mGenre; + private JCheckBox mPublic; + private JSlider mDelay; + private JLabel mDelayValue; + private JCheckBox mEnabled; + private JButton mSaveButton; + private JButton mResetButton; + + public IcecastTCPConfigurationEditor(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("Icecast (v2.3) Stream"); + + ImageIcon icon = mSettingsManager.getImageIcon(BroadcastServerType.ICECAST_TCP.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.icecast.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("Mount:")); + mMountPoint = new JTextField(); + mMountPoint.getDocument().addDocumentListener(this); + mMountPoint.setToolTipText("Mount point (e.g. /12345)"); + add(mMountPoint); + + add(new JLabel("Password:")); + mPassword = new JTextField(); + mPassword.getDocument().addDocumentListener(this); + mPassword.setToolTipText("Password for the stream"); + add(mPassword); + + add(new JLabel("Description:")); + mDescription = new JTextField(); + mDescription.getDocument().addDocumentListener(this); + mDescription.setToolTipText("Description of the stream contents"); + add(mDescription); + + add(new JLabel("Genre:")); + mGenre = new JTextField(); + mGenre.getDocument().addDocumentListener(this); + mGenre.setToolTipText("Genre (e.g. public safety)"); + add(mGenre); + + 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()) + { + IcecastTCPConfiguration config = (IcecastTCPConfiguration)getItem(); + + mName.setText(config.getName()); + mServer.setText(config.getHost()); + mPort.setText(config.getPort() != 0 ? String.valueOf(config.getPort()) : null); + mMountPoint.setText(config.getMountPoint()); + mPassword.setText(config.getPassword()); + mDescription.setText(config.getDescription()); + mGenre.setText(config.getGenre()); + 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); + mMountPoint.setText(null); + mPassword.setText(null); + mDescription.setText(null); + mGenre.setText(null); + mPublic.setSelected(true); + mDelay.setValue(0); + mEnabled.setSelected(false); + } + + setModified(false); + } + + @Override + public void save() + { + IcecastTCPConfiguration config = (IcecastTCPConfiguration)getItem(); + + if(validateConfiguration()) + { + updateConfigurationName(config, mName.getText()); + config.setHost(mServer.getText()); + config.setPort(getPort()); + config.setMountPoint(mMountPoint.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(IcecastTCPConfigurationEditor.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; + } + + //Mount Point is optional, but required + if(!validateTextField(mMountPoint, "Invalid Mount Point", "Please specify a mount point.")) + { + 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/icecast/codec/IcecastCodecFactory.java b/src/audio/broadcast/icecast/codec/IcecastCodecFactory.java new file mode 100644 index 00000000..fef72ba8 --- /dev/null +++ b/src/audio/broadcast/icecast/codec/IcecastCodecFactory.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * 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.icecast.codec; + +import org.apache.mina.core.session.IoSession; +import org.apache.mina.filter.codec.ProtocolCodecFactory; +import org.apache.mina.filter.codec.ProtocolDecoder; +import org.apache.mina.filter.codec.ProtocolEncoder; +import org.apache.mina.filter.codec.textline.LineDelimiter; +import org.apache.mina.filter.codec.textline.TextLineDecoder; + +import java.nio.charset.StandardCharsets; + +public class IcecastCodecFactory implements ProtocolCodecFactory +{ + private ProtocolEncoder mProtocolEncoder; + private ProtocolDecoder mProtocolDecoder; + + /** + * Factory for codecs to process Icecast string protocol and byte array audio frames for use with Apache Mina + */ + public IcecastCodecFactory() + { + } + + @Override + public ProtocolEncoder getEncoder(IoSession ioSession) throws Exception + { + if(mProtocolEncoder == null) + { + mProtocolEncoder = new IcecastEncoder(); + } + + return mProtocolEncoder; + } + + @Override + public ProtocolDecoder getDecoder(IoSession ioSession) throws Exception + { + if(mProtocolDecoder == null) + { + mProtocolDecoder = new TextLineDecoder(StandardCharsets.UTF_8, LineDelimiter.AUTO); + } + + return mProtocolDecoder; + } +} diff --git a/src/audio/broadcast/icecast/codec/IcecastEncoder.java b/src/audio/broadcast/icecast/codec/IcecastEncoder.java new file mode 100644 index 00000000..565b478d --- /dev/null +++ b/src/audio/broadcast/icecast/codec/IcecastEncoder.java @@ -0,0 +1,94 @@ +/******************************************************************************* + * 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.icecast.codec; + +import org.apache.mina.core.buffer.IoBuffer; +import org.apache.mina.core.session.AttributeKey; +import org.apache.mina.core.session.IoSession; +import org.apache.mina.filter.codec.ProtocolEncoderAdapter; +import org.apache.mina.filter.codec.ProtocolEncoderOutput; +import org.apache.mina.filter.codec.textline.LineDelimiter; +import org.apache.mina.filter.codec.textline.TextLineEncoder; + +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.StandardCharsets; + +public class IcecastEncoder extends ProtocolEncoderAdapter +{ + private static final AttributeKey ENCODER = new AttributeKey(TextLineEncoder.class, "encoder"); + private static final Charset mCharset = StandardCharsets.UTF_8; + private static final int MAX_LINE_LENGTH = 2147483647; + + /** + * Icecast Protocol Encoder is an Apache Mina compliant protocol encoder that supports Icecast string-based + * connection negotiation as well as byte array audio frame data. + */ + public IcecastEncoder() + { + } + + @Override + public void encode(IoSession session, Object message, ProtocolEncoderOutput out) throws Exception + { + //Audio frame data + if(message instanceof byte[]) + { + byte[] data = (byte[])message; + + IoBuffer buf = IoBuffer.allocate(data.length).setAutoExpand(false); + buf.put(data); + buf.flip(); + out.write(buf); + } + //Icecast connection messages + else if(message instanceof String) + { + CharsetEncoder encoder = (CharsetEncoder)session.getAttribute(ENCODER); + + if(encoder == null) + { + encoder = mCharset.newEncoder(); + session.setAttribute(ENCODER, encoder); + } + + String value = (String)message; + + IoBuffer buf = IoBuffer.allocate(value.length()).setAutoExpand(true); + + buf.putString(value, encoder); + + + if(buf.position() > MAX_LINE_LENGTH) + { + throw new IllegalArgumentException("Line length: " + buf.position()); + } + else + { + buf.putString(LineDelimiter.UNIX.getValue(), encoder); + buf.flip(); + out.write(buf); + } + } + else + { + throw new IllegalArgumentException("Can't encode - unrecognized object type: " + message.getClass()); + } + } +} diff --git a/src/audio/broadcast/shoutcast/v1/ShoutcastMetadata.java b/src/audio/broadcast/shoutcast/v1/ShoutcastMetadata.java new file mode 100644 index 00000000..b62eb032 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastMetadata.java @@ -0,0 +1,95 @@ +/******************************************************************************* + * 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.BroadcastFormat; + +public enum ShoutcastMetadata +{ + AIM("icy-aim:"), + AUDIO_BIT_RATE("icy-br:"), + CHANNELS("icy-channels:"), + CONTENT_TYPE("Content-Type:"), + DESCRIPTION("icy-description:"), + GENRE("icy-genre:"), + ICE_AUDIO_INFO("ice-audio-info:"), + ICQ("icy-icq:"), + INTERNET_RELAY_CHAT("icy-irc:"), + METADATA_INTERVAL("icy-metaint:"), + METADATA_REQUEST("icy-metadata:"), + PRE_BUFFER("icy-prebuffer:"), + PUBLIC("icy-pub:"), + RESET("icy-reset:"), + SERVER("server:"), + STREAM_NAME("icy-name:"), + SONG_TITLE("icy-title:"), + URL("icy-url:"), + USER_AGENT("User-Agent:"), + UNKNOWN("Unknown"); + + private String mTag; + + private ShoutcastMetadata(String tag) + { + mTag = tag; + } + + public static final String COMMAND_TERMINATOR = "\n"; + + public String toString() + { + return mTag; + } + + /** + * Encodes the metadata tag and value with an end-of-line character, appropriate for sending over the network. + */ + public String encode(String value) + { + if(value != null && !value.isEmpty()) + { + StringBuilder sb = new StringBuilder(); + sb.append(mTag).append(value).append(COMMAND_TERMINATOR); + return sb.toString(); + } + + return null; + } + + public String encode(boolean value) + { + StringBuilder sb = new StringBuilder(); + sb.append(mTag).append(value ? "1" : "0").append(COMMAND_TERMINATOR); + return sb.toString(); + } + + public String encode(BroadcastFormat broadcastFormat) + { + StringBuilder sb = new StringBuilder(); + sb.append(mTag).append(broadcastFormat.getValue()).append(COMMAND_TERMINATOR); + return sb.toString(); + } + + public String encode(int value) + { + StringBuilder sb = new StringBuilder(); + sb.append(mTag).append(String.valueOf(value)).append(COMMAND_TERMINATOR); + return sb.toString(); + } +} diff --git a/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java b/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java new file mode 100644 index 00000000..9d376174 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java @@ -0,0 +1,292 @@ +/******************************************************************************* + * 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.BroadcastState; +import audio.broadcast.IBroadcastMetadataUpdater; +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 java.io.IOException; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ShoutcastV1AudioBroadcaster extends AudioBroadcaster +{ + 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 IBroadcastMetadataUpdater mMetadataUpdater; + + private long mLastConnectionAttempt = 0; + private AtomicBoolean mConnecting = new AtomicBoolean(); + + /** + * 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) + { + super(threadPoolManager, configuration); + } + + /** + * Shoutcast V1 broadcast configuration + */ + private ShoutcastV1Configuration getConfiguration() + { + return (ShoutcastV1Configuration)getBroadcastConfiguration(); + } + + @Override + protected IBroadcastMetadataUpdater getMetadataUpdater() + { + if(mMetadataUpdater == null) + { + mMetadataUpdater = new ShoutcastV1BroadcastMetadataUpdater(getThreadPoolManager(), getConfiguration()); + } + + return mMetadataUpdater; + } + + /** + * Broadcasts the audio frame or sequence + */ + @Override + protected void broadcastAudio(byte[] audio) + { + if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) + { + IoBuffer buf = IoBuffer.allocate(audio.length).setAutoExpand(false); + buf.put(audio); + buf.flip(); + mStreamingSession.write(buf); + } + } + + /** + * (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. + * + * @return true if the audio handler can stream audio + */ + private boolean connect() + { + if(!connected() && canConnect() && + (mLastConnectionAttempt + RECONNECT_INTERVAL_MILLISECONDS < System.currentTimeMillis()) && + mConnecting.compareAndSet(false, true)) + { + 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(); + } + + + /** + * Disconnect from the remote broadcast server and cleanup input/output streams and socket connection + */ + public void disconnect() + { + if(connected()) + { + if(mStreamingSession != null) + { + mStreamingSession.closeNow(); + } + } + } + + /** + * IO Handler for managing Icecast TCP connection and credentials + */ + public class ShoutcastIOHandler extends IoHandlerAdapter + { + /** + * Sends stream configuration and user credentials upon connecting to remote server + */ + @Override + public void sessionOpened(IoSession session) throws Exception + { + StringBuilder sb = new StringBuilder(); + + //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()); + } + + @Override + public void sessionClosed(IoSession session) throws Exception + { + //If there is already an error state, don't override it. Otherwise, set state to disconnected + if(!getBroadcastState().isErrorState()) + { + setBroadcastState(BroadcastState.DISCONNECTED); + } + + 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 + { + 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/ShoutcastV1BroadcastMetadataUpdater.java b/src/audio/broadcast/shoutcast/v1/ShoutcastV1BroadcastMetadataUpdater.java new file mode 100644 index 00000000..9d80b6a4 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1BroadcastMetadataUpdater.java @@ -0,0 +1,267 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.IBroadcastMetadataUpdater; +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.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 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 ShoutcastV1BroadcastMetadataUpdater implements IBroadcastMetadataUpdater +{ + private final static Logger mLog = LoggerFactory.getLogger(ShoutcastV1BroadcastMetadataUpdater.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 ShoutcastV1BroadcastMetadataUpdater(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(ShoutcastV1BroadcastMetadataUpdater.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<>(); + + 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/ShoutcastV1Configuration.java b/src/audio/broadcast/shoutcast/v1/ShoutcastV1Configuration.java new file mode 100644 index 00000000..ee1133fa --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1Configuration.java @@ -0,0 +1,170 @@ +/******************************************************************************* + * 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.BroadcastConfiguration; +import audio.broadcast.BroadcastFormat; +import audio.broadcast.BroadcastServerType; + +import javax.xml.bind.annotation.XmlAttribute; + +public class ShoutcastV1Configuration extends BroadcastConfiguration +{ + private String mGenre; + private String mDescription; + private boolean mPublic; + private int mChannels = 1; + private int mBitRate = 16; + + public ShoutcastV1Configuration() + { + //No-arg JAXB constructor + } + + public ShoutcastV1Configuration(BroadcastFormat format) + { + super(format); + } + + @Override + public BroadcastConfiguration copyOf() + { + ShoutcastV1Configuration copy = new ShoutcastV1Configuration(getBroadcastFormat()); + + //Broadcast Configuration Parameters + copy.setName(getName()); + copy.setHost(getHost()); + copy.setPort(getPort()); + copy.setPassword(getPassword()); + copy.setDelay(getDelay()); + copy.setEnabled(false); + + //Icecast Configuration Parameters + copy.setGenre(getGenre()); + copy.setPublic(isPublic()); + copy.setChannels(getChannels()); + copy.setBitRate(getBitRate()); + + return copy; + } + + @Override + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.SHOUTCAST_V1; + } + + /** + * Stream genre + */ + @XmlAttribute(name="genre") + public String getGenre() + { + return mGenre; + } + + /** + * Stream genre + * @param genre + */ + public void setGenre(String genre) + { + 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; + } + + /** + * Sets public visibility of the broadcastAudio + * @param isPublic indicates if the broadcastAudio should be visible to the public + */ + public void setPublic(boolean isPublic) + { + mPublic = isPublic; + } + + /** + * Number of audio channels in the broadcastAudio + */ + @XmlAttribute(name="channels") + public int getChannels() + { + return mChannels; + } + + /** + * Sets the number of audio channels in the broadcastAudio + */ + public void setChannels(int channels) + { + mChannels = channels; + } + + /** + * Bit rate in bits per second + */ + @XmlAttribute(name="bitrate") + public int getBitRate() + { + return mBitRate; + } + + /** + * Bit rate + * @param bitRate in samples per second + */ + public void setBitRate(int bitRate) + { + mBitRate = bitRate; + } + + @Override + public boolean isValid() + { + 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..a6f5565a --- /dev/null +++ b/src/audio/broadcast/shoutcast/v1/ShoutcastV1ConfigurationEditor.java @@ -0,0 +1,319 @@ +/******************************************************************************* + * 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.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/ShoutcastV2AudioBroadcaster.java b/src/audio/broadcast/shoutcast/v2/ShoutcastV2AudioBroadcaster.java new file mode 100644 index 00000000..57a80596 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ShoutcastV2AudioBroadcaster.java @@ -0,0 +1,528 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.v2; + +import audio.broadcast.AudioBroadcaster; +import audio.broadcast.BroadcastState; +import audio.broadcast.IBroadcastMetadataUpdater; +import audio.broadcast.shoutcast.v2.ultravox.AuthenticateBroadcast; +import audio.broadcast.shoutcast.v2.ultravox.CacheableXMLMetadata; +import audio.broadcast.shoutcast.v2.ultravox.ConfigureIcyName; +import audio.broadcast.shoutcast.v2.ultravox.ConfigureIcyPublic; +import audio.broadcast.shoutcast.v2.ultravox.MP3Audio; +import audio.broadcast.shoutcast.v2.ultravox.NegotiateMaxPayloadSize; +import audio.broadcast.shoutcast.v2.ultravox.RequestCipher; +import audio.broadcast.shoutcast.v2.ultravox.SetupBroadcast; +import audio.broadcast.shoutcast.v2.ultravox.Standby; +import audio.broadcast.shoutcast.v2.ultravox.StreamMimeType; +import audio.broadcast.shoutcast.v2.ultravox.TerminateBroadcast; +import audio.broadcast.shoutcast.v2.ultravox.UltravoxMessage; +import audio.broadcast.shoutcast.v2.ultravox.UltravoxMessageFactory; +import audio.broadcast.shoutcast.v2.ultravox.UltravoxMessageType; +import audio.broadcast.shoutcast.v2.ultravox.UltravoxMetadata; +import audio.broadcast.shoutcast.v2.ultravox.UltravoxProtocolFactory; +import audio.metadata.AudioMetadata; +import audio.metadata.Metadata; +import audio.metadata.MetadataType; +import controller.ThreadPoolManager; +import org.apache.mina.core.RuntimeIoException; +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.transport.socket.nio.NioSocketConnector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import properties.SystemProperties; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.LinkedTransferQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ShoutcastV2AudioBroadcaster extends AudioBroadcaster implements IBroadcastMetadataUpdater +{ + private final static Logger mLog = LoggerFactory.getLogger( ShoutcastV2AudioBroadcaster.class ); + private static final long RECONNECT_INTERVAL_MILLISECONDS = 30000; //30 seconds + private int mMaxPayloadSize = 16377; + + private NioSocketConnector mSocketConnector; + private IoSession mStreamingSession = null; + + private long mLastConnectionAttempt = 0; + private AtomicBoolean mConnecting = new AtomicBoolean(); + private LinkedTransferQueue mMetadataMessageQueue = new LinkedTransferQueue<>(); + + + /** + * Shoutcast 2.x (Ultravox 2.1) broadcaster. This broadcaster is compatible with Shoutcast 2.x and newer versions + * of the server software. + * + * Note: use @see ShoutcastV1AudioBroadcaster for Shoutcast version 1.x and older. + * + * This broadcaster uses the Apache Mina library for the streaming socket connection. The + * ShoutcastV2IOHandler manages all interaction with the Shoutcast server and manages the overall broadcast state. + * + * @param threadPoolManager for thread pool access + * @param configuration for the Shoutcast V2 stream + */ + public ShoutcastV2AudioBroadcaster(ThreadPoolManager threadPoolManager, ShoutcastV2Configuration configuration) + { + super(threadPoolManager, configuration); + } + + public ShoutcastV2Configuration getConfiguration() + { + return (ShoutcastV2Configuration)getBroadcastConfiguration(); + } + + /** + * Broadcasts the audio frame or sequence + */ + @Override + protected void broadcastAudio(byte[] audio) + { + //Dispatch any queued metadata messages + UltravoxMessage metadataMessage = mMetadataMessageQueue.poll(); + + while(metadataMessage != null) + { + mStreamingSession.write(metadataMessage); + metadataMessage = mMetadataMessageQueue.poll(); + } + + //Dispatch audio message + if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) + { + MP3Audio mp3Audio = new MP3Audio(); + mp3Audio.setPayload(audio); + + mStreamingSession.write(mp3Audio); + } + } + + @Override + public void update(AudioMetadata metadata) + { + List metadataMessages = getMetadataMessages(metadata); + + if(!metadataMessages.isEmpty()) + { + mMetadataMessageQueue.addAll(metadataMessages); + } + } + + @Override + protected IBroadcastMetadataUpdater getMetadataUpdater() + { + return this; + } + + /** + * (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. + * + * @return true if the audio handler can stream audio + */ + private boolean connect() + { + if(!connected() && canConnect() && + (mLastConnectionAttempt + RECONNECT_INTERVAL_MILLISECONDS < System.currentTimeMillis()) && + mConnecting.compareAndSet(false, true)) + { + mLastConnectionAttempt = System.currentTimeMillis(); + + if(mSocketConnector == null) + { + mSocketConnector = new NioSocketConnector(); + mSocketConnector.setConnectTimeoutCheckInterval(10000); + +// mSocketConnector.getFilterChain().addLast("logger", +// new LoggingFilter(ShoutcastV2AudioBroadcaster.class)); + + mSocketConnector.getFilterChain().addLast("codec", + new ProtocolCodecFilter(new UltravoxProtocolFactory())); + + mSocketConnector.setHandler(new ShoutcastV2IOHandler()); + } + + 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.error("Failed to connect", rie); + } + else + { + setBroadcastState(BroadcastState.ERROR); + mLog.error("Failed to connect - no exception is available"); + } + + disconnect(); + } + } + }; + + getThreadPoolManager().scheduleOnce(runnable, 0l, TimeUnit.SECONDS); + + } + + return connected(); + } + + + /** + * Disconnect from the remote broadcast server and cleanup input/output streams and socket connection + */ + public void disconnect() + { + if(connected()) + { + if(mStreamingSession != null) + { + mStreamingSession.write(new TerminateBroadcast()); + mStreamingSession.closeNow(); + } + } + } + + /** + * Encodes the list of metadata in one or more Ultravox CacheableXMLMetadata messages according to the maximum negotiated + * payload size for the current connection. Each entry in the list of metadata strings should be an xml encoded + * value: value + * + * See UltravoxMetadata.TAG.asXML(String value) + * + * @param metadata containing audio metadata tags and attributes + * @return a sequence of CacheableXMLMetadata messages sufficient to carry the complete set of metadata + */ + private List getMetadataMessages(AudioMetadata metadata) + { + StringBuilder sb = new StringBuilder(); + + sb.append(""); + + sb.append(UltravoxMetadata.ALBUM_TITLE.asXML(getConfiguration().getName())); + + sb.append(UltravoxMetadata.BROADCAST_CLIENT_APPLICATION.asXML(SystemProperties.getInstance().getApplicationName())); + + if(getConfiguration().hasGenre()) + { + sb.append(UltravoxMetadata.GENRE.asXML(getConfiguration().getGenre())); + } + + if(getConfiguration().hasURL()) + { + sb.append(UltravoxMetadata.URL.asXML(getConfiguration().getURL())); + } + + if(metadata != null) + { + Metadata to = metadata.getMetadata(MetadataType.TO); + if(to != null) + { + if(to.hasAlias()) + { + sb.append(UltravoxMetadata.TITLE_2.asXML(to.getAlias().toString())); + } + else + { + sb.append(UltravoxMetadata.TITLE_2.asXML(to.getValue())); + } + } + + Metadata from = metadata.getMetadata(MetadataType.FROM); + if(from != null) + { + if(from.hasAlias()) + { + sb.append(UltravoxMetadata.TITLE_3.asXML("From: " + from.getAlias().toString())); + } + else + { + sb.append(UltravoxMetadata.TITLE_3.asXML("From: " + from.getValue())); + } + } + } + else + { + sb.append(UltravoxMetadata.TITLE_2.asXML("Scanning ...")); + } + + sb.append(""); + + byte[] xml = sb.toString().getBytes(); + + int pointer = 0; + int messageCounter = 1; + int messageCount = (int)Math.ceil((double)xml.length / (double)(mMaxPayloadSize - 6)); + + if(messageCount > 32) + { + messageCount = 32; //Max number of metadata messages in a sequence + } + + + List messages = new ArrayList<>(); + + while(pointer < xml.length && messageCounter <= messageCount) + { + int payloadSize = Math.min(mMaxPayloadSize - 6, xml.length - pointer); + + byte[] payload = new byte[payloadSize + 6]; + + payload[1] = (byte)(0x01); + payload[3] = (byte)(messageCount & 0xFF); + payload[5] = (byte)(messageCounter & 0xFF); + + System.arraycopy(xml, pointer, payload, 6, payloadSize); + + CacheableXMLMetadata message = new CacheableXMLMetadata(); + message.setPayload(payload); + messages.add(message); + + pointer += payloadSize; + } + + return messages; + } + + + /** + * IO Handler for managing Shoutcast V2 connection and credentials + */ + public class ShoutcastV2IOHandler extends IoHandlerAdapter + { + /** + * Sends stream configuration and user credentials upon connecting to remote server + */ + @Override + public void sessionOpened(IoSession session) throws Exception + { + session.write(UltravoxMessageFactory.getMessage(UltravoxMessageType.REQUEST_CIPHER)); + } + + @Override + public void sessionClosed(IoSession session) throws Exception + { + //If there is already an error state, don't override it. Otherwise, set state to disconnected + if(!getBroadcastState().isErrorState()) + { + setBroadcastState(BroadcastState.DISCONNECTED); + } + + 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 + { + if(object instanceof UltravoxMessage) + { + UltravoxMessage ultravoxMessage = (UltravoxMessage)object; + + switch (ultravoxMessage.getMessageType()) + { + case REQUEST_CIPHER: + String cipherKey = ((RequestCipher)ultravoxMessage).getCipher(); + + AuthenticateBroadcast authenticateBroadcast = new AuthenticateBroadcast(); + + authenticateBroadcast.setCredentials(cipherKey, getConfiguration().getStreamID(), + getConfiguration().getUserID(), getConfiguration().getPassword()); + + session.write(authenticateBroadcast); + break; + case AUTHENTICATE_BROADCAST: + if(ultravoxMessage.isErrorResponse()) + { + String errorMessage = ultravoxMessage.getErrorMessage(); + + if(errorMessage.startsWith(AuthenticateBroadcast.STREAM_ID_ERROR)) + { + setBroadcastState(BroadcastState.INVALID_MOUNT_POINT); + } + else + { + mLog.error("Invalid Credentials - response: " + ultravoxMessage.getPayload()); + setBroadcastState(BroadcastState.INVALID_CREDENTIALS); + } + } + else + { + StreamMimeType streamMimeType = new StreamMimeType(); + streamMimeType.setFormat(getConfiguration().getBroadcastFormat()); + session.write(streamMimeType); + } + break; + case STREAM_MIME_TYPE: + if(ultravoxMessage.isErrorResponse()) + { + mLog.error("Unsupported Audio Format:" + getConfiguration().getBroadcastFormat().toString() + + " - " + ultravoxMessage.getErrorMessage()); + setBroadcastState(BroadcastState.UNSUPPORTED_AUDIO_FORMAT); + } + else + { + SetupBroadcast setupBroadcast = new SetupBroadcast(); + + //Use the same value for average and minimum bit rates + setupBroadcast.setBitRate(getConfiguration().getBitRate(), getConfiguration().getBitRate()); + + session.write(setupBroadcast); + } + break; + case SETUP_BROADCAST: + if(ultravoxMessage.isErrorResponse()) + { + mLog.error("Unsupported Audio Bit Rate:" + getConfiguration().getBitRate() + + " - " + ultravoxMessage.getErrorMessage()); + setBroadcastState(BroadcastState.UNSUPPORTED_AUDIO_FORMAT); + } + else + { + NegotiateMaxPayloadSize negotiateMaxPayloadSize = new NegotiateMaxPayloadSize(); + + negotiateMaxPayloadSize.setMaximumPayloadSize(16377, 4192); + + session.write(negotiateMaxPayloadSize); + } + break; + case NEGOTIATE_MAX_PAYLOAD_SIZE: + if(ultravoxMessage.isErrorResponse()) + { + mLog.error("Unsupported maximum payload size (18 min - 36 kbps max) - " + + ultravoxMessage.getErrorMessage()); + setBroadcastState(BroadcastState.UNSUPPORTED_AUDIO_FORMAT); + } + else + { + mMaxPayloadSize = ((NegotiateMaxPayloadSize)ultravoxMessage).getMaximumPayloadSize(); + + ConfigureIcyPublic configureIcyPublic = new ConfigureIcyPublic(); + configureIcyPublic.setPublic(getConfiguration().isPublic()); + session.write(configureIcyPublic); + } + break; + case CONFIGURE_ICY_PUBLIC: + if(ultravoxMessage.isErrorResponse()) + { + mLog.error("Error setting shoutcast stream as public - " + ultravoxMessage.getErrorMessage()); + setBroadcastState(BroadcastState.ERROR); + } + else + { + ConfigureIcyName configureIcyName = new ConfigureIcyName(); + configureIcyName.setName(getConfiguration().getName()); + session.write(configureIcyName); + } + break; + case CONFIGURE_ICY_NAME: + if(ultravoxMessage.isErrorResponse()) + { + mLog.error("Error setting shoutcast stream name - " + ultravoxMessage.getErrorMessage()); + setBroadcastState(BroadcastState.CONFIGURATION_ERROR); + } + else + { + session.write(new Standby()); + } + break; + case STANDBY: + if(ultravoxMessage.isErrorResponse()) + { + mLog.error("Error following stream configuration and standby - " + + ultravoxMessage.getErrorMessage()); + setBroadcastState(BroadcastState.ERROR); + } + else + { + setBroadcastState(BroadcastState.CONNECTED); + } + break; + default: + mLog.error("Unrecognized ultravox message:" + ultravoxMessage.getMessageType() + + " payload:" + ultravoxMessage.getPayload()); + + setBroadcastState(BroadcastState.ERROR); + } + } + else + { + mLog.debug("Unrecognized message received from shoutcast v2 server:" + object.toString()); + + setBroadcastState(BroadcastState.ERROR); + } + } + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java b/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java new file mode 100644 index 00000000..c7fafe6a --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ShoutcastV2Configuration.java @@ -0,0 +1,205 @@ +/******************************************************************************* + * 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.v2; + +import audio.broadcast.BroadcastConfiguration; +import audio.broadcast.BroadcastFormat; +import audio.broadcast.BroadcastServerType; + +import javax.xml.bind.annotation.XmlAttribute; + +public class ShoutcastV2Configuration extends BroadcastConfiguration +{ + private int mStreamID; + private String mUserID; + private int mBitRate = 16; + private String mGenre; + private String mURL; + private boolean mPublic = true; + + public ShoutcastV2Configuration() + { + //No-arg JAXB constructor + } + + public ShoutcastV2Configuration(BroadcastFormat format) + { + super(format); + } + + @Override + public BroadcastConfiguration copyOf() + { + ShoutcastV2Configuration copy = new ShoutcastV2Configuration(getBroadcastFormat()); + + //Broadcast Configuration Parameters + copy.setName(getName()); + copy.setHost(getHost()); + copy.setPort(getPort()); + copy.setPassword(getPassword()); + copy.setDelay(getDelay()); + copy.setEnabled(false); + + //Shoutcast V2 Configuration Parameters + copy.setStreamID(getStreamID()); + copy.setUserID(getUserID()); + copy.setBitRate(getBitRate()); + copy.setGenre(getGenre()); + copy.setURL(getURL()); + copy.setPublic(isPublic()); + + return copy; + } + + + @Override + public BroadcastServerType getBroadcastServerType() + { + return BroadcastServerType.SHOUTCAST_V2; + } + + /** + * Stream ID + */ + @XmlAttribute(name = "stream_id") + public int getStreamID() + { + return mStreamID; + } + + /** + * Sets the stream ID + * + * @param streamID numeric stream identifier between 1 - 2147483647 + */ + public void setStreamID(int streamID) + { + assert(1 <= streamID && streamID <= Integer.MAX_VALUE); + mStreamID = streamID; + } + + /** + * User ID + */ + @XmlAttribute(name = "user_id") + public String getUserID() + { + return mUserID; + } + + /** + * Sets the user ID + * @param userID + */ + public void setUserID(String userID) + { + mUserID = userID; + } + + /** + * Bit rate in kilobits per second + */ + @XmlAttribute(name = "bitrate") + public int getBitRate() + { + return mBitRate; + } + + /** + * Bit rate + * @param bitRate in kilobits per second + */ + public void setBitRate(int bitRate) + { + mBitRate = bitRate; + } + + /** + * Stream genre + */ + @XmlAttribute(name = "genre") + public String getGenre() + { + return mGenre; + } + + /** + * Sets the genre tag for this stream + */ + public void setGenre(String genre) + { + mGenre = genre; + } + + + public boolean hasGenre() + { + return mGenre != null && !mGenre.isEmpty(); + } + + /** + * Public visibility of the broadcastAudio + */ + @XmlAttribute(name = "public") + public boolean isPublic() + { + return mPublic; + } + + /** + * Sets public visibility of the broadcastAudio + * @param isPublic indicates if the broadcastAudio should be visible to the public + */ + public void setPublic(boolean isPublic) + { + mPublic = isPublic; + } + + /** + * URL associated with the broadcastAudio where users can find additional details. + */ + @XmlAttribute(name = "url") + public String getURL() + { + return mURL; + } + + /** + * URL associated with the broadcastAudio where users can find additional details. + * @param url + */ + public void setURL(String url) + { + mURL = url; + } + + public boolean hasURL() + { + return mURL != null && !mURL.isEmpty(); + } + + @Override + public boolean isValid() + { + return super.isValid() && hasName() && + getUserID() != null && !getUserID().isEmpty() && + 1 <= getStreamID() && + getPassword() != null && !getPassword().isEmpty(); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ShoutcastV2ConfigurationEditor.java b/src/audio/broadcast/shoutcast/v2/ShoutcastV2ConfigurationEditor.java new file mode 100644 index 00000000..5371d17d --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ShoutcastV2ConfigurationEditor.java @@ -0,0 +1,363 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.v2; + +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 ShoutcastV2ConfigurationEditor extends BroadcastConfigurationEditor +{ + private final static Logger mLog = LoggerFactory.getLogger( ShoutcastV2ConfigurationEditor.class ); + + private static final int ONE_MINUTE_MS = 60000; + + private JTextField mName; + private JTextField mServer; + private JTextField mPort; + private JTextField mStreamID; + private JTextField mUserID; + private JTextField mPassword; + private JTextField mGenre; + private JCheckBox mPublic; + private JSlider mDelay; + private JLabel mDelayValue; + private JCheckBox mEnabled; + private JButton mSaveButton; + private JButton mResetButton; + + public ShoutcastV2ConfigurationEditor(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 (v2) Stream"); + + ImageIcon icon = mSettingsManager.getImageIcon(BroadcastServerType.SHOUTCAST_V2.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("Stream ID:")); + mStreamID = new JTextField(); + mStreamID.getDocument().addDocumentListener(this); + mStreamID.setToolTipText("Stream Identifier)"); + add(mStreamID); + + add(new JLabel("User ID:")); + mUserID = new JTextField(); + mUserID.getDocument().addDocumentListener(this); + mUserID.setToolTipText("User ID/Name"); + add(mUserID); + + 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("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()) + { + ShoutcastV2Configuration config = (ShoutcastV2Configuration)getItem(); + + mName.setText(config.getName()); + mServer.setText(config.getHost()); + mPort.setText(config.getPort() != 0 ? String.valueOf(config.getPort()) : null); + mStreamID.setText(String.valueOf(config.getStreamID())); + mUserID.setText(config.getUserID()); + mPassword.setText(config.getPassword()); + mGenre.setText(config.getGenre()); + 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); + mStreamID.setText(null); + mUserID.setText(null); + mPassword.setText(null); + mGenre.setText(null); + mPublic.setSelected(true); + mDelay.setValue(0); + mEnabled.setSelected(false); + } + + setModified(false); + } + + @Override + public void save() + { + ShoutcastV2Configuration config = (ShoutcastV2Configuration)getItem(); + + if(validateConfiguration()) + { + updateConfigurationName(config, mName.getText()); + config.setHost(mServer.getText()); + config.setPort(getPort()); + config.setUserID(mUserID.getText()); + config.setPassword(mPassword.getText()); + config.setStreamID(getStreamID()); + 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(ShoutcastV2ConfigurationEditor.this, + message, "Invalid Stream Name", JOptionPane.ERROR_MESSAGE); + + mName.requestFocus(); + + return false; + } + + //Server address is required + if(!validateTextField(mServer, "Invalid Server Address", "Please specify a server address.")) + { + return true; + } + + //Port is required + if(!validateIntegerTextField(mPort, "Invalid Port Number", "Please specify a port number (1 <> 65535)", 1, 65535)) + { + return true; + } + + //Stream ID is required + if(!validateIntegerTextField(mStreamID, "Invalid Stream ID", "Please specify a stream ID of 1 or higher", 1, Integer.MAX_VALUE)) + { + return true; + } + + //User ID is required + if(!validateTextField(mUserID, "Invalid User ID", "Please specify a user ID/name.")) + { + return true; + } + + //Password is 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; + } + + /** + * Parses an Integer from the port text control or returns 0 if the control doesn't contain an integer value. + */ + private int getStreamID() + { + String streamID = mStreamID.getText(); + + if(streamID != null && !streamID.isEmpty()) + { + try + { + return Integer.parseInt(streamID); + } + catch(Exception e) + { + //Do nothing, we couldn't parse the number value + } + } + + return 0; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/AuthenticateBroadcast.java b/src/audio/broadcast/shoutcast/v2/ultravox/AuthenticateBroadcast.java new file mode 100644 index 00000000..46463992 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/AuthenticateBroadcast.java @@ -0,0 +1,102 @@ +/******************************************************************************* + * 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.v2.ultravox; + +import util.XTEA; + +import java.util.Arrays; + +public class AuthenticateBroadcast extends UltravoxMessage +{ + public static final String STREAM_ID_ERROR = "Stream ID Error"; + + /** + * Client request to server to send authentication credentials + */ + public AuthenticateBroadcast() + { + super(UltravoxMessageType.AUTHENTICATE_BROADCAST); + } + + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + AuthenticateBroadcast(byte[] data) + { + super(data); + } + + /** + * Set credential to authenticate the stream with the server + * + * @param encryptionKey received from the server in the RequestCipher message + * @param streamID that we're going to stream to + * @param userID for the stream. Note: this is optional and can be null. The server currently ignores this value. + * @param password for the stream. + */ + public void setCredentials(String encryptionKey, int streamID, String userID, String password) + { + XTEA xtea = new XTEA(encryptionKey); + + StringBuilder sb = new StringBuilder(); + + sb.append(ULTRAVOX_VERSION); + sb.append(":"); + sb.append(streamID); + sb.append(":"); + sb.append(userID != null ? encrypt(xtea, userID) : ""); + sb.append(":"); + sb.append(password != null ? encrypt(xtea, password) : ""); + + setPayload(sb.toString()); + } + + /** + * Encrypts the value using the XTEA block cipher algorithm and returns the encrypted value as a 16 hex value + * + * @param xtea instance of the XTEA algorithm with a loaded encryption key + * @param value to encode + * @return encrypted password as 64-bit, 16 character hexadecimal value + */ + private static String encrypt(XTEA xtea, String value) + { + byte[] valueBytes = value.getBytes(); + + if(valueBytes.length % 8 != 0 || valueBytes.length == 0) + { + int newLength = ((valueBytes.length / 8) + 1) * 8; + valueBytes = Arrays.copyOf(valueBytes, newLength); + } + + byte[] encrypted = xtea.encrypt(valueBytes); + + StringBuilder sb = new StringBuilder(); + + for(byte b: encrypted) + { + sb.append(String.format("%02x", b)); + } + + return sb.toString(); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/CacheableXMLMetadata.java b/src/audio/broadcast/shoutcast/v2/ultravox/CacheableXMLMetadata.java new file mode 100644 index 00000000..06c35560 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/CacheableXMLMetadata.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class CacheableXMLMetadata extends UltravoxMessage +{ + /** + * Client request to server to send cacheable XML metadata + */ + public CacheableXMLMetadata() + { + super(UltravoxMessageType.CACHEABLE_XML_METADATA); + } + + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + CacheableXMLMetadata(byte[] data) + { + super(data); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyGenre.java b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyGenre.java new file mode 100644 index 00000000..917b0a67 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyGenre.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class ConfigureIcyGenre extends UltravoxMessage +{ + /** + * Client request to server to configure the ICY-GENRE metadata + */ + public ConfigureIcyGenre() + { + super(UltravoxMessageType.CONFIGURE_ICY_GENRE); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + ConfigureIcyGenre(byte[] data) + { + super(data); + } + + public void setGenre(String genre) + { + setPayload(genre); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyName.java b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyName.java new file mode 100644 index 00000000..8ee981af --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyName.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class ConfigureIcyName extends UltravoxMessage +{ + /** + * Client request to server to configure the ICY-NAME metadata + */ + public ConfigureIcyName() + { + super(UltravoxMessageType.CONFIGURE_ICY_NAME); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + ConfigureIcyName(byte[] data) + { + super(data); + } + + public void setName(String name) + { + setPayload(name); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyPublic.java b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyPublic.java new file mode 100644 index 00000000..d86f0e65 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyPublic.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class ConfigureIcyPublic extends UltravoxMessage +{ + /** + * Client request to server to configure the ICY-PUBLIC metadata + */ + public ConfigureIcyPublic() + { + super(UltravoxMessageType.CONFIGURE_ICY_PUBLIC); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + ConfigureIcyPublic(byte[] data) + { + super(data); + } + + public void setPublic(boolean publicStream) + { + setPayload(publicStream ? "1" : "0"); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyURL.java b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyURL.java new file mode 100644 index 00000000..b05d44f5 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/ConfigureIcyURL.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class ConfigureIcyURL extends UltravoxMessage +{ + /** + * Client request to server to configure the ICY-URL metadata + */ + public ConfigureIcyURL() + { + super(UltravoxMessageType.CONFIGURE_ICY_URL); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + ConfigureIcyURL(byte[] data) + { + super(data); + } + + public void setURL(String url) + { + setPayload(url); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/FlushCachedMetadata.java b/src/audio/broadcast/shoutcast/v2/ultravox/FlushCachedMetadata.java new file mode 100644 index 00000000..245279e5 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/FlushCachedMetadata.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class FlushCachedMetadata extends UltravoxMessage +{ + /** + * Client request to server to flush any currently cached stream metadata + */ + public FlushCachedMetadata() + { + super(UltravoxMessageType.FLUSH_CACHED_METADATA); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + FlushCachedMetadata(byte[] data) + { + super(data); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/MP3Audio.java b/src/audio/broadcast/shoutcast/v2/ultravox/MP3Audio.java new file mode 100644 index 00000000..f59d5ce9 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/MP3Audio.java @@ -0,0 +1,44 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class MP3Audio extends UltravoxMessage +{ + /** + * Client request to server to send authentication credentials + * + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + */ + public MP3Audio() + { + super(UltravoxMessageType.MP3_DATA); + } + + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + MP3Audio(byte[] data) + { + super(data); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/NegotiateBufferSize.java b/src/audio/broadcast/shoutcast/v2/ultravox/NegotiateBufferSize.java new file mode 100644 index 00000000..05e53544 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/NegotiateBufferSize.java @@ -0,0 +1,80 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class NegotiateBufferSize extends UltravoxMessage +{ + /** + * Client request to server to negotiate the server's buffer size + */ + public NegotiateBufferSize() + { + super(UltravoxMessageType.NEGOTIATE_BUFFER_SIZE); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + NegotiateBufferSize(byte[] data) + { + super(data); + } + + /** + * Sets the desired and minimum requested buffer sizes to send to the server + * @param desired buffer size in kilobytes + * @param minimum buffer size in kilobytes + */ + public void setBufferSize(int desired, int minimum) + { + StringBuilder sb = new StringBuilder(); + sb.append(desired).append(":").append(minimum); + + setPayload(sb.toString()); + } + + /** + * Negotiated buffer size from a server response ultravox. + * + * @return server negotiated buffer size, or zero if this is not a server response ultravox, or this is an error + * server response ultravox, or the server response ultravox buffer size cannot be determined from the ultravox payload. + */ + public int getBufferSize() + { + String payload = getPayload(); + + if(payload != null && payload.startsWith("ACK:")) + { + try + { + int bufferSize = Integer.parseInt(payload.substring(4)); + return bufferSize; + } + catch(Exception e) + { + //Do nothing, we couldn't parse the value + } + } + + return 0; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/NegotiateMaxPayloadSize.java b/src/audio/broadcast/shoutcast/v2/ultravox/NegotiateMaxPayloadSize.java new file mode 100644 index 00000000..11321319 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/NegotiateMaxPayloadSize.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class NegotiateMaxPayloadSize extends UltravoxMessage +{ + /** + * Client request to server to negotiate maximum payload size + */ + public NegotiateMaxPayloadSize() + { + super(UltravoxMessageType.NEGOTIATE_MAX_PAYLOAD_SIZE); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + NegotiateMaxPayloadSize(byte[] data) + { + super(data); + } + + /** + * Sets the maximum payload size in bytes + * + * @param desiredMaximum payload size (no larger than 16377) + * @param minimum acceptable payload size (no larger than 16377) + */ + public void setMaximumPayloadSize(int desiredMaximum, int minimum) + { + assert(desiredMaximum <= 16377); + assert(minimum <= 16377); + + StringBuilder sb = new StringBuilder(); + sb.append(desiredMaximum).append(":").append(minimum); + + setPayload(sb.toString()); + } + + /** + * Negotiated maximum payload size from a server response ultravox. + * + * @return server negotiated maximum payload size, or zero if this is not a server response ultravox, or this is an + * error server response ultravox, or the server response ultravox maximum payload size cannot be determined from the + * ultravox payload. + */ + public int getMaximumPayloadSize() + { + String payload = getPayload(); + + if(payload != null && payload.startsWith("ACK:")) + { + try + { + int maximumPayloadSize = Integer.parseInt(payload.substring(4)); + return maximumPayloadSize; + } + catch(Exception e) + { + //Do nothing, we couldn't parse the value + } + } + + return 0; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/PassThroughXMLMetadata.java b/src/audio/broadcast/shoutcast/v2/ultravox/PassThroughXMLMetadata.java new file mode 100644 index 00000000..9f33f82f --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/PassThroughXMLMetadata.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.v2.ultravox; + +public class PassThroughXMLMetadata extends UltravoxMessage +{ + /** + * Client request to server to send pass-through XML metadata + */ + public PassThroughXMLMetadata() + { + super(UltravoxMessageType.PASS_THROUGH_XML_METADATA); + } + + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + PassThroughXMLMetadata(byte[] data) + { + super(data); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/RequestCipher.java b/src/audio/broadcast/shoutcast/v2/ultravox/RequestCipher.java new file mode 100644 index 00000000..4bd2aa11 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/RequestCipher.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class RequestCipher extends UltravoxMessage +{ + /** + * Client request to server for encryption key + */ + public RequestCipher() + { + super(UltravoxMessageType.REQUEST_CIPHER); + setPayload(ULTRAVOX_VERSION); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + RequestCipher(byte[] data) + { + super(data); + } + + /** + * Cipher key returned from the server to use for encrypting user id and password credentials + * + * @return cipher key or null + */ + public String getCipher() + { + if(isValidResponse()) + { + return getPayload().replace(VALID_RESPONSE_PAYLOAD_PREFIX, ""); + } + + return null; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/SetupBroadcast.java b/src/audio/broadcast/shoutcast/v2/ultravox/SetupBroadcast.java new file mode 100644 index 00000000..59a5fe0c --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/SetupBroadcast.java @@ -0,0 +1,55 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class SetupBroadcast extends UltravoxMessage +{ + /** + * Client request to server to specify the stream bit rate + */ + public SetupBroadcast() + { + super(UltravoxMessageType.SETUP_BROADCAST); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + SetupBroadcast(byte[] data) + { + super(data); + } + + /** + * Sets the average and maximum bit rates. Set both to the same value for a Constant Bit Rate (CBR) stream. + * + * @param average bit rate of the stream in kbps + * @param maximum bit rate of the stream in kbps + */ + public void setBitRate(int average, int maximum) + { + StringBuilder sb = new StringBuilder(); + sb.append(average).append(":").append(maximum); + + setPayload(sb.toString()); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/Standby.java b/src/audio/broadcast/shoutcast/v2/ultravox/Standby.java new file mode 100644 index 00000000..b571f976 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/Standby.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class Standby extends UltravoxMessage +{ + /** + * Client request to server to notify that configuration is complete, prepare for streaming + */ + public Standby() + { + super(UltravoxMessageType.STANDBY); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + Standby(byte[] data) + { + super(data); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/StreamMimeType.java b/src/audio/broadcast/shoutcast/v2/ultravox/StreamMimeType.java new file mode 100644 index 00000000..86a65aaf --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/StreamMimeType.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * 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.v2.ultravox; + +import audio.broadcast.BroadcastFormat; + +public class StreamMimeType extends UltravoxMessage +{ + /** + * Client request to server to set stream MIME type + */ + public StreamMimeType() + { + super(UltravoxMessageType.STREAM_MIME_TYPE); + } + + /** + * Server response to client request + * + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + StreamMimeType(byte[] data) + { + super(data); + } + + /** + * Sets the broadcastAudio format or MIME type + * @param format of the stream + */ + public void setFormat(BroadcastFormat format) + { + setPayload(format.getValue()); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/TerminateBroadcast.java b/src/audio/broadcast/shoutcast/v2/ultravox/TerminateBroadcast.java new file mode 100644 index 00000000..9a5adccd --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/TerminateBroadcast.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class TerminateBroadcast extends UltravoxMessage +{ + /** + * Client request to server to terminate the broadcastAudio + */ + public TerminateBroadcast() + { + super(UltravoxMessageType.TERMINATE_BROADCAST); + } + + /** + * Server response to client request + * Package private constructor. Use the UltravoxMessageFactory for this constructor. + * + * @param data bytes received from the server + */ + TerminateBroadcast(byte[] data) + { + super(data); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxDecoder.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxDecoder.java new file mode 100644 index 00000000..b46efe84 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxDecoder.java @@ -0,0 +1,50 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.v2.ultravox; + +import org.apache.mina.core.buffer.IoBuffer; +import org.apache.mina.core.session.IoSession; +import org.apache.mina.filter.codec.ProtocolDecoderAdapter; +import org.apache.mina.filter.codec.ProtocolDecoderOutput; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class UltravoxDecoder extends ProtocolDecoderAdapter +{ + private final static Logger mLog = LoggerFactory.getLogger(UltravoxDecoder.class); + + @Override + public void decode(IoSession ioSession, IoBuffer ioBuffer, ProtocolDecoderOutput out) throws Exception + { + byte[] message = new byte[ioBuffer.limit()]; + + ioBuffer.get(message); + + UltravoxMessage ultravoxMessage = UltravoxMessageFactory.getMessage(message); + + if(ultravoxMessage != null) + { + out.write(ultravoxMessage); + } + else + { + out.write(message); + } + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxEncoder.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxEncoder.java new file mode 100644 index 00000000..bd43e8d2 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxEncoder.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.v2.ultravox; + +import org.apache.mina.core.buffer.IoBuffer; +import org.apache.mina.core.session.IoSession; +import org.apache.mina.filter.codec.ProtocolEncoderAdapter; +import org.apache.mina.filter.codec.ProtocolEncoderOutput; + +public class UltravoxEncoder extends ProtocolEncoderAdapter +{ + @Override + public void encode(IoSession ioSession, Object message, ProtocolEncoderOutput out) throws Exception + { + if(message instanceof UltravoxMessage) + { + byte[] data = ((UltravoxMessage)message).getMessage(); + + IoBuffer buf = IoBuffer.allocate(data.length).setAutoExpand(false); + buf.put(data); + buf.flip(); + out.write(buf); + } + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessage.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessage.java new file mode 100644 index 00000000..eb099b20 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessage.java @@ -0,0 +1,248 @@ +/******************************************************************************* + * 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.v2.ultravox; + +import bits.BinaryMessage; + +import java.util.Arrays; + +public abstract class UltravoxMessage +{ + public static final String ULTRAVOX_VERSION = "2.1"; + public static final String VALID_RESPONSE_PREFIX = "ACK"; + public static final String VALID_RESPONSE_PAYLOAD_PREFIX = "ACK:"; + public static final String ERROR_RESPONSE_PREFIX = "NAK:"; + + public static final int[] SYNC = {0,1,2,3,4,5,6,7}; + public static final int[] RESERVED = {8,9,10,11}; + public static final int REQUIRED_DELIVERY = 12; + public static final int[] SEND_QUEUE_PRIORITY = {13,14,15}; + public static final int[] MESSAGE_CLASS = {16,17,18,19}; + public static final int[] MESSAGE_CLASS_AND_TYPE = {16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31}; + public static final int[] PAYLOAD_LENGTH = {32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47}; + + private BinaryMessage mMessage; + + /** + * Constructs a ultravox from the byte array + */ + public UltravoxMessage(byte[] data) + { + mMessage = new BinaryMessage(data.length * 8); + + int pointer = 0; + + for(byte b: data) + { + mMessage.setByte(pointer, b); + pointer += 8; + } + } + + /** + * Constructs an empty ultravox of the specified length + * + * @param ultravoxMessageType type of ultravox + */ + public UltravoxMessage(UltravoxMessageType ultravoxMessageType) + { + mMessage = new BinaryMessage(56); + + setSync(); + setMessageType(ultravoxMessageType); + } + + /** + * Message byte array + * + * @return ultravox bytes + */ + public byte[] getMessage() + { + return mMessage.toByteArray(); + } + + public String toString() + { + return getMessageType().name(); + } + + /** + * Sets the ultravox sync bits to the predefined sync pattern: 01011010 (0x5A) + */ + private void setSync() + { + mMessage.setInt(0x5A, SYNC); + } + + /** + * Ultravox ultravox class + */ + public UltravoxMessageClass getMessageClass() + { + return UltravoxMessageClass.fromValue(mMessage.getInt(MESSAGE_CLASS)); + } + + /** + * Ultravox ultravox type + * @return + */ + public UltravoxMessageType getMessageType() + { + return UltravoxMessageType.fromValue(mMessage.getInt(MESSAGE_CLASS_AND_TYPE)); + } + + /** + * Sets the ultravox bits according to the ultravox class and type + * @param messageType of ultravox ultravox + */ + private void setMessageType(UltravoxMessageType messageType) + { + mMessage.setInt(messageType.getValue(), MESSAGE_CLASS_AND_TYPE); + } + + /** + * Indicates if this ultravox has the required delivery flag set + */ + public boolean isRequiredDelivery() + { + return mMessage.get(REQUIRED_DELIVERY); + } + + /** + * Sets the required delivery flag according to the argument + */ + public void setRequiredDelivery(boolean required) + { + if(required) + { + mMessage.set(REQUIRED_DELIVERY); + } + else + { + mMessage.clear(REQUIRED_DELIVERY); + } + } + + /** + * Send queue priority (0 - 7) for this ultravox + */ + public int getSendQueuePriority() + { + return mMessage.getInt(SEND_QUEUE_PRIORITY); + } + + /** + * Sets send queue priority for this ultravox + * @param priority 0 - 7 + */ + public void setSendQueuePriority(int priority) + { + assert(0 <= priority && priority <= 7); + + mMessage.setInt(priority, SEND_QUEUE_PRIORITY); + } + + /** + * Message payload + */ + public String getPayload() + { + int payloadLength = mMessage.getInt(PAYLOAD_LENGTH); + + byte[] payload = new byte[payloadLength]; + + int payloadPointer = 48; + + for(int x = 0; x < payloadLength; x++) + { + payload[x] = mMessage.getByte(payloadPointer); + payloadPointer += 8; + } + + //Strip the trailing 0x00 null value from a server response + if(payload[payload.length - 1] == 0) + { + return new String(Arrays.copyOf(payload, payload.length - 1)); + } + + return new String(payload); + } + + /** + * Sets the ultravox payload, updates the ultravox length field, and appends a trailing 0x00 byte after the payload. + */ + public void setPayload(String payload) + { + setPayload(payload.getBytes()); + } + + public void setPayload(byte[] payload) + { + int messageBitLength = (7 + payload.length) * 8; + + mMessage.setSize(messageBitLength); + mMessage.setInt(payload.length, PAYLOAD_LENGTH); + + int payloadPointer = 48; + + for(byte payloadByte: payload) + { + mMessage.setByte(payloadPointer, payloadByte); + payloadPointer += 8; + } + + mMessage.setByte(payloadPointer, (byte)0x00); + } + + /** + * Indicates if the response is a valid (ie non-error) response + */ + public boolean isValidResponse() + { + String payload = getPayload(); + + return payload != null && payload.startsWith(VALID_RESPONSE_PREFIX); + } + + /** + * Indicates if the response message contains an error message. + */ + public boolean isErrorResponse() + { + String payload = getPayload(); + + return payload != null && payload.startsWith(ERROR_RESPONSE_PREFIX); + } + + /** + * Textual message available when the error response flag is set. + */ + public String getErrorMessage() + { + String payload = getPayload(); + + if(payload != null) + { + return payload.replace(ERROR_RESPONSE_PREFIX, ""); + } + + return null; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageClass.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageClass.java new file mode 100644 index 00000000..08d3b35f --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageClass.java @@ -0,0 +1,57 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public enum UltravoxMessageClass +{ + OPERATIONS(0x0), + BROADCASTER(0x1), + LISTENER(0x2), + CACHEABLE_METADATA_1(0x3), + CACHEABLE_METADATA_2(0x4), + PASS_THROUGH_METADATA_1(0x5), + PASS_THROUGH_METADATA_2(0x6), + ENCODED_DATA(0x7), + ADVANCED_ENCODED_DATA(0x8), + FRAMED_DATA(0x9), + CACHEABLE_BINARY_METADATA(0xA), + UNKNOWN(0xF); + + private int mValue; + + private UltravoxMessageClass(int value) + { + mValue = value; + } + + public int getValue() + { + return mValue; + } + + public static UltravoxMessageClass fromValue(int value) + { + if(0x0 <= value && value <= 0xA) + { + return values()[value]; + } + + return UNKNOWN; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageFactory.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageFactory.java new file mode 100644 index 00000000..9423f912 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageFactory.java @@ -0,0 +1,135 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public class UltravoxMessageFactory +{ + /** + * Creates an empty ultravox of the specified ultravox type for sending to the server + */ + public static UltravoxMessage getMessage(UltravoxMessageType type) + { + switch(type) + { + case AUTHENTICATE_BROADCAST: + return new AuthenticateBroadcast(); + case CACHEABLE_XML_METADATA: + return new CacheableXMLMetadata(); + case CONFIGURE_ICY_GENRE: + return new ConfigureIcyGenre(); + case CONFIGURE_ICY_NAME: + return new ConfigureIcyName(); + case CONFIGURE_ICY_PUBLIC: + return new ConfigureIcyPublic(); + case CONFIGURE_ICY_URL: + return new ConfigureIcyURL(); + case FLUSH_CACHED_METADATA: + return new FlushCachedMetadata(); + case MP3_DATA: + return new MP3Audio(); + case NEGOTIATE_BUFFER_SIZE: + return new NegotiateBufferSize(); + case NEGOTIATE_MAX_PAYLOAD_SIZE: + return new NegotiateMaxPayloadSize(); + case PASS_THROUGH_XML_METADATA: + return new PassThroughXMLMetadata(); + case REQUEST_CIPHER: + return new RequestCipher(); + case SETUP_BROADCAST: + return new SetupBroadcast(); + case STANDBY: + return new Standby(); + case STREAM_MIME_TYPE: + return new StreamMimeType(); + case TERMINATE_BROADCAST: + return new TerminateBroadcast(); + default: + return null; + } + } + + /** + * Creates an ultravox ultravox from the server response bytes. + * + * @param bytes in network order (big-endian). + * + * @return constructed ultravox or null if the ultravox type cannot be determined or is unrecognized + */ + public static UltravoxMessage getMessage(byte[] bytes) + { + UltravoxMessageType type = getMessageType(bytes); + + switch(type) + { + case AUTHENTICATE_BROADCAST: + return new AuthenticateBroadcast(bytes); + case CACHEABLE_XML_METADATA: + return new CacheableXMLMetadata(bytes); + case CONFIGURE_ICY_GENRE: + return new ConfigureIcyGenre(bytes); + case CONFIGURE_ICY_NAME: + return new ConfigureIcyName(bytes); + case CONFIGURE_ICY_PUBLIC: + return new ConfigureIcyPublic(bytes); + case CONFIGURE_ICY_URL: + return new ConfigureIcyURL(bytes); + case FLUSH_CACHED_METADATA: + return new FlushCachedMetadata(bytes); + case MP3_DATA: + return new MP3Audio(bytes); + case NEGOTIATE_BUFFER_SIZE: + return new NegotiateBufferSize(bytes); + case NEGOTIATE_MAX_PAYLOAD_SIZE: + return new NegotiateMaxPayloadSize(bytes); + case PASS_THROUGH_XML_METADATA: + return new PassThroughXMLMetadata(bytes); + case REQUEST_CIPHER: + return new RequestCipher(bytes); + case SETUP_BROADCAST: + return new SetupBroadcast(bytes); + case STANDBY: + return new Standby(bytes); + case STREAM_MIME_TYPE: + return new StreamMimeType(bytes); + case TERMINATE_BROADCAST: + return new TerminateBroadcast(bytes); + case UNKNOWN: + default: + return null; + } + } + + /** + * Identifies the ultravox type from the server response byte array + * + * @param data in little-endian format + * @return ultravox type or UNKNOWN if the ultravox type cannot be determined. + */ + private static UltravoxMessageType getMessageType(byte[] data) + { + if(data != null && data.length > 4) + { + int value = (data[2] << 8) + data[3]; + + return UltravoxMessageType.fromValue(value); + } + + return UltravoxMessageType.UNKNOWN; + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageType.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageType.java new file mode 100644 index 00000000..a5836cc3 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMessageType.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public enum UltravoxMessageType +{ + AUTHENTICATE_BROADCAST(0x1001), + SETUP_BROADCAST(0x1002), + NEGOTIATE_BUFFER_SIZE(0x1003), + STANDBY(0x1004), + TERMINATE_BROADCAST(0x1005), + FLUSH_CACHED_METADATA(0x1006), + NEGOTIATE_MAX_PAYLOAD_SIZE(0x1008), + REQUEST_CIPHER(0x1009), + STREAM_MIME_TYPE(0x1040), + CONFIGURE_ICY_NAME(0x1100), + CONFIGURE_ICY_GENRE(0x1101), + CONFIGURE_ICY_URL(0x1102), + CONFIGURE_ICY_PUBLIC(0x1103), + CACHEABLE_XML_METADATA(0x3902), + PASS_THROUGH_XML_METADATA(0x5902), + MP3_DATA(0x7000), + UNKNOWN(0x0000); + + private int mValue; + + private UltravoxMessageType(int value) + { + mValue = value; + } + + public int getValue() + { + return mValue; + } + + public static UltravoxMessageType fromValue(int value) + { + switch(value) + { + case 0x1001: + return AUTHENTICATE_BROADCAST; + case 0x1002: + return SETUP_BROADCAST; + case 0x1003: + return NEGOTIATE_BUFFER_SIZE; + case 0x1004: + return STANDBY; + case 0x1005: + return TERMINATE_BROADCAST; + case 0x1006: + return FLUSH_CACHED_METADATA; + case 0x1008: + return NEGOTIATE_MAX_PAYLOAD_SIZE; + case 0x1009: + return REQUEST_CIPHER; + case 0x1040: + return STREAM_MIME_TYPE; + case 0x1100: + return CONFIGURE_ICY_NAME; + case 0x1101: + return CONFIGURE_ICY_GENRE; + case 0x1102: + return CONFIGURE_ICY_URL; + case 0x1103: + return CONFIGURE_ICY_PUBLIC; + case 0x3902: + return CACHEABLE_XML_METADATA; + case 0x5902: + return PASS_THROUGH_XML_METADATA; + case 0x7000: + return MP3_DATA; + default: + return UNKNOWN; + } + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMetadata.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMetadata.java new file mode 100644 index 00000000..73d21cb3 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxMetadata.java @@ -0,0 +1,70 @@ +/******************************************************************************* + * 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.v2.ultravox; + +public enum UltravoxMetadata +{ + ALBUM_TITLE("TALB"), + ARTIST("TPE1"), + AUDIO_SOURCE_WEBPAGE("WOAS"), + BROADCAST_CLIENT_APPLICATION("TENC"), + COMMENT("COMM"), + CUSTOM_TEXT_FIELD("TXXX"), + ENCODE_SETTINGS("TSSE"), + GENERAL_BINARY_OBJECT("GEOB"), + GENRE("TCON"), + OWNER("TOWN"), + RADIO_STATION("TRSN"), + RADIO_STATION_OWNER("TRSO"), + RADIO_STATION_WEBPAGE("WORS"), + RECORDING_TIME("TDRC"), + SOURCE_MEDIA_TYPE("TMED"), + TITLE_1("TIT1"), + TITLE_2("TIT2"), + TITLE_3("TIT3"), + URL("URL"), + UNKNOWN("UNKNOWN"); + + private String mXMLTag; + + private UltravoxMetadata(String xmlTag) + { + mXMLTag = xmlTag; + } + + public String getXMLTag() + { + return mXMLTag; + } + + /** + * Encodes the value as an XML tag stream + * @param value to enclose in XML tags + * @return xml tag wrapped value + */ + public String asXML(String value) + { + StringBuilder sb = new StringBuilder(); + sb.append("<").append(getXMLTag()).append(">"); + sb.append(value); + sb.append(""); + + return sb.toString(); + } +} diff --git a/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxProtocolFactory.java b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxProtocolFactory.java new file mode 100644 index 00000000..8aff45b9 --- /dev/null +++ b/src/audio/broadcast/shoutcast/v2/ultravox/UltravoxProtocolFactory.java @@ -0,0 +1,52 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.v2.ultravox; + +import org.apache.mina.core.session.IoSession; +import org.apache.mina.filter.codec.ProtocolCodecFactory; +import org.apache.mina.filter.codec.ProtocolDecoder; +import org.apache.mina.filter.codec.ProtocolEncoder; + +public class UltravoxProtocolFactory implements ProtocolCodecFactory +{ + private ProtocolEncoder mEncoder; + private ProtocolDecoder mDecoder; + + @Override + public ProtocolEncoder getEncoder(IoSession ioSession) throws Exception + { + if(mEncoder == null) + { + mEncoder = new UltravoxEncoder(); + } + + return mEncoder; + } + + @Override + public ProtocolDecoder getDecoder(IoSession ioSession) throws Exception + { + if(mDecoder == null) + { + mDecoder = new UltravoxDecoder(); + } + + return mDecoder; + } +} diff --git a/src/audio/convert/IAudioConverter.java b/src/audio/convert/IAudioConverter.java new file mode 100644 index 00000000..92ccb5dc --- /dev/null +++ b/src/audio/convert/IAudioConverter.java @@ -0,0 +1,37 @@ +/******************************************************************************* + * 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.convert; + +import audio.AudioPacket; + +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/ISilenceGenerator.java b/src/audio/convert/ISilenceGenerator.java new file mode 100644 index 00000000..ac2e1c5c --- /dev/null +++ b/src/audio/convert/ISilenceGenerator.java @@ -0,0 +1,24 @@ +/******************************************************************************* + * 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.convert; + +public interface ISilenceGenerator +{ + byte[] generate(long duration); +} diff --git a/src/audio/convert/MP3AudioConverter.java b/src/audio/convert/MP3AudioConverter.java new file mode 100644 index 00000000..ea813f1c --- /dev/null +++ b/src/audio/convert/MP3AudioConverter.java @@ -0,0 +1,96 @@ +/******************************************************************************* + * 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.convert; + +import audio.AudioFormats; +import audio.AudioPacket; +import audio.AudioUtils; +import net.sourceforge.lame.lowlevel.LameEncoder; +import net.sourceforge.lame.mp3.Lame; +import net.sourceforge.lame.mp3.MPEGMode; +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 +{ + private final static Logger mLog = LoggerFactory.getLogger( MP3AudioConverter.class ); + public static final int AUDIO_QUALITY = Lame.QUALITY_LOW; + private LameEncoder mEncoder; + private ByteArrayOutputStream mMP3Stream = new ByteArrayOutputStream(); + private byte[] mMP3Buffer; + + /** + * Converts PCM 8kHz 16-bit Little Endian audio packets to Mono, Low Quality MP3 compressed audio. + * + * @param bitRate for converted MP3 audio + * @param variableBitRate (VBR) true or false for constant bit rate (CBR) + */ + public MP3AudioConverter(int bitRate, boolean variableBitRate) + { + mEncoder = new LameEncoder(AudioFormats.PCM_SIGNED_8KHZ_16BITS_MONO, + bitRate, MPEGMode.MONO, AUDIO_QUALITY, variableBitRate); + + mMP3Buffer = new byte[mEncoder.getPCMBufferSize()]; + } + + @Override + public byte[] convert(List audioPackets) + { + mMP3Stream.reset(); + + byte[] pcmBytes = AudioUtils.convertTo16BitSamples(audioPackets); + + int pcmBufferSize = Math.min(mMP3Buffer.length, pcmBytes.length); + + int mp3BufferSize = 0; + + int pcmBytesPosition = 0; + + try + { + while (0 < (mp3BufferSize = mEncoder.encodeBuffer(pcmBytes, pcmBytesPosition, pcmBufferSize, mMP3Buffer))) + { + pcmBytesPosition += pcmBufferSize; + pcmBufferSize = Math.min(mMP3Buffer.length, pcmBytes.length - pcmBytesPosition); + mMP3Stream.write(mMP3Buffer, 0, mp3BufferSize); + } + + return mMP3Stream.toByteArray(); + } + catch(Exception e) + { + mLog.error("There was an error converting audio to MP3: " + e.getMessage()); + 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/MP3FrameInspector.java b/src/audio/convert/MP3FrameInspector.java new file mode 100644 index 00000000..56c91a4a --- /dev/null +++ b/src/audio/convert/MP3FrameInspector.java @@ -0,0 +1,89 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2017 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.convert; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MP3FrameInspector +{ + private final static Logger mLog = LoggerFactory.getLogger(MP3FrameInspector.class); + + public static void inspect(byte[] frames) + { + if(frames == null || frames.length == 0) + { + return; + } + + if(frames.length % 144 != 0) + { + log("Frame byte array not multiple of 144 byte frame size - length:" + frames.length, frames); + return; + } + + //Check for erroneous frame sync + for(int x = 0; x < frames.length - 1; x++) + { + if(frames[x] == (byte)0xFF && (frames[x + 1] & 0xE0) == 0xE0 && (x % 144) != 0) + { + log("Bad frame sync detected at byte " + x + " in frame " + (x / 144), frames); + return; + } + } + + //Check MP3 headers + +// log("Frames ...", frames); + } + + private static void log(String message, byte[] frames) + { + StringBuilder sb = new StringBuilder(); + sb.append(message).append("\n"); + + int frameCounter = 0; + sb.append(frameCounter++).append(" "); + + for(int x = 0; x < frames.length; x++) + { + if(x > 0 && x % 144 == 0) + { + sb.append("\n"); + sb.append(frameCounter++).append(" "); + } + + sb.append(String.format("%02X ", frames[x])); + } + + mLog.info(sb.toString()); + } + + public static void main(String[] args) + { + mLog.info("Starting ..."); + MP3SilenceGenerator gen = new MP3SilenceGenerator(); + + byte[] audio = gen.generate(173); + + inspect(audio); + + mLog.info("Finished"); + } +} diff --git a/src/audio/convert/MP3SilenceGenerator.java b/src/audio/convert/MP3SilenceGenerator.java new file mode 100644 index 00000000..8e68e6f3 --- /dev/null +++ b/src/audio/convert/MP3SilenceGenerator.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * 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.convert; + +import audio.AudioPacket; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import record.mp3.MP3Recorder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MP3SilenceGenerator implements ISilenceGenerator +{ + private final static Logger mLog = LoggerFactory.getLogger(MP3SilenceGenerator.class); + + private MP3AudioConverter mGenerator = new MP3AudioConverter(MP3Recorder.MP3_BIT_RATE, MP3Recorder.CONSTANT_BIT_RATE); + private byte[] mPreviousPartialFrameData; + + /** + * Generates MP3 audio silence frames + */ + public MP3SilenceGenerator() + { + } + + public byte[] generate(long duration) + { + int length = (int)(duration * 8); //8000 Hz sample rate + float[] silence = new float[length]; + AudioPacket silencePacket = new AudioPacket(silence, null); + List silencePackets = new ArrayList<>(); + silencePackets.add(silencePacket); + + byte[] frameData = mGenerator.convert(silencePackets); + + frameData = merge(mPreviousPartialFrameData, frameData); + + if(frameData != null && frameData.length > 0) + { + if(frameData.length < 144) + { + mPreviousPartialFrameData = frameData; + return null; + } + else if((frameData.length % 144) == 0) + { + mPreviousPartialFrameData = null; + return frameData; + } + else + { + int integralFrameLength = (int)(frameData.length / 144) * 144; + mPreviousPartialFrameData = Arrays.copyOfRange(frameData, integralFrameLength, frameData.length); + return Arrays.copyOf(frameData, integralFrameLength); + } + } + + return null; + } + + private static byte[] merge(byte[] a, byte[] b) + { + if(a == null && b == null) + { + return null; + } + else if(a == null) + { + return b; + } + else if(b == null) + { + return a; + } + + byte[] c = new byte[a.length + b.length]; + System.arraycopy(a,0,c,0,a.length); + System.arraycopy(b,0,c,a.length,b.length); + + return c; + } + + public static void main(String[] args) + { + mLog.debug("Starting ..."); + + for(long x = 243; x < 500; x ++) + { + MP3SilenceGenerator generator = new MP3SilenceGenerator(); + + byte[] silence = generator.generate(x); + + if(silence != null && silence.length > 0) + { + mLog.debug("Silence:" + x + " Length:" + silence.length); + MP3FrameInspector.inspect(silence); + } + } + + mLog.debug("Finished"); + } +} diff --git a/src/audio/inverted/AudioAdapter.java b/src/audio/invert/AudioAdapter.java similarity index 98% rename from src/audio/inverted/AudioAdapter.java rename to src/audio/invert/AudioAdapter.java index f529fb06..db45c734 100644 --- a/src/audio/inverted/AudioAdapter.java +++ b/src/audio/invert/AudioAdapter.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ -package audio.inverted; +package audio.invert; import dsp.filter.FilterFactory; import dsp.filter.FloatFIRFilter; @@ -23,7 +23,7 @@ import dsp.filter.Window.WindowType; import dsp.mixer.Oscillator; /** - * Based on the AudioType applied, produces normal, muted, or audio inverted + * Based on the AudioType applied, produces normal, muted, or audio invert * audio output. */ public class AudioAdapter diff --git a/src/audio/inverted/AudioInverter.java b/src/audio/invert/AudioInverter.java similarity index 96% rename from src/audio/inverted/AudioInverter.java rename to src/audio/invert/AudioInverter.java index e14dbb15..f9ec3755 100644 --- a/src/audio/inverted/AudioInverter.java +++ b/src/audio/invert/AudioInverter.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ -package audio.inverted; +package audio.invert; import audio.InversionFrequency; import sample.real.RealSampleListener; @@ -24,7 +24,7 @@ import dsp.filter.FloatFIRFilter; import dsp.mixer.Oscillator; /** - * Applies audio inversion (or un-inversion) to a stream of float audio samples + * Applies audio inversion (or un-inversion) to a broadcast of float audio samples * by multiplying each successive sample by either a 1 or a -1 value. */ public class AudioInverter implements RealSampleListener diff --git a/src/audio/inverted/AudioType.java b/src/audio/invert/AudioType.java similarity index 99% rename from src/audio/inverted/AudioType.java rename to src/audio/invert/AudioType.java index 706ee683..0659b781 100644 --- a/src/audio/inverted/AudioType.java +++ b/src/audio/invert/AudioType.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ -package audio.inverted; +package audio.invert; public enum AudioType { diff --git a/src/audio/inverted/IAudioTypeListener.java b/src/audio/invert/IAudioTypeListener.java similarity index 97% rename from src/audio/inverted/IAudioTypeListener.java rename to src/audio/invert/IAudioTypeListener.java index 52321dcf..a55feec1 100644 --- a/src/audio/inverted/IAudioTypeListener.java +++ b/src/audio/invert/IAudioTypeListener.java @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ -package audio.inverted; +package audio.invert; public interface IAudioTypeListener diff --git a/src/audio/metadata/AudioMetadata.java b/src/audio/metadata/AudioMetadata.java index 521ffa79..9bc19cd0 100644 --- a/src/audio/metadata/AudioMetadata.java +++ b/src/audio/metadata/AudioMetadata.java @@ -1,28 +1,32 @@ /******************************************************************************* * SDR Trunk * Copyright (C) 2014-2016 Dennis Sheirer - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ package audio.metadata; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; +import java.util.TreeSet; +import alias.id.broadcast.BroadcastChannel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,288 +36,322 @@ import alias.id.priority.Priority; public class AudioMetadata implements Listener { - protected static final Logger mLog = LoggerFactory.getLogger( AudioMetadata.class ); + protected static final Logger mLog = LoggerFactory.getLogger(AudioMetadata.class); - private int mSource; - private Map mMetadata = new HashMap<>(); - private boolean mUpdated = false; + private int mSource; + private Map mMetadata = new HashMap<>(); + private boolean mUpdated = false; - /* Channel Selected == highest priority */ - private boolean mSelected = false; + /* Channel Selected == highest priority */ + private boolean mSelected = false; - /* Call Priority */ - private int mPriority = Priority.DEFAULT_PRIORITY; + /* Call Priority */ + private int mPriority = Priority.DEFAULT_PRIORITY; - /* Indicates if the overall source is recordable */ - private boolean mSourceRecordable = false; - - /* Indicates the current recordable state */ - private boolean mRecordable = false; - - /* A unique identifier for this metadata */ - private String mIdentifier; - - public AudioMetadata( int source, boolean recordable ) - { - mSource = source; - - setIdentifier( mSource ); - - mSourceRecordable = recordable; - - setRecordable( mSourceRecordable ); - } + /* Indicates if the overall source is recordable */ + private boolean mSourceRecordable = false; - /** - * Returns a unique identifier for this audio metadata that contains the - * source id and optionally the TO metadata string. - */ - public String getIdentifier() - { - return mIdentifier; - } - - public void setIdentifier( int source ) - { - setIdentifier( source, null ); - } - - public void setIdentifier( int source, String id ) - { - mIdentifier = "SRC:" + source + " ID:" + ( id == null ? "UNKNOWN" : id ); - mUpdated = true; - } - - /** - * Returns a copy of the current audio metadata. - */ - public AudioMetadata copyOf() - { - AudioMetadata copy = new AudioMetadata( mSource, mSourceRecordable ); - - copy.mPriority = mPriority; - copy.mSelected = mSelected; - copy.mRecordable = mRecordable; + /* Indicates the current recordable state */ + private boolean mRecordable = false; - copy.mMetadata.putAll( mMetadata ); - copy.mUpdated = mUpdated; - copy.mIdentifier = new String( mIdentifier ); - - mUpdated = false; - - return copy; - } + /* Indicates the current streamable state */ + private boolean mStreamable = false; - /** - * Removes any accumulated temporal metadata and returns to a default set. - */ - public void reset() - { - /* Reset recordable state, identifier and audio call priority levels - * and reprocess the metadata */ - mRecordable = mSourceRecordable; - setIdentifier( mSource ); - mPriority = Priority.DEFAULT_PRIORITY; - - List toRemove = new ArrayList<>(); + /* A unique identifier for this metadata */ + private String mIdentifier; - for( MetadataType type: mMetadata.keySet() ) - { - Metadata metadata = mMetadata.get( type ); + public AudioMetadata(int source, boolean recordable) + { + mSource = source; - if( metadata != null ) - { - if( metadata.isTemporal() ) - { - toRemove.add( type ); - } - else - { - processMetadata( metadata ); - } - } - } - - for( MetadataType type: toRemove ) - { - mMetadata.remove( type ); - } + setIdentifier(mSource); - mUpdated = true; - } - - private void processMetadata( Metadata metadata ) - { - if( metadata.hasAlias() ) - { - Alias alias = metadata.getAlias(); - - if( metadata.getMetadataType() == MetadataType.FROM || - metadata.getMetadataType() == MetadataType.TO ) - { - if( !alias.isRecordable() ) - { - mRecordable = false; - } - } + mSourceRecordable = recordable; - if( metadata.getMetadataType() == MetadataType.TO ) - { - setIdentifier( mSource, metadata.getValue() ); - } + setRecordable(mSourceRecordable); + } - if( alias != null && alias.getCallPriority() < mPriority ) - { - mPriority = alias.getCallPriority(); - } - } - } - - /** - * Indicates if this audio metadata contains updated information. The flag - * will only be set when new data is added and will be reset by a copyOf() - * method invocation. - */ - public boolean isUpdated() - { - return mUpdated; - } - - public void setUpdated( boolean updated ) - { - mUpdated = updated; - } + /** + * Returns a unique identifier for this audio metadata that contains the + * source id and optionally the TO metadata string. + */ + public String getIdentifier() + { + return mIdentifier; + } - /** - * Adds the metadata. Any temporal metadata will be removed when the - * dispose() method is invoked. - */ - @Override - public void receive( Metadata metadata ) - { - if( metadata.isReset() ) - { - reset(); - } - else - { - Metadata existing = mMetadata.get( metadata.getMetadataType() ); + public void setIdentifier(int source) + { + setIdentifier(source, null); + } - if( existing == null || !existing.equals( metadata ) ) - { - mMetadata.put( metadata.getMetadataType(), metadata ); - processMetadata( metadata ); - setUpdated( true ); - } - } - - mUpdated = true; - } - - /** - * Returns the complete set of metadata - */ - public Map getMetadata() - { - return mMetadata; - } + public void setIdentifier(int source, String id) + { + mIdentifier = "SRC:" + source + " ID:" + (id == null ? "UNKNOWN" : id); + mUpdated = true; + } - /** - * Returns the metadata object matching the type or null - */ - public Metadata getMetadata( MetadataType type ) - { - return mMetadata.get( type ); - } + /** + * Returns a copy of the current audio metadata. + */ + public AudioMetadata copyOf() + { + AudioMetadata copy = new AudioMetadata(mSource, mSourceRecordable); - /** - * Source channel ID - */ - public void setSource( int source ) - { - mSource = source; - - mUpdated = true; - } - - public int getSource() - { - return mSource; - } + copy.mPriority = mPriority; + copy.mSelected = mSelected; + copy.mRecordable = mRecordable; + copy.mStreamable = mStreamable; - /** - * Sets the priority of this audio packet within the defined min/max priority range */ - public void setPriority( int priority ) - { - mPriority = priority; - - mUpdated = true; - } + copy.mMetadata.putAll(mMetadata); + copy.mUpdated = mUpdated; + copy.mIdentifier = new String(mIdentifier); - /** - * Returns the priority of this audio metadata - * - * @return - priority of audio packet where lower numbers are higher priority - */ - public int getPriority() - { - return mPriority; - } - - public boolean isDoNotMonitor() - { - return mPriority == Priority.DO_NOT_MONITOR; - } + mUpdated = false; - /** - * Selected audio channel. A selected channel will override the priority - * level so that the audio can be listened to immediately. - */ - public void setSelected( boolean selected ) - { - mSelected = selected; - - mUpdated = true; - } + return copy; + } - public boolean isSelected() - { - return mSelected; - } - - /** - * Indicates if audio associated with this metadata is recordable. - */ - public boolean isRecordable() - { - return mRecordable; - } - - public void setRecordable( boolean recordable ) - { - mRecordable = recordable; - mUpdated = true; - } - - public String toString() - { - StringBuilder sb = new StringBuilder(); - - sb.append( "Audio Metadata - source:" ); - sb.append( getSource() ); - sb.append( " priority:" ); - sb.append( getPriority() ); - sb.append( " updated:" + isUpdated() ); - sb.append( " " ); - - for( Entry entry: getMetadata().entrySet() ) - { - sb.append( entry.getValue().getKey() ); - sb.append( ":" ); - sb.append( entry.getValue().getValue() ); - sb.append( " " ); - } + /** + * Removes any accumulated temporal metadata and returns to a default set. + */ + public void reset() + { + //Reset recordable state, identifier and audio call priority levels and reprocess the metadata + mRecordable = mSourceRecordable; + mStreamable = false; + setIdentifier(mSource); + mPriority = Priority.DEFAULT_PRIORITY; - return sb.toString(); - } + List toRemove = new ArrayList<>(); + + for (MetadataType type : mMetadata.keySet()) + { + Metadata metadata = mMetadata.get(type); + + if (metadata != null) + { + if (metadata.isTemporal()) + { + toRemove.add(type); + } + else + { + processMetadata(metadata); + } + } + } + + for (MetadataType type : toRemove) + { + mMetadata.remove(type); + } + + mUpdated = true; + } + + private void processMetadata(Metadata metadata) + { + if (metadata.hasAlias()) + { + Alias alias = metadata.getAlias(); + + if (metadata.getMetadataType() == MetadataType.FROM || metadata.getMetadataType() == MetadataType.TO) + { + if (!alias.isRecordable()) + { + mRecordable = false; + } + + if (alias.isStreamable()) + { + mStreamable = true; + } + } + + if (metadata.getMetadataType() == MetadataType.TO) + { + setIdentifier(mSource, metadata.getValue()); + } + + if (alias != null && alias.getCallPriority() < mPriority) + { + mPriority = alias.getCallPriority(); + } + } + } + + /** + * Indicates if this audio metadata contains updated information. The flag + * will only be set when new data is added and will be reset by a copyOf() + * method invocation. + */ + public boolean isUpdated() + { + return mUpdated; + } + + public void setUpdated(boolean updated) + { + mUpdated = updated; + } + + /** + * Adds the metadata. Any temporal metadata will be removed when the + * dispose() method is invoked. + */ + @Override + public void receive(Metadata metadata) + { + if (metadata.isReset()) + { + reset(); + } + else + { + Metadata existing = mMetadata.get(metadata.getMetadataType()); + + if (existing == null || !existing.equals(metadata)) + { + mMetadata.put(metadata.getMetadataType(), metadata); + processMetadata(metadata); + setUpdated(true); + } + } + + mUpdated = true; + } + + /** + * Returns the complete set of metadata + */ + public Map getMetadata() + { + return mMetadata; + } + + /** + * Returns the metadata object matching the type or null + */ + public Metadata getMetadata(MetadataType type) + { + return mMetadata.get(type); + } + + /** + * Source channel ID + */ + public void setSource(int source) + { + mSource = source; + + mUpdated = true; + } + + public int getSource() + { + return mSource; + } + + /** + * Sets the priority of this audio packet within the defined min/max priority range + */ + public void setPriority(int priority) + { + mPriority = priority; + + mUpdated = true; + } + + /** + * Returns the priority of this audio metadata + * + * @return - priority of audio packet where lower numbers are higher priority + */ + public int getPriority() + { + return mPriority; + } + + public boolean isDoNotMonitor() + { + return mPriority == Priority.DO_NOT_MONITOR; + } + + /** + * Selected audio channel. A selected channel will override the priority + * level so that the audio can be listened to immediately. + */ + public void setSelected(boolean selected) + { + mSelected = selected; + + mUpdated = true; + } + + public boolean isSelected() + { + return mSelected; + } + + /** + * Indicates if audio associated with this metadata is recordable. + */ + public boolean isRecordable() + { + return mRecordable; + } + + public void setRecordable(boolean recordable) + { + mRecordable = recordable; + mUpdated = true; + } + + /** + * Indicates if the audio associated with this metadata is streamable + */ + public boolean isStreamable() + { + return mStreamable; + } + + public Collection getBroadcastChannels() + { + Set broadcastChannels = new TreeSet<>(); + + for(Metadata metadata: mMetadata.values()) + { + if(metadata.hasAlias() && metadata.getAlias().isStreamable()) + { + Alias alias = metadata.getAlias(); + + broadcastChannels.addAll(alias.getBroadcastChannels()); + } + } + + return broadcastChannels; + } + + public String toString() + { + StringBuilder sb = new StringBuilder(); + + sb.append("Audio Metadata - source:"); + sb.append(getSource()); + sb.append(" priority:"); + sb.append(getPriority()); + sb.append(" updated:" + isUpdated()); + sb.append(" "); + + for (Entry entry : getMetadata().entrySet()) + { + sb.append(entry.getValue().getKey()); + sb.append(":"); + sb.append(entry.getValue().getValue()); + sb.append(" "); + } + + return sb.toString(); + } } diff --git a/src/bits/BinaryMessage.java b/src/bits/BinaryMessage.java index 66b9463e..56e21a12 100644 --- a/src/bits/BinaryMessage.java +++ b/src/bits/BinaryMessage.java @@ -17,7 +17,9 @@ ******************************************************************************/ package bits; +import java.util.Arrays; import java.util.BitSet; +import java.util.logging.LogManager; import edac.CRC; @@ -103,6 +105,11 @@ public class BinaryMessage extends BitSet this.or( bitset ); this.mPointer = size - 1; } + + public BinaryMessage(byte[] data) + { + this(BitSet.valueOf(data), data.length * 8); + } /** * Returns a mew binary message containing the bits from (inclusive) to @@ -277,6 +284,33 @@ public class BinaryMessage extends BitSet return values; } + + /** + * Returns this message as a little endian byte array. Extra 0 bits will be padded to the end to make the overall + * length a multiple of 8. + * @return little endian message byte array + */ + public byte[] toByteArray() + { + int length = size() / 8; + + if(length * 8 < size()) + { + length ++; + } + + byte[] bytes = new byte[length]; + + int pointer = 0; + + for(int x = 0; x < length; x++) + { + bytes[x] = getByte(pointer); + pointer += 8; + } + + return bytes; + } /** * Returns this bitset as a reversed bit order array of integer ones and zeros @@ -367,7 +401,24 @@ public class BinaryMessage extends BitSet return value; } - + + public void setInt(int value, int[] indices) + { + for(int x = 0; x < indices.length; x++) + { + int mask = 1<<(indices.length - x - 1); + + if((value & mask) == mask) + { + set(indices[x]); + } + else + { + clear(indices[x]); + } + } + } + /** * Returns the byte value represented by the bit array * @param bits - an array of bit positions that will be treated as if they @@ -398,6 +449,57 @@ public class BinaryMessage extends BitSet return (byte)( value & 0xFF ); } + /** + * Returns the byte value contained between index and index + 7 bit positions + * @param index specifying the start of the byte value + * @return byte value contained at index <> index + 7 bit positions + */ + public byte getByte(int index) + { + assert((index + 7) <= size()); + + int value = 0; + + for(int x = 0; x < 8; x++) + { + value = value << 1; + + if(get(index + x)) + { + value++; + } + } + + return (byte)value; + } + + /** + * Sets the byte value at index position through index + 7 position. + * + * @param index bit position where to write the MSB of the byte value + * @param value to write at the index through index + 7 bit positions + */ + public void setByte(int index, byte value) + { + assert((index + 8) <= size()); + + int mask = 0x80; + + for(int x = 0; x < 8; x++) + { + if((mask & value) == mask) + { + set(index + x); + } + else + { + clear(index + x); + } + + mask = mask >> 1; + } + } + /** * Returns the long value represented by the bit array * @param bits - an array of bit positions that will be treated as if they @@ -601,11 +703,11 @@ public class BinaryMessage extends BitSet } /** - * Loads the value into the buffer starting at the offset index, and + * Loads the value into the buffer starting at the offset index, and * assuming that the value represents (width) number of bits. The MSB of - * the value will be located at the offset and the LSB of the value will + * the value will be located at the offset and the LSB of the value will * be located at ( offset + width ). - * + * * @param offset - starting bit index for the MSB of the value * @param width - representative bit width of the value * @param value - value to be loaded into the buffer @@ -626,7 +728,7 @@ public class BinaryMessage extends BitSet } } } - + /** * Generates an array of message bit position indexes to support accessing * a contiguous field value @@ -775,4 +877,15 @@ public class BinaryMessage extends BitSet this.xor( mask ); } + + public static void main(String[] args) + { + BinaryMessage b = new BinaryMessage(32); + + int[] indices = {2,3,7,8}; + + b.setInt(0xF, indices); + + System.out.println(b.toString()); + } } diff --git a/src/controller/ControllerPanel.java b/src/controller/ControllerPanel.java index 1243b164..a66a994c 100644 --- a/src/controller/ControllerPanel.java +++ b/src/controller/ControllerPanel.java @@ -1,17 +1,17 @@ /******************************************************************************* * SDR Trunk * Copyright (C) 2014-2016 Dennis Sheirer - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ @@ -27,6 +27,8 @@ import javax.swing.JTable; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; +import audio.broadcast.BroadcastModel; +import audio.broadcast.BroadcastPanel; import map.MapPanel; import map.MapService; import module.decode.event.CallEventPanel; @@ -56,122 +58,121 @@ public class ControllerPanel extends JPanel private static final long serialVersionUID = 1L; private AliasController mAliasController; - private AudioPanel mAudioPanel; + private AudioPanel mAudioPanel; + private BroadcastPanel mBroadcastPanel; private CallEventPanel mCallEventPanel; - private ChannelController mChannelController; + private ChannelController mChannelController; private ChannelList mChannelStateList; - private ChannelModel mChannelModel; + private ChannelModel mChannelModel; private ChannelSpectrumPanel mChannelSpectrumPanel; - private MapPanel mMapPanel; + private MapPanel mMapPanel; private MessageActivityPanel mMessageActivityPanel; private TunerViewPanel mTunerManagerPanel; private JideTabbedPane mTabbedPane; - protected JTable mChannelActivityTable = new JTable(); + protected JTable mChannelActivityTable = new JTable(); - public ControllerPanel( AudioManager audioManager, - AliasModel aliasModel, - ChannelModel channelModel, - ChannelMapModel channelMapModel, - ChannelProcessingManager channelProcessingManager, - MapService mapService, - SettingsManager settingsManager, - SourceManager sourceManager, - TunerModel tunerModel ) - { - mChannelModel = channelModel; + public ControllerPanel(AudioManager audioManager, + AliasModel aliasModel, + BroadcastModel broadcastModel, + ChannelModel channelModel, + ChannelMapModel channelMapModel, + ChannelProcessingManager channelProcessingManager, + MapService mapService, + SettingsManager settingsManager, + SourceManager sourceManager, + TunerModel tunerModel) + { + mChannelModel = channelModel; - mAudioPanel = new AudioPanel( settingsManager, sourceManager, audioManager ); + mAudioPanel = new AudioPanel(settingsManager, sourceManager, audioManager); - mMapPanel = new MapPanel( mapService, settingsManager ); - - mMessageActivityPanel = new MessageActivityPanel( channelProcessingManager ); + mMapPanel = new MapPanel(mapService, settingsManager); - mCallEventPanel = new CallEventPanel( settingsManager, channelProcessingManager ); - - mChannelSpectrumPanel = new ChannelSpectrumPanel( settingsManager, - channelProcessingManager ); + mMessageActivityPanel = new MessageActivityPanel(channelProcessingManager); - mChannelStateList = new ChannelList( channelModel, channelProcessingManager, - settingsManager, mAudioPanel ); + mBroadcastPanel = new BroadcastPanel(broadcastModel, aliasModel, settingsManager); - mChannelController = new ChannelController( channelModel, channelMapModel, - sourceManager, aliasModel ); - - mAliasController = new AliasController( aliasModel, settingsManager ); - - mTunerManagerPanel = new TunerViewPanel( tunerModel ); + mCallEventPanel = new CallEventPanel(settingsManager, channelProcessingManager); - init(); - } - - private void init() - { - setLayout( new MigLayout( "insets 0 0 0 0 ", - "[grow,fill]", - "[grow,fill]") ); - - //Tabbed View - configuration, calls, messages, map - mTabbedPane = new JideTabbedPane(); - mTabbedPane.setFont( this.getFont() ); - mTabbedPane.setForeground( Color.BLACK ); - mTabbedPane.addTab( "Tuners", mTunerManagerPanel ); - mTabbedPane.addTab( "Channels", mChannelController ); - mTabbedPane.addTab( "Aliases", mAliasController ); - mTabbedPane.addTab( "Channel Spectrum", mChannelSpectrumPanel ); - mTabbedPane.addTab( "Events", mCallEventPanel ); - mTabbedPane.addTab( "Messages", mMessageActivityPanel ); - mTabbedPane.addTab( "Map", mMapPanel ); + mChannelSpectrumPanel = new ChannelSpectrumPanel(settingsManager, + channelProcessingManager); + + mChannelStateList = new ChannelList(channelModel, channelProcessingManager, + settingsManager, mAudioPanel); + + mChannelController = new ChannelController(channelModel, channelMapModel, + sourceManager, aliasModel); + + mAliasController = new AliasController(aliasModel, broadcastModel, settingsManager); + + mTunerManagerPanel = new TunerViewPanel(tunerModel); + + init(); + } + + private void init() + { + setLayout(new MigLayout("insets 0 0 0 0 ", + "[grow,fill]", + "[grow,fill]")); + + //Tabbed View - configuration, calls, messages, map + mTabbedPane = new JideTabbedPane(); + mTabbedPane.setFont(this.getFont()); + mTabbedPane.setForeground(Color.BLACK); + mTabbedPane.addTab("Tuners", mTunerManagerPanel); + mTabbedPane.addTab("Channels", mChannelController); + mTabbedPane.addTab("Aliases", mAliasController); + mTabbedPane.addTab("Channel Spectrum", mChannelSpectrumPanel); + mTabbedPane.addTab("Events", mCallEventPanel); + mTabbedPane.addTab("Map", mMapPanel); + mTabbedPane.addTab("Messages", mMessageActivityPanel); + mTabbedPane.addTab("Streaming", mBroadcastPanel); + + /** + * Change listener to enable/disable the channel spectrum display + * only when the tab is visible, and a channel has been selected + */ + mTabbedPane.addChangeListener(new ChangeListener() + { + @Override + public void stateChanged(ChangeEvent event) + { + int index = mTabbedPane.getSelectedIndex(); + + Component component = mTabbedPane.getComponentAt(index); + + if (component instanceof ChannelSpectrumPanel) + { + mChannelSpectrumPanel.setEnabled(true); + } + else + { + mChannelSpectrumPanel.setEnabled(false); + } + } + }); - /** - * Change listener to enable/disable the channel spectrum display - * only when the tab is visible, and a channel has been selected - */ - mTabbedPane.addChangeListener( new ChangeListener() - { - @Override - public void stateChanged( ChangeEvent event ) - { - int index = mTabbedPane.getSelectedIndex(); - - Component component = mTabbedPane.getComponentAt( index ); - - if( component instanceof ChannelSpectrumPanel ) - { - mChannelSpectrumPanel.setEnabled( true ); - } - else - { - mChannelSpectrumPanel.setEnabled( false ); - } - } - } ); - /* Register each of the components to receive channel events when the * channels are selected or change */ - mChannelModel.addListener( mCallEventPanel ); - mChannelModel.addListener( mChannelStateList ); - mChannelModel.addListener( mChannelSpectrumPanel ); - mChannelModel.addListener( mMessageActivityPanel ); - - JScrollPane channelStateListScroll = new JScrollPane(); - channelStateListScroll.getViewport().setView( mChannelStateList ); - channelStateListScroll.setPreferredSize( new Dimension( 400, 500 ) ); + mChannelModel.addListener(mCallEventPanel); + mChannelModel.addListener(mChannelStateList); + mChannelModel.addListener(mChannelSpectrumPanel); + mChannelModel.addListener(mMessageActivityPanel); -// JideSplitPane audioChannelListSplit = new JideSplitPane( JideSplitPane.VERTICAL_SPLIT ); -// audioChannelListSplit.setDividerSize( 5 ); -// audioChannelListSplit.add( mAudioPanel ); -// audioChannelListSplit.add( channelStateListScroll ); + JScrollPane channelStateListScroll = new JScrollPane(); + channelStateListScroll.getViewport().setView(mChannelStateList); + channelStateListScroll.setPreferredSize(new Dimension(400, 500)); - //Set preferred size to influence the split between these panels -// audioChannelListSplit.setPreferredSize( new Dimension( 400, 500 ) ); - mTabbedPane.setPreferredSize( new Dimension( 880, 500 ) ); - - JideSplitPane channelSplit = new JideSplitPane( JideSplitPane.HORIZONTAL_SPLIT ); - channelSplit.setDividerSize( 5 ); - channelSplit.add( channelStateListScroll ); - channelSplit.add( mTabbedPane ); - - add( channelSplit ); - } + //Set preferred size to influence the split between these panels + mTabbedPane.setPreferredSize(new Dimension(880, 500)); + + JideSplitPane channelSplit = new JideSplitPane(JideSplitPane.HORIZONTAL_SPLIT); + channelSplit.setDividerSize(5); + channelSplit.add(channelStateListScroll); + channelSplit.add(mTabbedPane); + + add(channelSplit); + } } diff --git a/src/controller/ThreadPoolManager.java b/src/controller/ThreadPoolManager.java index ca993295..d96cf9c6 100644 --- a/src/controller/ThreadPoolManager.java +++ b/src/controller/ThreadPoolManager.java @@ -44,6 +44,12 @@ public class ThreadPoolManager public ScheduledExecutorService getScheduledExecutorService() { + if( mExecutor == null ) + { + mExecutor = Executors.newScheduledThreadPool( THREAD_POOL_SIZE, + new NamingThreadFactory( "sdrtrunk" ) ); + } + return mExecutor; } @@ -53,14 +59,10 @@ public class ThreadPoolManager TimeUnit unit ) throws RejectedExecutionException { - if( mExecutor == null ) - { - mExecutor = Executors.newScheduledThreadPool( THREAD_POOL_SIZE, - new NamingThreadFactory( "sdrtrunk" ) ); - } + ScheduledExecutorService executorService = getScheduledExecutorService(); ScheduledFuture task = - mExecutor.scheduleAtFixedRate( command, 0, period, unit ); + executorService.scheduleAtFixedRate( command, 0, period, unit ); mTasks.put( task, type ); @@ -70,13 +72,9 @@ public class ThreadPoolManager public void scheduleOnce( Runnable command, long delay, TimeUnit unit ) throws RejectedExecutionException { - if( mExecutor == null ) - { - mExecutor = Executors.newScheduledThreadPool( THREAD_POOL_SIZE, - new NamingThreadFactory( "sdrtrunk" ) ); - } + ScheduledExecutorService executorService = getScheduledExecutorService(); - mExecutor.schedule( command, delay, unit ); + executorService.schedule( command, delay, unit ); } public boolean cancel( ScheduledFuture task ) diff --git a/src/external/radioreference/RadioReferenceService.java b/src/external/radioreference/RadioReferenceService.java new file mode 100644 index 00000000..df46e9f0 --- /dev/null +++ b/src/external/radioreference/RadioReferenceService.java @@ -0,0 +1,191 @@ +/******************************************************************************* + * 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 external.radioreference; + +import com.radioreference.api.soap2.AuthInfo; +import com.radioreference.api.soap2.RRWsdlLocator; +import com.radioreference.api.soap2.RRWsdlPortType; +import com.radioreference.api.soap2.UserFeedBroadcast; +import com.radioreference.api.soap2.UserInfo; +import org.apache.axis.AxisFault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.rpc.ServiceException; +import java.rmi.RemoteException; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class RadioReferenceService +{ + private final static Logger mLog = LoggerFactory.getLogger( RadioReferenceService.class ); + private final SimpleDateFormat SDF = new SimpleDateFormat("MM-dd-yyyy"); + + public static final String SDRTRUNK_APP_KEY = "88969092"; + public static final String RADIO_REFERENCE_API_VERSION = "14"; + public static final String SOAP_RPC_RESPONSE_STYLE = "rpc"; + + private RRWsdlLocator mService = new RRWsdlLocator(); + private RRWsdlPortType mClient; + private Long mAccountExpiration; + + private AuthInfo mUserAuthorization; + + /** + * Creates a SOAP web service client for interaction with the radioreference.com server. + * + * @param username for a premium subscriber + * @param password for a premium subscriber + * @throws ServiceException if the client can't be created + * @throws IllegalArgumentException if either the username or password is null or empty + */ + public RadioReferenceService(String username, String password) throws ServiceException + { + mClient = mService.getRRWsdlPort(); + + setAuthorizationInformation(username, password); + } + + /** + * Sets new premium subscriber user credentials to authenticate to the server. + * @param username for a premium subscriber on radioreference.com + * @param password for a premium subscriber + */ + public void setAuthorizationInformation(String username, String password) throws IllegalArgumentException + { + if(username == null || username.isEmpty() || password == null || password.isEmpty()) + { + throw new IllegalArgumentException("Username [" + username + "] and/or password [" + password + + "] cannot be null or empty"); + } + + mUserAuthorization = new AuthInfo(username, password, SDRTRUNK_APP_KEY, RADIO_REFERENCE_API_VERSION, + SOAP_RPC_RESPONSE_STYLE); + } + + /** + * Indicates if the service has a non-null user authorization + */ + public void checkCredentials() throws IllegalArgumentException + { + if(mUserAuthorization == null) + { + throw new IllegalStateException("Authorization information (username/password) required"); + } + } + + /** + * Account expiration date + * + * @return expiration date in millis since epoch + * @throws AxisFault for remote server errors + * @throws IllegalStateException for null or empty login credentials + * @throws RemoteException for web service client errors + */ + public long getAccountExpirationDate() throws IllegalStateException, RemoteException + { + checkCredentials(); + + if(mAccountExpiration == null) + { + UserInfo userInfo = mClient.getUserData(mUserAuthorization); + + if(userInfo != null) + { + String expiration = userInfo.getSubExpireDate(); + + if(expiration != null) + { + try + { + mAccountExpiration = SDF.parse(expiration).getTime(); + } + catch(Exception e) + { + mLog.error("Couldn't parse account expiration date from web service response: " + expiration, e); + } + } + } + } + + return mAccountExpiration; + } + + /** + * Indicates if the radio reference user account is active (ie not expired) + * + * @throws IllegalStateException for null or empty login credentials + * @throws RemoteException for web service client errors + */ + public boolean isAccountActive() throws IllegalStateException, RemoteException + { + Long expiration = getAccountExpirationDate(); + + if(expiration != null) + { + return System.currentTimeMillis() < expiration; + } + + return false; + } + + public UserFeedBroadcast[] getUserFeedBroadcasts() throws IllegalArgumentException, RemoteException + { + checkCredentials(); + + return mClient.getUserFeedBroadcasts(mUserAuthorization); + } + + + public static void main(String[] args) + { + try + { + RadioReferenceService service = new RadioReferenceService("dsheirer", "1dodgeram"); + + Long expiration = service.getAccountExpirationDate(); + + mLog.debug("Expiration Date:" + (expiration != null ? new Date(expiration).toString() : "unknown")); + + mLog.debug("Is Active: " + service.isAccountActive()); + + UserFeedBroadcast[] feeds = service.getUserFeedBroadcasts(); + + for(UserFeedBroadcast feed: feeds) + { + mLog.debug("Feed ID: " + feed.getFeedId() + " Desc:" + feed.getDescr()); + } + } + catch(AxisFault af) + { + mLog.debug("Hey, I caught an axis fault!"); + mLog.debug("Reason:" + af.getFaultReason()); + } + catch(RemoteException re) + { + re.printStackTrace(); + } + catch(Exception e) + { + e.printStackTrace(); + } + + mLog.debug("Finished!"); + } +} diff --git a/src/gui/SDRTrunk.java b/src/gui/SDRTrunk.java index 40bfb51a..db8cd501 100644 --- a/src/gui/SDRTrunk.java +++ b/src/gui/SDRTrunk.java @@ -1,56 +1,41 @@ /******************************************************************************* * SDR Trunk * Copyright (C) 2014-2016 Dennis Sheirer - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see ******************************************************************************/ package gui; -import java.awt.AWTException; -import java.awt.Desktop; -import java.awt.Dimension; -import java.awt.EventQueue; -import java.awt.Robot; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.awt.event.KeyEvent; -import java.awt.image.BufferedImage; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.TimeUnit; - -import javax.imageio.ImageIO; -import javax.swing.JFrame; -import javax.swing.JMenu; -import javax.swing.JMenuBar; -import javax.swing.JMenuItem; -import javax.swing.JOptionPane; -import javax.swing.JSeparator; -import javax.swing.KeyStroke; - +import alias.AliasModel; +import alias.action.AliasActionManager; +import audio.AudioManager; +import audio.broadcast.BroadcastModel; +import com.jidesoft.swing.JideSplitPane; +import controller.ControllerPanel; +import controller.ThreadPoolManager; +import controller.ThreadPoolManager.ThreadType; +import controller.channel.ChannelEventListener; +import controller.channel.ChannelModel; +import controller.channel.ChannelProcessingManager; +import controller.channel.ChannelSelectionManager; +import controller.channel.map.ChannelMapModel; import map.IconManager; import map.MapService; import module.log.EventLogManager; import net.miginfocom.swing.MigLayout; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import playlist.PlaylistManager; import properties.SystemProperties; import record.RecorderManager; @@ -63,152 +48,155 @@ import source.tuner.TunerSpectralDisplayManager; import source.tuner.configuration.TunerConfigurationModel; import spectrum.SpectralDisplayPanel; import util.TimeStamp; -import alias.AliasModel; -import alias.action.AliasActionManager; -import audio.AudioManager; -import com.jidesoft.swing.JideSplitPane; - -import controller.ControllerPanel; -import controller.ThreadPoolManager; -import controller.ThreadPoolManager.ThreadType; -import controller.channel.ChannelEventListener; -import controller.channel.ChannelModel; -import controller.channel.ChannelProcessingManager; -import controller.channel.ChannelSelectionManager; -import controller.channel.map.ChannelMapModel; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; public class SDRTrunk implements Listener { - private final static Logger mLog = LoggerFactory.getLogger( SDRTrunk.class ); + private final static Logger mLog = LoggerFactory.getLogger(SDRTrunk.class); - private ControllerPanel mControllerPanel; - private SettingsManager mSettingsManager; - private SpectralDisplayPanel mSpectralPanel; - private JFrame mMainGui = new JFrame(); - - private String mTitle; - - private boolean mLogChannelAndMemoryUsage = false; - - public SDRTrunk() + private ControllerPanel mControllerPanel; + private SettingsManager mSettingsManager; + private SpectralDisplayPanel mSpectralPanel; + private JFrame mMainGui = new JFrame(); + + private String mTitle; + + private boolean mLogChannelAndMemoryUsage = false; + + public SDRTrunk() { - mLog.info( "" ); - mLog.info( "" ); - mLog.info( "*******************************************************************" ); - mLog.info( "**** sdrtrunk: a trunked radio and digital decoding application ***" ); - mLog.info( "**** website: https://github.com/dsheirer/sdrtrunk ***" ); - mLog.info( "*******************************************************************" ); - mLog.info( "" ); - mLog.info( "" ); - - //Setup the application home directory - Path home = getHomePath(); - - mLog.info( "Home path: " + home.toString() ); + mLog.info(""); + mLog.info(""); + mLog.info("*******************************************************************"); + mLog.info("**** sdrtrunk: a trunked radio and digital decoding application ***"); + mLog.info("**** website: https://github.com/dsheirer/sdrtrunk ***"); + mLog.info("*******************************************************************"); + mLog.info(""); + mLog.info(""); - //Load properties file - if( home != null ) - { - loadProperties( home ); - } - - //Log current properties setting - SystemProperties.getInstance().logCurrentSettings(); - - TunerConfigurationModel tunerConfigurationModel = new TunerConfigurationModel(); - TunerModel tunerModel = new TunerModel( tunerConfigurationModel ); - - ThreadPoolManager threadPoolManager = new ThreadPoolManager(); - - mSettingsManager = new SettingsManager( threadPoolManager, tunerConfigurationModel ); + //Setup the application home directory + Path home = getHomePath(); - AliasModel aliasModel = new AliasModel(); + mLog.info("Home path: " + home.toString()); - ChannelModel channelModel = new ChannelModel(); - - ChannelMapModel channelMapModel = new ChannelMapModel(); + //Load properties file + if (home != null) + { + loadProperties(home); + } - EventLogManager eventLogManager = new EventLogManager(); + //Log current properties setting + SystemProperties.getInstance().logCurrentSettings(); - RecorderManager recorderManager = new RecorderManager( threadPoolManager ); - - SourceManager sourceManager = new SourceManager( tunerModel, - mSettingsManager, threadPoolManager ); - - ChannelProcessingManager channelProcessingManager = new ChannelProcessingManager( - channelModel, channelMapModel, aliasModel, eventLogManager, recorderManager, sourceManager ); - channelProcessingManager.addAudioPacketListener( recorderManager ); - - channelModel.addListener( channelProcessingManager ); - - ChannelSelectionManager channelSelectionManager = - new ChannelSelectionManager( channelModel ); - channelModel.addListener( channelSelectionManager ); - - AliasActionManager aliasActionManager = new AliasActionManager( threadPoolManager ); - channelProcessingManager.addMessageListener( aliasActionManager ); - - AudioManager audioManager = new AudioManager( threadPoolManager, sourceManager.getMixerManager() ); - channelProcessingManager.addAudioPacketListener( audioManager ); + TunerConfigurationModel tunerConfigurationModel = new TunerConfigurationModel(); + TunerModel tunerModel = new TunerModel(tunerConfigurationModel); - MapService mapService = new MapService( mSettingsManager ); - channelProcessingManager.addMessageListener( mapService ); + ThreadPoolManager threadPoolManager = new ThreadPoolManager(); - mControllerPanel = new ControllerPanel( audioManager, aliasModel, - channelModel, channelMapModel, channelProcessingManager, - mapService, mSettingsManager, sourceManager, tunerModel ); + mSettingsManager = new SettingsManager(threadPoolManager, tunerConfigurationModel); - mSpectralPanel = new SpectralDisplayPanel( channelModel, - channelProcessingManager, mSettingsManager ); + AliasModel aliasModel = new AliasModel(); - TunerSpectralDisplayManager tunerSpectralDisplayManager = - new TunerSpectralDisplayManager( mSpectralPanel, - channelModel, channelProcessingManager, mSettingsManager ); - tunerModel.addListener( tunerSpectralDisplayManager ); - tunerModel.addListener( this ); - - PlaylistManager playlistManager = new PlaylistManager( threadPoolManager, - aliasModel, channelModel, channelMapModel ); + ChannelModel channelModel = new ChannelModel(); - playlistManager.init(); + ChannelMapModel channelMapModel = new ChannelMapModel(); - if( mLogChannelAndMemoryUsage ) - { - Runnable cml = new ChannelMemoryLogger(); - channelModel.addListener( (ChannelEventListener)cml ); - threadPoolManager.scheduleFixedRate( - ThreadType.DECODER, cml, 5, TimeUnit.SECONDS ); - } + EventLogManager eventLogManager = new EventLogManager(); - mLog.info( "starting main application gui" ); - - //Initialize the GUI + RecorderManager recorderManager = new RecorderManager(threadPoolManager); + + SourceManager sourceManager = new SourceManager(tunerModel, + mSettingsManager, threadPoolManager); + + ChannelProcessingManager channelProcessingManager = new ChannelProcessingManager( + channelModel, channelMapModel, aliasModel, eventLogManager, recorderManager, sourceManager); + channelProcessingManager.addAudioPacketListener(recorderManager); + + channelModel.addListener(channelProcessingManager); + + ChannelSelectionManager channelSelectionManager = + new ChannelSelectionManager(channelModel); + channelModel.addListener(channelSelectionManager); + + AliasActionManager aliasActionManager = new AliasActionManager(threadPoolManager); + channelProcessingManager.addMessageListener(aliasActionManager); + + AudioManager audioManager = new AudioManager(threadPoolManager, sourceManager.getMixerManager()); + channelProcessingManager.addAudioPacketListener(audioManager); + + BroadcastModel broadcastModel = new BroadcastModel(threadPoolManager, mSettingsManager); + + channelProcessingManager.addAudioPacketListener(broadcastModel); + + MapService mapService = new MapService(mSettingsManager); + channelProcessingManager.addMessageListener(mapService); + + mControllerPanel = new ControllerPanel(audioManager, aliasModel, broadcastModel, + channelModel, channelMapModel, channelProcessingManager, + mapService, mSettingsManager, sourceManager, tunerModel); + + mSpectralPanel = new SpectralDisplayPanel(channelModel, + channelProcessingManager, mSettingsManager); + + TunerSpectralDisplayManager tunerSpectralDisplayManager = + new TunerSpectralDisplayManager(mSpectralPanel, + channelModel, channelProcessingManager, mSettingsManager); + tunerModel.addListener(tunerSpectralDisplayManager); + tunerModel.addListener(this); + + PlaylistManager playlistManager = new PlaylistManager(threadPoolManager, + aliasModel, broadcastModel, channelModel, channelMapModel); + + playlistManager.init(); + + if (mLogChannelAndMemoryUsage) + { + Runnable cml = new ChannelMemoryLogger(); + channelModel.addListener((ChannelEventListener) cml); + threadPoolManager.scheduleFixedRate( + ThreadType.DECODER, cml, 5, TimeUnit.SECONDS); + } + + mLog.info("starting main application gui"); + + //Initialize the GUI initGUI(); - - tunerModel.requestFirstTunerDisplay(); + + tunerModel.requestFirstTunerDisplay(); //Start the gui - EventQueue.invokeLater( new Runnable() + EventQueue.invokeLater(new Runnable() { public void run() { try { - mMainGui.setVisible( true ); + mMainGui.setVisible(true); } - catch( Exception e ) + catch (Exception e) { e.printStackTrace(); } } - } ); + }); } /** * Launch the application. */ - public static void main( String[] args ) + public static void main(String[] args) { new SDRTrunk(); } @@ -218,264 +206,239 @@ public class SDRTrunk implements Listener */ private void initGUI() { - mMainGui.setLayout( new MigLayout( "insets 0 0 0 0 ", - "[grow,fill]", - "[grow,fill]") ); - - /** - * Setup main JFrame window - */ - mTitle = getTitle(); - mMainGui.setTitle( mTitle ); - mMainGui.setBounds( 100, 100, 1280, 800 ); - mMainGui.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE ); + mMainGui.setLayout(new MigLayout("insets 0 0 0 0 ", + "[grow,fill]", + "[grow,fill]")); - //Set preferred sizes to influence the split - mSpectralPanel.setPreferredSize( new Dimension( 1280, 300 ) ); - mControllerPanel.setPreferredSize( new Dimension( 1280, 500 ) ); - - JideSplitPane splitPane = new JideSplitPane( JideSplitPane.VERTICAL_SPLIT ); - splitPane.setDividerSize( 5 ); - splitPane.add( mSpectralPanel ); - splitPane.add( mControllerPanel ); + /** + * Setup main JFrame window + */ + mTitle = SystemProperties.getInstance().getApplicationName(); + mMainGui.setTitle(mTitle); + mMainGui.setBounds(100, 100, 1280, 800); + mMainGui.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + + //Set preferred sizes to influence the split + mSpectralPanel.setPreferredSize(new Dimension(1280, 300)); + mControllerPanel.setPreferredSize(new Dimension(1280, 500)); + + JideSplitPane splitPane = new JideSplitPane(JideSplitPane.VERTICAL_SPLIT); + splitPane.setDividerSize(5); + splitPane.add(mSpectralPanel); + splitPane.add(mControllerPanel); + + mMainGui.add(splitPane, "cell 0 0,span,grow"); - mMainGui.add( splitPane, "cell 0 0,span,grow"); - /** * Menu items */ JMenuBar menuBar = new JMenuBar(); - mMainGui.setJMenuBar( menuBar ); - - JMenu fileMenu = new JMenu( "File" ); - menuBar.add( fileMenu ); + mMainGui.setJMenuBar(menuBar); - JMenuItem logFilesMenu = new JMenuItem( "Logs & Recordings" ); - logFilesMenu.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent arg0 ) - { - try - { - Desktop.getDesktop().open( getHomePath().toFile() ); - } - catch( Exception e ) - { - mLog.error( "Couldn't open file explorer" ); - - JOptionPane.showMessageDialog( mMainGui, - "Can't launch file explorer - files are located at: " + - getHomePath().toString(), - "Can't launch file explorer", - JOptionPane.ERROR_MESSAGE ); - } - } - } ); - fileMenu.add( logFilesMenu ); - - JMenuItem settingsMenu = new JMenuItem( "Icon Manager" ); - settingsMenu.addActionListener( new ActionListener() + JMenu fileMenu = new JMenu("File"); + menuBar.add(fileMenu); + + JMenuItem logFilesMenu = new JMenuItem("Logs & Recordings"); + logFilesMenu.addActionListener(new ActionListener() { - @Override - public void actionPerformed( ActionEvent arg0 ) + @Override + public void actionPerformed(ActionEvent arg0) { - final IconManager iconManager = new IconManager( mSettingsManager, mMainGui ); - - EventQueue.invokeLater( new Runnable() - { - @Override - public void run() - { - iconManager.setVisible( true ); - } - } ); - } - } ); - fileMenu.add( settingsMenu ); - - fileMenu.add( new JSeparator() ); - - JMenuItem exitMenu = new JMenuItem( "Exit" ); - exitMenu.addActionListener( - new ActionListener() - { - public void actionPerformed( ActionEvent event ) - { - System.exit( 0 ); - } - } - ); - - fileMenu.add( exitMenu ); - - JMenuItem screenCaptureItem = new JMenuItem( "Screen Capture" ); - - screenCaptureItem.setMnemonic( KeyEvent.VK_C ); - screenCaptureItem.setAccelerator( - KeyStroke.getKeyStroke( KeyEvent.VK_C, ActionEvent.ALT_MASK ) ); - - screenCaptureItem.addActionListener( new ActionListener() - { - @Override - public void actionPerformed( ActionEvent arg0 ) - { - try + try { - Robot robot = new Robot(); - - final BufferedImage image = - robot.createScreenCapture( mMainGui.getBounds() ); - - SystemProperties props = SystemProperties.getInstance(); - - Path capturePath = props.getApplicationFolder( "screen_captures" ); - - if( !Files.exists( capturePath ) ) - { - try - { - Files.createDirectory( capturePath ); - } - catch ( IOException e ) - { - mLog.error( "Couldn't create 'screen_captures' " - + "subdirectory in the " + - "SDRTrunk application directory", e ); - } - } - - String filename = TimeStamp.getTimeStamp( "_" ) + - "_screen_capture.png"; + Desktop.getDesktop().open(getHomePath().toFile()); + } + catch (Exception e) + { + mLog.error("Couldn't open file explorer"); - final Path captureFile = capturePath.resolve( filename ); + JOptionPane.showMessageDialog(mMainGui, + "Can't launch file explorer - files are located at: " + + getHomePath().toString(), + "Can't launch file explorer", + JOptionPane.ERROR_MESSAGE); + } + } + }); + fileMenu.add(logFilesMenu); - EventQueue.invokeLater( new Runnable() - { - @Override + JMenuItem settingsMenu = new JMenuItem("Icon Manager"); + settingsMenu.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent arg0) + { + final IconManager iconManager = new IconManager(mSettingsManager, mMainGui); + + EventQueue.invokeLater(new Runnable() + { + @Override + public void run() + { + iconManager.setVisible(true); + } + }); + } + }); + fileMenu.add(settingsMenu); + + fileMenu.add(new JSeparator()); + + JMenuItem exitMenu = new JMenuItem("Exit"); + exitMenu.addActionListener( + new ActionListener() + { + public void actionPerformed(ActionEvent event) + { + System.exit(0); + } + } + ); + + fileMenu.add(exitMenu); + + JMenuItem screenCaptureItem = new JMenuItem("Screen Capture"); + + screenCaptureItem.setMnemonic(KeyEvent.VK_C); + screenCaptureItem.setAccelerator( + KeyStroke.getKeyStroke(KeyEvent.VK_C, ActionEvent.ALT_MASK)); + + screenCaptureItem.addActionListener(new ActionListener() + { + @Override + public void actionPerformed(ActionEvent arg0) + { + try + { + Robot robot = new Robot(); + + final BufferedImage image = + robot.createScreenCapture(mMainGui.getBounds()); + + SystemProperties props = SystemProperties.getInstance(); + + Path capturePath = props.getApplicationFolder("screen_captures"); + + if (!Files.exists(capturePath)) + { + try + { + Files.createDirectory(capturePath); + } + catch (IOException e) + { + mLog.error("Couldn't create 'screen_captures' " + + "subdirectory in the " + + "SDRTrunk application directory", e); + } + } + + String filename = TimeStamp.getTimeStamp("_") + + "_screen_capture.png"; + + final Path captureFile = capturePath.resolve(filename); + + EventQueue.invokeLater(new Runnable() + { + @Override public void run() { - try + try { - ImageIO.write( image, "png", - captureFile.toFile() ); + ImageIO.write(image, "png", + captureFile.toFile()); } - catch ( IOException e ) + catch (IOException e) { - mLog.error( "Couldn't write screen capture to " - + "file [" + captureFile.toString() + "]", e ); + mLog.error("Couldn't write screen capture to " + + "file [" + captureFile.toString() + "]", e); } - }} ); + } + }); } - catch ( AWTException e ) + catch (AWTException e) { - mLog.error( "Exception while taking screen capture", e ); + mLog.error("Exception while taking screen capture", e); } } - } ); - - menuBar.add( screenCaptureItem ); + }); + + menuBar.add(screenCaptureItem); } - + /** * Loads the application properties file from the user's home directory, * creating the properties file for the first-time, if necessary */ - private void loadProperties( Path homePath ) + private void loadProperties(Path homePath) { - Path propsPath = homePath.resolve( "SDRTrunk.properties" ); + Path propsPath = homePath.resolve("SDRTrunk.properties"); - if( !Files.exists( propsPath ) ) - { - try + if (!Files.exists(propsPath)) + { + try { - mLog.info( "SDRTrunk - creating application properties file [" + - propsPath.toAbsolutePath() + "]" ); + mLog.info("SDRTrunk - creating application properties file [" + + propsPath.toAbsolutePath() + "]"); - Files.createFile( propsPath ); + Files.createFile(propsPath); } - catch ( IOException e ) + catch (IOException e) { - mLog.error( "SDRTrunk - couldn't create application properties " - + "file [" + propsPath.toAbsolutePath(), e ); + mLog.error("SDRTrunk - couldn't create application properties " + + "file [" + propsPath.toAbsolutePath(), e); } - } - - if( Files.exists( propsPath ) ) - { - SystemProperties.getInstance().load( propsPath ); - } - else - { - mLog.error( "SDRTrunk - couldn't find or recreate the SDRTrunk " + - "application properties file" ); - } + } + + if (Files.exists(propsPath)) + { + SystemProperties.getInstance().load(propsPath); + } + else + { + mLog.error("SDRTrunk - couldn't find or recreate the SDRTrunk " + + "application properties file"); + } } /** - * Gets (or creates) the SDRTRunk application home directory. - * - * Note: the user can change this setting to allow log files and other + * Gets (or creates) the SDRTRunk application home directory. + * + * Note: the user can change this setting to allow log files and other * files to reside elsewhere on the file system. */ private Path getHomePath() { - Path homePath = FileSystems.getDefault() - .getPath( System.getProperty( "user.home" ), "SDRTrunk" ); + Path homePath = FileSystems.getDefault() + .getPath(System.getProperty("user.home"), "SDRTrunk"); - if( !Files.exists( homePath ) ) - { - try - { - Files.createDirectory( homePath ); - - mLog.info( "SDRTrunk - created application home directory [" + - homePath.toString() + "]" ); - } - catch ( Exception e ) - { - homePath = null; - - mLog.error( "SDRTrunk: exception while creating SDRTrunk home " + - "directory in the user's home directory", e ); - } - } + if (!Files.exists(homePath)) + { + try + { + Files.createDirectory(homePath); - return homePath; + mLog.info("SDRTrunk - created application home directory [" + + homePath.toString() + "]"); + } + catch (Exception e) + { + homePath = null; + + mLog.error("SDRTrunk: exception while creating SDRTrunk home " + + "directory in the user's home directory", e); + } + } + + return homePath; } - - private String getTitle() + + @Override + public void receive(TunerEvent event) { - StringBuilder sb = new StringBuilder(); - - sb.append( "sdrtrunk" ); - - try( BufferedReader reader = new BufferedReader( - new InputStreamReader( this.getClass() - .getResourceAsStream( "/sdrtrunk-version" ) ) ) ) - { - String version = reader.readLine(); - - if( version != null ) - { - sb.append( " V" ); - sb.append( version ); - } - } - catch( Exception e ) - { - mLog.error( "Couldn't read sdrtrunk version from application jar file" ); - } - - return sb.toString(); + if (event.getEvent() == TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY) + { + mMainGui.setTitle(mTitle + " - " + event.getTuner().getName()); + } } - - @Override - public void receive( TunerEvent event ) - { - if( event.getEvent() == TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY ) - { - mMainGui.setTitle( mTitle + " - " + event.getTuner().getName() ); - } - } } diff --git a/src/instrument/gui/DecoderSelectionFrame.java b/src/instrument/gui/DecoderSelectionFrame.java index ccd310c8..23239335 100644 --- a/src/instrument/gui/DecoderSelectionFrame.java +++ b/src/instrument/gui/DecoderSelectionFrame.java @@ -8,6 +8,7 @@ import javax.swing.JButton; import javax.swing.JDesktopPane; import javax.swing.JInternalFrame; +import audio.broadcast.BroadcastModel; import module.decode.DecodeConfigurationEditor; import net.miginfocom.swing.MigLayout; import playlist.PlaylistManager; @@ -30,13 +31,14 @@ public class DecoderSelectionFrame extends JInternalFrame public DecoderSelectionFrame( JDesktopPane desktop, IControllableFileSource source ) { + ThreadPoolManager tpm = new ThreadPoolManager(); + AliasModel aliasModel = new AliasModel(); + BroadcastModel broadcastModel = new BroadcastModel(tpm, null); ChannelModel channelModel = new ChannelModel(); ChannelMapModel channelMapModel = new ChannelMapModel(); - - ThreadPoolManager tpm = new ThreadPoolManager(); - - mPlaylistManager = new PlaylistManager( tpm, aliasModel, channelModel, channelMapModel ); + + mPlaylistManager = new PlaylistManager( tpm, aliasModel, broadcastModel, channelModel, channelMapModel ); mDesktop = desktop; mSource = source; diff --git a/src/module/decode/p25/P25DecoderEditor.java b/src/module/decode/p25/P25DecoderEditor.java index e647e407..3d54ace1 100644 --- a/src/module/decode/p25/P25DecoderEditor.java +++ b/src/module/decode/p25/P25DecoderEditor.java @@ -137,7 +137,6 @@ public class P25DecoderEditor extends ValidatingEditor @Override public void validate( Editor editor ) throws EditorValidationException { - mLog.debug( "Validating editor:" + editor.getClass() ); /** * Enforce CQPSK modulation uses a Tuner Source for I/Q sample data */ diff --git a/src/module/decode/p25/message/pdu/osp/control/AdjacentStatusBroadcastExtended.java b/src/module/decode/p25/message/pdu/osp/control/AdjacentStatusBroadcastExtended.java index 1334c1ab..de1183cd 100644 --- a/src/module/decode/p25/message/pdu/osp/control/AdjacentStatusBroadcastExtended.java +++ b/src/module/decode/p25/message/pdu/osp/control/AdjacentStatusBroadcastExtended.java @@ -65,14 +65,14 @@ public class AdjacentStatusBroadcastExtended extends PDUMessage sb.append( " RFSS:" + getRFSS() ); sb.append( " SITE:" + getSiteID() ); - - sb.append( " CTRL CHAN:" + getReceiveIdentifier() + "-" + getReceiveChannel() ); - + + sb.append( " CTRL CHAN:" + getTransmitIdentifier() + "-" + getTransmitChannel() ); + sb.append( " DN:" + getDownlinkFrequency() ); - - sb.append( " " + getTransmitIdentifier() + "-" + getTransmitChannel() ); - - sb.append( " UP:" + getUplinkFrequency() ); + + sb.append( " " + getReceiveIdentifier() + "-" + getReceiveChannel() ); + + sb.append( " UP:" + getUplinkFrequency() ); sb.append( " SYS SVC CLASS:" + getSystemServiceClass() ); diff --git a/src/network.java b/src/network.java new file mode 100644 index 00000000..b7a93210 --- /dev/null +++ b/src/network.java @@ -0,0 +1,21 @@ +/******************************************************************************* + * 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 + * + ******************************************************************************/ +public class network +{ +} diff --git a/src/org/jma/encoder/audio/IAudioEncoder.java b/src/org/jma/encoder/audio/IAudioEncoder.java new file mode 100644 index 00000000..ab5eeef2 --- /dev/null +++ b/src/org/jma/encoder/audio/IAudioEncoder.java @@ -0,0 +1,18 @@ +package org.jma.encoder.audio; + +/** + * Retrieved from: http://livertmpjavapublisher.blogspot.com/2011/06/orgtritonuslowlevellameapiiaudioencoder.html + * on 5 Sep 2016 + */ +public interface IAudioEncoder +{ + public int encodeBuffer(byte[] input, int offset, int encodedCount, byte[] output); + + public int encodeFinish(byte[] input); + + public int getInputBufferSize(); + + public int getOutputBufferSize(); + + public void close(); +} diff --git a/src/playlist/PlaylistManager.java b/src/playlist/PlaylistManager.java index df075a70..d50f7e03 100644 --- a/src/playlist/PlaylistManager.java +++ b/src/playlist/PlaylistManager.java @@ -33,6 +33,8 @@ import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import javax.xml.bind.Unmarshaller; +import audio.broadcast.BroadcastEvent; +import audio.broadcast.BroadcastModel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +58,7 @@ public class PlaylistManager implements ChannelEventListener private ThreadPoolManager mThreadPoolManager; private AliasModel mAliasModel; + private BroadcastModel mBroadcastModel; private ChannelModel mChannelModel; private ChannelMapModel mChannelMapModel; private Path mCurrentPlaylistPath; @@ -74,11 +77,13 @@ public class PlaylistManager implements ChannelEventListener */ public PlaylistManager( ThreadPoolManager threadPoolManager, AliasModel aliasModel, + BroadcastModel broadcastModel, ChannelModel channelModel, - ChannelMapModel channelMapModel ) + ChannelMapModel channelMapModel) { mThreadPoolManager = threadPoolManager; mAliasModel = aliasModel; + mBroadcastModel = broadcastModel; mChannelModel = channelModel; mChannelMapModel = channelMapModel; @@ -103,6 +108,15 @@ public class PlaylistManager implements ChannelEventListener schedulePlaylistSave(); } } ); + + mBroadcastModel.addListener(new Listener() + { + @Override + public void receive(BroadcastEvent broadcastEvent) + { + schedulePlaylistSave(); + } + }); } /** @@ -166,6 +180,7 @@ public class PlaylistManager implements ChannelEventListener mPlaylistLoading = true; mAliasModel.addAliases( playlist.getAliases() ); + mBroadcastModel.addBroadcastConfigurations(playlist.getBroadcastConfigurations()); mChannelModel.addChannels( playlist.getChannels() ); mChannelMapModel.addChannelMaps( playlist.getChannelMaps() ); @@ -219,6 +234,7 @@ public class PlaylistManager implements ChannelEventListener PlaylistV2 playlist = new PlaylistV2(); playlist.setAliases( mAliasModel.getAliases() ); + playlist.setBroadcastConfigurations(mBroadcastModel.getBroadcastConfigurations()); playlist.setChannels( mChannelModel.getChannels() ); playlist.setChannelMaps( mChannelMapModel.getChannelMaps() ); @@ -277,7 +293,7 @@ public class PlaylistManager implements ChannelEventListener } catch ( Exception e ) { - mLog.error( "coulcn't open outputstream to save playlist [" + + mLog.error( "couldn't open outputstream to save playlist [" + mCurrentPlaylistPath.toString() + "]", e ); } finally @@ -330,7 +346,7 @@ public class PlaylistManager implements ChannelEventListener } catch ( Exception e ) { - mLog.error( "coulcn't open inputstream to load playlist [" + playlistPath.toString() + "]", e ); + mLog.error( "couldn't open input stream to load playlist [" + playlistPath.toString() + "]", e ); } finally { diff --git a/src/playlist/PlaylistV2.java b/src/playlist/PlaylistV2.java index aa3fa2fe..584ba6f6 100644 --- a/src/playlist/PlaylistV2.java +++ b/src/playlist/PlaylistV2.java @@ -17,24 +17,23 @@ ******************************************************************************/ package playlist; -import java.util.ArrayList; -import java.util.List; +import alias.Alias; +import audio.broadcast.BroadcastConfiguration; +import controller.channel.Channel; +import controller.channel.map.ChannelMap; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlSeeAlso; +import java.util.ArrayList; +import java.util.List; -import alias.Alias; -import controller.channel.Channel; -import controller.channel.map.ChannelMap; - -@XmlSeeAlso( { Alias.class,Channel.class,ChannelMap.class } ) - +@XmlSeeAlso( { Alias.class,Channel.class,ChannelMap.class,BroadcastConfiguration.class} ) @XmlRootElement( name = "playlist" ) public class PlaylistV2 { - //Version 2 member variables private List mAliases = new ArrayList<>(); + private List mBroadcastConfigurations = new ArrayList<>(); private List mChannels = new ArrayList<>(); private List mChannelMaps = new ArrayList<>(); @@ -74,4 +73,15 @@ public class PlaylistV2 { mChannelMaps = channelMaps; } + + @XmlElement(name = "stream") + public List getBroadcastConfigurations() + { + return mBroadcastConfigurations; + } + + public void setBroadcastConfigurations(List configurations) + { + mBroadcastConfigurations = configurations; + } } diff --git a/src/playlist/version1/AliasListOld.java b/src/playlist/version1/AliasListOld.java index 7df3c6be..199c8db9 100644 --- a/src/playlist/version1/AliasListOld.java +++ b/src/playlist/version1/AliasListOld.java @@ -153,7 +153,7 @@ public class AliasListOld implements Comparable mESN.put( esn.getEsn(), alias ); } break; - case Fleetsync: + case FLEETSYNC: FleetsyncID fs = (FleetsyncID)id; if( fs.getIdent().contains( "*" ) ) @@ -167,7 +167,7 @@ public class AliasListOld implements Comparable mFleetsync.put( fs.getIdent(), alias ); } break; - case LoJack: + case LOJACK: mLoJack.put( (LoJackFunctionAndID)id, alias ); break; case MDC1200: @@ -218,12 +218,12 @@ public class AliasListOld implements Comparable mMobileID.put( min.getMin(), alias ); } break; - case LTRNetUID: + case LTR_NET_UID: UniqueID uid = (UniqueID)id; mUniqueID.put( uid.getUid(), alias ); break; - case Site: + case SITE: SiteID siteID = (SiteID)id; if( siteID.getSite().contains( "*" ) ) @@ -237,10 +237,10 @@ public class AliasListOld implements Comparable } break; - case Status: + case STATUS: mStatus.put( ((StatusID)id).getStatus(), alias ); break; - case Talkgroup: + case TALKGROUP: TalkgroupID tgid = (TalkgroupID)id; if( tgid.getTalkgroup().contains( "*" ) ) @@ -255,8 +255,8 @@ public class AliasListOld implements Comparable mTalkgroup.put( tgid.getTalkgroup(), alias ); } break; - case NonRecordable: - case Priority: + case NON_RECORDABLE: + case PRIORITY: //We don't maintain lookups for these items break; default: diff --git a/src/properties/SystemProperties.java b/src/properties/SystemProperties.java index 38837ed5..de25de05 100644 --- a/src/properties/SystemProperties.java +++ b/src/properties/SystemProperties.java @@ -17,11 +17,13 @@ ******************************************************************************/ package properties; +import java.io.BufferedReader; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -47,7 +49,7 @@ public class SystemProperties private static SystemProperties mInstance; private static Properties mProperties; private Path mPropertiesPath; - + private String mApplicationName; private SystemProperties() { @@ -67,7 +69,7 @@ public class SystemProperties return mInstance; } - + /** * Saves any currently changed settings to the application properties file */ @@ -86,9 +88,6 @@ public class SystemProperties "SDRTrunk - SDR Trunking Decoder Application Settings"; mProperties.store( out, comments ); - -// mLog.info( "SystemProperties - saved current properties [" + -// propsPath.toString() + "]" ); } catch( Exception e ) { @@ -216,6 +215,37 @@ public class SystemProperties propertiesPath.toString() + "]" ); } + public String getApplicationName() + { + if(mApplicationName == null) + { + StringBuilder sb = new StringBuilder(); + + sb.append( "sdrtrunk" ); + + try(BufferedReader reader = new BufferedReader( + new InputStreamReader( this.getClass() + .getResourceAsStream( "/sdrtrunk-version" ) ) ) ) + { + String version = reader.readLine(); + + if( version != null ) + { + sb.append( " V" ); + sb.append( version ); + } + } + catch( Exception e ) + { + mLog.error( "Couldn't read sdrtrunk version from application jar file" ); + } + + mApplicationName = sb.toString(); + } + + return mApplicationName; + } + /** * Returns the value of the property, or null if the property doesn't exist */ diff --git a/src/record/AudioRecorder.java b/src/record/AudioRecorder.java new file mode 100644 index 00000000..61f48158 --- /dev/null +++ b/src/record/AudioRecorder.java @@ -0,0 +1,322 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package record; + +import audio.AudioPacket; +import audio.IAudioPacketListener; +import audio.metadata.AudioMetadata; +import module.Module; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sample.Listener; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public abstract class AudioRecorder extends Module implements Listener, IAudioPacketListener +{ + private final static Logger mLog = LoggerFactory.getLogger(AudioRecorder.class); + + private LinkedBlockingQueue mAudioPacketQueue = new LinkedBlockingQueue<>(500); + private List mPacketsToProcess = new ArrayList<>(); + + private FileOutputStream mFileOutputStream; + private AtomicBoolean mRunning = new AtomicBoolean(); + + protected Path mPath; + protected AudioMetadata mAudioMetadata; + protected long mTimeRecordingStart; + protected long mTimeLastPacketReceived; + private BufferProcessor mBufferProcessor; + private ScheduledFuture mProcessorHandle; + private Listener mRecordingClosedListener; + + private long mSampleCount; + + /** + * Abstract audio recorder that implements audio packet queueing and threaded audio conversion/writing to a file + * + * @param path for the output recording + */ + public AudioRecorder(Path path) + { + mPath = path; + } + + /** + * Path for the audio recording file + */ + public Path getPath() + { + return mPath; + } + + /** + * Latest audio metadata received for this recording + */ + public AudioMetadata getMetadata() + { + return mAudioMetadata; + } + + /** + * Timestamp of the last buffer received by this recorder - allows this recorder to be monitored for automatic + * closure after a time period has elapsed. + */ + public long getTimeLastPacketReceived() + { + return mTimeLastPacketReceived; + } + + public long getTimeRecordingStart() + { + return mTimeRecordingStart; + } + + /** + * Recording length in milliseconds + */ + public long getRecordingLength() + { + //Assumes audio sample rate of 8000 samples/second or 8 samples/milli-second + return mSampleCount / 8; + } + + /** + * Implements the IAudioPacketListener interface and simply redirects to the Listener interface. + * This is necessary since you can't have multiple methods with the same erasure (ie Listener) in the + * parent module class. + */ + @Override + public Listener getAudioPacketListener() + { + return this; + } + + /** + * Processes the audio packet and captures the latest AudioMetadata for the recording for easy access. + */ + @Override + public void receive(AudioPacket audioPacket) + { + if(mRunning.get()) + { + mTimeRecordingStart = System.currentTimeMillis(); + mTimeLastPacketReceived = mTimeRecordingStart; + + if(audioPacket.hasAudioMetadata()) + { + mAudioMetadata = audioPacket.getAudioMetadata(); + } + + boolean success = mAudioPacketQueue.offer(audioPacket); + + if (!success) + { + mLog.error("recorder buffer overflow - stopping recorder [" + getPath().toString() + "]"); + stop(); + } + } + } + + /** + * File output stream for the current recording. Intended to allow sub-classes to write binary data to the file. + */ + protected OutputStream getOutputStream() + { + return mFileOutputStream; + } + + /** + * 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() + { + close(null); + } + + /** + * Closes the recording file. Upon successful closing of the recording file, the listener is notified that the + * audio recorder is closed. + */ + public void close(Listener listener) + { + mRecordingClosedListener = listener; + + if(!mRunning.compareAndSet(true, false)) + { + //If this recorder has already been stopped/closed, immediately inform the listener + mRecordingClosedListener.receive(this); + } + } + + + /** + * Records the list of audio packets in the sub-class specific audio format. + */ + protected abstract void record(List audioPackets) throws IOException; + + /** + * Starts this recorder as a scheduled thread running under the executor argument + * + * @param executor to use in scheduling audio conversion and file writes. + */ + public void start(ScheduledExecutorService executor) + { + if (mRunning.compareAndSet(false, true)) + { + mTimeLastPacketReceived = System.currentTimeMillis(); + + if (mBufferProcessor == null) + { + mBufferProcessor = new BufferProcessor(); + } + + try + { + mFileOutputStream = new FileOutputStream(mPath.toFile()); + + /* Schedule the handler to run every half second */ + mProcessorHandle = executor.scheduleAtFixedRate(mBufferProcessor, 0, 500, TimeUnit.MILLISECONDS); + } + catch (IOException io) + { + mLog.error("Error starting audio recorder [" + getPath().toString() + "]", io); + + mRunning.set(false); + } + } + } + + /** + * Processes the audio packet queue. + */ + private void processAudioPacketQueue() + { + mAudioPacketQueue.drainTo(mPacketsToProcess); + + if (!mPacketsToProcess.isEmpty()) + { + try + { + record(mPacketsToProcess); + + for(AudioPacket packet: mPacketsToProcess) + { + if(packet.getType() == AudioPacket.Type.AUDIO) + { + mSampleCount += packet.getAudioBuffer().getSamples().length; + } + } + } + catch(IOException ioe) + { + mLog.debug("Error while recording audio to [" + getPath().toString() + "] - stopping recorder"); + stop(); + } + + mPacketsToProcess.clear(); + } + } + + /** + * Disposes this audio recorder and prepares it for reclamation + */ + @Override + public void dispose() + { + stop(); + } + + /** + * Not implemented. Recorder modules are not appropriate for reset and reuse. + */ + @Override + public void reset() + { + } + + /** + * 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 + */ + public class BufferProcessor implements Runnable + { + private AtomicBoolean mProcessing = new AtomicBoolean(); + + public void run() + { + if (mProcessing.compareAndSet(false, true)) + { + 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); + } + } + } +} diff --git a/src/record/mp3/MP3Recorder.java b/src/record/mp3/MP3Recorder.java new file mode 100644 index 00000000..747496ce --- /dev/null +++ b/src/record/mp3/MP3Recorder.java @@ -0,0 +1,121 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package record.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; +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. + */ +public class MP3Recorder extends AudioRecorder +{ + private final static Logger mLog = LoggerFactory.getLogger(MP3Recorder.class); + + public static final int MP3_BIT_RATE = 16; + public static final boolean CONSTANT_BIT_RATE = false; + + private MP3AudioConverter mMP3Converter; + + /** + * MP3 audio recorder module for converting audio packets to 16 kHz constant bit rate MP3 format and + * recording to a file. + * + * @param path to the output file. File name should include the .mp3 file extension. + */ + public MP3Recorder(Path path) + { + super(path); + + mMP3Converter = new MP3AudioConverter(MP3_BIT_RATE, CONSTANT_BIT_RATE); + } + + @Override + protected void record(List audioPackets) throws IOException + { + OutputStream outputStream = getOutputStream(); + + if(outputStream != null) + { + processMetadata(audioPackets); + + byte[] mp3Audio = mMP3Converter.convert(audioPackets); + + outputStream.write(mp3Audio); + } + } + + @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. + * @param audioPackets + */ + private void processMetadata(List audioPackets) + { + //TODO: detect metadata changes and write out ID3 tags to the MP3 stream + } +} diff --git a/src/record/wave/AudioPacketMonoWaveReader.java b/src/record/wave/AudioPacketMonoWaveReader.java new file mode 100644 index 00000000..b8a4af71 --- /dev/null +++ b/src/record/wave/AudioPacketMonoWaveReader.java @@ -0,0 +1,70 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package record.wave; + +import audio.AudioPacket; +import audio.IAudioPacketListener; +import audio.metadata.AudioMetadata; +import sample.Listener; +import sample.real.RealBuffer; + +import java.io.IOException; +import java.nio.file.Path; + +public class AudioPacketMonoWaveReader extends MonoWaveReader implements Listener +{ + private AudioMetadata mMetadata = new AudioMetadata(0, false); + + private IAudioPacketListener mListener; + + /** + * Reads wav files and converts the contents to AudioPackets. + * + * @param path of file to read containing 8kHz, 16-bit signed litte endian PCM audio + * @param realTime causes the read() method to read and dispatch audio packets in real time. Note: when using + * real time playback, the thread calling the read() method will sleep as needed to effect real-time playback, + * therefore you should invoke read() on a separate thread if this will impact the calling thread. + * + * @throws IOException on any file IO errors + */ + public AudioPacketMonoWaveReader(Path path, boolean realTime) throws IOException + { + super(path, realTime); + setListener(this); + } + + public void setMetadata(AudioMetadata metadata) + { + mMetadata = metadata; + } + + public void setListener(IAudioPacketListener listener) + { + mListener = listener; + } + + @Override + public void receive(RealBuffer realBuffer) + { + if(mListener != null) + { + mListener.getAudioPacketListener().receive(new AudioPacket(realBuffer.getSamples(), mMetadata)); + } + } +} diff --git a/src/record/wave/MonoWaveReader.java b/src/record/wave/MonoWaveReader.java new file mode 100644 index 00000000..25b076e9 --- /dev/null +++ b/src/record/wave/MonoWaveReader.java @@ -0,0 +1,257 @@ +/******************************************************************************* + * sdrtrunk + * Copyright (C) 2014-2016 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * + ******************************************************************************/ +package record.wave; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sample.ConversionUtils; +import sample.Listener; +import sample.real.RealBuffer; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.ShortBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.regex.Pattern; + +public class MonoWaveReader implements AutoCloseable +{ + private final static Logger mLog = LoggerFactory.getLogger( MonoWaveReader.class); + + private static final Pattern FILENAME_PATTERN = Pattern.compile( "(.*_)(\\d+)(\\.wav)" ); + + private Path mPath; + private boolean mRealTime; + private InputStream mInputStream; + private int mDataByteSize = 0; + private Listener mListener; + private byte[] mDataBuffer = new byte[8000]; + + /** + * Wave file reader for PCM 8kHz, 16-bit little-endian format. + * + * @param path of the wave file + * @param realTime to force replay to real-time. Note: you should run this reader on a separate thread if you + * choose real time since this reader will sleep the calling thread as needed to effect real-time playback. + */ + public MonoWaveReader(Path path, boolean realTime) throws IOException + { + assert( path != null ); + mPath = path; + mRealTime = realTime; + + open(); + } + + /** + * Opens the file + */ + private void open() throws IOException + { + if(!Files.exists(mPath)) + { + throw new IOException("File not found"); + } + + mInputStream = Files.newInputStream(mPath, StandardOpenOption.READ); + + //Check for RIFF header + byte[] buffer = new byte[4]; + mInputStream.read(buffer); + + if(!Arrays.equals(buffer, WaveUtils.RIFF_CHUNK)) + { + throw new IOException("File is not .wav format - missing RIFF chunk"); + } + + //Get file size + mInputStream.read(buffer); + int fileSize = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(); + + //Check for WAVE format + mInputStream.read(buffer); + + if(!Arrays.equals(buffer, WaveUtils.WAV_FORMAT)) + { + throw new IOException("File is not .wav format - missing WAVE format"); + } + + //Check for format chunk + mInputStream.read(buffer); + + if(!Arrays.equals(buffer, WaveUtils.CHUNK_FORMAT)) + { + throw new IOException("File is not .wav format - missing format chunk"); + } + + //Get chunk size + mInputStream.read(buffer); + int chunkSize = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(); + + //Get format + mInputStream.read(buffer); + + ShortBuffer shortBuffer = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer(); + short format = shortBuffer.get(); + if(format != WaveUtils.PCM_FORMAT) + { + throw new IOException("File format not supported - expecting PCM format"); + } + + //Get number of channels + short channels = shortBuffer.get(); + if(channels != 1) + { + throw new IOException("Unsupported channel count - mono audio only"); + } + + //Get samples per second + mInputStream.read(buffer); + int sampleRate = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(); + + //Get bytes per second + mInputStream.read(buffer); + int bytesPerSecond = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(); + + mInputStream.read(buffer); + + //Get frame size + shortBuffer = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer(); + short frameSize = shortBuffer.get(); + if(frameSize != 2) + { + throw new IOException("PCM frame size not supported - expecting 2 bytes per frame"); + } + + //Get bits per sample + short bitsPerSample = shortBuffer.get(); + if(bitsPerSample != 16) + { + throw new IOException("PCM sample size not supported - expecting 16 bits per sample"); + } + + mInputStream.read(buffer); + + if(!Arrays.equals(buffer, WaveUtils.CHUNK_DATA)) + { + throw new IOException("Unexpected chunk - expecting data chunk"); + } + + //Get data chunk size + mInputStream.read(buffer); + mDataByteSize = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(); + } + + public void read() throws IOException + { + long start = System.currentTimeMillis(); + long samplesRead = 0; + + int totalBytesRead = 0; + + while(totalBytesRead < mDataByteSize) + { + int bytesRead = mInputStream.read(mDataBuffer); + + totalBytesRead += bytesRead; + + float[] samples = ConversionUtils.convertFromSigned16BitSamples(mDataBuffer); + + if(bytesRead != mDataBuffer.length) + { + samples = Arrays.copyOf(samples, bytesRead / 2); + } + + if(mRealTime) + { + samplesRead += samples.length; + + long actualElapsed = System.currentTimeMillis() - start; + + long audioElapsed = (long)(((double)samplesRead / 8000.0) * 1000.0); + + if(audioElapsed > actualElapsed) + { + try + { + Thread.sleep(audioElapsed - actualElapsed); + } + catch (InterruptedException e) + { + } + } + } + + if(mListener != null) + { + mListener.receive(new RealBuffer(samples)); + } + } + + close(); + } + + public void setListener(Listener listener) + { + mListener = listener; + } + + /** + * Closes the file + */ + public void close() throws IOException + { + if(mInputStream != null) + { + mInputStream.close(); + } + } + + public static void main(String[] args) + { + Path path = Paths.get("/home/denny/Music/PCM.wav"); + mLog.debug("Opening: " + path.toString()); + + try + { + MonoWaveReader reader = new MonoWaveReader(path, true); + reader.setListener(new Listener() + { + @Override + public void receive(RealBuffer realBuffer) + { + mLog.debug("Received buffer"); + } + }); + reader.read(); + } + catch(IOException e) + { + mLog.error("Error", e); + } + + mLog.debug("Finished"); + } +} diff --git a/src/record/wave/WaveUtils.java b/src/record/wave/WaveUtils.java index 050fa83f..68fb8d8e 100644 --- a/src/record/wave/WaveUtils.java +++ b/src/record/wave/WaveUtils.java @@ -17,14 +17,19 @@ ******************************************************************************/ package record.wave; +import javax.sound.sampled.AudioFormat; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import javax.sound.sampled.AudioFormat; - public class WaveUtils { + public static final byte[] RIFF_CHUNK = {(byte)0x52, (byte)0x49, (byte)0x46, (byte)0x46}; //RIFF + public static final byte[] WAV_FORMAT = {(byte)0x57, (byte)0x41, (byte)0x56, (byte)0x45}; //WAVE + public static final byte[] CHUNK_FORMAT = {(byte)0x66, (byte)0x6D, (byte)0x74, (byte)0x20}; //fmt space + public static final byte[] CHUNK_DATA = {(byte)0x64, (byte)0x61, (byte)0x74, (byte)0x61}; //data + public static final int PCM_FORMAT = 1; + /** * Creates a wave file header with the RIFF chunk descriptor and the WAVE * format chunk 1 and chunk 2 header diff --git a/src/sample/Broadcaster.java b/src/sample/Broadcaster.java index 65fa765b..4fc1f819 100644 --- a/src/sample/Broadcaster.java +++ b/src/sample/Broadcaster.java @@ -21,7 +21,7 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** - * Broadcasts a received float sample to multiple listeners + * Broadcasts an item to multiple listeners */ public class Broadcaster implements Listener { diff --git a/src/util/XTEA.java b/src/util/XTEA.java new file mode 100644 index 00000000..575c92c8 --- /dev/null +++ b/src/util/XTEA.java @@ -0,0 +1,184 @@ +/******************************************************************************* + * 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 util; + +import java.util.Arrays; + +public class XTEA +{ + //Key Schedule = (sqrt(5) ­ 1) * 2^31 + private static final int K = 0x9E3779B9; + + //Byte mask values + private static final int MASK0 = 0xFF; + private static final int MASK1 = 0xFF00; + private static final int MASK2 = 0xFF0000; + private static final int MASK3 = 0xFF000000; + + //Pre-calculated sub keys derived from encryption key + private int mSubKeys1[] = new int[32]; + private int mSubKeys2[] = new int[32]; + + /** + * XTEA block cipher encryption using 128 bit encryption key and 64 bit plain text block size with 32 Feistel rounds. + * Shorter keys and/or plaintext values can be zero padded to the specified lengths. + * + * Algorithm source code adapted from: https://people.rit.edu/rab3106/CryptoReport.pdf (as accessed Sep. 2016) + * + * The encryption key can be changed at any point by invoking the setKey() method. + * + * @param key for encryption + */ + public XTEA(String key) + { + setKey(Arrays.copyOf(key.getBytes(), 16)); + } + + /** + * Set the key for this block cipher. Key must be 128 bits or 16 bytes. The key value can be zero padded to + * achieve the specified key length. + * + * @param key encryption key to use + */ + public void setKey(byte[] key) + { + assert(key.length == 16); + + int keyInts[] = new int[8]; + + //Convert the key to an array of integers + for (int i = 0; i < 16; i += 4) + { + keyInts[i / 4] = ((key[i + 3] << 0)) & MASK0 | ((key[i + 2]) << 8) & MASK1 | + ((key[i + 1]) << 16) & MASK2 | ((key[i + 0]) << 24) & MASK3; + } + + //Calculate subkeys for each Feistel round + int sum, cycle; + + for (sum = 0, cycle = 0; cycle < 32; cycle++) + { + //Calculate round 1 subkey + mSubKeys1[cycle] = keyInts[sum & 3]; + + //Add K to sum + sum += K; + + //Calculate round 2 subkey + mSubKeys2[cycle] = keyInts[(sum >>> 11) & 3]; + } + } + + /** + * Encrypts the plaintext argument. Textual byte array must be 64 bits or 8 bytes in length and can be zero padded + * to achieve the specified length. Encrypted text byte array replaces the original plaintext byte array. + * + * @param plainText to encrypt + * @return cipher text + */ + public byte[] encrypt(byte[] plainText) + { + assert(plainText.length == 8); + + //Convert the 8 bytes of plaintext to 2 integers + int result0 = (plainText[3] << 0) & MASK0 | (plainText[2] << 8) & MASK1 | + (plainText[1] << 16) & MASK2 | (plainText[0] << 24) & MASK3; + int result1 = (plainText[7] << 0) & MASK0 | (plainText[6] << 8) & MASK1 | + (plainText[5] << 16) & MASK2 | (plainText[4] << 24) & MASK3; + + //Unrolled loop for all Feistel rounds + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 0) + mSubKeys1[0]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 1) + mSubKeys2[0]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 1) + mSubKeys1[1]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 2) + mSubKeys2[1]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 2) + mSubKeys1[2]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 3) + mSubKeys2[2]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 3) + mSubKeys1[3]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 4) + mSubKeys2[3]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 4) + mSubKeys1[4]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 5) + mSubKeys2[4]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 5) + mSubKeys1[5]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 6) + mSubKeys2[5]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 6) + mSubKeys1[6]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 7) + mSubKeys2[6]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 7) + mSubKeys1[7]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 8) + mSubKeys2[7]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 8) + mSubKeys1[8]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 9) + mSubKeys2[8]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 9) + mSubKeys1[9]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 10) + mSubKeys2[9]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 10) + mSubKeys1[10]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 11) + mSubKeys2[10]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 11) + mSubKeys1[11]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 12) + mSubKeys2[11]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 12) + mSubKeys1[12]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 13) + mSubKeys2[12]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 13) + mSubKeys1[13]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 14) + mSubKeys2[13]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 14) + mSubKeys1[14]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 15) + mSubKeys2[14]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 15) + mSubKeys1[15]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 16) + mSubKeys2[15]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 16) + mSubKeys1[16]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 17) + mSubKeys2[16]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 17) + mSubKeys1[17]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 18) + mSubKeys2[17]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 18) + mSubKeys1[18]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 19) + mSubKeys2[18]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 19) + mSubKeys1[19]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 20) + mSubKeys2[19]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 20) + mSubKeys1[20]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 21) + mSubKeys2[20]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 21) + mSubKeys1[21]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 22) + mSubKeys2[21]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 22) + mSubKeys1[22]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 23) + mSubKeys2[22]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 23) + mSubKeys1[23]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 24) + mSubKeys2[23]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 24) + mSubKeys1[24]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 25) + mSubKeys2[24]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 25) + mSubKeys1[25]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 26) + mSubKeys2[25]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 26) + mSubKeys1[26]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 27) + mSubKeys2[26]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 27) + mSubKeys1[27]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 28) + mSubKeys2[27]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 28) + mSubKeys1[28]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 29) + mSubKeys2[28]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 29) + mSubKeys1[29]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 30) + mSubKeys2[29]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 30) + mSubKeys1[30]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 31) + mSubKeys2[30]; + result0 += (result1 << 4 ^ result1 >>> 5) + result1 ^ (K * 31) + mSubKeys1[31]; + result1 += (result0 << 4 ^ result0 >>> 5) + result0 ^ (K * 32) + mSubKeys2[31]; + + byte[] cipherText = new byte[8]; + + cipherText[0] = (byte)(result0 >>> 24); + cipherText[1] = (byte)(result0 >>> 16); + cipherText[2] = (byte)(result0 >>> 8); + cipherText[3] = (byte)(result0 >>> 0); + cipherText[4] = (byte)(result1 >>> 24); + cipherText[5] = (byte)(result1 >>> 16); + cipherText[6] = (byte)(result1 >>> 8); + cipherText[7] = (byte)(result1 >>> 0); + + return cipherText; + } +}