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; + } + } +}