diff --git a/lib/pom.xml b/lib/pom.xml
index dfb8238e..2c650f6d 100644
--- a/lib/pom.xml
+++ b/lib/pom.xml
@@ -103,7 +103,7 @@
org.slf4j
slf4j-api
- 1.7.30
+ ${slf4j-api.version}
\ No newline at end of file
diff --git a/lib/src/main/java/xyz/gianlu/librespot/common/Utils.java b/lib/src/main/java/xyz/gianlu/librespot/common/Utils.java
index 4b86948f..8df3a427 100644
--- a/lib/src/main/java/xyz/gianlu/librespot/common/Utils.java
+++ b/lib/src/main/java/xyz/gianlu/librespot/common/Utils.java
@@ -11,7 +11,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import javax.sound.sampled.Mixer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -294,20 +293,6 @@ public static String artistsToString(List artists) {
return builder.toString();
}
- @NotNull
- public static String mixersToString(List list) {
- StringBuilder builder = new StringBuilder();
- boolean first = true;
- for (Mixer mixer : list) {
- if (!first) builder.append(", ");
- first = false;
-
- builder.append('\'').append(mixer.getMixerInfo().getName()).append('\'');
- }
-
- return builder.toString();
- }
-
@NotNull
public static String toBase64(@NotNull ByteString bytes) {
return Base64.getEncoder().encodeToString(bytes.toByteArray());
diff --git a/player/pom.xml b/player/pom.xml
index ba930c7f..327516cd 100644
--- a/player/pom.xml
+++ b/player/pom.xml
@@ -55,6 +55,11 @@
librespot-lib
${project.version}
+
+ xyz.gianlu.librespot
+ librespot-sink-api
+ ${project.version}
+
@@ -93,5 +98,12 @@
toml
3.6.3
+
+
+
+ xyz.gianlu.librespot
+ librespot-sink
+ ${project.version}
+
\ No newline at end of file
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java b/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java
index 22a92a4c..008b5551 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java
@@ -394,6 +394,7 @@ public PlayerConfiguration toPlayer() {
.setMixerSearchKeywords(getStringArray("player.mixerSearchKeywords", ';'))
.setNormalisationPregain(normalisationPregain())
.setOutput(config.getEnum("player.output", PlayerConfiguration.AudioOutput.class))
+ .setOutputClass(config.get("player.outputClass"))
.setOutputPipe(outputPipe())
.setPreferredQuality(preferredQuality())
.setPreloadEnabled(config.get("preload.enabled"))
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/Player.java b/player/src/main/java/xyz/gianlu/librespot/player/Player.java
index 13bd642c..36f9e682 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/Player.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/Player.java
@@ -30,12 +30,10 @@
import xyz.gianlu.librespot.player.metrics.PlaybackMetrics;
import xyz.gianlu.librespot.player.metrics.PlayerMetrics;
import xyz.gianlu.librespot.player.mixing.AudioSink;
-import xyz.gianlu.librespot.player.mixing.LineHelper;
import xyz.gianlu.librespot.player.playback.PlayerSession;
import xyz.gianlu.librespot.player.state.DeviceStateHandler;
import xyz.gianlu.librespot.player.state.DeviceStateHandler.PlayCommandHelper;
-import javax.sound.sampled.LineUnavailableException;
import java.io.Closeable;
import java.io.IOException;
import java.util.*;
@@ -63,11 +61,7 @@ public Player(@NotNull PlayerConfiguration conf, @NotNull Session session) {
this.session = session;
this.events = new EventsDispatcher(conf);
this.sink = new AudioSink(conf, ex -> {
- if (ex instanceof LineHelper.MixerException || ex instanceof LineUnavailableException)
- LOGGER.fatal("An error with the mixer occurred. This is likely a configuration issue, please consult the project repository.", ex);
- else
- LOGGER.fatal("Sink error!", ex);
-
+ LOGGER.fatal("Sink error!", ex);
panicState(PlaybackMetrics.Reason.TRACK_ERROR);
});
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java b/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java
index 65591b69..c4621f2d 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java
@@ -20,6 +20,7 @@ public final class PlayerConfiguration {
// Output
public final AudioOutput output;
+ public final String outputClass;
public final File outputPipe;
public final File metadataPipe;
public final String[] mixerSearchKeywords;
@@ -32,7 +33,7 @@ public final class PlayerConfiguration {
public final boolean bypassSinkVolume;
private PlayerConfiguration(AudioQuality preferredQuality, boolean enableNormalisation, float normalisationPregain, boolean autoplayEnabled, int crossfadeDuration, boolean preloadEnabled,
- AudioOutput output, File outputPipe, File metadataPipe, String[] mixerSearchKeywords, boolean logAvailableMixers, int releaseLineDelay,
+ AudioOutput output, String outputClass, File outputPipe, File metadataPipe, String[] mixerSearchKeywords, boolean logAvailableMixers, int releaseLineDelay,
int initialVolume, int volumeSteps, boolean bypassSinkVolume) {
this.preferredQuality = preferredQuality;
this.enableNormalisation = enableNormalisation;
@@ -40,6 +41,7 @@ private PlayerConfiguration(AudioQuality preferredQuality, boolean enableNormali
this.autoplayEnabled = autoplayEnabled;
this.crossfadeDuration = crossfadeDuration;
this.output = output;
+ this.outputClass = outputClass;
this.outputPipe = outputPipe;
this.metadataPipe = metadataPipe;
this.mixerSearchKeywords = mixerSearchKeywords;
@@ -52,7 +54,7 @@ private PlayerConfiguration(AudioQuality preferredQuality, boolean enableNormali
}
public enum AudioOutput {
- MIXER, PIPE, STDOUT
+ MIXER, PIPE, STDOUT, CUSTOM
}
public final static class Builder {
@@ -66,6 +68,7 @@ public final static class Builder {
// Output
private AudioOutput output = AudioOutput.MIXER;
+ private String outputClass;
private File outputPipe;
private File metadataPipe;
private String[] mixerSearchKeywords;
@@ -110,6 +113,11 @@ public Builder setOutput(AudioOutput output) {
return this;
}
+ public Builder setOutputClass(String outputClass) {
+ this.outputClass = outputClass;
+ return this;
+ }
+
public Builder setOutputPipe(File outputPipe) {
this.outputPipe = outputPipe;
return this;
@@ -164,7 +172,7 @@ public Builder setBypassSinkVolume(boolean bypassSinkVolume) {
@Contract(value = " -> new", pure = true)
public @NotNull PlayerConfiguration build() {
return new PlayerConfiguration(preferredQuality, enableNormalisation, normalisationPregain, autoplayEnabled, crossfadeDuration, preloadEnabled,
- output, outputPipe, metadataPipe, mixerSearchKeywords, logAvailableMixers, releaseLineDelay,
+ output, outputClass, outputPipe, metadataPipe, mixerSearchKeywords, logAvailableMixers, releaseLineDelay,
initialVolume, volumeSteps, bypassSinkVolume);
}
}
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java
index efab31df..40096384 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java
@@ -8,8 +8,8 @@
import xyz.gianlu.librespot.audio.GeneralAudioStream;
import xyz.gianlu.librespot.audio.NormalizationData;
import xyz.gianlu.librespot.player.PlayerConfiguration;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
-import javax.sound.sampled.AudioFormat;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
@@ -26,7 +26,7 @@ public abstract class Codec implements Closeable {
private final GeneralAudioStream audioFile;
protected volatile boolean closed = false;
protected int seekZero = 0;
- private AudioFormat format;
+ private OutputAudioFormat format;
Codec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull PlayerConfiguration conf, int duration) {
this.audioIn = audioFile.stream();
@@ -75,12 +75,12 @@ public void seek(int positionMs) {
}
@NotNull
- public final AudioFormat getAudioFormat() {
+ public final OutputAudioFormat getAudioFormat() {
if (format == null) throw new IllegalStateException();
return format;
}
- protected final void setAudioFormat(@NotNull AudioFormat format) {
+ protected final void setAudioFormat(@NotNull OutputAudioFormat format) {
this.format = format;
}
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java
index 3c688e71..d268d3e9 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java
@@ -6,8 +6,8 @@
import xyz.gianlu.librespot.audio.GeneralAudioStream;
import xyz.gianlu.librespot.audio.NormalizationData;
import xyz.gianlu.librespot.player.PlayerConfiguration;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
-import javax.sound.sampled.AudioFormat;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -29,7 +29,7 @@ public Mp3Codec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationDa
audioIn.mark(-1);
- setAudioFormat(new AudioFormat(in.getSampleRate(), 16, in.getChannels(), true, false));
+ setAudioFormat(new OutputAudioFormat(in.getSampleRate(), 16, in.getChannels(), true, false));
}
private static void skipMp3Tags(@NotNull InputStream in) throws IOException {
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java
index 80ae6af5..718bff19 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java
@@ -13,9 +13,8 @@
import xyz.gianlu.librespot.audio.GeneralAudioStream;
import xyz.gianlu.librespot.audio.NormalizationData;
import xyz.gianlu.librespot.player.PlayerConfiguration;
-import xyz.gianlu.librespot.player.mixing.LineHelper;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
-import javax.sound.sampled.AudioFormat;
import java.io.IOException;
import java.io.OutputStream;
@@ -41,7 +40,7 @@ public final class VorbisCodec extends Codec {
private int index;
private long pcm_offset;
- public VorbisCodec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull PlayerConfiguration conf, int duration) throws IOException, CodecException, LineHelper.MixerException {
+ public VorbisCodec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull PlayerConfiguration conf, int duration) throws IOException, CodecException {
super(audioFile, normalizationData, conf, duration);
this.joggSyncState.init();
@@ -59,7 +58,7 @@ public VorbisCodec(@NotNull GeneralAudioStream audioFile, @Nullable Normalizatio
pcmInfo = new float[1][][];
pcmIndex = new int[jorbisInfo.channels];
- setAudioFormat(new AudioFormat(jorbisInfo.rate, 16, jorbisInfo.channels, true, false));
+ setAudioFormat(new OutputAudioFormat(jorbisInfo.rate, 16, jorbisInfo.channels, true, false));
}
/**
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/events/EventsMetadataPipe.java b/player/src/main/java/xyz/gianlu/librespot/player/events/EventsMetadataPipe.java
index 8fa8d1f9..46cd34c0 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/events/EventsMetadataPipe.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/events/EventsMetadataPipe.java
@@ -8,7 +8,7 @@
import xyz.gianlu.librespot.metadata.PlayableId;
import xyz.gianlu.librespot.player.Player;
import xyz.gianlu.librespot.player.TrackOrEpisode;
-import xyz.gianlu.librespot.player.mixing.AudioSink;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
import java.io.Closeable;
import java.io.File;
@@ -88,8 +88,9 @@ private void sendProgress(@NotNull Player player) {
TrackOrEpisode metadata = player.currentMetadata();
if (metadata == null) return;
- String data = String.format("1/%.0f/%.0f", player.time() * AudioSink.DEFAULT_FORMAT.getSampleRate() / 1000 + 1,
- metadata.duration() * AudioSink.DEFAULT_FORMAT.getSampleRate() / 1000 + 1);
+ String data = String.format("1/%.0f/%.0f",
+ player.time() * OutputAudioFormat.DEFAULT_FORMAT.getSampleRate() / 1000 + 1,
+ metadata.duration() * OutputAudioFormat.DEFAULT_FORMAT.getSampleRate() / 1000 + 1);
safeSend(EventsMetadataPipe.TYPE_SSNC, EventsMetadataPipe.CODE_PRGR, data);
}
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java
index 81ea2689..4ad41fb0 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java
@@ -6,8 +6,7 @@
import xyz.gianlu.librespot.player.codecs.Mp3Codec;
import xyz.gianlu.librespot.player.codecs.VorbisCodec;
import xyz.gianlu.librespot.player.crossfade.CrossfadeController;
-
-import javax.sound.sampled.AudioFormat;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
/**
* @author devgianlu
@@ -32,7 +31,7 @@ public PlayerMetrics(@Nullable PlayableContentFeeder.Metrics contentMetrics, @Nu
decodedLength = codec.decodedLength();
decryptTime = codec.decryptTimeMs();
- AudioFormat format = codec.getAudioFormat();
+ OutputAudioFormat format = codec.getAudioFormat();
bitrate = (int) (format.getFrameRate() * format.getFrameSize());
if (codec instanceof VorbisCodec) encoding = "vorbis";
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java
index 5a16268e..2e3b7fd8 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java
@@ -1,27 +1,21 @@
package xyz.gianlu.librespot.player.mixing;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.gianlu.librespot.player.Player;
import xyz.gianlu.librespot.player.PlayerConfiguration;
import xyz.gianlu.librespot.player.codecs.Codec;
+import xyz.gianlu.librespot.player.mixing.output.*;
-import javax.sound.sampled.AudioFormat;
-import javax.sound.sampled.FloatControl;
-import javax.sound.sampled.LineUnavailableException;
-import javax.sound.sampled.SourceDataLine;
-import java.io.*;
+import java.io.Closeable;
+import java.io.IOException;
/**
* @author devgianlu
*/
public final class AudioSink implements Runnable, Closeable {
- public static final AudioFormat DEFAULT_FORMAT = new AudioFormat(44100, 16, 2, true, false);
- private static final Logger LOGGER = LogManager.getLogger(AudioSink.class);
private final Object pauseLock = new Object();
- private final Output output;
+ private final SinkOutput output;
private final MixingLine mixing = new MixingLine();
private final Thread thread;
private final Listener listener;
@@ -35,29 +29,49 @@ public AudioSink(@NotNull PlayerConfiguration conf, @NotNull Listener listener)
this.listener = listener;
switch (conf.output) {
case MIXER:
- output = new Output(Output.Type.MIXER, mixing, conf, null, null);
+ output = initCustomOutputSink("xyz.gianlu.librespot.player.mixing.output.MixerOutput",
+ conf.mixerSearchKeywords, conf.logAvailableMixers);
break;
case PIPE:
- File pipe = conf.outputPipe;
- if (pipe == null || !pipe.exists() || !pipe.canWrite())
- throw new IllegalArgumentException("Invalid pipe file: " + pipe);
+ if (conf.outputPipe == null)
+ throw new IllegalArgumentException("Pipe file not configured!");
- output = new Output(Output.Type.PIPE, mixing, conf, pipe, null);
+ output = new PipeOutput(conf.outputPipe);
break;
case STDOUT:
- output = new Output(Output.Type.STREAM, mixing, conf, null, System.out);
+ output = new StreamOutput(System.out, false);
+ break;
+ case CUSTOM:
+ if (conf.outputClass == null || conf.outputClass.isEmpty())
+ throw new IllegalArgumentException("Custom output sink class not configured!");
+
+ output = initCustomOutputSink(conf.outputClass);
break;
default:
throw new IllegalArgumentException("Unknown output: " + conf.output);
}
- if (conf.bypassSinkVolume) output.setVolume(Player.VOLUME_MAX);
- else output.setVolume(conf.initialVolume);
+ if (conf.bypassSinkVolume) setVolume(Player.VOLUME_MAX);
+ else setVolume(conf.initialVolume);
thread = new Thread(this, "player-audio-sink");
thread.start();
}
+ @NotNull
+ private static SinkOutput initCustomOutputSink(@NotNull String className, Object... params) {
+ try {
+ Class>[] parameterTypes = new Class>[params.length];
+ for (int i = 0; i < params.length; i++)
+ parameterTypes[i] = params[i].getClass();
+
+ Class> clazz = Class.forName(className);
+ return (SinkOutput) clazz.getConstructor(parameterTypes).newInstance(params);
+ } catch (ReflectiveOperationException | ClassCastException ex) {
+ throw new IllegalArgumentException("Invalid custom output sink class: " + className, ex);
+ }
+ }
+
public void clearOutputs() {
mixing.firstOut().clear();
mixing.secondOut().clear();
@@ -82,11 +96,11 @@ public void resume() {
}
/**
- * Pauses the sink and then releases the {@link javax.sound.sampled.Line} if specified by {@param release}.
+ * Pauses the sink and then releases the underling output if specified by {@param release}.
*/
public void pause(boolean release) {
paused = true;
- if (release) output.releaseLine();
+ if (release) output.release();
}
/**
@@ -105,7 +119,9 @@ public void setVolume(int volume) {
if (volume < 0 || volume > Player.VOLUME_MAX)
throw new IllegalArgumentException("Invalid volume: " + volume);
- output.setVolume(volume);
+ float volumeNorm = ((float) volume) / Player.VOLUME_MAX;
+ if (output.setVolume(volumeNorm)) mixing.setGlobalGain(1);
+ else mixing.setGlobalGain(volumeNorm);
}
@Override
@@ -136,14 +152,14 @@ public void run() {
} else {
try {
if (!started || mixing.switchFormat) {
- AudioFormat format = mixing.getFormat();
+ OutputAudioFormat format = mixing.getFormat();
if (format != null) started = output.start(format);
mixing.switchFormat = false;
}
int count = mixing.read(buffer);
- output.write(buffer, count);
- } catch (IOException | LineUnavailableException | LineHelper.MixerException ex) {
+ output.write(buffer, 0, count);
+ } catch (IOException | SinkException ex) {
if (closed) break;
pause(true);
@@ -162,144 +178,4 @@ public void run() {
public interface Listener {
void sinkError(@NotNull Exception ex);
}
-
- private static class Output implements Closeable {
- private final File pipe;
- private final MixingLine mixing;
- private final PlayerConfiguration conf;
- private final Type type;
- private SourceDataLine line;
- private OutputStream out;
- private int lastVolume = -1;
-
- Output(@NotNull Type type, @NotNull MixingLine mixing, @NotNull PlayerConfiguration conf, @Nullable File pipe, @Nullable OutputStream out) {
- this.conf = conf;
- this.mixing = mixing;
- this.type = type;
- this.pipe = pipe;
- this.out = out;
-
- if (type == Type.PIPE && pipe == null)
- throw new IllegalArgumentException("Pipe cannot be null!");
-
- if (type == Type.STREAM && out == null)
- throw new IllegalArgumentException("Output stream cannot be null!");
- }
-
- private static float calcLogarithmic(int val) {
- return (float) (Math.log10((double) val / Player.VOLUME_MAX) * 20f);
- }
-
- private void acquireLine(@NotNull AudioFormat format) throws LineUnavailableException, LineHelper.MixerException {
- if (type != Type.MIXER)
- return;
-
- if (line == null || !line.getFormat().matches(format)) {
- if (line != null) line.close();
-
- try {
- line = LineHelper.getLineFor(conf, format);
- line.open(format);
- } catch (LineUnavailableException | LineHelper.MixerException ex) {
- LOGGER.warn("Failed opening like for custom format '{}'. Opening default.", format);
- line = LineHelper.getLineFor(conf, DEFAULT_FORMAT);
- line.open(DEFAULT_FORMAT);
- }
- }
-
- if (lastVolume != -1) setVolume(lastVolume);
- }
-
- void flush() {
- if (line != null) line.flush();
- }
-
- void stop() {
- if (line != null) line.stop();
- }
-
- boolean start(@NotNull AudioFormat format) throws LineUnavailableException {
- if (type == Type.MIXER) {
- acquireLine(format);
- line.start();
- return true;
- }
-
- return false;
- }
-
- void write(byte[] buffer, int len) throws IOException, LineHelper.MixerException {
- if (type == Type.MIXER) {
- if (line != null) line.write(buffer, 0, len);
- } else if (type == Type.PIPE) {
- if (out == null) {
- if (pipe == null)
- throw new IllegalStateException();
-
- if (!pipe.exists()) {
- try {
- Process p = new ProcessBuilder().command("mkfifo " + pipe.getAbsolutePath())
- .redirectError(ProcessBuilder.Redirect.INHERIT).start();
- p.waitFor();
- if (p.exitValue() != 0)
- LOGGER.warn("Failed creating pipe! {exit: {}}", p.exitValue());
- else
- LOGGER.info("Created pipe: " + pipe);
- } catch (InterruptedException ex) {
- throw new IllegalStateException(ex);
- }
- }
-
- out = new FileOutputStream(pipe, true);
- }
-
- out.write(buffer, 0, len);
- } else if (type == Type.STREAM) {
- out.write(buffer, 0, len);
- } else {
- throw new IllegalStateException();
- }
- }
-
- void drain() {
- if (line != null) line.drain();
- }
-
- @Override
- public void close() throws IOException {
- if (line != null) {
- line.close();
- line = null;
- }
-
- if (out != null && out != System.out) out.close();
- }
-
- void setVolume(int volume) {
- lastVolume = volume;
-
- if (line != null) {
- FloatControl ctrl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN);
- if (ctrl != null) {
- mixing.setGlobalGain(1);
- ctrl.setValue(calcLogarithmic(volume));
- return;
- }
- }
-
- // Cannot set volume through line
- mixing.setGlobalGain(((float) volume) / Player.VOLUME_MAX);
- }
-
- void releaseLine() {
- if (line == null) return;
-
- line.close();
- line = null;
- }
-
- enum Type {
- MIXER, PIPE, STREAM
- }
- }
}
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java
index 8d7a6669..7b886d76 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java
@@ -5,9 +5,8 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.gianlu.librespot.player.codecs.Codec;
-import xyz.gianlu.librespot.player.codecs.StreamConverter;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
-import javax.sound.sampled.AudioFormat;
import java.io.InputStream;
import java.io.OutputStream;
@@ -26,7 +25,7 @@ public final class MixingLine extends InputStream {
private volatile float fg = 1;
private volatile float sg = 1;
private volatile float gg = 1;
- private AudioFormat format = AudioSink.DEFAULT_FORMAT;
+ private OutputAudioFormat format = OutputAudioFormat.DEFAULT_FORMAT;
public MixingLine() {
}
@@ -89,12 +88,12 @@ public void setGlobalGain(float gain) {
}
@Nullable
- public AudioFormat getFormat() {
+ public OutputAudioFormat getFormat() {
return format;
}
@Nullable
- private StreamConverter setFormat(@NotNull AudioFormat format, @NotNull MixingOutput from) {
+ private StreamConverter setFormat(@NotNull OutputAudioFormat format, @NotNull MixingOutput from) {
if (this.format == null) {
this.format = format;
return null;
@@ -140,7 +139,7 @@ protected void writeBuffer(byte[] b) {
protected abstract void writeBuffer(@NotNull byte[] b, int off, int len);
- public abstract void toggle(boolean enabled, @Nullable AudioFormat format);
+ public abstract void toggle(boolean enabled, @Nullable OutputAudioFormat format);
public abstract void gain(float gain);
@@ -159,7 +158,7 @@ public void writeBuffer(@NotNull byte[] b, int off, int len) {
@Override
@SuppressWarnings("DuplicatedCode")
- public void toggle(boolean enabled, @Nullable AudioFormat format) {
+ public void toggle(boolean enabled, @Nullable OutputAudioFormat format) {
if (enabled == fe) return;
if (enabled && (fout == null || fout != this)) return;
if (enabled && format == null) throw new IllegalArgumentException();
@@ -206,7 +205,7 @@ public void writeBuffer(@NotNull byte[] b, int off, int len) {
@Override
@SuppressWarnings("DuplicatedCode")
- public void toggle(boolean enabled, @Nullable AudioFormat format) {
+ public void toggle(boolean enabled, @Nullable OutputAudioFormat format) {
if (enabled == se) return;
if (enabled && (sout == null || sout != this)) return;
if (enabled && format == null) throw new IllegalArgumentException();
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/StreamConverter.java
similarity index 88%
rename from player/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java
rename to player/src/main/java/xyz/gianlu/librespot/player/mixing/StreamConverter.java
index f65555e5..b32ac6b0 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java
+++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/StreamConverter.java
@@ -1,9 +1,9 @@
-package xyz.gianlu.librespot.player.codecs;
+package xyz.gianlu.librespot.player.mixing;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
+import xyz.gianlu.librespot.player.mixing.output.OutputAudioFormat;
-import javax.sound.sampled.AudioFormat;
import java.io.OutputStream;
public final class StreamConverter extends OutputStream {
@@ -12,24 +12,22 @@ public final class StreamConverter extends OutputStream {
private final int sampleSizeTo;
private byte[] buffer;
- private StreamConverter(@NotNull AudioFormat from, @NotNull AudioFormat to) {
+ private StreamConverter(@NotNull OutputAudioFormat from, @NotNull OutputAudioFormat to) {
monoToStereo = from.getChannels() == 1 && to.getChannels() == 2;
sampleSizeFrom = from.getSampleSizeInBits();
sampleSizeTo = to.getSampleSizeInBits();
}
- public static boolean canConvert(@NotNull AudioFormat from, @NotNull AudioFormat to) {
+ public static boolean canConvert(@NotNull OutputAudioFormat from, @NotNull OutputAudioFormat to) {
if (from.isBigEndian() || to.isBigEndian()) return false;
if (from.matches(to)) return true;
if (from.getEncoding() != to.getEncoding()) return false;
return from.getSampleRate() == to.getSampleRate();
- // It is possible to convert the sample size
- // It is possible to convert the number of channels
}
@NotNull
- public static StreamConverter converter(@NotNull AudioFormat from, @NotNull AudioFormat to) {
+ public static StreamConverter converter(@NotNull OutputAudioFormat from, @NotNull OutputAudioFormat to) {
if (!canConvert(from, to))
throw new UnsupportedOperationException(String.format("From '%s' to '%s'", from, to));
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/mixing/output/PipeOutput.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/output/PipeOutput.java
new file mode 100644
index 00000000..57f3edb5
--- /dev/null
+++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/output/PipeOutput.java
@@ -0,0 +1,53 @@
+package xyz.gianlu.librespot.player.mixing.output;
+
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author devgianlu
+ */
+public final class PipeOutput implements SinkOutput {
+ private static final Logger LOGGER = LoggerFactory.getLogger(PipeOutput.class);
+ private final File file;
+ private OutputStream stream;
+
+ public PipeOutput(@NotNull File file) {
+ this.file = file;
+ }
+
+ @Override
+ public void write(byte[] buffer, int offset, int len) throws IOException {
+ if (stream == null) {
+ if (!file.exists()) {
+ try {
+ Process p = new ProcessBuilder()
+ .command("mkfifo", file.getAbsolutePath())
+ .redirectError(ProcessBuilder.Redirect.INHERIT)
+ .start();
+ p.waitFor();
+ if (p.exitValue() != 0)
+ LOGGER.warn("Failed creating pipe! {exit: {}}", p.exitValue());
+ else
+ LOGGER.info("Created pipe: " + file);
+ } catch (InterruptedException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ stream = new FileOutputStream(file);
+ }
+
+ stream.write(buffer, 0, len);
+ }
+
+ @Override
+ public void close() throws IOException {
+ stream.close();
+ }
+}
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/mixing/output/StreamOutput.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/output/StreamOutput.java
new file mode 100644
index 00000000..e7afb207
--- /dev/null
+++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/output/StreamOutput.java
@@ -0,0 +1,29 @@
+package xyz.gianlu.librespot.player.mixing.output;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author devgianlu
+ */
+public final class StreamOutput implements SinkOutput {
+ private final OutputStream stream;
+ private final boolean close;
+
+ public StreamOutput(@NotNull OutputStream stream, boolean close) {
+ this.stream = stream;
+ this.close = close;
+ }
+
+ @Override
+ public void write(byte[] buffer, int offset, int len) throws IOException {
+ stream.write(buffer, offset, len);
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (close) stream.close();
+ }
+}
diff --git a/player/src/main/resources/default.toml b/player/src/main/resources/default.toml
index f8eb3dda..8e791e8b 100644
--- a/player/src/main/resources/default.toml
+++ b/player/src/main/resources/default.toml
@@ -42,7 +42,8 @@ volumeSteps = 64 # Number of volume notches
logAvailableMixers = true # Log available mixers
mixerSearchKeywords = "" # Mixer/backend search keywords (semicolon separated)
crossfadeDuration = 0 # Crossfade overlap time (in milliseconds)
-output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT)
+output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT, CUSTOM)
+outputClass = "" # Audio output Java class name
releaseLineDelay = 20 # Release mixer line after set delay (in seconds)
pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE)
retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails
diff --git a/pom.xml b/pom.xml
index 5bc96f8c..821e7500 100644
--- a/pom.xml
+++ b/pom.xml
@@ -39,6 +39,7 @@
1.8
2.8.6
3.15.8
+ 1.7.30
2.13.3
2.14.1
3.4.2
@@ -46,6 +47,8 @@
lib
+ sink-api
+ sink
player
api
diff --git a/sink-api/pom.xml b/sink-api/pom.xml
new file mode 100644
index 00000000..370383c2
--- /dev/null
+++ b/sink-api/pom.xml
@@ -0,0 +1,16 @@
+
+ 4.0.0
+
+
+ xyz.gianlu.librespot
+ librespot-java
+ 1.5.6-SNAPSHOT
+ ../pom.xml
+
+
+ librespot-sink-api
+ jar
+
+ librespot-java sink API
+
\ No newline at end of file
diff --git a/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/OutputAudioFormat.java b/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/OutputAudioFormat.java
new file mode 100644
index 00000000..be6edd12
--- /dev/null
+++ b/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/OutputAudioFormat.java
@@ -0,0 +1,63 @@
+package xyz.gianlu.librespot.player.mixing.output;
+
+/**
+ * @author devgianlu
+ */
+public final class OutputAudioFormat {
+ public static final OutputAudioFormat DEFAULT_FORMAT = new OutputAudioFormat(44100, 16, 2, true, false);
+ private final String encoding;
+ private final float sampleRate;
+ private final int sampleSizeInBits;
+ private final int channels;
+ private final int frameSize;
+ private final float frameRate;
+ private final boolean bigEndian;
+
+ public OutputAudioFormat(float sampleRate, int sampleSizeInBits, int channels, boolean signed, boolean bigEndian) {
+ this.encoding = signed ? "PCM_SIGNED" : "PCM_UNSIGNED";
+ this.sampleRate = sampleRate;
+ this.sampleSizeInBits = sampleSizeInBits;
+ this.channels = channels;
+ this.frameSize = (channels == -1 || sampleSizeInBits == -1) ? -1 : ((sampleSizeInBits + 7) / 8) * channels;
+ this.frameRate = sampleRate;
+ this.bigEndian = bigEndian;
+ }
+
+ public int getFrameSize() {
+ return frameSize;
+ }
+
+ public float getSampleRate() {
+ return sampleRate;
+ }
+
+ public boolean isBigEndian() {
+ return bigEndian;
+ }
+
+ public int getSampleSizeInBits() {
+ return sampleSizeInBits;
+ }
+
+ public int getChannels() {
+ return channels;
+ }
+
+ public String getEncoding() {
+ return encoding;
+ }
+
+ public float getFrameRate() {
+ return frameRate;
+ }
+
+ public boolean matches(OutputAudioFormat format) {
+ return format.getEncoding().equals(getEncoding())
+ && (format.getChannels() == -1 || format.getChannels() == getChannels())
+ && (format.getSampleRate() == (float) -1 || format.getSampleRate() == getSampleRate())
+ && (format.getSampleSizeInBits() == -1 || format.getSampleSizeInBits() == getSampleSizeInBits())
+ && (format.getFrameRate() == (float) -1 || format.getFrameRate() == getFrameRate())
+ && (format.getFrameSize() == -1 || format.getFrameSize() == getFrameSize())
+ && (getSampleSizeInBits() <= 8 || format.isBigEndian() == isBigEndian());
+ }
+}
diff --git a/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/SinkException.java b/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/SinkException.java
new file mode 100644
index 00000000..791bffed
--- /dev/null
+++ b/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/SinkException.java
@@ -0,0 +1,10 @@
+package xyz.gianlu.librespot.player.mixing.output;
+
+/**
+ * @author devgianlu
+ */
+public final class SinkException extends Exception{
+ public SinkException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/SinkOutput.java b/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/SinkOutput.java
new file mode 100644
index 00000000..4d388d61
--- /dev/null
+++ b/sink-api/src/main/java/xyz/gianlu/librespot/player/mixing/output/SinkOutput.java
@@ -0,0 +1,34 @@
+package xyz.gianlu.librespot.player.mixing.output;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Range;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * @author devgianlu
+ */
+public interface SinkOutput extends Closeable {
+ default boolean start(@NotNull OutputAudioFormat format) throws SinkException{
+ return false;
+ }
+
+ void write(byte[] buffer, int offset, int len) throws IOException;
+
+ default boolean setVolume(@Range(from = 0, to = 1) float volume) {
+ return false;
+ }
+
+ default void release() {
+ }
+
+ default void drain() {
+ }
+
+ default void flush() {
+ }
+
+ default void stop() {
+ }
+}
diff --git a/sink/pom.xml b/sink/pom.xml
new file mode 100644
index 00000000..36c4ecb7
--- /dev/null
+++ b/sink/pom.xml
@@ -0,0 +1,37 @@
+
+ 4.0.0
+
+
+ xyz.gianlu.librespot
+ librespot-java
+ 1.5.6-SNAPSHOT
+ ../pom.xml
+
+
+ librespot-sink
+ jar
+
+ librespot-java default sink
+
+
+
+ xyz.gianlu.librespot
+ librespot-sink-api
+ ${project.version}
+
+
+
+
+ org.slf4j
+ slf4j-api
+ ${slf4j-api.version}
+
+
+ xyz.gianlu.librespot
+ librespot-java
+ 1.5.6-SNAPSHOT
+ compile
+
+
+
\ No newline at end of file
diff --git a/player/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java b/sink/src/main/java/xyz/gianlu/librespot/player/mixing/output/LineHelper.java
similarity index 65%
rename from player/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java
rename to sink/src/main/java/xyz/gianlu/librespot/player/mixing/output/LineHelper.java
index f98b9db5..d7ce3724 100644
--- a/player/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java
+++ b/sink/src/main/java/xyz/gianlu/librespot/player/mixing/output/LineHelper.java
@@ -1,11 +1,9 @@
-package xyz.gianlu.librespot.player.mixing;
+package xyz.gianlu.librespot.player.mixing.output;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
-import xyz.gianlu.librespot.common.Utils;
-import xyz.gianlu.librespot.player.PlayerConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import javax.sound.sampled.*;
import java.util.ArrayList;
@@ -15,12 +13,26 @@
/**
* @author Gianlu
*/
-public final class LineHelper {
- private static final Logger LOGGER = LogManager.getLogger(LineHelper.class);
+final class LineHelper {
+ private static final Logger LOGGER = LoggerFactory.getLogger(LineHelper.class);
private LineHelper() {
}
+ @NotNull
+ private static String mixersToString(List list) {
+ StringBuilder builder = new StringBuilder();
+ boolean first = true;
+ for (Mixer mixer : list) {
+ if (!first) builder.append(", ");
+ first = false;
+
+ builder.append('\'').append(mixer.getMixerInfo().getName()).append('\'');
+ }
+
+ return builder.toString();
+ }
+
@NotNull
private static List findSupportingMixersFor(@NotNull Line.Info info) throws MixerException {
List mixers = new ArrayList<>();
@@ -50,21 +62,21 @@ private static Mixer findMixer(@NotNull List mixers, @Nullable String[] k
}
if (list.size() > 1)
- LOGGER.info("Multiple mixers available after keyword search: " + Utils.mixersToString(list));
+ LOGGER.info("Multiple mixers available after keyword search: " + mixersToString(list));
return list.get(0);
}
@NotNull
- public static SourceDataLine getLineFor(@NotNull PlayerConfiguration conf, @NotNull AudioFormat format) throws MixerException, LineUnavailableException {
+ static SourceDataLine getLineFor(@NotNull String[] searchKeywords, boolean logAvailableMixers, @NotNull AudioFormat format) throws MixerException, LineUnavailableException {
DataLine.Info info = new DataLine.Info(SourceDataLine.class, format, AudioSystem.NOT_SPECIFIED);
List mixers = findSupportingMixersFor(info);
- if (conf.logAvailableMixers) LOGGER.info("Available mixers: " + Utils.mixersToString(mixers));
- Mixer mixer = findMixer(mixers, conf.mixerSearchKeywords);
+ if (logAvailableMixers) LOGGER.info("Available mixers: " + mixersToString(mixers));
+ Mixer mixer = findMixer(mixers, searchKeywords);
return (SourceDataLine) mixer.getLine(info);
}
- public static class MixerException extends RuntimeException {
+ static class MixerException extends RuntimeException {
MixerException(String message) {
super(message);
}
diff --git a/sink/src/main/java/xyz/gianlu/librespot/player/mixing/output/MixerOutput.java b/sink/src/main/java/xyz/gianlu/librespot/player/mixing/output/MixerOutput.java
new file mode 100644
index 00000000..f15508ff
--- /dev/null
+++ b/sink/src/main/java/xyz/gianlu/librespot/player/mixing/output/MixerOutput.java
@@ -0,0 +1,116 @@
+package xyz.gianlu.librespot.player.mixing.output;
+
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.sound.sampled.AudioFormat;
+import javax.sound.sampled.FloatControl;
+import javax.sound.sampled.LineUnavailableException;
+import javax.sound.sampled.SourceDataLine;
+
+/**
+ * @author devgianlu
+ */
+@SuppressWarnings("unused")
+public final class MixerOutput implements SinkOutput {
+ private static final Logger LOGGER = LoggerFactory.getLogger(MixerOutput.class);
+ private final String[] mixerSearchKeywords;
+ private final boolean logAvailableMixers;
+ private SourceDataLine line;
+ private float lastVolume = -1;
+
+ public MixerOutput(@NotNull String[] mixerSearchKeywords, @NotNull Boolean logAvailableMixers) {
+ this.mixerSearchKeywords = mixerSearchKeywords;
+ this.logAvailableMixers = logAvailableMixers;
+ }
+
+ private static AudioFormat makeJavaxAudioFormat(@NotNull OutputAudioFormat format) {
+ return new AudioFormat(new AudioFormat.Encoding(format.getEncoding()), format.getSampleRate(),
+ format.getSampleSizeInBits(), format.getChannels(), format.getFrameSize(),
+ format.getFrameRate(), format.isBigEndian());
+ }
+
+ private void acquireLine(@NotNull AudioFormat format) throws LineUnavailableException, LineHelper.MixerException {
+ if (line == null || !line.getFormat().matches(format)) {
+ if (line != null) line.close();
+
+ try {
+ line = LineHelper.getLineFor(mixerSearchKeywords, logAvailableMixers, format);
+ line.open(format);
+ } catch (LineUnavailableException | LineHelper.MixerException ex) {
+ LOGGER.warn("Failed opening line for custom format '{}'. Opening default.", format);
+
+ format = makeJavaxAudioFormat(OutputAudioFormat.DEFAULT_FORMAT);
+ line = LineHelper.getLineFor(mixerSearchKeywords, logAvailableMixers, format);
+ line.open(format);
+ }
+ }
+
+ if (lastVolume != -1) setVolume(lastVolume);
+ }
+
+ @Override
+ public void flush() {
+ if (line != null) line.flush();
+ }
+
+ @Override
+ public void stop() {
+ if (line != null) line.stop();
+ }
+
+ @Override
+ public boolean start(@NotNull OutputAudioFormat format) throws SinkException {
+ try {
+ acquireLine(makeJavaxAudioFormat(format));
+ line.start();
+ return true;
+ } catch (LineUnavailableException | LineHelper.MixerException ex) {
+ throw new SinkException("Failed acquiring line.", ex);
+ }
+ }
+
+ @Override
+ public void write(byte[] buffer, int offset, int len) {
+ if (line != null) line.write(buffer, offset, len);
+ }
+
+ @Override
+ public void drain() {
+ if (line != null) line.drain();
+ }
+
+ @Override
+ public void close() {
+ if (line != null) {
+ line.close();
+ line = null;
+ }
+ }
+
+ @Override
+ public boolean setVolume(float volume) {
+ lastVolume = volume;
+
+ if (line != null) {
+ FloatControl ctrl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN);
+ if (ctrl != null) {
+ ctrl.setValue((float) (Math.log10(volume) * 20f));
+ return true;
+ } else {
+ return false; // The line doesn't support volume control
+ }
+ } else {
+ return true; // The line will be available at some point
+ }
+ }
+
+ @Override
+ public void release() {
+ if (line != null) {
+ line.close();
+ line = null;
+ }
+ }
+}