Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/MicrophoneTest.java
ericek111 7396c4ca40 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>
2026-09-25 08:01:28 +00:00

183 lines
6.4 KiB
Java

package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.AudioPlayback;
import com.ts3client.audio.VoiceInput;
import com.ts3client.config.Settings;
import javax.swing.SwingUtilities;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.function.Consumer;
/**
* Drives the settings dialog's microphone test from a real capture chain.
*
* <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
* the server, the level and the gate shown here are exactly what would be transmitted —
* pre-processing, voice detection, hangover and pre-roll included.
*
* <p>Loopback is optional: when enabled, the frames that would be sent are played back
* locally so you can hear precisely what the other side would.
*/
final class MicrophoneTest {
/** Frames buffered for loopback before the oldest is dropped (~100 ms). */
private static final int LOOPBACK_QUEUE_FRAMES = 5;
private final Consumer<Double> onLevel;
private final Consumer<Boolean> onTransmitting;
private final AudioBackend audio;
private VoiceInput mic;
private final ArrayBlockingQueue<byte[]> loopbackQueue =
new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES);
private volatile boolean loopbackEnabled;
private volatile boolean loopbackRunning;
private Thread loopbackThread;
private String outputDevice = "";
MicrophoneTest(AudioBackend audio, Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) {
this.audio = audio;
this.onLevel = onLevel;
this.onTransmitting = onTransmitting;
}
/** Whether a capture chain is currently running. */
boolean isRunning() {
return mic != null;
}
/**
* (Re)starts the test chain against {@code settings}' devices and voice options.
*
* @return false if the capture device could not be opened
*/
boolean start(Settings settings) {
stop();
VoiceInput input = audio.createInput(settings);
input.setLevelListener(db -> SwingUtilities.invokeLater(() -> onLevel.accept(db)));
input.setTalkListener(talking -> SwingUtilities.invokeLater(() -> onTransmitting.accept(talking)));
input.setMonitorListener(this::enqueueForLoopback);
this.outputDevice = settings.outputDevice;
try {
input.start();
} catch (RuntimeException e) {
return false; // Device busy or gone; leave the test switched off.
}
this.mic = input;
return true;
}
/**
* Stops the capture chain. The caller owns the UI reset: doing it here would race with
* a restart, whose own state has already been put on screen.
*/
void stop() {
setLoopback(false);
if (mic != null) {
mic.stop();
mic = null;
}
}
/** Applies a change to the test chain, if it is running. */
void configure(Consumer<VoiceInput> change) {
VoiceInput m = mic;
if (m != null) change.accept(m);
}
/** The playback device to loop back through; takes effect on the next enable. */
void setOutputDevice(String deviceId) {
this.outputDevice = deviceId == null ? "" : deviceId;
}
void setLoopback(boolean enabled) {
if (enabled == loopbackEnabled) {
return;
}
loopbackEnabled = enabled;
if (enabled) {
loopbackRunning = true;
loopbackThread = new Thread(this::loopbackLoop, "settings-loopback");
loopbackThread.setDaemon(true);
loopbackThread.start();
} else {
loopbackRunning = false;
if (loopbackThread != null) {
loopbackThread.interrupt();
loopbackThread = null;
}
loopbackQueue.clear();
}
}
/**
* Converts a transmitted frame to 16-bit PCM and queues it. Runs on the capture thread,
* so it must not block: a full queue means playback has fallen behind and the oldest
* frame is dropped instead.
*/
private void enqueueForLoopback(float[] interleaved, int channels) {
if (!loopbackEnabled) {
return;
}
byte[] pcm = toPcm16(interleaved);
if (!loopbackQueue.offer(pcm)) {
loopbackQueue.poll();
loopbackQueue.offer(pcm);
}
}
private void loopbackLoop() {
AudioPlayback line = null;
try {
// 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.
line = audio.io().openPlayback(outputDevice, 1);
line.start();
int channels = line.channels();
while (loopbackRunning) {
byte[] frame = loopbackQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS);
if (frame == null) {
continue;
}
byte[] out = (channels == 1) ? frame : upmix(frame, channels);
line.write(out, 0, out.length);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// The device may be busy or gone; the test simply runs without loopback.
} finally {
if (line != null) line.close();
}
}
/** Converts float samples in [-1, 1] to 16-bit little-endian PCM. */
static byte[] toPcm16(float[] samples) {
byte[] pcm = new byte[samples.length * 2];
for (int i = 0; i < samples.length; i++) {
float f = samples[i];
if (f > 1f) f = 1f;
else if (f < -1f) f = -1f;
int s = Math.round(f * 32767f);
pcm[2 * i] = (byte) (s & 0xFF);
pcm[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
}
return pcm;
}
/** Copies a mono frame across {@code channels} interleaved channels. */
static byte[] upmix(byte[] mono, int channels) {
byte[] out = new byte[mono.length * channels];
for (int i = 0, frames = mono.length / 2; i < frames; i++) {
for (int c = 0; c < channels; c++) {
out[2 * (i * channels + c)] = mono[2 * i];
out[2 * (i * channels + c) + 1] = mono[2 * i + 1];
}
}
return out;
}
}