package com.ts3client.ui; 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 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. * *

The dialog runs its own {@link DesktopVoiceInput} 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. * *

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 onLevel; private final Consumer onTransmitting; private DesktopVoiceInput mic; private final ArrayBlockingQueue loopbackQueue = new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES); private volatile boolean loopbackEnabled; private volatile boolean loopbackRunning; private Thread loopbackThread; private String outputDevice = ""; MicrophoneTest(Consumer onLevel, Consumer onTransmitting) { 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(); DesktopVoiceInput input = new DesktopVoiceInput(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 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 = AudioDevices.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; } }