diff --git a/examples/audio/pom.xml b/examples/audio/pom.xml new file mode 100644 index 0000000..22b5370 --- /dev/null +++ b/examples/audio/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.github.manevolent.ts3j + audio-example + 1.0-SNAPSHOT + + + + tomp2p.net + tomp2p + http://tomp2p.net/dev/mvn/ + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + + + + + org.apache.commons + commons-lang3 + 3.8.1 + + + + + net.tomp2p + opus-wrapper + 1.4 + + + + + com.sun.jna + jna + 3.0.9 + + + + + com.github.manevolent + ts3j + 1.0-SNAPSHOT + + + \ No newline at end of file diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/AudioSender.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/AudioSender.java new file mode 100644 index 0000000..881abd6 --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/AudioSender.java @@ -0,0 +1,73 @@ +package com.github.manevolent.ts3j.examples.audio; + +import com.github.manevolent.ts3j.identity.LocalIdentity; +import com.github.manevolent.ts3j.protocol.client.ClientConnectionState; +import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; +import com.github.manevolent.ts3j.util.Ts3Debugging; + +import static com.github.manevolent.ts3j.examples.audio.TeamspeakFastMixerSink.AUDIO_FORMAT; + +public class AudioSender { + public static void main(String[] args) throws Exception { + Ts3Debugging.setEnabled(false); + + LocalTeamspeakClientSocket client = new LocalTeamspeakClientSocket(); + + LocalIdentity identity = LocalIdentity.generateNew(10); + + client.setIdentity(identity); + client.setNickname(AudioSender.class.getSimpleName()); + client.setHWID("TestTestTest"); + + TeamspeakFastMixerSink sink = new TeamspeakFastMixerSink( + AUDIO_FORMAT, + (int) AUDIO_FORMAT.getSampleRate() * AUDIO_FORMAT.getChannels() * 4 /*2=16bit float*/, + new OpusParameters( + 20, + 96000, // 96kbps + 10, // max complexity + 0, // 0 expected packet loss + false, // no VBR + false, // no FEC + true // OPUS MUSIC - channel doesn't have to be Opus Music ;) + ) + ); + + client.setMicrophone(sink); + + // -1 to 1 (signed) positions of samples + float[] thirtySecondsOfAudio = new float[(int) AUDIO_FORMAT.getSampleRate() * AUDIO_FORMAT.getChannels()]; + + // Generate a sine wave + for (int i = 0; i < thirtySecondsOfAudio.length; i ++) { + thirtySecondsOfAudio[i] = (float) Math.sin(i / 60f); + } + + sink.start(); + + // Write into the sink + sink.write(thirtySecondsOfAudio, thirtySecondsOfAudio.length); + + client.connect( + "your.teamspeak.server", + 10000L + ); + + sink.drain(); // this will block until the sink is stopped + + System.err.println("Encoder time taken = " + sink.getNanotime() + " ns"); + System.err.println("Audio position = " + sink.getPosition() + " samples sank"); + System.err.println("Encoder position = " + sink.getEncoderPosition() + " samples encoded"); + System.err.println("Network position = " + sink.getNetworkPosition() + " bytes sent"); + System.err.println("Packets encoded = " + sink.getPacketsEncoded() + " packets encoded"); + System.err.println("Packets sent = " + sink.getPacketsSent() + " packets sent"); + System.err.println("Overflow events = " + sink.getOverflows()); + + // (resource starvation; can cause lost packets) + System.err.println("Underflow events = " + sink.getUnderflows()); + + client.disconnect("BYE"); + + System.exit(0); + } +} diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusDecoder.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusDecoder.java new file mode 100644 index 0000000..0a6b360 --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusDecoder.java @@ -0,0 +1,83 @@ +package com.github.manevolent.ts3j.examples.audio; + +import com.sun.jna.ptr.PointerByReference; +import net.tomp2p.opuswrapper.Opus; + +import java.nio.FloatBuffer; +import java.nio.IntBuffer; + +public class OpusDecoder implements AutoCloseable { + private final PointerByReference decoder; + private final int sampleRate, frameSize, channels; + + public OpusDecoder(int sampleRate, int frameSize, int channels) { + this.channels = channels; + this.sampleRate = sampleRate; + this.frameSize = frameSize; + + IntBuffer errorBuffer = IntBuffer.allocate(1); + + decoder = Opus.INSTANCE.opus_decoder_create( + sampleRate, + channels, + errorBuffer + ); + + OpusUtil.checkError("opus_decoder_create", errorBuffer.get()); + } + + public int reset() { + return OpusUtil.checkError( + "opus_decoder_ctl/OPUS_RESET_STATE", + Opus.INSTANCE.opus_decoder_ctl(decoder, Opus.OPUS_RESET_STATE) + ); + } + + public int setDecoderValue(int field, Object... values) { + return OpusUtil.checkError( + "opus_decoder_ctl/" + field, + Opus.INSTANCE.opus_decoder_ctl(decoder, field, values) + ); + } + + public int decodePLC(float[] floats) { + return decode(null, floats); + } + + public int decode(byte[] packet, float[] floats) { + if (floats == null) + throw new NullPointerException("floats"); + else if (floats.length < frameSize * channels) + throw new IllegalArgumentException(floats.length + " < " + frameSize * channels); + + int result = Opus.INSTANCE.opus_decode_float( + decoder, + packet, + packet == null ? 0 : packet.length, + FloatBuffer.wrap(floats), + frameSize, + 0 + ); + + OpusUtil.checkError("opus_decode_float", result); + + return result; + } + + public int getSampleRate() { + return sampleRate; + } + + public int getChannels() { + return channels; + } + + @Override + public void close() { + Opus.INSTANCE.opus_decoder_destroy(decoder); + } + + public static String getVersion() { + return Opus.INSTANCE.opus_get_version_string(); + } +} diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusEncoder.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusEncoder.java new file mode 100644 index 0000000..48ec239 --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusEncoder.java @@ -0,0 +1,201 @@ +package com.github.manevolent.ts3j.examples.audio; + +import com.sun.jna.ptr.PointerByReference; +import net.tomp2p.opuswrapper.Opus; +import org.apache.commons.lang3.ArrayUtils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.nio.ShortBuffer; + +/** + * Thread-safe opus encoder + */ +public class OpusEncoder implements AutoCloseable { + private static final int[] OPUS_PERMITTED_SAMPLE_RATES = + { 8000, 12000, 16000, 24000, 48000 }; + + private static final int[] OPUS_PERMITTED_FRAME_SIZES = + { 120, 240, 480, 960, 1920, 2880 }; + + private static final int[] OPUS_PERMITTED_CHANNEL_COUNTS = + { 1, 2 }; + + private final PointerByReference encoder; + private final int sampleRate, frameSize, channels; + private final int expectedByteSize; + private final boolean bigEndian; + + private final ShortBuffer sourceShortBuffer; + private final ByteBuffer sourceByteBuffer; + private final ByteBuffer targetBuffer; + + private final Object encoderLock = new Object(); + + private boolean closed = false; + + public OpusEncoder(int sampleRate, int frameSize, int channels, boolean bigEndian) { + this(sampleRate, frameSize, channels, bigEndian, 4096); + } + + public OpusEncoder(int sampleRate, int frameSize, + int channels, boolean bigEndian, + int maxPacketLength) { + this.channels = channels; + this.sampleRate = sampleRate; + this.frameSize = frameSize; + this.bigEndian = bigEndian; + this.expectedByteSize = frameSize * channels * 2; + + if (!ArrayUtils.contains(OPUS_PERMITTED_FRAME_SIZES, frameSize)) + throw new IllegalArgumentException("Invalid Opus frame size: " + frameSize); + + if (!ArrayUtils.contains(OPUS_PERMITTED_SAMPLE_RATES, sampleRate)) + throw new IllegalArgumentException("Invalid Opus sample rate: " + sampleRate); + + if (!ArrayUtils.contains(OPUS_PERMITTED_CHANNEL_COUNTS, channels)) + throw new IllegalArgumentException("Invalid Opus channel count: " + channels); + + this.sourceShortBuffer = ShortBuffer.allocate(frameSize * channels); + this.sourceByteBuffer = ByteBuffer.allocate(frameSize * channels * 2); + this.sourceByteBuffer.order(bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN); + + this.targetBuffer = ByteBuffer.allocate(maxPacketLength); + + IntBuffer errorBuffer = IntBuffer.allocate(1); + + encoder = Opus.INSTANCE.opus_encoder_create( + sampleRate, + channels, + Opus.OPUS_APPLICATION_AUDIO, + errorBuffer + ); + + if (encoder == null) throw new NullPointerException("encoder"); + + OpusUtil.checkError( + "opus_encoder_create", + errorBuffer.get() + ); + } + + public void reset() { + synchronized (encoderLock) { + OpusUtil.checkError( + "opus_encoder_ctl/OPUS_RESET_STATE", + Opus.INSTANCE.opus_encoder_ctl(encoder, Opus.OPUS_RESET_STATE) + ); + } + } + + public int setEncoderValue(int field, Object... values) { + synchronized (encoderLock) { + if (closed) throw new IllegalStateException("encoder closed"); + + return OpusUtil.checkError( + "opus_encoder_ctl/" + field, + Opus.INSTANCE.opus_encoder_ctl(encoder, field, values) + ); + } + } + + public byte[] encode(float[] floats, int len) { + if (len != getFrameSize() * getChannels()) + throw new IllegalArgumentException(len + " != " + getFrameSize() * getChannels()); + + targetBuffer.clear(); + + int result; + synchronized (encoderLock) { + if (closed) throw new IllegalStateException("encoder closed"); + + if (encoder == null) throw new IllegalStateException("encoder is null"); + + result = Opus.INSTANCE.opus_encode_float( + encoder, + floats, + frameSize, // this is STATIC. + targetBuffer, + targetBuffer.capacity() + ); + } + + OpusUtil.checkError( + "opus_encode_float," + + " ch=" + channels + + " smprate=" + sampleRate + + " rate=" + sampleRate + + " len=" + len + "/" + floats.length + ", frame_size=" + frameSize + + ", max_data_bytes=" + targetBuffer.capacity(), + result + ); + + byte[] encoded = new byte[result]; + targetBuffer.get(encoded); + + return encoded; + } + + public byte[] encode(float[] floats) { + return encode(floats, floats.length); + } + + public byte[] encode(byte[] pcm) { + if (pcm.length != expectedByteSize) + throw new IllegalArgumentException(pcm.length + " != " + expectedByteSize); + + // Clear source and input the PCM samples + sourceByteBuffer.clear(); + sourceByteBuffer.put(pcm); + sourceByteBuffer.flip(); + + sourceShortBuffer.clear(); + while (sourceByteBuffer.position() < sourceByteBuffer.limit()) + sourceShortBuffer.put(sourceByteBuffer.getShort()); + + // Clear target + sourceShortBuffer.flip(); + targetBuffer.clear(); + + int result; + + synchronized (encoderLock) { + if (closed) throw new IllegalStateException("encoder closed"); + + result = Opus.INSTANCE.opus_encode( + encoder, + sourceShortBuffer, + frameSize, // this is STATIC. + targetBuffer, + targetBuffer.capacity() + ); + } + + OpusUtil.checkError("opus_encode", result); + + byte[] encoded = new byte[result]; + targetBuffer.get(encoded); + + return encoded; + } + + public int getFrameSize() { return frameSize; } + + public int getSampleRate() { + return sampleRate; + } + + public int getChannels() { + return channels; + } + + @Override + public void close() { + synchronized (encoderLock) { + if (closed) throw new IllegalStateException("already closed"); + Opus.INSTANCE.opus_encoder_destroy(encoder); + closed = true; + } + } +} diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusParameters.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusParameters.java new file mode 100644 index 0000000..1c92517 --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusParameters.java @@ -0,0 +1,51 @@ +package com.github.manevolent.ts3j.examples.audio; + +public class OpusParameters { + private final int opusFrameRate; + private final int opusBitrate; + private final int opusComplexity; + private final int opusPacketLoss; + private final boolean opusVbr; + private final boolean opusFec; + private final boolean opusMusic; + + public OpusParameters(int opusFrameRate, int opusBitrate, + int opusComplexity, int opusPacketLoss, + boolean opusVbr, boolean opusFec, boolean opusMusic) { + this.opusFrameRate = opusFrameRate; + this.opusBitrate = opusBitrate; + this.opusComplexity = opusComplexity; + this.opusPacketLoss = opusPacketLoss; + this.opusVbr = opusVbr; + this.opusFec = opusFec; + this.opusMusic = opusMusic; + } + + public int getOpusFrameTime() { + return opusFrameRate; + } + + public int getOpusBitrate() { + return opusBitrate; + } + + public int getOpusComplexity() { + return opusComplexity; + } + + public int getOpusPacketLoss() { + return opusPacketLoss; + } + + public boolean isOpusVbr() { + return opusVbr; + } + + public boolean isOpusFec() { + return opusFec; + } + + public boolean isOpusMusic() { + return opusMusic; + } +} \ No newline at end of file diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusUtil.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusUtil.java new file mode 100644 index 0000000..95f6a3e --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/OpusUtil.java @@ -0,0 +1,26 @@ +package com.github.manevolent.ts3j.examples.audio; + +import net.tomp2p.opuswrapper.Opus; + +public class OpusUtil { + public static int checkError(String description, int returnCode) throws RuntimeException { + if (returnCode < Opus.OPUS_OK) + throw new RuntimeException("Opus error [" + description + "] (" + returnCode + "): " + + Opus.INSTANCE.opus_strerror(returnCode).trim() + ); + else return returnCode; + } + + public static int checkError(int returnCode) throws RuntimeException { + if (returnCode < Opus.OPUS_OK) + throw new RuntimeException( + "Opus error (" + returnCode + "): " + + Opus.INSTANCE.opus_strerror(returnCode).trim() + ); + else return returnCode; + } + + public static String getVersion() { + return Opus.INSTANCE.opus_get_version_string(); + } +} diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/TeamspeakFastMixerSink.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/TeamspeakFastMixerSink.java new file mode 100644 index 0000000..7454e03 --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/TeamspeakFastMixerSink.java @@ -0,0 +1,410 @@ +package com.github.manevolent.ts3j.examples.audio; + +import com.github.manevolent.ts3j.enums.CodecType; +import net.tomp2p.opuswrapper.Opus; + +import javax.sound.sampled.AudioFormat; +import java.util.NoSuchElementException; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class TeamspeakFastMixerSink implements TeamspeakMixerSink { + public static final AudioFormat AUDIO_FORMAT = + new AudioFormat(48000, 32, 2, true, false); + + private final Object stateLock = new Object(); + private final Object drainLock = new Object(); + + // Audio format + private final AudioFormat audioFormat; // Audio output format (mostly just ensured to match certain parameters) + + // OPUS variables + private final OpusParameters opusParameters; // Opus parameters set by configurations + private volatile OpusEncoder encoder = null; // Opus encoder instance + private final int opusFrameSize; // Opus frame size (PER CHANNEL!) (also the ASIO chunk size) + private long opusPacketsEncoded = 0, opusPacketsSent = 0; + private long opusTime = 0; + private long networkTime = 0; + private long opusPosition = 0; + private long opusBytePosition = 0; + + private long waitTime = 0; + + // Statistics variables + private long underflowed = 0; // Underflowed samples (ASIO has to return 0 samples to the encoder) + private long overflowed = 0; // Overflowed samples (write returns 0) + private long clipped = 0; // Clipped samples (outside -1F,1F bounds) + private long position = 0; // Position in samples + + // I/O variables + private final Queue packetQueue = new ConcurrentLinkedQueue<>(); + private final int bufferSize; // Buffer size, in samples + private volatile int availableInput; + private volatile int samplePosition; // Position, in samples, of the ASIO buffer (available samples) + private final float[] sampleBuffer; // ASIO buffer + + // Mixer sink state variables + private volatile boolean running = false, opening = false; + + public TeamspeakFastMixerSink(AudioFormat audioFormat, + int bufferSizeInBytes, + OpusParameters opusParameters) { + + this.audioFormat = audioFormat; + + if (audioFormat.getSampleRate() != 48000) + throw new IllegalArgumentException("Invalid audio sample rate: " + + audioFormat.getSampleRate() + " != 48000: OPUS requires 48KHz audio"); + else if (audioFormat.getChannels() != 2) + throw new IllegalArgumentException("Invalid audio channel count: " + + audioFormat.getChannels() + " != 2: OPUS requires stereo audio"); + + this.bufferSize = bufferSizeInBytes / (audioFormat.getSampleSizeInBits() / 8); + if (bufferSize <= 0 || bufferSize % audioFormat.getChannels() != 0) + throw new IllegalArgumentException("invalid bufferSize: " + bufferSize); + + this.sampleBuffer = new float[bufferSize]; + this.availableInput = 0; + + this.opusParameters = opusParameters; + this.opusFrameSize = (int) getAudioFormat().getSampleRate() / (1000 / opusParameters.getOpusFrameTime()); + } + + public AudioFormat getAudioFormat() { + return audioFormat; + } + + public void write(float[] buffer, int len) { + synchronized (stateLock) { + if (!running) throw new IllegalStateException("not running"); + + if (len > availableInput()) { // This should never happen + overflowed++; // Probably never will run + throw new IllegalArgumentException(len + " > " + availableInput()); + } + + if (len > buffer.length) { + overflowed++; + throw new IllegalArgumentException(len + " > " + buffer.length); + } + + if (len <= 0) + throw new IllegalArgumentException(len + " <= 0"); + + if (len % getChannels() != 0) + throw new IllegalArgumentException("not a full frame"); + + // Write to the buffer + System.arraycopy(buffer, 0, sampleBuffer, samplePosition, len); + samplePosition += len; + availableInput -= len; + + // Read samples from the buffer and encode them into packets + position += encode(false); + } + } + + /** + * Encodes the buffer to packets for the packet queue + * @return Packets encoded + */ + private int encode(boolean flush) { + if (flush) Logger.getGlobal().log(Level.FINE, "Flushing TeamspeakFastMixerSink..."); + + int written = 0; + int frameSize = opusFrameSize * getChannels(); + byte[] encoded; + long now; + int copy; + + while (samplePosition >= (flush ? 1 : frameSize)) { + copy = Math.min(samplePosition, frameSize); + if (flush && copy < frameSize) { + for (int i = copy; i < frameSize; i++) + sampleBuffer[i] = 0f; + + samplePosition = frameSize; + } + + if (encoder == null) openOpusEncoder(); + + now = System.nanoTime(); + encoded = encoder.encode(sampleBuffer, frameSize); + opusTime += (System.nanoTime() - now); + opusPacketsEncoded ++; + opusPosition += frameSize; + + System.arraycopy(sampleBuffer, frameSize, sampleBuffer, 0, samplePosition - frameSize); + samplePosition -= frameSize; + if (packetQueue.add(new OpusPacket(frameSize, encoded))) + written+=frameSize; + } + + if (written > 0) opening = false; + + if (flush) Logger.getGlobal().log(Level.FINE, "Flushed TeamspeakFastMixerSink."); + + return written; + } + + public long getNanotime() { + return opusTime; + } + public long getFrameSize() { + return opusFrameSize; + } + public long getEncoderPosition() { + return opusPosition; + } + public long getNetworkPosition() { + return opusBytePosition; + } + public OpusParameters getEncoderParameters() { + return opusParameters; + } + + @Override + public long getPacketsEncoded() { + return opusPacketsEncoded; + } + + @Override + public long getPacketsSent() { + return opusPacketsSent; + } + + /** + * Finds the count of available samples to be written to write() in the len param + * @return Available sample count + */ + public int availableInput() { + return availableInput; + } + + public boolean isRunning() { + return running; + } + + /** + * Opens the Opus encoder + */ + private void openOpusEncoder() { + synchronized (stateLock) { + if (encoder != null) return; + Logger.getGlobal().log(Level.FINE, "Opening TeamspeakFastMixerSink encoder..."); + + encoder = new OpusEncoder( + (int) getAudioFormat().getSampleRate(), // smp rate (always 48kHz) + opusFrameSize, + getChannels(), + audioFormat.isBigEndian() + //,MAXIMUM_OPUS_PACKET_SIZE // Prevent IP fragmentation + ); + + encoder.setEncoderValue( + Opus.OPUS_SET_SIGNAL_REQUEST, + opusParameters.isOpusMusic() ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE + ); + + encoder.setEncoderValue(Opus.OPUS_SET_BITRATE_REQUEST, opusParameters.getOpusBitrate()); + encoder.setEncoderValue(Opus.OPUS_SET_COMPLEXITY_REQUEST, opusParameters.getOpusComplexity()); + encoder.setEncoderValue(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, opusParameters.getOpusPacketLoss()); + encoder.setEncoderValue(Opus.OPUS_SET_VBR_REQUEST, opusParameters.isOpusVbr() ? 1 : 0); + encoder.setEncoderValue(Opus.OPUS_SET_INBAND_FEC_REQUEST, opusParameters.isOpusFec() ? 1 : 0); + + Logger.getGlobal().log(Level.FINE, "Opened TeamspeakFastMixerSink encoder."); + } + } + + private void closeOpusEncoder() { + synchronized (stateLock) { + if (encoder != null) { + Logger.getGlobal().log(Level.FINE, "Closing TeamspeakFastMixerSink encoder..."); + encoder.close(); + encoder = null; + Logger.getGlobal().log(Level.FINE, "Closed TeamspeakFastMixerSink encoder."); + } + } + } + + public void close() { + if (isRunning()) stop(); + + closeOpusEncoder(); + } + + public boolean start() { + synchronized (stateLock) { + if (running) return false; + + Logger.getGlobal().log(Level.FINE, "Starting TeamspeakFastMixerSink..."); + + // Flush buffers, clear outgoing packet queues. + for (int i = 0; i < sampleBuffer.length; i ++) sampleBuffer[i] = 0f; + samplePosition = 0; + availableInput = bufferSize; + packetQueue.clear(); + + // Open (or re-open) Opus encoder. + openOpusEncoder(); + + // Reset the encoder + if (encoder != null) { + Logger.getGlobal().log(Level.FINE, "Resetting TeamspeakFastMixerSink..."); + encoder.reset(); // Reset encoder output + Logger.getGlobal().log(Level.FINE, "Reset TeamspeakFastMixerSink."); + } + + // Mark as running. + opening = true; + running = true; + + Logger.getGlobal().log(Level.FINE, "Started TeamspeakFastMixerSink."); + + return true; + } + } + + public void drain() throws InterruptedException { + synchronized (drainLock) { + while (running && packetQueue.peek() != null) { + drainLock.wait(); + } + } + } + + /** + * Closes mixer sink + * + * Note that we don't close the Opus encoder - just in case there are more samples to write/flush out. + */ + public boolean stop() { + synchronized (stateLock) { + if (!running) return false; + + Logger.getGlobal().log(Level.FINE, "Stopping TeamspeakFastMixerSink..."); + + try { + encode(true); + } catch (RuntimeException e) { + Logger.getGlobal().log(Level.WARNING, "Problem flushing audio buffer upon close", e); + } + + running = false; + Logger.getGlobal().log(Level.FINE, "Stopped TeamspeakFastMixerSink."); + + return true; + } + } + + public int getBufferSize() { + return bufferSize; + } + + public long getPosition() { + return position; + } + + public long getClippedSamples() { + return clipped; + } + + public long getUnderflows() { + return underflowed; + } + + public long getOverflows() { + return overflowed; + } + + /** + * Called by the TS3J encoder thread to determine if the sink is ready to send samples. + * @return true if the sink has samples to write, or if samples will become available. + */ + @Override + public boolean isReady() { + return (running && !opening) || packetQueue.size() > 0; + } + + @Override + public CodecType getCodec() { + return CodecType.OPUS_MUSIC; + } + + /** + * Provides OPUS encoded audio (as a packet) to the TS3J UDP audio network thread. + * @return Encoded OPUS audio packet (zero-length packet if there is an underflow). + */ + @Override + public byte[] provide() { + long start = System.nanoTime(); + + try { + if (packetQueue.peek() == null) { + underflowed ++; + return new byte[0]; + } + + OpusPacket packet = packetQueue.remove(); + + if (packet == null) { + underflowed ++; + return new byte[0]; + } + + availableInput += packet.getSamples(); + opusBytePosition += packet.getBytes().length; + opusPacketsSent ++; + + return packet.getBytes(); + } catch (NoSuchElementException ex) { + underflowed ++; + return new byte[0]; + } finally { + long networkTime = System.nanoTime() - start; + + if (networkTime >= (opusParameters.getOpusFrameTime() * 1000000L)) { + underflowed++; + + Logger.getGlobal().warning("[TeamspeakFastMixerSink] provide() took longer than the expected " + + opusParameters.getOpusFrameTime() + "ms: " + ((double)networkTime / 1_000_000D) + "ms"); + } + + this.networkTime += networkTime; + + if (packetQueue.peek() == null) { + synchronized (drainLock) { + drainLock.notifyAll(); + } + } + } + } + + public int getChannels() { + return getAudioFormat().getChannels(); + } + + public long getWaitTime() { + return waitTime; + } + + private final class OpusPacket { + private final int samples; + private final byte[] bytes; + + protected OpusPacket(int samples, byte[] bytes) { + this.samples = samples; + this.bytes = bytes; + } + + public int getSamples() { + return samples; + } + + public byte[] getBytes() { + return bytes; + } + } +} \ No newline at end of file diff --git a/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/TeamspeakMixerSink.java b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/TeamspeakMixerSink.java new file mode 100644 index 0000000..22b8afa --- /dev/null +++ b/examples/audio/src/main/java/com/github/manevolent/ts3j/examples/audio/TeamspeakMixerSink.java @@ -0,0 +1,54 @@ +package com.github.manevolent.ts3j.examples.audio; + +import com.github.manevolent.ts3j.audio.Microphone; + +public interface TeamspeakMixerSink extends Microphone { + /** + * Gets the Encoder parameters + * @return Encoder parameters + */ + OpusParameters getEncoderParameters(); + + /** + * Gets the count of encoded Encoder packets + * @return Encoder packets + */ + long getPacketsEncoded(); + + /** + * Gets the count of written encoded Encoder packets + * @return Encoder packets + */ + long getPacketsSent(); + + /** + * Gets the time spent encoding Encoder packets, in nanoseconds + * @return Encoder time + */ + long getNanotime(); + + /** + * Gets the Encoder frame size for packets + * @return Encoder frame size + */ + long getFrameSize(); + + /** + * Gets the Encoder position in samples + * @return Encoder position + */ + long getEncoderPosition(); + + /** + * Gets the Encoder network position in bytes + * @return Encoder network position + */ + long getNetworkPosition(); + + /** + * Gets the channel count + * @return Channel count + */ + int getChannels(); + +} diff --git a/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java b/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java index 2018ea3..4780f1c 100644 --- a/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java +++ b/src/main/java/com/github/manevolent/ts3j/protocol/socket/client/LocalTeamspeakClientSocket.java @@ -1088,16 +1088,34 @@ public class LocalTeamspeakClientSocket throws IOException, TimeoutException, ExecutionException, InterruptedException { + disconnect(null); + } + + public void disconnect(String reason) + throws + IOException, TimeoutException, + ExecutionException, InterruptedException { try { switch (getState()) { case CONNECTING: case RETRIEVING_DATA: waitForState(ClientConnectionState.CONNECTED, 10000L); case CONNECTED: - writePacket(new PacketBody2Command( - ProtocolRole.CLIENT, - new SingleCommand("clientdisconnect", ProtocolRole.CLIENT)) - ); + if (reason == null) { + writePacket(new PacketBody2Command( + ProtocolRole.CLIENT, + new SingleCommand("clientdisconnect", ProtocolRole.CLIENT)) + ); + } else { + writePacket(new PacketBody2Command( + ProtocolRole.CLIENT, + new SingleCommand( + "clientdisconnect", ProtocolRole.CLIENT, + new CommandSingleParameter("reasonid", "8"), + new CommandSingleParameter("reasonmsg", reason) + )) + ); + } waitForState(ClientConnectionState.DISCONNECTED, 30000L); diff --git a/src/test/java/com/github/manevolent/ts3j/ServerConnectionTest.java b/src/test/java/com/github/manevolent/ts3j/ServerConnectionTest.java index ca51b07..c02eb7c 100644 --- a/src/test/java/com/github/manevolent/ts3j/ServerConnectionTest.java +++ b/src/test/java/com/github/manevolent/ts3j/ServerConnectionTest.java @@ -10,6 +10,7 @@ import com.github.manevolent.ts3j.protocol.client.ClientConnectionState; import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice; import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; import com.github.manevolent.ts3j.util.Ts3Debugging; +import com.sun.security.ntlm.Server; import java.math.BigInteger; import java.net.InetSocketAddress; @@ -20,34 +21,29 @@ import static org.junit.Assert.assertEquals; public class ServerConnectionTest { public static void main(String[] args) throws Exception { + Ts3Debugging.setEnabled(true); LocalTeamspeakClientSocket client = new LocalTeamspeakClientSocket(); - Ts3Debugging.setEnabled(false); - LocalIdentity identity = LocalIdentity.generateNew(10); client.setIdentity(identity); - client.setNickname("ts3j issue 2"); + client.setNickname(ServerConnectionTest.class.getSimpleName()); client.setHWID("TestTestTest"); - client.setVoiceHandler(packet -> - System.out.println(packet.getServerFlag0()) - ); + try { + client.connect( + "teamlixo.net", + 10000L + ); - while (true) { - try { - client.connect( - "teamlixo.net", - 10000L - ); + assertEquals(client.getState(), ClientConnectionState.CONNECTED); - assertEquals(client.getState(), ClientConnectionState.CONNECTED); - } catch (Exception ex) { - ex.printStackTrace(); - } - - Thread.sleep(30000); + client.disconnect("BYE"); + } catch (Exception ex) { + ex.printStackTrace(); } + + Thread.sleep(3000L); } }