From 60489e1fef1e392caa262436e39dab45e8e308e5 Mon Sep 17 00:00:00 2001 From: Doug Hoffman Date: Mon, 25 Apr 2022 04:38:54 -0400 Subject: [PATCH] (icecast) Add support for inline metadata (#1212) --- .../broadcast/AudioStreamingBroadcaster.java | 18 ++- .../BroadcastifyFeedConfiguration.java | 3 +- .../icecast/IcecastAudioBroadcaster.java | 2 +- .../IcecastBroadcastMetadataUpdater.java | 88 ++--------- .../icecast/IcecastConfiguration.java | 32 ++++ .../icecast/IcecastHTTPAudioBroadcaster.java | 47 +++++- .../icecast/IcecastHTTPConfiguration.java | 1 + .../broadcast/icecast/IcecastHeader.java | 1 + .../broadcast/icecast/IcecastMetadata.java | 138 ++++++++++++++++++ .../icecast/IcecastTCPAudioBroadcaster.java | 46 +++++- .../icecast/IcecastTCPConfiguration.java | 1 + .../v1/ShoutcastV1AudioBroadcaster.java | 3 +- .../ShoutcastV2AudioStreamingBroadcaster.java | 2 +- .../streaming/IcecastStreamEditor.java | 27 ++++ 14 files changed, 315 insertions(+), 94 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastMetadata.java diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/AudioStreamingBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/AudioStreamingBroadcaster.java index b487bf38..185c82eb 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/AudioStreamingBroadcaster.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/AudioStreamingBroadcaster.java @@ -52,6 +52,10 @@ public abstract class AudioStreamingBroadcaster silenceFrames = mSilenceGenerator.generate(PROCESSOR_RUN_INTERVAL_MS); for(byte[] silence: silenceFrames) { - broadcastAudio(silence); + broadcastAudio(silence, null); } } } @@ -343,6 +350,7 @@ public abstract class AudioStreamingBroadcaster aliases = aliasList.getAliases(to); - - if(aliases != null && !aliases.isEmpty()) - { - sb.append(" ").append(Joiner.on(", ").skipNulls().join(aliases)); - } - } - else - { - sb.append("TO:UNKNOWN"); - } - - Identifier from = identifierCollection.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM); - - if(from == null) - { - List fromIdentifiers = identifierCollection.getIdentifiers(Role.FROM); - - if(!fromIdentifiers.isEmpty()) - { - from = fromIdentifiers.get(0); - } - } - - if(from != null) - { - sb.append(" FROM:").append(from); - - List aliases = aliasList.getAliases(from); - - if(aliases != null && !aliases.isEmpty()) - { - sb.append(" ").append(Joiner.on(", ").skipNulls().join(aliases)); - } - } - else - { - sb.append(" FROM:UNKNOWN"); - } - } - else - { - sb.append("Scanning ...."); - } - - return sb.toString(); - } } diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastConfiguration.java index c7a91581..efa0a47a 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastConfiguration.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastConfiguration.java @@ -45,6 +45,7 @@ public abstract class IcecastConfiguration extends BroadcastConfiguration private int mChannels = 1; private int mSampleRate = 8000; private String mURL; + private boolean mInline = true; public IcecastConfiguration(BroadcastFormat format) { @@ -275,6 +276,37 @@ public abstract class IcecastConfiguration extends BroadcastConfiguration return mURL != null; } + /** + * Control whether metadata is sent inline or out-of-band. + */ + @JacksonXmlProperty(isAttribute = true, localName = "inline") + public boolean getInline() + { + return mInline; + } + + @JsonIgnore + public int getInlineInterval() + { + if (hasBitRate()) + { + // Interval = BitRate * 1000 / 8 = 1 second + return getBitRate() * 125; + } + return -1; + } + + public void setInline(boolean inline) + { + mInline = inline; + } + + public boolean hasInline() + { + // Bitrate must be known to calculate metadata interval + return mInline != false && hasBitRate(); + } + @Override public boolean isValid() { diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java index a311fa66..221d3d9e 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPAudioBroadcaster.java @@ -20,9 +20,11 @@ package io.github.dsheirer.audio.broadcast.icecast; import io.github.dsheirer.alias.AliasModel; import io.github.dsheirer.audio.broadcast.BroadcastState; +import io.github.dsheirer.audio.broadcast.icecast.IcecastMetadata; import io.github.dsheirer.audio.convert.InputAudioFormat; import io.github.dsheirer.audio.convert.MP3AudioConverter; import io.github.dsheirer.audio.convert.MP3Setting; +import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.util.ThreadPool; import org.apache.mina.core.RuntimeIoException; @@ -42,6 +44,7 @@ import org.slf4j.LoggerFactory; import java.net.ConnectException; import java.net.InetSocketAddress; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -80,14 +83,41 @@ public class IcecastHTTPAudioBroadcaster extends IcecastAudioBroadcaster * Broadcasts the audio frame or sequence */ @Override - protected void broadcastAudio(byte[] audio) + protected void broadcastAudio(byte[] audio, IdentifierCollection identifierCollection) { if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) { IoBuffer buffer = IoBuffer.allocate(audio.length); - buffer.put(audio); - buffer.flip(); + if(mInlineActive) + { + byte[] metadata = IcecastMetadata.formatInline(IcecastMetadata.getTitle(identifierCollection, mAliasModel)).getBytes(); + buffer.setAutoExpand(true); + if (mInlineRemaining == -1) + { + mInlineRemaining = mInlineInterval; + } + int audioOffset = 0; + while(audioOffset < audio.length) + { + byte[] chunk = Arrays.copyOfRange(audio, audioOffset, Math.min(audioOffset + mInlineRemaining, audio.length)); + mInlineRemaining -= chunk.length; + audioOffset += chunk.length; + buffer.put(chunk); + if(mInlineRemaining == 0) + { + mInlineRemaining = mInlineInterval; + buffer.put(metadata); + } + } + buffer.shrink(); + } + else + { + buffer.put(audio); + } + + buffer.flip(); mStreamingSession.write(buffer); } } @@ -238,6 +268,17 @@ public class IcecastHTTPAudioBroadcaster extends IcecastAudioBroadcaster { mHTTPHeaders.put(IcecastHeader.BITRATE.getValue(), String.valueOf(getConfiguration().getBitRate())); } + + if(getConfiguration().hasInline()) + { + mInlineActive = true; + mInlineInterval = getConfiguration().getInlineInterval(); + mHTTPHeaders.put(IcecastHeader.METAINT.getValue(), String.valueOf(mInlineInterval)); + } + else + { + mInlineActive = false; + } } return mHTTPHeaders; diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPConfiguration.java index 6599d96e..a2efd9a8 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPConfiguration.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHTTPConfiguration.java @@ -55,6 +55,7 @@ public class IcecastHTTPConfiguration extends IcecastConfiguration copy.setName(getName()); copy.setHost(getHost()); copy.setPort(getPort()); + copy.setInline(getInline()); copy.setPassword(getPassword()); copy.setDelay(getDelay()); copy.setEnabled(false); diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHeader.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHeader.java index 8d6275f8..62537597 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHeader.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastHeader.java @@ -33,6 +33,7 @@ public enum IcecastHeader PUBLIC("ice-public"), URL("ice-url"), USER_AGENT("User-Agent"), + METAINT("icy-metaint"), UNKNOWN("unknown"); diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastMetadata.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastMetadata.java new file mode 100644 index 00000000..6fa78fc3 --- /dev/null +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastMetadata.java @@ -0,0 +1,138 @@ +/* + * + * * ****************************************************************************** + * * Copyright (C) 2014-2020 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 io.github.dsheirer.audio.broadcast.icecast; + +import com.google.common.base.Joiner; +import io.github.dsheirer.alias.Alias; +import io.github.dsheirer.alias.AliasList; +import io.github.dsheirer.alias.AliasModel; +import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration; +import io.github.dsheirer.identifier.Form; +import io.github.dsheirer.identifier.Identifier; +import io.github.dsheirer.identifier.IdentifierClass; +import io.github.dsheirer.identifier.IdentifierCollection; +import io.github.dsheirer.identifier.Role; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.util.ThreadPool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.List; + +public class IcecastMetadata +{ + private final static Logger mLog = LoggerFactory.getLogger(IcecastMetadata.class); + + /** + * Creates the title for a metadata update + */ + public static String getTitle(IdentifierCollection identifierCollection, AliasModel AliasModel) + { + StringBuilder sb = new StringBuilder(); + + if(identifierCollection != null) + { + AliasList aliasList = AliasModel.getAliasList(identifierCollection); + + Identifier to = identifierCollection.getIdentifier(IdentifierClass.USER, Form.PATCH_GROUP, Role.TO); + + if(to == null) + { + to = identifierCollection.getIdentifier(IdentifierClass.USER, Form.TALKGROUP, Role.TO); + } + + if(to == null) + { + List toIdentifiers = identifierCollection.getIdentifiers(Role.TO); + if(!toIdentifiers.isEmpty()) + { + to = toIdentifiers.get(0); + } + } + + if(to != null) + { + sb.append("TO:").append(to); + + List aliases = aliasList.getAliases(to); + + if(aliases != null && !aliases.isEmpty()) + { + sb.append(" ").append(Joiner.on(", ").skipNulls().join(aliases)); + } + } + else + { + sb.append("TO:UNKNOWN"); + } + + Identifier from = identifierCollection.getIdentifier(IdentifierClass.USER, Form.RADIO, Role.FROM); + + if(from == null) + { + List fromIdentifiers = identifierCollection.getIdentifiers(Role.FROM); + + if(!fromIdentifiers.isEmpty()) + { + from = fromIdentifiers.get(0); + } + } + + if(from != null) + { + sb.append(" FROM:").append(from); + + List aliases = aliasList.getAliases(from); + + if(aliases != null && !aliases.isEmpty()) + { + sb.append(" ").append(Joiner.on(", ").skipNulls().join(aliases)); + } + } + else + { + sb.append(" FROM:UNKNOWN"); + } + } + else + { + sb.append("Scanning..."); + } + + return sb.toString(); + } + + public static String formatInline(String title) + { + title = "StreamTitle='" + title + "';"; + int chunks = (int)Math.ceil((title.length() + 1) / 16.0d); + int nulls = (chunks * 16) - title.length(); + char[] padding = new char[nulls]; + + StringBuilder sb = new StringBuilder(); + sb.append((char)chunks).append(title).append(padding); + return sb.toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java index 8f0a4438..611e5b9b 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPAudioBroadcaster.java @@ -21,9 +21,11 @@ package io.github.dsheirer.audio.broadcast.icecast; import io.github.dsheirer.alias.AliasModel; import io.github.dsheirer.audio.broadcast.BroadcastState; import io.github.dsheirer.audio.broadcast.icecast.codec.IcecastCodecFactory; +import io.github.dsheirer.audio.broadcast.icecast.IcecastMetadata; import io.github.dsheirer.audio.convert.InputAudioFormat; import io.github.dsheirer.audio.convert.MP3AudioConverter; import io.github.dsheirer.audio.convert.MP3Setting; +import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.util.ThreadPool; import org.apache.mina.core.RuntimeIoException; @@ -39,13 +41,14 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketException; import java.nio.channels.UnresolvedAddressException; +import java.util.Arrays; 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 TERMINATOR = "\n"; private final static String SEPARATOR = ":"; private static final long RECONNECT_INTERVAL_MILLISECONDS = 3000; //3 seconds private static final long CONNECTION_ATTEMPT_TIMEOUT_MILLISECONDS = 5000; //5 seconds @@ -78,11 +81,35 @@ public class IcecastTCPAudioBroadcaster extends IcecastAudioBroadcaster * Broadcasts the audio frame or sequence */ @Override - protected void broadcastAudio(byte[] audio) + protected void broadcastAudio(byte[] audio, IdentifierCollection identifierCollection) { if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) { - mStreamingSession.write(audio); + if(mInlineActive) + { + byte[] metadata = IcecastMetadata.formatInline(IcecastMetadata.getTitle(identifierCollection, mAliasModel)).getBytes(); + if (mInlineRemaining == -1) + { + mInlineRemaining = mInlineInterval; + } + int audioOffset = 0; + while(audioOffset < audio.length) + { + byte[] chunk = Arrays.copyOfRange(audio, audioOffset, Math.min(audioOffset + mInlineRemaining, audio.length)); + mInlineRemaining -= chunk.length; + audioOffset += chunk.length; + mStreamingSession.write(chunk); + if(mInlineRemaining == 0) + { + mInlineRemaining = mInlineInterval; + mStreamingSession.write(metadata); + } + } + } + else + { + mStreamingSession.write(audio); + } } } @@ -238,7 +265,18 @@ public class IcecastTCPAudioBroadcaster extends IcecastAudioBroadcaster .append(getConfiguration().getDescription()).append(TERMINATOR); } - sb.append(TERMINATOR).append(TERMINATOR); + if(getConfiguration().hasInline()) + { + mInlineActive = true; + mInlineInterval = getConfiguration().getInlineInterval(); + sb.append(IcecastHeader.METAINT.getValue()).append(SEPARATOR) + .append(String.valueOf(mInlineInterval)) + .append(TERMINATOR); + } + else + { + mInlineActive = false; + } session.write(sb.toString()); } diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPConfiguration.java b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPConfiguration.java index 19cd9fdc..cd30052f 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPConfiguration.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/icecast/IcecastTCPConfiguration.java @@ -63,6 +63,7 @@ public class IcecastTCPConfiguration extends IcecastConfiguration copy.setName(getName()); copy.setHost(getHost()); copy.setPort(getPort()); + copy.setInline(getInline()); copy.setPassword(getPassword()); copy.setDelay(getDelay()); copy.setEnabled(false); diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java index 986c4637..f7af099c 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v1/ShoutcastV1AudioBroadcaster.java @@ -24,6 +24,7 @@ import io.github.dsheirer.audio.broadcast.BroadcastState; import io.github.dsheirer.audio.broadcast.IBroadcastMetadataUpdater; import io.github.dsheirer.audio.convert.InputAudioFormat; import io.github.dsheirer.audio.convert.MP3Setting; +import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.util.ThreadPool; import org.apache.mina.core.RuntimeIoException; import org.apache.mina.core.buffer.IoBuffer; @@ -93,7 +94,7 @@ public class ShoutcastV1AudioBroadcaster extends AudioStreamingBroadcaster * Broadcasts the audio frame or sequence */ @Override - protected void broadcastAudio(byte[] audio) + protected void broadcastAudio(byte[] audio, IdentifierCollection identifierCollection) { if(audio != null && audio.length > 0 && connect() && mStreamingSession != null && mStreamingSession.isConnected()) { diff --git a/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v2/ShoutcastV2AudioStreamingBroadcaster.java b/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v2/ShoutcastV2AudioStreamingBroadcaster.java index 17f003e0..f10fb690 100644 --- a/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v2/ShoutcastV2AudioStreamingBroadcaster.java +++ b/src/main/java/io/github/dsheirer/audio/broadcast/shoutcast/v2/ShoutcastV2AudioStreamingBroadcaster.java @@ -109,7 +109,7 @@ public class ShoutcastV2AudioStreamingBroadcaster extends AudioStreamingBroadcas * Broadcasts the audio frame or sequence */ @Override - protected void broadcastAudio(byte[] audio) + protected void broadcastAudio(byte[] audio, IdentifierCollection identifierCollection) { //Dispatch any queued metadata messages UltravoxMessage metadataMessage = mMetadataMessageQueue.poll(); diff --git a/src/main/java/io/github/dsheirer/gui/playlist/streaming/IcecastStreamEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/streaming/IcecastStreamEditor.java index 9d939f7f..11fc08a6 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/streaming/IcecastStreamEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/streaming/IcecastStreamEditor.java @@ -30,6 +30,7 @@ import javafx.geometry.Insets; import javafx.scene.control.Label; import javafx.scene.control.TextField; import javafx.scene.layout.GridPane; +import org.controlsfx.control.ToggleSwitch; /** * Icecast streaming configuration editor @@ -37,6 +38,7 @@ import javafx.scene.layout.GridPane; public abstract class IcecastStreamEditor extends AbstractStreamEditor { private GridPane mEditorPane; + private ToggleSwitch mInlineToggleSwitch; private TextField mMountPointTextField; private TextField mUserNameTextField; private TextField mDescriptionTextField; @@ -52,6 +54,7 @@ public abstract class IcecastStreamEditor extends AbstractStreamEditor modifiedProperty().set(true)); + } + + return mInlineToggleSwitch; + } + private TextField getUserNameTextField() { if(mUserNameTextField == null)