Move the voice pipelines into core

Capture processing, the jitter buffer and decoding were desktop classes,
so any other platform would have had to copy them. They now live in core
and reach the platform only through two interfaces:

- AudioIo lists devices and opens capture/playback lines; the desktop's
  AudioDevices implements it over PipeWire and Java Sound.
- OpusCodec creates encoders and decoders; the desktop binds libopus
  through the FFM API as before.

DesktopVoiceInput becomes CaptureVoiceInput unchanged in behaviour.
DesktopVoiceOutput splits into VoiceStream, one speaker's jitter buffer
and decoder, paced by whoever pulls it, and StreamingVoiceOutput around
it. Speakers reach the device either on a line each (the desktop, so
each shows up in the PipeWire mixer) or mixed onto one shared line, as
mobile audio APIs want.

The settings dialog's device lists and microphone test now go through
the AudioBackend instead of desktop classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:01:28 +00:00
parent 3c389de36b
commit 7396c4ca40
36 changed files with 1290 additions and 531 deletions

View File

@@ -1,17 +1,30 @@
package com.ts3client.audio; package com.ts3client.audio;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.sound.SoundPlayer; import com.ts3client.sound.SoundPlayer;
/** /**
* Factory for a platform's voice capture and playback. Injected into the * A platform's audio stack: its devices, its libopus and its sound player. Injected into
* connection layer so the core stays independent of any concrete audio stack. * the connection layer so the core stays independent of any concrete audio stack; the
* voice pipelines on top are the same everywhere.
*/ */
public interface AudioBackend { public interface AudioBackend {
VoiceInput createInput(Settings settings); AudioIo io();
VoiceOutput createOutput(Settings settings); OpusCodec opus();
/** How incoming voice reaches this platform's devices. */
StreamingVoiceOutput.Lines outputLines();
default VoiceInput createInput(Settings settings) {
return new CaptureVoiceInput(settings, io(), opus());
}
default VoiceOutput createOutput(Settings settings) {
return new StreamingVoiceOutput(io(), opus(), settings.outputDevice, outputLines());
}
/** Player for notification sounds (sound packs); shared by all connections. */ /** Player for notification sounds (sound packs); shared by all connections. */
SoundPlayer createSoundPlayer(Settings settings); SoundPlayer createSoundPlayer(Settings settings);

View File

@@ -1,11 +1,11 @@
package com.ts3client.audio.desktop; package com.ts3client.audio;
/** /**
* An open microphone line, delivering 16-bit little-endian PCM at * An open microphone line, delivering 16-bit little-endian PCM at
* {@link AudioDevices#SAMPLE_RATE}. * {@link VoiceFormat#SAMPLE_RATE}.
* *
* <p>Modelled on {@link javax.sound.sampled.TargetDataLine} so the capture pipeline does * <p>Modelled on Java Sound's {@code TargetDataLine} so the capture pipeline does
* not care whether the audio comes from PipeWire or Java Sound. * not care which platform audio API delivers it.
*/ */
public interface AudioCapture extends AutoCloseable { public interface AudioCapture extends AutoCloseable {

View File

@@ -0,0 +1,13 @@
package com.ts3client.audio;
/** A selectable audio device. {@code id} is what gets persisted in the settings. */
public record AudioDevice(String id, String label) {
/** The implicit entry that lets the platform choose. */
public static final AudioDevice DEFAULT = new AudioDevice("", "(System default)");
@Override
public String toString() {
return label;
}
}

View File

@@ -0,0 +1,23 @@
package com.ts3client.audio;
import java.util.List;
/** The platform's audio devices: what can be picked, and opening lines on them. */
public interface AudioIo {
/** Devices that can provide microphone lines, {@link AudioDevice#DEFAULT} first. */
List<AudioDevice> inputDevices();
/** Devices that can provide speaker lines, {@link AudioDevice#DEFAULT} first. */
List<AudioDevice> outputDevices();
/**
* Opens a capture line at {@link VoiceFormat#SAMPLE_RATE}, preferring
* {@code preferredChannels} but falling back to what the device takes; inspect
* {@link AudioCapture#channels()} for the result. An empty id means the default device.
*/
AudioCapture openCapture(String deviceId, int preferredChannels) throws Exception;
/** Opens a playback line; see {@link #openCapture} for the channel negotiation. */
AudioPlayback openPlayback(String deviceId, int preferredChannels) throws Exception;
}

View File

@@ -1,11 +1,11 @@
package com.ts3client.audio.desktop; package com.ts3client.audio;
/** /**
* An open speaker line, accepting 16-bit little-endian PCM at * An open speaker line, accepting 16-bit little-endian PCM at
* {@link AudioDevices#SAMPLE_RATE}. * {@link VoiceFormat#SAMPLE_RATE}.
* *
* <p>Modelled on {@link javax.sound.sampled.SourceDataLine} so the playback pipeline does * <p>Modelled on Java Sound's {@code SourceDataLine} so the playback pipeline does
* not care whether the audio goes to PipeWire or Java Sound. * not care which platform audio API plays it.
*/ */
public interface AudioPlayback extends AutoCloseable { public interface AudioPlayback extends AutoCloseable {

View File

@@ -1,11 +1,8 @@
package com.ts3client.audio.desktop; package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType; import com.github.manevolent.ts3j.enums.CodecType;
import com.ts3client.audio.AudioFrameListener; import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.InputLevel; import com.ts3client.audio.opus.OpusEncoder;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.SpeechProbabilityDetector;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.processing.AudioProcessor; import com.ts3client.audio.processing.AudioProcessor;
import com.ts3client.audio.processing.ns.SuppressionLevel; import com.ts3client.audio.processing.ns.SuppressionLevel;
import com.ts3client.audio.vad.RnnSpeechDetector; import com.ts3client.audio.vad.RnnSpeechDetector;
@@ -16,11 +13,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
* Desktop {@link VoiceInput}: captures the microphone, applies voice-activation * The {@link VoiceInput} every platform shares: captures the microphone, applies voice-activation
* or push-to-talk gating, and Opus-encodes 20&nbsp;ms frames. * or push-to-talk gating, and Opus-encodes 20&nbsp;ms frames.
* *
* <p>The capture line comes from {@link AudioDevices}, which picks PipeWire or Java * <p>The capture line comes from the platform's {@link AudioIo}; it is opened in stereo
* Sound; it is opened in stereo when the device offers it. Voice * when the device offers it. Voice
* ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing * ({@code OPUS_VOICE}) is transmitted mono, from the downmix, so the pre-processing
* chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits * chain and VAD see a single channel; the music codec ({@code OPUS_MUSIC}) transmits
* the stereo capture as-is. * the stereo capture as-is.
@@ -30,7 +27,7 @@ import java.util.function.Consumer;
* network sending. When the gate closes and the queue drains {@link #isReady()} * network sending. When the gate closes and the queue drains {@link #isReady()}
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet. * returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
*/ */
public final class DesktopVoiceInput implements VoiceInput { public final class CaptureVoiceInput implements VoiceInput {
/** /**
* How long the gate stays open after the last active frame. TS3 counts 64 of its * How long the gate stays open after the last active frame. TS3 counts 64 of its
@@ -46,9 +43,9 @@ public final class DesktopVoiceInput implements VoiceInput {
private static final int PREROLL_MS = 30; private static final int PREROLL_MS = 30;
private static final int HANGOVER_FRAMES = private static final int HANGOVER_FRAMES =
Math.max(1, HANGOVER_MS * AudioDevices.SAMPLE_RATE / 1000 / AudioDevices.FRAME_SIZE); Math.max(1, HANGOVER_MS * VoiceFormat.SAMPLE_RATE / 1000 / VoiceFormat.FRAME_SIZE);
private static final int PREROLL_FRAMES = private static final int PREROLL_FRAMES =
Math.max(1, PREROLL_MS * AudioDevices.SAMPLE_RATE / 1000 / AudioDevices.FRAME_SIZE); Math.max(1, PREROLL_MS * VoiceFormat.SAMPLE_RATE / 1000 / VoiceFormat.FRAME_SIZE);
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean muted = new AtomicBoolean(false); private final AtomicBoolean muted = new AtomicBoolean(false);
@@ -66,7 +63,7 @@ public final class DesktopVoiceInput implements VoiceInput {
private volatile CodecType codec = CodecType.OPUS_VOICE; private volatile CodecType codec = CodecType.OPUS_VOICE;
private final SpeechProbabilityDetector speechDetector = private final SpeechProbabilityDetector speechDetector =
new RnnSpeechDetector(AudioDevices.SAMPLE_RATE); new RnnSpeechDetector(VoiceFormat.SAMPLE_RATE);
private final AudioProcessor processor = new AudioProcessor(); private final AudioProcessor processor = new AudioProcessor();
private volatile Consumer<Double> levelListener; // input level, InputLevel scale private volatile Consumer<Double> levelListener; // input level, InputLevel scale
@@ -75,12 +72,14 @@ public final class DesktopVoiceInput implements VoiceInput {
private volatile AudioFrameListener monitorListener; // local monitoring of what is sent private volatile AudioFrameListener monitorListener; // local monitoring of what is sent
private boolean mutedTalking; private boolean mutedTalking;
private final AudioIo io;
private final OpusCodec opus;
private final String deviceName; private final String deviceName;
private final Object encoderLock = new Object(); private final Object encoderLock = new Object();
private volatile OpusParameters params; private volatile OpusParameters params;
private OpusParameters appliedParams; private OpusParameters appliedParams;
private int encoderApplication = -1; private OpusCodec.Application encoderApplication;
private volatile int encoderChannels = 1; private volatile int encoderChannels = 1;
private volatile int captureChannels = 1; private volatile int captureChannels = 1;
@@ -96,7 +95,9 @@ public final class DesktopVoiceInput implements VoiceInput {
private int prerollTail; private int prerollTail;
private int prerollCount; private int prerollCount;
public DesktopVoiceInput(Settings settings) { public CaptureVoiceInput(Settings settings, AudioIo io, OpusCodec opus) {
this.io = io;
this.opus = opus;
this.deviceName = settings.inputDevice; this.deviceName = settings.inputDevice;
this.mode = settings.inputMode; this.mode = settings.inputMode;
this.vadMode = settings.vadMode; this.vadMode = settings.vadMode;
@@ -173,8 +174,8 @@ public final class DesktopVoiceInput implements VoiceInput {
} }
} }
private static int applicationFor(OpusParameters p) { private static OpusCodec.Application applicationFor(OpusParameters p) {
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP; return p.music ? OpusCodec.Application.AUDIO : OpusCodec.Application.VOIP;
} }
private static CodecType codecFor(OpusParameters p) { private static CodecType codecFor(OpusParameters p) {
@@ -191,13 +192,13 @@ public final class DesktopVoiceInput implements VoiceInput {
/** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */ /** Creates or reconfigures the encoder to match {@code p}. Call under {@link #encoderLock}. */
private void applyParams(OpusParameters p) { private void applyParams(OpusParameters p) {
int application = applicationFor(p); OpusCodec.Application application = applicationFor(p);
int channels = channelsFor(p); int channels = channelsFor(p);
// Application (VOIP vs AUDIO) and channel count are fixed at creation, so // Application (VOIP vs AUDIO) and channel count are fixed at creation, so
// switching voice<->music means building a new encoder. // switching voice<->music means building a new encoder.
if (encoder == null || application != encoderApplication || channels != encoderChannels) { if (encoder == null || application != encoderApplication || channels != encoderChannels) {
OpusEncoder replacement = new OpusEncoder( OpusEncoder replacement = opus.createEncoder(
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, channels, application); VoiceFormat.SAMPLE_RATE, VoiceFormat.FRAME_SIZE, channels, application);
configureEncoder(replacement, p); configureEncoder(replacement, p);
OpusEncoder previous = encoder; OpusEncoder previous = encoder;
encoder = replacement; encoder = replacement;
@@ -217,7 +218,7 @@ public final class DesktopVoiceInput implements VoiceInput {
enc.setVbr(p.vbr); enc.setVbr(p.vbr);
enc.setInbandFec(p.fec); enc.setInbandFec(p.fec);
enc.setExpectedPacketLoss(p.expectedPacketLoss); enc.setExpectedPacketLoss(p.expectedPacketLoss);
enc.setSignal(p.music ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE); enc.setSignal(p.music ? OpusEncoder.Signal.MUSIC : OpusEncoder.Signal.VOICE);
} }
public void setLevelListener(Consumer<Double> l) { public void setLevelListener(Consumer<Double> l) {
@@ -261,7 +262,7 @@ public final class DesktopVoiceInput implements VoiceInput {
public synchronized void start() { public synchronized void start() {
if (running.get()) return; if (running.get()) return;
try { try {
line = AudioDevices.openCapture(deviceName, AudioDevices.MAX_CHANNELS); line = io.openCapture(deviceName, VoiceFormat.MAX_CHANNELS);
captureChannels = line.channels(); captureChannels = line.channels();
synchronized (encoderLock) { synchronized (encoderLock) {
applyParams(params); applyParams(params);
@@ -301,14 +302,14 @@ public final class DesktopVoiceInput implements VoiceInput {
} catch (Exception ignored) { } catch (Exception ignored) {
} }
encoder = null; encoder = null;
encoderApplication = -1; encoderApplication = null;
appliedParams = null; appliedParams = null;
} }
} }
} }
private void captureLoop() { private void captureLoop() {
final int frameSamples = AudioDevices.FRAME_SIZE; final int frameSamples = VoiceFormat.FRAME_SIZE;
final int channels = captureChannels; final int channels = captureChannels;
final byte[] buf = new byte[frameSamples * 2 * channels]; final byte[] buf = new byte[frameSamples * 2 * channels];
final float[] pcm = new float[frameSamples * channels]; // interleaved capture final float[] pcm = new float[frameSamples * channels]; // interleaved capture
@@ -409,7 +410,7 @@ public final class DesktopVoiceInput implements VoiceInput {
/** Encodes and queues the buffered lead-in, oldest first. Call under {@link #encoderLock}. */ /** Encodes and queues the buffered lead-in, oldest first. Call under {@link #encoderLock}. */
private void flushPreroll(boolean stereo) { private void flushPreroll(boolean stereo) {
int expected = stereo ? AudioDevices.FRAME_SIZE * encoderChannels : AudioDevices.FRAME_SIZE; int expected = stereo ? VoiceFormat.FRAME_SIZE * encoderChannels : VoiceFormat.FRAME_SIZE;
for (int i = 0; i < prerollCount; i++) { for (int i = 0; i < prerollCount; i++) {
int idx = (prerollTail - prerollCount + i + PREROLL_FRAMES) % PREROLL_FRAMES; int idx = (prerollTail - prerollCount + i + PREROLL_FRAMES) % PREROLL_FRAMES;
float[] frame = preroll[idx]; float[] frame = preroll[idx];

View File

@@ -0,0 +1,110 @@
package com.ts3client.audio;
import java.util.Arrays;
import java.util.Collection;
import java.util.function.Supplier;
import java.util.function.ToDoubleFunction;
/**
* Mixes every speaker onto one playback line, as mobile audio APIs want: a single
* low-latency stream. The line's blocking writes pace the mix, so it runs on the device's
* clock; between talk bursts the mixer writes nothing and waits.
*/
final class MixedPlayout implements Playout {
private static final int FRAME = VoiceFormat.FRAME_SIZE;
private final AudioIo io;
private final Supplier<String> outputDevice;
private final ToDoubleFunction<VoiceStream> gain;
private final Collection<VoiceStream> streams;
private final Object lock = new Object();
/** Guarded by {@link #lock}. */
private boolean running = true;
private Thread mixer;
MixedPlayout(AudioIo io, Supplier<String> outputDevice, ToDoubleFunction<VoiceStream> gain,
Collection<VoiceStream> streams) {
this.io = io;
this.outputDevice = outputDevice;
this.gain = gain;
this.streams = streams;
}
@Override
public void started(VoiceStream stream) {
synchronized (lock) {
if (!running) return;
if (mixer == null) {
mixer = new Thread(this::mixLoop, "ts3j-voice-mixer");
mixer.setDaemon(true);
mixer.start();
}
lock.notifyAll();
}
}
@Override
public void removed(VoiceStream stream) {
// The stream has already left the collection the mixer iterates.
}
@Override
public void shutdown() {
synchronized (lock) {
running = false;
if (mixer != null) mixer.interrupt();
lock.notifyAll();
}
}
private void mixLoop() {
AudioPlayback line = null;
try {
int channels = 0;
float[] mix = null;
float[] scratch = null;
byte[] out = null;
while (awaitSpeech()) {
if (line == null) {
line = io.openPlayback(outputDevice.get(), VoiceFormat.MAX_CHANNELS);
line.start();
channels = line.channels();
mix = new float[FRAME * channels];
scratch = new float[VoiceStream.MAX_FRAME * channels];
out = new byte[mix.length * 2];
}
Arrays.fill(mix, 0f);
for (VoiceStream stream : streams) {
int frames = stream.pull(scratch, channels, gain.applyAsDouble(stream));
// TeamSpeak clients send 20 ms packets; anything longer is cut to the frame.
int samples = Math.min(frames, FRAME) * channels;
for (int i = 0; i < samples; i++) mix[i] += scratch[i];
}
Pcm16.encode(mix, mix.length, out);
line.write(out, 0, out.length);
}
} catch (Exception ignored) {
// No usable line: this mixer ends, and the next talk burst tries again.
} finally {
if (line != null) line.close();
synchronized (lock) {
mixer = null;
}
}
}
/** Waits until some stream is playing; false once shut down. */
private boolean awaitSpeech() throws InterruptedException {
synchronized (lock) {
while (running) {
for (VoiceStream stream : streams) {
if (stream.isPlaying()) return true;
}
lock.wait();
}
return false;
}
}
}

View File

@@ -0,0 +1,20 @@
package com.ts3client.audio;
/** Conversion between float samples in [-1, 1] and 16-bit little-endian PCM. */
public final class Pcm16 {
private Pcm16() {
}
/** Writes {@code count} samples to {@code out}, clipping anything out of range. */
public static void encode(float[] samples, int count, byte[] out) {
for (int i = 0; i < count; i++) {
float f = samples[i];
if (f > 1f) f = 1f;
else if (f < -1f) f = -1f;
int s = Math.round(f * 32767f);
out[2 * i] = (byte) (s & 0xFF);
out[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
}
}
}

View File

@@ -0,0 +1,149 @@
package com.ts3client.audio;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.function.ToDoubleFunction;
/**
* Gives every speaker a playback line and worker thread of its own, so simultaneous
* speakers are mixed by the audio server and one slow decode never blocks another. On a
* PipeWire desktop every speaker then shows up as a separate stream in the mixer.
*/
final class PerSpeakerPlayout implements Playout {
private static final long FRAME_NANOS =
TimeUnit.SECONDS.toNanos(VoiceFormat.FRAME_SIZE) / VoiceFormat.SAMPLE_RATE;
private final class Speaker {
final VoiceStream stream;
final AudioPlayback line;
final int lineChannels;
final ExecutorService worker;
final AtomicBoolean tickQueued = new AtomicBoolean();
/** Touched only by {@link #worker}. */
final float[] pcm;
final byte[] out;
/** Guarded by this speaker. */
ScheduledFuture<?> ticks;
Speaker(VoiceStream stream) throws Exception {
this.stream = stream;
this.line = io.openPlayback(outputDevice.get(), VoiceFormat.MAX_CHANNELS);
this.lineChannels = line.channels();
this.line.start();
this.pcm = new float[VoiceStream.MAX_FRAME * lineChannels];
this.out = new byte[pcm.length * 2];
this.worker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-play-" + stream.clientId);
t.setDaemon(true);
return t;
});
}
void close() {
synchronized (this) {
if (ticks != null) ticks.cancel(false);
}
worker.shutdownNow();
line.close();
}
}
private final AudioIo io;
private final Supplier<String> outputDevice;
private final ToDoubleFunction<VoiceStream> gain;
private final ScheduledExecutorService clock;
private final Map<VoiceStream, Speaker> speakers = new ConcurrentHashMap<>();
PerSpeakerPlayout(AudioIo io, Supplier<String> outputDevice, ToDoubleFunction<VoiceStream> gain,
ScheduledExecutorService clock) {
this.io = io;
this.outputDevice = outputDevice;
this.gain = gain;
this.clock = clock;
}
@Override
public void started(VoiceStream stream) {
Speaker speaker = speakers.get(stream);
if (speaker == null) {
try {
speaker = new Speaker(stream);
} catch (Exception e) {
stream.stop(); // couldn't open a line; drop the burst
return;
}
Speaker existing = speakers.putIfAbsent(stream, speaker);
if (existing != null) {
speaker.close();
speaker = existing;
}
}
synchronized (speaker) {
if (speaker.ticks == null) {
Speaker s = speaker;
speaker.ticks = clock.scheduleAtFixedRate(
() -> queueTick(s), FRAME_NANOS, FRAME_NANOS, TimeUnit.NANOSECONDS);
}
}
}
private void queueTick(Speaker speaker) {
if (!speaker.tickQueued.compareAndSet(false, true)) return;
try {
speaker.worker.submit(() -> {
try {
tick(speaker);
} finally {
speaker.tickQueued.set(false);
}
});
} catch (RuntimeException e) {
if (speaker.worker.isShutdown()) {
speaker.tickQueued.set(false);
} else {
throw e;
}
}
}
private void tick(Speaker speaker) {
int frames = speaker.stream.pull(speaker.pcm, speaker.lineChannels, gain.applyAsDouble(speaker.stream));
if (frames == VoiceStream.IDLE) {
synchronized (speaker) {
// A packet may have started the next burst since the pull; keep ticking then.
if (!speaker.stream.isPlaying() && speaker.ticks != null) {
speaker.ticks.cancel(false);
speaker.ticks = null;
}
}
return;
}
if (frames == 0) return;
int samples = frames * speaker.lineChannels;
Pcm16.encode(speaker.pcm, samples, speaker.out);
try {
speaker.line.write(speaker.out, 0, samples * 2);
} catch (RuntimeException ignored) {
}
}
@Override
public void removed(VoiceStream stream) {
Speaker speaker = speakers.remove(stream);
if (speaker != null) speaker.close();
}
@Override
public void shutdown() {
for (Speaker speaker : speakers.values()) speaker.close();
speakers.clear();
}
}

View File

@@ -0,0 +1,13 @@
package com.ts3client.audio;
/** Drives the {@link VoiceStream}s of a {@link StreamingVoiceOutput} out to the speakers. */
interface Playout {
/** A stream began a talk burst and must now be pulled every 20 ms. */
void started(VoiceStream stream);
/** The stream's client left; it will not be pulled again. */
void removed(VoiceStream stream);
void shutdown();
}

View File

@@ -0,0 +1,182 @@
package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType;
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
import com.ts3client.audio.opus.OpusCodec;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
/**
* The {@link VoiceOutput} every platform shares: decodes incoming voice per speaker, each
* through its own jitter buffer, and plays it on the platform's {@link AudioIo}.
*/
public final class StreamingVoiceOutput implements VoiceOutput {
/** How speakers reach the device. */
public enum Lines {
/** A line per speaker, mixed by the platform's audio server. */
PER_SPEAKER,
/** One line all speakers are mixed onto here. */
SHARED
}
/**
* A speaker is only meant to stop when the empty voice packet marking the end of a
* talk burst arrives — but that packet is UDP too, and a lost one otherwise leaves
* the talking indicator stuck until the speaker's next burst. Opus frames are 20&nbsp;ms
* apart while someone is actually talking, so a gap several times that long, with no
* packet of either kind, is unambiguous: force the indicator off rather than trust
* the one packet that could go missing.
*/
private static final long TALK_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(400);
private final OpusCodec opus;
private final Map<Integer, VoiceStream> streams = new ConcurrentHashMap<>();
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
/** Per-client gain on top of the master volume; absent means 1.0. */
private final Map<Integer, Double> clientVolumes = new ConcurrentHashMap<>();
private volatile String outputDevice;
private volatile double masterVolume = 1.0;
private volatile boolean deafened = false;
/** Notified (clientId, talking) on an audio thread when a speaker starts/stops. */
private volatile BiConsumer<Integer, Boolean> talkListener;
/** Catches talk bursts whose end packet never arrived, and paces per-speaker lines. */
private final ScheduledExecutorService clock = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ts3j-talk-watchdog");
t.setDaemon(true);
return t;
});
private final Playout playout;
public StreamingVoiceOutput(AudioIo io, OpusCodec opus, String outputDevice, Lines lines) {
this.opus = opus;
this.outputDevice = outputDevice;
this.playout = switch (lines) {
case PER_SPEAKER -> new PerSpeakerPlayout(io, () -> this.outputDevice, this::gainFor, clock);
case SHARED -> new MixedPlayout(io, () -> this.outputDevice, this::gainFor, streams.values());
};
clock.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
}
private void checkTalkTimeouts() {
long now = System.nanoTime();
for (VoiceStream s : streams.values()) {
if (s.isTalking() && now - s.lastPacketNanos() > TALK_TIMEOUT_NANOS) s.stop();
}
}
private double gainFor(VoiceStream stream) {
return masterVolume * clientVolumes.getOrDefault(stream.clientId, 1.0);
}
private void fireTalk(int clientId, boolean talking) {
BiConsumer<Integer, Boolean> l = talkListener;
if (l != null) l.accept(clientId, talking);
}
@Override
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
this.talkListener = l;
}
@Override
public void setMasterVolume(double v) {
this.masterVolume = Math.max(0, Math.min(2.0, v));
}
@Override
public void setDeafened(boolean d) {
this.deafened = d;
if (d) {
// Stop everyone talking immediately.
for (VoiceStream s : streams.values()) s.stop();
}
}
@Override
public boolean isDeafened() {
return deafened;
}
@Override
public void setClientMuted(int clientId, boolean muted) {
if (muted) {
mutedClients.add(clientId);
VoiceStream s = streams.get(clientId);
if (s != null) s.stop();
} else {
mutedClients.remove(clientId);
}
}
@Override
public boolean isClientMuted(int clientId) {
return mutedClients.contains(clientId);
}
@Override
public void setClientVolume(int clientId, double gain) {
if (gain == 1.0) clientVolumes.remove(clientId);
else clientVolumes.put(clientId, Math.max(0, gain));
}
@Override
public double getClientVolume(int clientId) {
return clientVolumes.getOrDefault(clientId, 1.0);
}
@Override
public void setOutputDevice(String device) {
this.outputDevice = device;
}
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
@Override
public void handleVoice(PacketBody0Voice voice) {
route(voice.getClientId(), voice.getPacketId(), voice.getCodecType(), voice.getCodecData());
}
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
@Override
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
route(whisper.getClientId(), whisper.getPacketId(), whisper.getCodecType(), whisper.getCodecData());
}
private void route(int clientId, int packetId, CodecType codec, byte[] data) {
if (deafened) return;
if (mutedClients.contains(clientId)) return;
VoiceStream stream = streams.computeIfAbsent(clientId, id -> new VoiceStream(id, opus, this::fireTalk));
if (stream.offer(packetId, codec, data)) playout.started(stream);
}
/** Forgets a client that left; the server may hand its id to somebody else. */
@Override
public void removeClient(int clientId) {
VoiceStream s = streams.remove(clientId);
if (s != null) {
playout.removed(s);
s.close();
}
mutedClients.remove(clientId);
clientVolumes.remove(clientId);
}
@Override
public void shutdown() {
clock.shutdownNow();
playout.shutdown();
for (VoiceStream s : streams.values()) s.close();
streams.clear();
}
}

View File

@@ -0,0 +1,16 @@
package com.ts3client.audio;
/**
* The PCM format voice is captured, processed and played in. TeamSpeak's Opus codecs run at
* 48&nbsp;kHz in 20&nbsp;ms frames, mono for {@code OPUS_VOICE} and stereo for {@code OPUS_MUSIC}.
*/
public final class VoiceFormat {
public static final int SAMPLE_RATE = 48_000;
/** Samples per channel in one 20 ms frame. */
public static final int FRAME_SIZE = 960;
public static final int MAX_CHANNELS = 2;
private VoiceFormat() {
}
}

View File

@@ -0,0 +1,254 @@
package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.opus.OpusDecoder;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
/**
* One speaker's incoming voice: packets go into a small jitter buffer as they arrive, and
* every {@link #pull} plays out the next one in sequence, concealing any that went missing.
*
* <p>The stream keeps no clock of its own: whoever pulls sets the pace, one call per
* 20&nbsp;ms frame. {@link #offer} and {@link #stop} may be called from any thread; pulls
* must come from one thread at a time.
*/
final class VoiceStream {
/** Returned by {@link #pull} when no talk burst is playing. */
static final int IDLE = -1;
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
static final int MAX_FRAME = 5760;
/** Initial playout delay: enough room for common UDP jitter without feeling laggy. */
private static final int JITTER_DELAY_FRAMES = 3;
/** Cap PLC during a lost end-of-talk marker or a hard network stall. */
private static final int MAX_CONSECUTIVE_PLC_FRAMES = 10;
/** Keep memory bounded when a talk burst runs far ahead of playback. */
private static final int MAX_PENDING_PACKETS = 32;
private record VoiceFrame(int packetId, CodecType codec, byte[] data, boolean end) {
}
final int clientId;
private final OpusCodec opus;
private final BiConsumer<Integer, Boolean> talkListener;
private final Object lock = new Object();
private final Map<Integer, VoiceFrame> pending = new HashMap<>();
private boolean playing;
private int expectedPacketId;
private int activeChannels = 1;
private int consecutivePlcFrames;
/** Pulls still to sit out before the burst starts playing. */
private int warmup;
/** Guards the decoder: a stop or close from another thread may reach it mid-pull. */
private final Object decoderLock = new Object();
private final float[] pcm = new float[MAX_FRAME * VoiceFormat.MAX_CHANNELS];
private OpusDecoder decoder;
private int decoderChannels;
private boolean closed;
private volatile boolean talking;
private volatile long lastPacketNanos;
VoiceStream(int clientId, OpusCodec opus, BiConsumer<Integer, Boolean> talkListener) {
this.clientId = clientId;
this.opus = opus;
this.talkListener = talkListener;
}
/** TeamSpeak streams {@code OPUS_MUSIC} in stereo and everything else in mono. */
private static int channelsFor(CodecType codec) {
return codec == CodecType.OPUS_MUSIC ? 2 : 1;
}
/**
* Queues an incoming packet; an empty one marks the end of the talk burst.
*
* @return true if the packet started a new burst, so playout has to begin
*/
boolean offer(int packetId, CodecType codec, byte[] data) {
lastPacketNanos = System.nanoTime();
VoiceFrame frame = new VoiceFrame(packetId & 0xFFFF, codec, data, data == null || data.length == 0);
boolean started = false;
synchronized (lock) {
if (!playing) {
playing = true;
started = true;
expectedPacketId = frame.packetId();
activeChannels = channelsFor(codec);
consecutivePlcFrames = 0;
warmup = JITTER_DELAY_FRAMES - 1;
} else if (packetDistance(expectedPacketId, frame.packetId()) < 0) {
return false; // too late for this burst; do not replay stale audio
}
pending.putIfAbsent(frame.packetId(), frame);
trimPending();
}
if (!frame.end()) markTalking(true);
return started;
}
/**
* Plays out the next frame into {@code out}, interleaved over {@code outChannels} and
* scaled by {@code gain} but not clipped, so it can still be mixed.
*
* @param out room for {@link #MAX_FRAME} samples per channel
* @return the samples written per channel; 0 while the burst is still buffering or a
* packet could not be decoded, or {@link #IDLE} once the burst is over
*/
int pull(float[] out, int outChannels, double gain) {
VoiceFrame frame;
int channels;
synchronized (lock) {
if (!playing) return IDLE;
if (warmup > 0) {
warmup--;
return 0;
}
frame = pending.remove(expectedPacketId);
boolean conceal = frame == null;
if (conceal) {
consecutivePlcFrames++;
} else {
consecutivePlcFrames = 0;
}
if ((frame != null && frame.end()) || consecutivePlcFrames > MAX_CONSECUTIVE_PLC_FRAMES) {
stopLocked();
frame = null;
channels = 0;
} else {
channels = frame != null ? channelsFor(frame.codec()) : activeChannels;
activeChannels = channels;
expectedPacketId = (expectedPacketId + 1) & 0xFFFF;
}
}
if (channels == 0) {
finish();
return IDLE;
}
return decode(frame != null ? frame.data() : null, channels, out, outChannels, gain);
}
private int decode(byte[] data, int channels, float[] out, int outChannels, double gain) {
int frames;
synchronized (decoderLock) {
if (closed) return 0;
try {
frames = decoderFor(channels).decode(data, pcm);
} catch (RuntimeException e) {
return 0;
}
}
// Match the decoded stream to the output: duplicate mono across stereo, fold a
// stereo (music) stream down onto mono.
for (int i = 0, k = 0; i < frames; i++) {
for (int c = 0; c < outChannels; c++, k++) {
double v;
if (channels == outChannels) {
v = pcm[i * channels + c];
} else if (channels == 1) {
v = pcm[i];
} else {
double sum = 0;
for (int s = 0; s < channels; s++) sum += pcm[i * channels + s];
v = sum / channels;
}
out[k] = (float) (v * gain);
}
}
return frames;
}
/**
* Returns a decoder matching the stream's channel count, rebuilding it when a speaker
* switches between the mono {@code OPUS_VOICE} and stereo {@code OPUS_MUSIC} codecs.
*/
private OpusDecoder decoderFor(int channels) {
if (decoder == null || decoderChannels != channels) {
if (decoder != null) decoder.close();
decoder = opus.createDecoder(VoiceFormat.SAMPLE_RATE, MAX_FRAME, channels);
decoderChannels = channels;
}
return decoder;
}
/** Ends the current talk burst, if any; the next packet starts a new one. */
void stop() {
synchronized (lock) {
stopLocked();
}
finish();
}
private void stopLocked() {
playing = false;
pending.clear();
}
private void finish() {
synchronized (decoderLock) {
if (decoder != null) decoder.reset();
}
markTalking(false);
}
boolean isPlaying() {
synchronized (lock) {
return playing;
}
}
boolean isTalking() {
return talking;
}
long lastPacketNanos() {
return lastPacketNanos;
}
void close() {
synchronized (lock) {
stopLocked();
}
synchronized (decoderLock) {
closed = true;
if (decoder != null) decoder.close();
decoder = null;
}
}
private void markTalking(boolean talking) {
if (this.talking == talking) return;
this.talking = talking;
talkListener.accept(clientId, talking);
}
private static int packetDistance(int from, int to) {
int distance = (to - from) & 0xFFFF;
return distance >= 0x8000 ? distance - 0x10000 : distance;
}
private void trimPending() {
while (pending.size() > MAX_PENDING_PACKETS) {
Integer furthest = null;
int furthestDistance = Integer.MIN_VALUE;
for (Integer packetId : pending.keySet()) {
int distance = packetDistance(expectedPacketId, packetId);
if (distance > furthestDistance) {
furthestDistance = distance;
furthest = packetId;
}
}
if (furthest == null) return;
pending.remove(furthest);
}
}
}

View File

@@ -0,0 +1,16 @@
package com.ts3client.audio.opus;
/** Creates Opus encoders and decoders; each platform binds its own libopus. */
public interface OpusCodec {
/** Opus' coding mode, fixed when an encoder is created. */
enum Application { VOIP, AUDIO }
OpusEncoder createEncoder(int sampleRate, int frameSize, int channels, Application application);
/** @param maxFrameSize the longest frame, per channel, a packet may decode to */
OpusDecoder createDecoder(int sampleRate, int maxFrameSize, int channels);
/** libopus' version string, for display. */
String version();
}

View File

@@ -0,0 +1,17 @@
package com.ts3client.audio.opus;
/** One Opus decoder, used from one thread at a time. */
public interface OpusDecoder extends AutoCloseable {
/**
* Decodes {@code packet} into interleaved samples, or conceals a lost packet when it is
* {@code null}. Returns the samples decoded per channel.
*/
int decode(byte[] packet, float[] out);
/** Forgets the stream's state, as at the start of a new talk burst. */
void reset();
@Override
void close();
}

View File

@@ -0,0 +1,25 @@
package com.ts3client.audio.opus;
/** One Opus encoder. Its methods may be called from different threads, but not concurrently. */
public interface OpusEncoder extends AutoCloseable {
enum Signal { VOICE, MUSIC }
void setBitrate(int bitsPerSecond);
void setComplexity(int complexity);
void setVbr(boolean vbr);
void setInbandFec(boolean fec);
void setExpectedPacketLoss(int percent);
void setSignal(Signal signal);
/** Encodes exactly one frame of interleaved samples. */
byte[] encode(float[] pcm);
@Override
void close();
}

View File

@@ -0,0 +1,53 @@
package com.ts3client.audio;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.opus.OpusDecoder;
import com.ts3client.audio.opus.OpusEncoder;
import java.util.Arrays;
/**
* Stands in for libopus: a "packet" is one byte, decoded to a 20 ms frame holding that byte
* divided by 100 in every sample. Concealment decodes to -1, so it is easy to spot.
*/
final class FakeOpus implements OpusCodec {
static final float CONCEALED = -1f;
int resets;
static byte[] packet(int value) {
return new byte[]{(byte) value};
}
@Override
public OpusEncoder createEncoder(int sampleRate, int frameSize, int channels, Application application) {
throw new UnsupportedOperationException();
}
@Override
public OpusDecoder createDecoder(int sampleRate, int maxFrameSize, int channels) {
return new OpusDecoder() {
@Override
public int decode(byte[] packet, float[] out) {
float v = packet == null ? CONCEALED : packet[0] / 100f;
Arrays.fill(out, 0, VoiceFormat.FRAME_SIZE * channels, v);
return VoiceFormat.FRAME_SIZE;
}
@Override
public void reset() {
resets++;
}
@Override
public void close() {
}
};
}
@Override
public String version() {
return "fake";
}
}

View File

@@ -0,0 +1,102 @@
package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType;
import com.github.manevolent.ts3j.protocol.ProtocolRole;
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MixedPlayoutTest {
/** A mono line that records every frame written and blocks until the test lets it go on. */
private static final class RecordingLine implements AudioPlayback {
final BlockingQueue<Short> firstSamples = new LinkedBlockingQueue<>();
final Semaphore permits = new Semaphore(0);
@Override
public int channels() {
return 1;
}
@Override
public void start() {
}
@Override
public void write(byte[] buffer, int offset, int length) {
firstSamples.add((short) ((buffer[offset + 1] << 8) | (buffer[offset] & 0xFF)));
permits.acquireUninterruptibly();
}
@Override
public void drain() {
}
@Override
public void close() {
}
}
@Test
void mixesSimultaneousSpeakersOntoOneLine() throws Exception {
RecordingLine line = new RecordingLine();
AudioIo io = new AudioIo() {
@Override
public List<AudioDevice> inputDevices() {
return List.of();
}
@Override
public List<AudioDevice> outputDevices() {
return List.of();
}
@Override
public AudioCapture openCapture(String deviceId, int preferredChannels) {
throw new UnsupportedOperationException();
}
@Override
public AudioPlayback openPlayback(String deviceId, int preferredChannels) {
return line;
}
};
StreamingVoiceOutput output = new StreamingVoiceOutput(io, new FakeOpus(), "", StreamingVoiceOutput.Lines.SHARED);
try {
for (int i = 0; i < 10; i++) {
output.handleVoice(voice(1, i, 25));
output.handleVoice(voice(2, i, 50));
}
line.permits.release(100);
short expected = (short) Math.round(0.75f * 32767f);
boolean mixed = false;
for (int i = 0; i < 20 && !mixed; i++) {
Short sample = line.firstSamples.poll(1, TimeUnit.SECONDS);
assertNotNull(sample, "the mixer stopped writing");
mixed = sample == expected;
}
assertTrue(mixed, "no frame carried both speakers");
} finally {
output.shutdown();
line.permits.release(1000);
}
}
private static PacketBody0Voice voice(int clientId, int packetId, int value) {
PacketBody0Voice voice = new PacketBody0Voice(ProtocolRole.SERVER);
voice.setClientId(clientId);
voice.setPacketId(packetId);
voice.setCodecType(CodecType.OPUS_VOICE);
voice.setCodecData(FakeOpus.packet(value));
return voice;
}
}

View File

@@ -0,0 +1,111 @@
package com.ts3client.audio;
import com.github.manevolent.ts3j.enums.CodecType;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class VoiceStreamTest {
private final FakeOpus opus = new FakeOpus();
private final List<Boolean> talk = new ArrayList<>();
private final VoiceStream stream = new VoiceStream(7, opus, (id, talking) -> talk.add(talking));
private final float[] out = new float[VoiceStream.MAX_FRAME * 2];
private boolean offer(int packetId, int value) {
return stream.offer(packetId, CodecType.OPUS_VOICE, FakeOpus.packet(value));
}
/** Pulls one frame and returns its first sample, or NaN if nothing was played. */
private float pull() {
int frames = stream.pull(out, 1, 1.0);
return frames > 0 ? out[0] : Float.NaN;
}
@Test
void holdsBackTheStartOfABurstThenPlaysInOrder() {
assertTrue(offer(10, 1));
assertFalse(offer(12, 3));
assertFalse(offer(11, 2));
assertEquals(Float.NaN, pull());
assertEquals(Float.NaN, pull());
assertEquals(0.01f, pull());
assertEquals(0.02f, pull());
assertEquals(0.03f, pull());
assertEquals(List.of(true), talk);
}
@Test
void concealsALostPacket() {
offer(0, 1);
offer(2, 3);
pull();
pull();
assertEquals(0.01f, pull());
assertEquals(FakeOpus.CONCEALED, pull());
assertEquals(0.03f, pull());
}
@Test
void dropsPacketsThatArriveTooLate() {
offer(0, 1);
offer(1, 2);
pull();
pull();
pull();
pull();
assertFalse(offer(0, 9));
assertEquals(FakeOpus.CONCEALED, pull());
}
@Test
void endPacketFinishesTheBurst() {
offer(0, 1);
stream.offer(1, CodecType.OPUS_VOICE, new byte[0]);
pull();
pull();
pull();
assertEquals(VoiceStream.IDLE, stream.pull(out, 1, 1.0));
assertFalse(stream.isPlaying());
assertEquals(List.of(true, false), talk);
assertEquals(1, opus.resets);
assertTrue(offer(2, 1), "the next packet starts a new burst");
}
@Test
void givesUpAfterTooMuchConcealment() {
offer(0, 1);
pull();
pull();
pull();
int concealed = 0;
while (stream.pull(out, 1, 1.0) != VoiceStream.IDLE) concealed++;
assertEquals(10, concealed);
assertEquals(List.of(true, false), talk);
}
@Test
void stopFromAnotherThreadEndsTheBurst() {
offer(0, 1);
stream.stop();
assertEquals(VoiceStream.IDLE, stream.pull(out, 1, 1.0));
assertEquals(List.of(true, false), talk);
}
@Test
void spreadsMonoOverStereoWithGain() {
offer(0, 50);
stream.pull(out, 2, 0.5);
stream.pull(out, 2, 0.5);
assertEquals(VoiceFormat.FRAME_SIZE, stream.pull(out, 2, 0.5));
assertEquals(0.25f, out[0]);
assertEquals(0.25f, out[1]);
assertEquals(0.25f, out[2 * VoiceFormat.FRAME_SIZE - 1]);
}
}

View File

@@ -1,5 +1,10 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.AudioCapture;
import com.ts3client.audio.AudioDevice;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceFormat;
import com.ts3client.audio.desktop.pipewire.PipeWire; import com.ts3client.audio.desktop.pipewire.PipeWire;
import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioFormat;
@@ -36,11 +41,9 @@ import java.util.List;
* {@link #openCapture} / {@link #openPlayback}, which fall back through the supported * {@link #openCapture} / {@link #openPlayback}, which fall back through the supported
* channel counts. * channel counts.
*/ */
public final class AudioDevices { public final class AudioDevices implements AudioIo {
public static final int SAMPLE_RATE = 48_000; public static final AudioDevices INSTANCE = new AudioDevices();
public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz
public static final int MAX_CHANNELS = 2;
/** Buffer size, in 20 ms frames, requested when opening a Java Sound line. */ /** Buffer size, in 20 ms frames, requested when opening a Java Sound line. */
private static final int BUFFER_FRAMES = 8; private static final int BUFFER_FRAMES = 8;
@@ -48,42 +51,31 @@ public final class AudioDevices {
/** Marks a device id as a PipeWire node name rather than a Java Sound mixer name. */ /** Marks a device id as a PipeWire node name rather than a Java Sound mixer name. */
private static final String PIPEWIRE_PREFIX = "pw:"; private static final String PIPEWIRE_PREFIX = "pw:";
/** A selectable audio device. {@code id} is what gets persisted in the settings. */
public record Device(String id, String label) {
/** The implicit entry that lets the platform (PipeWire, Pulse, ALSA) choose. */
public static final Device DEFAULT = new Device("", "(System default)");
@Override
public String toString() {
return label;
}
}
private AudioDevices() { private AudioDevices() {
} }
/** 48 kHz signed 16-bit little-endian PCM with the given channel count. */ /** 48 kHz signed 16-bit little-endian PCM with the given channel count. */
public static AudioFormat format(int channels) { public static AudioFormat format(int channels) {
return new AudioFormat(SAMPLE_RATE, 16, channels, true, false); return new AudioFormat(VoiceFormat.SAMPLE_RATE, 16, channels, true, false);
} }
/** Devices that can provide microphone (capture) lines, system default first. */ @Override
public static List<Device> inputDevices() { public List<AudioDevice> inputDevices() {
return devices(TargetDataLine.class, false); return devices(TargetDataLine.class, false);
} }
/** Devices that can provide speaker (playback) lines, system default first. */ @Override
public static List<Device> outputDevices() { public List<AudioDevice> outputDevices() {
return devices(SourceDataLine.class, true); return devices(SourceDataLine.class, true);
} }
private static List<Device> devices(Class<?> lineClass, boolean sinks) { private static List<AudioDevice> devices(Class<?> lineClass, boolean sinks) {
List<Device> devices = new ArrayList<>(); List<AudioDevice> devices = new ArrayList<>();
devices.add(Device.DEFAULT); devices.add(AudioDevice.DEFAULT);
for (PipeWire.Node node : PipeWire.nodes()) { for (PipeWire.Node node : PipeWire.nodes()) {
if (node.sink() == sinks) { if (node.sink() == sinks) {
devices.add(new Device(PIPEWIRE_PREFIX + node.name(), node.description())); devices.add(new AudioDevice(PIPEWIRE_PREFIX + node.name(), node.description()));
} }
} }
@@ -95,7 +87,7 @@ public final class AudioDevices {
if (isDefaultMixer(name)) continue; if (isDefaultMixer(name)) continue;
if (!AudioSystem.getMixer(mi).isLineSupported(anyFormat)) continue; if (!AudioSystem.getMixer(mi).isLineSupported(anyFormat)) continue;
if (devices.stream().anyMatch(d -> d.id().equals(name))) continue; if (devices.stream().anyMatch(d -> d.id().equals(name))) continue;
devices.add(new Device(name, "ALSA: " + name)); devices.add(new AudioDevice(name, "ALSA: " + name));
} }
return devices; return devices;
} }
@@ -109,17 +101,13 @@ public final class AudioDevices {
return mixerName.endsWith("[default]"); return mixerName.endsWith("[default]");
} }
/** @Override
* Opens a capture line, preferring {@code preferredChannels} and falling back to the public AudioCapture openCapture(String deviceId, int preferredChannels)
* other channel count if the device won't take it. Inspect {@link AudioCapture#channels()}
* for what was actually opened.
*/
public static AudioCapture openCapture(String deviceId, int preferredChannels)
throws LineUnavailableException { throws LineUnavailableException {
if (usePipeWire(deviceId)) { if (usePipeWire(deviceId)) {
try { try {
return PipeWire.openCapture("TS3J Microphone", pipeWireNode(deviceId), return PipeWire.openCapture("TS3J Microphone", pipeWireNode(deviceId),
SAMPLE_RATE, clampChannels(preferredChannels)); VoiceFormat.SAMPLE_RATE, clampChannels(preferredChannels));
} catch (Exception e) { } catch (Exception e) {
if (isPipeWireDevice(deviceId)) throw unavailable(true, deviceId, e); if (isPipeWireDevice(deviceId)) throw unavailable(true, deviceId, e);
// The default device: Java Sound may still reach it through ALSA. // The default device: Java Sound may still reach it through ALSA.
@@ -128,13 +116,13 @@ public final class AudioDevices {
return new JavaSoundCapture(open(TargetDataLine.class, deviceId, preferredChannels)); return new JavaSoundCapture(open(TargetDataLine.class, deviceId, preferredChannels));
} }
/** Opens a playback line; see {@link #openCapture} for the channel negotiation. */ @Override
public static AudioPlayback openPlayback(String deviceId, int preferredChannels) public AudioPlayback openPlayback(String deviceId, int preferredChannels)
throws LineUnavailableException { throws LineUnavailableException {
if (usePipeWire(deviceId)) { if (usePipeWire(deviceId)) {
try { try {
return PipeWire.openPlayback("TS3J Playback", pipeWireNode(deviceId), return PipeWire.openPlayback("TS3J Playback", pipeWireNode(deviceId),
SAMPLE_RATE, clampChannels(preferredChannels)); VoiceFormat.SAMPLE_RATE, clampChannels(preferredChannels));
} catch (Exception e) { } catch (Exception e) {
if (isPipeWireDevice(deviceId)) throw unavailable(false, deviceId, e); if (isPipeWireDevice(deviceId)) throw unavailable(false, deviceId, e);
} }
@@ -167,7 +155,7 @@ public final class AudioDevices {
/** PipeWire converts freely, so any channel count works; keep it in the range we handle. */ /** PipeWire converts freely, so any channel count works; keep it in the range we handle. */
private static int clampChannels(int channels) { private static int clampChannels(int channels) {
return Math.max(1, Math.min(MAX_CHANNELS, channels)); return Math.max(1, Math.min(VoiceFormat.MAX_CHANNELS, channels));
} }
private static <T extends DataLine> T open(Class<T> lineClass, String deviceId, int preferredChannels) private static <T extends DataLine> T open(Class<T> lineClass, String deviceId, int preferredChannels)
@@ -180,7 +168,7 @@ public final class AudioDevices {
try { try {
T line = lineClass.cast( T line = lineClass.cast(
(mixer != null) ? mixer.getLine(info) : AudioSystem.getLine(info)); (mixer != null) ? mixer.getLine(info) : AudioSystem.getLine(info));
openLine(line, fmt, FRAME_SIZE * 2 * fmt.getChannels() * BUFFER_FRAMES); openLine(line, fmt, VoiceFormat.FRAME_SIZE * 2 * fmt.getChannels() * BUFFER_FRAMES);
return line; return line;
} catch (Exception e) { } catch (Exception e) {
failure = e; failure = e;

View File

@@ -1,9 +1,10 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.AudioIo;
import com.ts3client.audio.VoiceOutput; import com.ts3client.audio.StreamingVoiceOutput;
import com.ts3client.audio.desktop.pipewire.PipeWire; import com.ts3client.audio.desktop.pipewire.PipeWire;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.sound.SoundPlayer; import com.ts3client.sound.SoundPlayer;
@@ -13,19 +14,27 @@ import com.ts3client.sound.SoundPlayer;
*/ */
public final class DesktopAudioBackend implements AudioBackend { public final class DesktopAudioBackend implements AudioBackend {
private final OpusCodec opus = new NativeOpusCodec();
@Override @Override
public VoiceInput createInput(Settings settings) { public AudioIo io() {
return new DesktopVoiceInput(settings); return AudioDevices.INSTANCE;
} }
@Override @Override
public VoiceOutput createOutput(Settings settings) { public OpusCodec opus() {
return new DesktopVoiceOutput(settings.outputDevice); return opus;
}
/** Every speaker gets a line of their own, so each shows up in the desktop's mixer. */
@Override
public StreamingVoiceOutput.Lines outputLines() {
return StreamingVoiceOutput.Lines.PER_SPEAKER;
} }
@Override @Override
public SoundPlayer createSoundPlayer(Settings settings) { public SoundPlayer createSoundPlayer(Settings settings) {
return new WavSoundPlayer(settings.outputDevice); return new WavSoundPlayer(io(), settings.outputDevice);
} }
@Override @Override
@@ -33,9 +42,9 @@ public final class DesktopAudioBackend implements AudioBackend {
return codec() + ", " + audioSystem(); return codec() + ", " + audioSystem();
} }
private static String codec() { private String codec() {
try { try {
return "Opus " + Opus.getVersionString(); return "Opus " + opus.version();
} catch (Throwable t) { } catch (Throwable t) {
return "Opus (native library unavailable)"; return "Opus (native library unavailable)";
} }

View File

@@ -1,404 +0,0 @@
package com.ts3client.audio.desktop;
import com.github.manevolent.ts3j.enums.CodecType;
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
import com.ts3client.audio.VoiceOutput;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
/**
* Desktop {@link VoiceOutput}: decodes and plays incoming voice per speaker.
* Each client gets its own Opus decoder, playback line and worker thread, so
* simultaneous speakers are mixed by the audio server and one slow decode never
* blocks another (or the network thread). Lines come from {@link AudioDevices},
* so on a PipeWire desktop every speaker is a separate stream in the mixer.
*/
public final class DesktopVoiceOutput implements VoiceOutput {
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
private static final int MAX_FRAME = 5760;
/** TS3 voice packets normally carry one 20 ms Opus frame. */
private static final long FRAME_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
/** Initial playout delay: enough room for common UDP jitter without feeling laggy. */
private static final long JITTER_DELAY_NANOS = FRAME_NANOS * 3;
/** Cap PLC during a lost end-of-talk marker or a hard network stall. */
private static final int MAX_CONSECUTIVE_PLC_FRAMES = 10;
/** Keep memory bounded when a talk burst runs far ahead of playback. */
private static final int MAX_PENDING_PACKETS = 32;
/**
* A speaker is only meant to stop when the empty voice packet marking the end of a
* talk burst arrives — but that packet is UDP too, and a lost one otherwise leaves
* the talking indicator stuck until the speaker's next burst. Opus frames are 20&nbsp;ms
* apart while someone is actually talking, so a gap several times that long, with no
* packet of either kind, is unambiguous: force the indicator off rather than trust
* the one packet that could go missing.
*/
private static final long TALK_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(400);
/** One speaker's decode + playback pipeline. */
private final class ClientStream {
final Object lock = new Object();
final int clientId;
final AudioPlayback line;
final int lineChannels;
final ExecutorService worker;
final Map<Integer, VoiceFrame> pending = new HashMap<>();
final AtomicBoolean tickQueued = new AtomicBoolean();
/** Decoder scratch and byte buffer, touched only by {@link #worker}. */
final float[] pcm = new float[MAX_FRAME * AudioDevices.MAX_CHANNELS];
byte[] out = new byte[0];
OpusDecoder decoder;
int decoderChannels;
ScheduledFuture<?> playout;
int expectedPacketId = -1;
int activeChannels = 1;
int consecutivePlcFrames;
volatile boolean talking;
volatile long lastPacketNanos;
ClientStream(int clientId) throws Exception {
this.clientId = clientId;
this.line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS);
this.lineChannels = line.channels();
this.line.start();
this.worker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-play-" + clientId);
t.setDaemon(true);
return t;
});
}
/**
* Returns a decoder matching the stream's channel count, rebuilding it when a
* speaker switches between the mono {@code OPUS_VOICE} and stereo
* {@code OPUS_MUSIC} codecs.
*/
OpusDecoder decoderFor(int channels) {
if (decoder == null || decoderChannels != channels) {
if (decoder != null) decoder.close();
decoder = new OpusDecoder(AudioDevices.SAMPLE_RATE, MAX_FRAME, channels);
decoderChannels = channels;
}
return decoder;
}
void close() {
synchronized (lock) {
if (playout != null) playout.cancel(false);
pending.clear();
}
worker.shutdownNow();
line.close();
if (decoder != null) decoder.close();
}
}
private record VoiceFrame(int packetId, CodecType codec, byte[] data, boolean end) {
}
private final Map<Integer, ClientStream> streams = new ConcurrentHashMap<>();
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
/** Per-client gain on top of the master volume; absent means 1.0. */
private final Map<Integer, Double> clientVolumes = new ConcurrentHashMap<>();
private volatile String outputDevice;
private volatile double masterVolume = 1.0;
private volatile boolean deafened = false;
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
private volatile BiConsumer<Integer, Boolean> talkListener;
/** Catches a talk burst whose end packet never arrived; see {@link #TALK_TIMEOUT_NANOS}. */
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ts3j-talk-watchdog");
t.setDaemon(true);
return t;
});
public DesktopVoiceOutput(String outputDevice) {
this.outputDevice = outputDevice;
watchdog.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
}
private void checkTalkTimeouts() {
long now = System.nanoTime();
for (ClientStream s : streams.values()) {
if (s.talking && now - s.lastPacketNanos > TALK_TIMEOUT_NANOS) {
s.worker.submit(() -> stopStream(s));
}
}
}
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
this.talkListener = l;
}
public void setMasterVolume(double v) {
this.masterVolume = Math.max(0, Math.min(2.0, v));
}
public void setDeafened(boolean d) {
this.deafened = d;
if (d) {
// Stop everyone talking immediately.
for (ClientStream s : streams.values()) {
s.worker.submit(() -> stopStream(s));
}
}
}
public boolean isDeafened() {
return deafened;
}
public void setClientMuted(int clientId, boolean muted) {
if (muted) {
mutedClients.add(clientId);
ClientStream s = streams.get(clientId);
if (s != null) s.worker.submit(() -> stopStream(s));
} else {
mutedClients.remove(clientId);
}
}
public boolean isClientMuted(int clientId) {
return mutedClients.contains(clientId);
}
public void setClientVolume(int clientId, double gain) {
if (gain == 1.0) clientVolumes.remove(clientId);
else clientVolumes.put(clientId, Math.max(0, gain));
}
public double getClientVolume(int clientId) {
return clientVolumes.getOrDefault(clientId, 1.0);
}
public void setOutputDevice(String device) {
this.outputDevice = device;
}
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
public void handleVoice(PacketBody0Voice voice) {
route(voice.getClientId(), voice.getPacketId(), voice.getCodecType(), voice.getCodecData());
}
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
route(whisper.getClientId(), whisper.getPacketId(), whisper.getCodecType(), whisper.getCodecData());
}
/** TeamSpeak streams {@code OPUS_MUSIC} in stereo and everything else in mono. */
private static int channelsFor(CodecType codec) {
return codec == CodecType.OPUS_MUSIC ? 2 : 1;
}
private void route(int clientId, int packetId, CodecType codec, byte[] data) {
if (deafened) return;
if (mutedClients.contains(clientId)) return;
ClientStream stream = streams.get(clientId);
if (stream == null) {
try {
stream = new ClientStream(clientId);
ClientStream existing = streams.putIfAbsent(clientId, stream);
if (existing != null) {
stream.close();
stream = existing;
}
} catch (Exception e) {
return; // couldn't open a line; drop
}
}
final ClientStream target = stream;
target.lastPacketNanos = System.nanoTime();
VoiceFrame frame = new VoiceFrame(packetId & 0xFFFF, codec, data, data == null || data.length == 0);
synchronized (target.lock) {
if (target.expectedPacketId < 0 || target.playout == null || target.playout.isDone()) {
target.expectedPacketId = frame.packetId();
target.activeChannels = channelsFor(codec);
target.consecutivePlcFrames = 0;
target.playout = watchdog.scheduleAtFixedRate(
() -> queuePlayoutTick(target),
JITTER_DELAY_NANOS,
FRAME_NANOS,
TimeUnit.NANOSECONDS);
} else if (packetDistance(target.expectedPacketId, frame.packetId()) < 0) {
return; // too late for this burst; do not replay stale audio
}
target.pending.putIfAbsent(frame.packetId(), frame);
trimPending(target);
}
if (!frame.end()) markTalking(target, true);
}
private void queuePlayoutTick(ClientStream stream) {
if (!stream.tickQueued.compareAndSet(false, true)) return;
try {
stream.worker.submit(() -> {
try {
playNext(stream);
} finally {
stream.tickQueued.set(false);
}
});
} catch (RuntimeException e) {
if (stream.worker.isShutdown()) {
stream.tickQueued.set(false);
} else {
throw e;
}
}
}
private void playNext(ClientStream stream) {
VoiceFrame frame;
int channels;
boolean conceal;
synchronized (stream.lock) {
if (stream.expectedPacketId < 0) return;
frame = stream.pending.remove(stream.expectedPacketId);
if (frame != null && frame.end()) {
stopPlayoutLocked(stream);
return;
}
conceal = frame == null;
channels = frame != null ? channelsFor(frame.codec()) : stream.activeChannels;
stream.activeChannels = channels;
if (conceal) {
stream.consecutivePlcFrames++;
if (stream.consecutivePlcFrames > MAX_CONSECUTIVE_PLC_FRAMES) {
stopPlayoutLocked(stream);
return;
}
} else {
stream.consecutivePlcFrames = 0;
}
stream.expectedPacketId = (stream.expectedPacketId + 1) & 0xFFFF;
}
decodeAndPlay(stream, channels, conceal ? null : frame.data());
}
private void stopStream(ClientStream stream) {
synchronized (stream.lock) {
stopPlayoutLocked(stream);
}
}
private void stopPlayoutLocked(ClientStream stream) {
stream.expectedPacketId = -1;
if (stream.playout != null) stream.playout.cancel(false);
stream.playout = null;
stream.pending.clear();
finishStream(stream);
}
private void finishStream(ClientStream stream) {
if (stream.decoder != null) stream.decoder.reset();
markTalking(stream, false);
}
private static int packetDistance(int from, int to) {
int distance = (to - from) & 0xFFFF;
return distance >= 0x8000 ? distance - 0x10000 : distance;
}
private static void trimPending(ClientStream stream) {
while (stream.pending.size() > MAX_PENDING_PACKETS) {
Integer furthest = null;
int furthestDistance = Integer.MIN_VALUE;
for (Integer packetId : stream.pending.keySet()) {
int distance = packetDistance(stream.expectedPacketId, packetId);
if (distance > furthestDistance) {
furthestDistance = distance;
furthest = packetId;
}
}
if (furthest == null) return;
stream.pending.remove(furthest);
}
}
private void decodeAndPlay(ClientStream stream, int channels, byte[] data) {
try {
float[] pcm = stream.pcm;
int frames = stream.decoderFor(channels).decode(data, pcm);
double vol = masterVolume * clientVolumes.getOrDefault(stream.clientId, 1.0);
// Match the decoded stream to the line: duplicate mono across a stereo
// line, fold a stereo (music) stream down onto a mono-only line.
int lineChannels = stream.lineChannels;
int bytes = frames * lineChannels * 2;
if (stream.out.length < bytes) stream.out = new byte[bytes];
byte[] out = stream.out;
for (int i = 0, k = 0; i < frames; i++) {
for (int c = 0; c < lineChannels; c++, k += 2) {
double v;
if (channels == lineChannels) {
v = pcm[i * channels + c];
} else if (channels == 1) {
v = pcm[i];
} else {
double sum = 0;
for (int s = 0; s < channels; s++) sum += pcm[i * channels + s];
v = sum / channels;
}
v *= vol;
if (v > 1.0) v = 1.0;
else if (v < -1.0) v = -1.0;
short s = (short) Math.round(v * 32767.0);
out[k] = (byte) (s & 0xFF);
out[k + 1] = (byte) ((s >> 8) & 0xFF);
}
}
stream.line.write(out, 0, bytes);
} catch (Exception ignored) {
}
}
private void markTalking(ClientStream stream, boolean talking) {
if (stream.talking == talking) return;
stream.talking = talking;
BiConsumer<Integer, Boolean> l = talkListener;
if (l != null) l.accept(stream.clientId, talking);
}
/** Drop a speaker's pipeline entirely (e.g. they left the server). */
/** Forgets a client that left; the server may hand its id to somebody else. */
public void removeClient(int clientId) {
ClientStream s = streams.remove(clientId);
if (s != null) s.close();
mutedClients.remove(clientId);
clientVolumes.remove(clientId);
}
public void shutdown() {
watchdog.shutdownNow();
for (ClientStream s : streams.values()) {
s.close();
}
streams.clear();
}
}

View File

@@ -1,5 +1,7 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.AudioCapture;
import javax.sound.sampled.TargetDataLine; import javax.sound.sampled.TargetDataLine;
/** {@link AudioCapture} backed by a Java Sound {@link TargetDataLine}. */ /** {@link AudioCapture} backed by a Java Sound {@link TargetDataLine}. */

View File

@@ -1,5 +1,7 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.AudioPlayback;
import javax.sound.sampled.SourceDataLine; import javax.sound.sampled.SourceDataLine;
/** {@link AudioPlayback} backed by a Java Sound {@link SourceDataLine}. */ /** {@link AudioPlayback} backed by a Java Sound {@link SourceDataLine}. */

View File

@@ -0,0 +1,25 @@
package com.ts3client.audio.desktop;
import com.ts3client.audio.opus.OpusCodec;
import com.ts3client.audio.opus.OpusDecoder;
import com.ts3client.audio.opus.OpusEncoder;
/** The system's (or the bundled) libopus, reached through the FFM API. */
public final class NativeOpusCodec implements OpusCodec {
@Override
public OpusEncoder createEncoder(int sampleRate, int frameSize, int channels, Application application) {
int app = application == Application.AUDIO ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP;
return new NativeOpusEncoder(sampleRate, frameSize, channels, app);
}
@Override
public OpusDecoder createDecoder(int sampleRate, int maxFrameSize, int channels) {
return new NativeOpusDecoder(sampleRate, maxFrameSize, channels);
}
@Override
public String version() {
return Opus.getVersionString();
}
}

View File

@@ -1,5 +1,7 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.opus.OpusDecoder;
import java.lang.foreign.Arena; import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment; import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout; import java.lang.foreign.ValueLayout;
@@ -11,7 +13,7 @@ import java.lang.foreign.ValueLayout;
* (mono for {@code OPUS_VOICE}, stereo for {@code OPUS_MUSIC}); Opus will up-/down-mix * (mono for {@code OPUS_VOICE}, stereo for {@code OPUS_MUSIC}); Opus will up-/down-mix
* a mismatched stream, which costs the stereo image. * a mismatched stream, which costs the stereo image.
*/ */
public final class OpusDecoder implements AutoCloseable { final class NativeOpusDecoder implements OpusDecoder {
private static final int MAX_PACKET_BYTES = 4096; private static final int MAX_PACKET_BYTES = 4096;
@@ -23,7 +25,7 @@ public final class OpusDecoder implements AutoCloseable {
private final int channels; private final int channels;
private boolean closed; private boolean closed;
public OpusDecoder(int sampleRate, int frameSize, int channels) { NativeOpusDecoder(int sampleRate, int frameSize, int channels) {
this.frameSize = frameSize; this.frameSize = frameSize;
this.channels = channels; this.channels = channels;
@@ -48,6 +50,7 @@ public final class OpusDecoder implements AutoCloseable {
* @param out output buffer, at least {@code frameSize * channels} long * @param out output buffer, at least {@code frameSize * channels} long
* @return number of samples decoded per channel * @return number of samples decoded per channel
*/ */
@Override
public int decode(byte[] packet, float[] out) { public int decode(byte[] packet, float[] out) {
if (closed) throw new IllegalStateException("decoder closed"); if (closed) throw new IllegalStateException("decoder closed");
@@ -70,15 +73,12 @@ public final class OpusDecoder implements AutoCloseable {
return samples; return samples;
} }
@Override
public void reset() { public void reset() {
if (closed) return; if (closed) return;
Opus.decoderCtl(handle, Opus.OPUS_RESET_STATE, 0); Opus.decoderCtl(handle, Opus.OPUS_RESET_STATE, 0);
} }
public int getChannels() {
return channels;
}
@Override @Override
public void close() { public void close() {
if (closed) return; if (closed) return;

View File

@@ -1,5 +1,7 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.opus.OpusEncoder;
import java.lang.foreign.Arena; import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment; import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout; import java.lang.foreign.ValueLayout;
@@ -14,7 +16,7 @@ import java.lang.foreign.ValueLayout;
* are freed by {@link #close()}; access is serialised by the instance lock, so the * are freed by {@link #close()}; access is serialised by the instance lock, so the
* encoder may be driven from any thread. * encoder may be driven from any thread.
*/ */
public final class OpusEncoder implements AutoCloseable { final class NativeOpusEncoder implements OpusEncoder {
private static final int MAX_PACKET_BYTES = 4096; private static final int MAX_PACKET_BYTES = 4096;
@@ -27,7 +29,7 @@ public final class OpusEncoder implements AutoCloseable {
private final Object lock = new Object(); private final Object lock = new Object();
private boolean closed; private boolean closed;
public OpusEncoder(int sampleRate, int frameSize, int channels, int application) { NativeOpusEncoder(int sampleRate, int frameSize, int channels, int application) {
this.frameSize = frameSize; this.frameSize = frameSize;
this.channels = channels; this.channels = channels;
@@ -44,28 +46,34 @@ public final class OpusEncoder implements AutoCloseable {
this.packetBuffer = arena.allocate(MAX_PACKET_BYTES); this.packetBuffer = arena.allocate(MAX_PACKET_BYTES);
} }
@Override
public void setBitrate(int bitsPerSecond) { public void setBitrate(int bitsPerSecond) {
ctl(Opus.OPUS_SET_BITRATE_REQUEST, bitsPerSecond); ctl(Opus.OPUS_SET_BITRATE_REQUEST, bitsPerSecond);
} }
@Override
public void setComplexity(int complexity) { public void setComplexity(int complexity) {
ctl(Opus.OPUS_SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity))); ctl(Opus.OPUS_SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity)));
} }
@Override
public void setVbr(boolean vbr) { public void setVbr(boolean vbr) {
ctl(Opus.OPUS_SET_VBR_REQUEST, vbr ? 1 : 0); ctl(Opus.OPUS_SET_VBR_REQUEST, vbr ? 1 : 0);
} }
@Override
public void setInbandFec(boolean fec) { public void setInbandFec(boolean fec) {
ctl(Opus.OPUS_SET_INBAND_FEC_REQUEST, fec ? 1 : 0); ctl(Opus.OPUS_SET_INBAND_FEC_REQUEST, fec ? 1 : 0);
} }
@Override
public void setExpectedPacketLoss(int percent) { public void setExpectedPacketLoss(int percent) {
ctl(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent))); ctl(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent)));
} }
public void setSignal(int signal) { @Override
ctl(Opus.OPUS_SET_SIGNAL_REQUEST, signal); public void setSignal(Signal signal) {
ctl(Opus.OPUS_SET_SIGNAL_REQUEST, signal == Signal.MUSIC ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE);
} }
private void ctl(int request, int value) { private void ctl(int request, int value) {
@@ -85,6 +93,7 @@ public final class OpusEncoder implements AutoCloseable {
* *
* @return a newly allocated byte array holding the encoded packet * @return a newly allocated byte array holding the encoded packet
*/ */
@Override
public byte[] encode(float[] pcm) { public byte[] encode(float[] pcm) {
int expected = frameSize * channels; int expected = frameSize * channels;
if (pcm.length != expected) { if (pcm.length != expected) {

View File

@@ -1,5 +1,8 @@
package com.ts3client.audio.desktop; package com.ts3client.audio.desktop;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceFormat;
import com.ts3client.sound.SoundPlayer; import com.ts3client.sound.SoundPlayer;
import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioFormat;
@@ -28,7 +31,7 @@ public final class WavSoundPlayer implements SoundPlayer {
* triggered, so it is kept around for a spell of quiet rather than per sound. * triggered, so it is kept around for a spell of quiet rather than per sound.
*/ */
private static final long IDLE_KEEP_OPEN_NANOS = 30_000_000_000L; private static final long IDLE_KEEP_OPEN_NANOS = 30_000_000_000L;
private static final long FRAME_NANOS = AudioDevices.FRAME_SIZE * 1_000_000_000L / AudioDevices.SAMPLE_RATE; private static final long FRAME_NANOS = VoiceFormat.FRAME_SIZE * 1_000_000_000L / VoiceFormat.SAMPLE_RATE;
/** /**
* How far ahead of real time the mixer renders. The line would happily take a * How far ahead of real time the mixer renders. The line would happily take a
* few hundred milliseconds at once, but anything written is fixed: a sound that * few hundred milliseconds at once, but anything written is fixed: a sound that
@@ -61,11 +64,13 @@ public final class WavSoundPlayer implements SoundPlayer {
private final List<Voice> voices = new ArrayList<>(); private final List<Voice> voices = new ArrayList<>();
private final Object lock = new Object(); private final Object lock = new Object();
private final AudioIo io;
private volatile String outputDevice; private volatile String outputDevice;
private volatile boolean running = true; private volatile boolean running = true;
private Thread mixer; private Thread mixer;
public WavSoundPlayer(String outputDevice) { public WavSoundPlayer(AudioIo io, String outputDevice) {
this.io = io;
this.outputDevice = outputDevice; this.outputDevice = outputDevice;
} }
@@ -121,11 +126,11 @@ public final class WavSoundPlayer implements SoundPlayer {
private void mixLoop() { private void mixLoop() {
AudioPlayback line = null; AudioPlayback line = null;
try { try {
float[] mix = new float[AudioDevices.FRAME_SIZE]; float[] mix = new float[VoiceFormat.FRAME_SIZE];
long due = 0; // when the next frame is due to leave the speaker long due = 0; // when the next frame is due to leave the speaker
while (awaitVoices()) { while (awaitVoices()) {
if (line == null) { if (line == null) {
line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS); line = io.openPlayback(outputDevice, VoiceFormat.MAX_CHANNELS);
line.start(); line.start();
} }
long now = System.nanoTime(); long now = System.nanoTime();
@@ -236,7 +241,7 @@ public final class WavSoundPlayer implements SoundPlayer {
} }
mono[i] = sum / channels; mono[i] = sum / channels;
} }
return resample(mono, source.getSampleRate(), AudioDevices.SAMPLE_RATE); return resample(mono, source.getSampleRate(), VoiceFormat.SAMPLE_RATE);
} }
} }
} }

View File

@@ -1,7 +1,7 @@
package com.ts3client.audio.desktop.pipewire; package com.ts3client.audio.desktop.pipewire;
import com.ts3client.audio.desktop.AudioCapture; import com.ts3client.audio.AudioCapture;
import com.ts3client.audio.desktop.AudioPlayback; import com.ts3client.audio.AudioPlayback;
import java.util.List; import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.ts3client.audio.desktop.pipewire; package com.ts3client.audio.desktop.pipewire;
import com.ts3client.audio.desktop.AudioCapture; import com.ts3client.audio.AudioCapture;
import java.lang.foreign.MemorySegment; import java.lang.foreign.MemorySegment;

View File

@@ -1,6 +1,6 @@
package com.ts3client.audio.desktop.pipewire; package com.ts3client.audio.desktop.pipewire;
import com.ts3client.audio.desktop.AudioPlayback; import com.ts3client.audio.AudioPlayback;
import java.lang.foreign.MemorySegment; import java.lang.foreign.MemorySegment;

View File

@@ -1,9 +1,10 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioDevice;
import com.ts3client.audio.AudioIo;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput; import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import javax.swing.Box; import javax.swing.Box;
@@ -24,8 +25,8 @@ import java.util.function.Consumer;
*/ */
final class DevicesPanel extends FormPanel { final class DevicesPanel extends FormPanel {
private final JComboBox<AudioDevices.Device> inputCombo; private final JComboBox<AudioDevice> inputCombo;
private final JComboBox<AudioDevices.Device> outputCombo; private final JComboBox<AudioDevice> outputCombo;
private final JSlider inputGain; private final JSlider inputGain;
private final JSlider outputVol; private final JSlider outputVol;
private final JCheckBox denoiseCheck; private final JCheckBox denoiseCheck;
@@ -34,16 +35,16 @@ final class DevicesPanel extends FormPanel {
private final JCheckBox agcCheck; private final JCheckBox agcCheck;
private final JCheckBox mutedWarningCheck; private final JCheckBox mutedWarningCheck;
DevicesPanel(Settings settings, VoiceOutput livePlayback, DevicesPanel(Settings settings, AudioIo io, VoiceOutput livePlayback,
Consumer<Consumer<VoiceInput>> applyLive, Consumer<Consumer<VoiceInput>> applyLive,
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) { Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
GridBagConstraints c = gbc(); GridBagConstraints c = gbc();
List<AudioDevices.Device> ins = AudioDevices.inputDevices(); List<AudioDevice> ins = io.inputDevices();
List<AudioDevices.Device> outs = AudioDevices.outputDevices(); List<AudioDevice> outs = io.outputDevices();
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0])); inputCombo = new JComboBox<>(ins.toArray(new AudioDevice[0]));
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0])); outputCombo = new JComboBox<>(outs.toArray(new AudioDevice[0]));
selectOrDefault(inputCombo, settings.inputDevice); selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice); selectOrDefault(outputCombo, settings.outputDevice);
@@ -158,7 +159,7 @@ final class DevicesPanel extends FormPanel {
target.mutedTalkWarning = mutedWarningCheck.isSelected(); target.mutedTalkWarning = mutedWarningCheck.isSelected();
} }
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) { private static void selectOrDefault(JComboBox<AudioDevice> combo, String deviceId) {
if (deviceId != null && !deviceId.isEmpty()) { if (deviceId != null && !deviceId.isEmpty()) {
for (int i = 0; i < combo.getItemCount(); i++) { for (int i = 0; i < combo.getItemCount(); i++) {
if (deviceId.equals(combo.getItemAt(i).id())) { if (deviceId.equals(combo.getItemAt(i).id())) {
@@ -170,8 +171,8 @@ final class DevicesPanel extends FormPanel {
combo.setSelectedIndex(0); combo.setSelectedIndex(0);
} }
private static String comboValue(JComboBox<AudioDevices.Device> combo) { private static String comboValue(JComboBox<AudioDevice> combo) {
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem(); AudioDevice d = (AudioDevice) combo.getSelectedItem();
return d == null ? "" : d.id(); return d == null ? "" : d.id();
} }
} }

View File

@@ -834,7 +834,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void showSettings() { private void showSettings() {
// Device and voice-gating changes apply to the capturing connection and the // Device and voice-gating changes apply to the capturing connection and the
// playback of the visible one; the rest pick the new settings up on connect. // playback of the visible one; the rest pick the new settings up on connect.
SettingsDialog dlg = new SettingsDialog(this, settings, SettingsDialog dlg = new SettingsDialog(this, settings, audio,
micTab == null ? null : micTab.connection().getMicrophone(), micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(), selected == null ? null : selected.connection().getPlayback(),
sounds, hotkeys, sounds, hotkeys,

View File

@@ -1,9 +1,8 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.audio.desktop.AudioPlayback;
import com.ts3client.audio.desktop.DesktopVoiceInput;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
@@ -13,7 +12,7 @@ import java.util.function.Consumer;
/** /**
* Drives the settings dialog's microphone test from a real capture chain. * Drives the settings dialog's microphone test from a real capture chain.
* *
* <p>The dialog runs its own {@link DesktopVoiceInput} rather than borrowing the connected * <p>The dialog runs its own {@link VoiceInput} rather than borrowing the connected
* one, whose listeners belong to the connection. Because it is the same class that feeds * one, whose listeners belong to the connection. Because it is the same class that feeds
* the server, the level and the gate shown here are exactly what would be transmitted — * the server, the level and the gate shown here are exactly what would be transmitted —
* pre-processing, voice detection, hangover and pre-roll included. * pre-processing, voice detection, hangover and pre-roll included.
@@ -29,7 +28,8 @@ final class MicrophoneTest {
private final Consumer<Double> onLevel; private final Consumer<Double> onLevel;
private final Consumer<Boolean> onTransmitting; private final Consumer<Boolean> onTransmitting;
private DesktopVoiceInput mic; private final AudioBackend audio;
private VoiceInput mic;
private final ArrayBlockingQueue<byte[]> loopbackQueue = private final ArrayBlockingQueue<byte[]> loopbackQueue =
new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES); new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES);
@@ -38,7 +38,8 @@ final class MicrophoneTest {
private Thread loopbackThread; private Thread loopbackThread;
private String outputDevice = ""; private String outputDevice = "";
MicrophoneTest(Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) { MicrophoneTest(AudioBackend audio, Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) {
this.audio = audio;
this.onLevel = onLevel; this.onLevel = onLevel;
this.onTransmitting = onTransmitting; this.onTransmitting = onTransmitting;
} }
@@ -55,7 +56,7 @@ final class MicrophoneTest {
*/ */
boolean start(Settings settings) { boolean start(Settings settings) {
stop(); stop();
DesktopVoiceInput input = new DesktopVoiceInput(settings); VoiceInput input = audio.createInput(settings);
input.setLevelListener(db -> SwingUtilities.invokeLater(() -> onLevel.accept(db))); input.setLevelListener(db -> SwingUtilities.invokeLater(() -> onLevel.accept(db)));
input.setTalkListener(talking -> SwingUtilities.invokeLater(() -> onTransmitting.accept(talking))); input.setTalkListener(talking -> SwingUtilities.invokeLater(() -> onTransmitting.accept(talking)));
input.setMonitorListener(this::enqueueForLoopback); input.setMonitorListener(this::enqueueForLoopback);
@@ -133,7 +134,7 @@ final class MicrophoneTest {
try { try {
// The monitored frames are mono for voice; ask for a matching line so no // The monitored frames are mono for voice; ask for a matching line so no
// channel juggling is needed, and up-mix only if the device insists on stereo. // channel juggling is needed, and up-mix only if the device insists on stereo.
line = AudioDevices.openPlayback(outputDevice, 1); line = audio.io().openPlayback(outputDevice, 1);
line.start(); line.start();
int channels = line.channels(); int channels = line.channels();
while (loopbackRunning) { while (loopbackRunning) {

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.OpusParameters; import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput; import com.ts3client.audio.VoiceOutput;
@@ -39,7 +40,7 @@ public final class SettingsDialog extends JDialog {
private final ClientVersionPanel clientVersionPanel; private final ClientVersionPanel clientVersionPanel;
private final ContactsPanel contactsPanel; private final ContactsPanel contactsPanel;
public SettingsDialog(Frame owner, Settings settings, public SettingsDialog(Frame owner, Settings settings, AudioBackend audio,
VoiceInput liveMic, VoiceOutput livePlayback, VoiceInput liveMic, VoiceOutput livePlayback,
SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) { SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) {
super(owner, "Options", true); super(owner, "Options", true);
@@ -53,9 +54,9 @@ public final class SettingsDialog extends JDialog {
hotkeysPanel = new HotkeysPanel(hotkeys); hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings); clientVersionPanel = new ClientVersionPanel(settings);
contactsPanel = new ContactsPanel(settings); contactsPanel = new ContactsPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback, devicesPanel = new DevicesPanel(settings, audio.io(), livePlayback,
this::applyLive, this::restartTest, this::setTestOutputDevice); this::applyLive, this::restartTest, this::setTestOutputDevice);
voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys, voiceActivationPanel = new VoiceActivationPanel(settings, audio, liveMic, hotkeys,
hotkeysPanel, this::audioSnapshot); hotkeysPanel, this::audioSnapshot);
JTabbedPane tabs = new JTabbedPane(); JTabbedPane tabs = new JTabbedPane();

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.InputLevel; import com.ts3client.audio.InputLevel;
import com.ts3client.audio.OpusParameters; import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
@@ -42,7 +43,7 @@ final class VoiceActivationPanel extends FormPanel {
private final HotkeyService hotkeys; private final HotkeyService hotkeys;
private final HotkeysPanel hotkeysPanel; private final HotkeysPanel hotkeysPanel;
private final Supplier<Settings> audioSnapshot; private final Supplier<Settings> audioSnapshot;
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking); private final MicrophoneTest micTest;
private final JRadioButton vadRadio; private final JRadioButton vadRadio;
private final JRadioButton pttRadio; private final JRadioButton pttRadio;
@@ -62,10 +63,11 @@ final class VoiceActivationPanel extends FormPanel {
private final JCheckBox fecCheck; private final JCheckBox fecCheck;
private final JCheckBox musicCheck; private final JCheckBox musicCheck;
VoiceActivationPanel(Settings settings, VoiceInput liveMic, HotkeyService hotkeys, VoiceActivationPanel(Settings settings, AudioBackend audio, VoiceInput liveMic, HotkeyService hotkeys,
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) { HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
this.settings = settings; this.settings = settings;
this.liveMic = liveMic; this.liveMic = liveMic;
this.micTest = new MicrophoneTest(audio, this::onTestLevel, this::onTestTalking);
this.hotkeys = hotkeys; this.hotkeys = hotkeys;
this.hotkeysPanel = hotkeysPanel; this.hotkeysPanel = hotkeysPanel;
this.audioSnapshot = audioSnapshot; this.audioSnapshot = audioSnapshot;