diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java index 3312523..3566de7 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java @@ -18,6 +18,15 @@ public interface VoiceInput extends Microphone { void setMuted(boolean muted); + /** + * Silences capture the same way {@link #setMuted} does, but locally only: it does + * not touch {@link #isMuted}, so the mute status published to the server (and the + * mute/unmute sound) is unaffected. TS3's "Local Mic Mute". + */ + void setLocalMuted(boolean muted); + + boolean isLocalMuted(); + void setMode(Settings.InputMode mode); void setVadMode(Settings.VadMode mode); diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java index 4e905a3..7746d02 100644 --- a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java @@ -36,13 +36,16 @@ public enum HotkeyAction { "Unmute Microphone", true, Argument.NONE, true), MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle", "Toggle Microphone Mute", false, Argument.NONE, true), - /** TS3's "local" mute is the hardware-level mute of the capture device itself. */ + /** + * TS3's "local" mute silences capture without publishing a mute status, or playing + * a mute/unmute sound — unlike {@link #MIC_MUTE}. + */ MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute", - "Disable Local Mic Mute", true, Argument.NONE, false), + "Disable Local Mic Mute", true, Argument.NONE, true), MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute", - "Enable Local Mic Mute", true, Argument.NONE, false), + "Enable Local Mic Mute", true, Argument.NONE, true), MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute", - "Toggle Local Mic Mute", true, Argument.NONE, false), + "Toggle Local Mic Mute", true, Argument.NONE, true), SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute", "Mute Speaker", true, Argument.NONE, true), diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java index 61631cc..7b03b6e 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java @@ -21,6 +21,10 @@ public final class ClientEntry { public boolean talking; public boolean inputMuted; // microphone muted (client_input_muted) public boolean outputMuted; // speakers muted / deafened (client_output_muted) + /** False while the capture device is unavailable: another tab holds the microphone. */ + public boolean inputHardware = true; + /** False while the playback device is unavailable. */ + public boolean outputHardware = true; public boolean away; /** The message published with the away state, empty when there is none. */ public String awayMessage = ""; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 40959ed..cf14096 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -153,6 +153,22 @@ public final class TeamspeakConnection implements TS3Listener { } catch (Exception e) { ui.onError("Microphone unavailable: " + rootMessage(e)); } + pushInputHardware(); + } + + /** + * Reports whether the capture device is actually available on this connection, so + * other clients see "Microphone Disabled" rather than plain silence while another + * tab holds it. {@code clientinit} always claims hardware input, so this is what + * corrects that once tabs start sharing one microphone. + */ + private void pushInputHardware() { + boolean hardware = microphoneActive; + updateSelf(self -> self.inputHardware = hardware); + if (client == null || !connected) return; + selfUpdate(cmd -> + cmd.add(new CommandSingleParameter("client_input_hardware", hardware ? "1" : "0")), + "Microphone hardware status update failed"); } // ---- connection lifecycle ---- @@ -403,6 +419,8 @@ public final class TeamspeakConnection implements TS3Listener { e.talkPower = cl.getTalkPower(); e.inputMuted = cl.isInputMuted(); e.outputMuted = cl.isOutputMuted(); + e.inputHardware = cl.isInputHardware(); + e.outputHardware = cl.isOutputHardware(); e.away = cl.isAway(); e.awayMessage = orEmpty(cl.get("client_away_message")); e.uniqueId = cl.getUniqueIdentifier(); @@ -505,6 +523,14 @@ public final class TeamspeakConnection implements TS3Listener { pushSelfFlags(); } + /** + * TS3's "Local Mic Mute": silences capture without touching the {@code client_input_muted} + * status the server (and other clients) see, and without a mute/unmute sound. + */ + public void setMicLocalMuted(boolean muted) { + if (microphone != null) microphone.setLocalMuted(muted); + } + public void setDeafened(boolean deaf) { // Announce muting while we can still be heard, and unmuting once we can again. if (deaf) sound(SoundEvent.SOUND_PLAYBACK_MUTED); @@ -524,15 +550,14 @@ public final class TeamspeakConnection implements TS3Listener { self.outputMuted = deaf; }); - // Best-effort: publish input/output muted flags to the server so others see them. + // Publish the input/output muted flags to the server so others see them. These + // are runtime status, not editable client-database properties, so they go out + // through clientupdate (like nickname/away), not clientedit. if (client == null || !connected) return; - try { - java.util.Map props = new java.util.HashMap<>(); - props.put("client_input_muted", microphone.isMuted() ? "1" : "0"); - props.put("client_output_muted", playback.isDeafened() ? "1" : "0"); - client.editClient(selfClientId, props); - } catch (Exception ignored) { - } + selfUpdate(cmd -> { + cmd.add(new CommandSingleParameter("client_input_muted", micMuted ? "1" : "0")); + cmd.add(new CommandSingleParameter("client_output_muted", deaf ? "1" : "0")); + }, "Mute status update failed"); } public void joinChannel(int channelId, String password) { @@ -758,6 +783,8 @@ public final class TeamspeakConnection implements TS3Listener { c.talkPower = e.getClientTalkPower(); c.inputMuted = e.isClientInputMuted(); c.outputMuted = e.isClientOutputMuted(); + c.inputHardware = e.isClientUsingHardwareInput(); + c.outputHardware = e.isClientUsingHardwareOutput(); c.away = e.isClientAway(); c.awayMessage = orEmpty(e.get("client_away_message")); c.uniqueId = orEmpty(e.getUniqueClientIdentifier()); @@ -926,6 +953,8 @@ public final class TeamspeakConnection implements TS3Listener { if (renamed) c.nickname = e.get("client_nickname"); if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted"); if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); + if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware"); + if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware"); if (has(e, "client_away")) { c.away = e.getBoolean("client_away"); // Both fields travel together, so an absent message here means "no message" diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java index b1c7766..96eacfc 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java @@ -51,6 +51,7 @@ public final class DesktopVoiceInput implements VoiceInput { private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); private final AtomicBoolean muted = new AtomicBoolean(false); + private final AtomicBoolean localMuted = new AtomicBoolean(false); private final AtomicBoolean transmitting = new AtomicBoolean(false); private final AtomicBoolean pttDown = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false); @@ -239,6 +240,16 @@ public final class DesktopVoiceInput implements VoiceInput { this.muted.set(m); } + @Override + public void setLocalMuted(boolean m) { + this.localMuted.set(m); + } + + @Override + public boolean isLocalMuted() { + return localMuted.get(); + } + // ---- lifecycle ---- public synchronized void start() { @@ -405,7 +416,7 @@ public final class DesktopVoiceInput implements VoiceInput { } private boolean decideGate(double db, float[] pcm) { - if (muted.get()) { + if (muted.get() || localMuted.get()) { hangover = 0; detectMutedSpeech(db); return false; diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java index d8b7f05..79bdfce 100644 --- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java +++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceOutput.java @@ -10,6 +10,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; /** @@ -24,6 +26,16 @@ 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; + /** + * 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 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(200); + /** One speaker's decode + playback pipeline. */ private final class ClientStream { final int clientId; @@ -81,8 +93,32 @@ public final class DesktopVoiceOutput implements VoiceOutput { /** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */ private volatile BiConsumer 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(() -> { + try { + s.line.drain(); + } catch (Exception ignored) { + } + if (s.decoder != null) s.decoder.reset(); + markTalking(s, false); + }); + } + } } public void setTalkListener(BiConsumer l) { @@ -226,6 +262,7 @@ public final class DesktopVoiceOutput implements VoiceOutput { } public void shutdown() { + watchdog.shutdownNow(); for (ClientStream s : streams.values()) { s.close(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java index a6575c2..a371ebc 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java @@ -49,6 +49,10 @@ final class HotkeyActions implements HotkeyEngine.Handler { case MIC_UNMUTE -> setMicMuted(targets, false); case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets)); + case MIC_LOCAL_MUTE -> setMicLocalMuted(targets, true); + case MIC_LOCAL_UNMUTE -> setMicLocalMuted(targets, false); + case MIC_LOCAL_TOGGLE -> setMicLocalMuted(targets, !anyMicLocalMuted(targets)); + case SPEAKER_MUTE -> setDeafened(targets, true); case SPEAKER_UNMUTE -> setDeafened(targets, false); case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets)); @@ -105,6 +109,11 @@ final class HotkeyActions implements HotkeyEngine.Handler { frame.refreshAfterHotkey(); } + private void setMicLocalMuted(List targets, boolean muted) { + for (ServerTab tab : targets) tab.setMicLocalMuted(muted); + frame.refreshAfterHotkey(); + } + private void setDeafened(List targets, boolean deaf) { for (ServerTab tab : targets) tab.setDeafened(deaf); frame.refreshAfterHotkey(); @@ -124,6 +133,10 @@ final class HotkeyActions implements HotkeyEngine.Handler { return tabs.stream().anyMatch(ServerTab::isMicMuted); } + private static boolean anyMicLocalMuted(List tabs) { + return tabs.stream().anyMatch(ServerTab::isMicLocalMuted); + } + private static boolean anyDeafened(List tabs) { return tabs.stream().anyMatch(ServerTab::isDeafened); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index 5b97760..6a700bd 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -170,6 +170,33 @@ public final class Icons { return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted); } + /** The capture device is unavailable (another tab holds it), as opposed to muted. */ + public static ImageIcon micDisabled() { + return micDisabled(SZ); + } + + public static ImageIcon micDisabled(int size) { + return themed("HARDWARE_INPUT_MUTED", size, Icons::paintMicDisabled); + } + + /** The playback device is unavailable, as opposed to muted/deafened. */ + public static ImageIcon speakerDisabled() { + return speakerDisabled(SZ); + } + + public static ImageIcon speakerDisabled(int size) { + return themed("HARDWARE_OUTPUT_MUTED", size, Icons::paintSpeakerDisabled); + } + + /** TS3's "Local Mic Mute": silenced, but not reported as muted. */ + public static ImageIcon micLocalMuted() { + return micLocalMuted(SZ); + } + + public static ImageIcon micLocalMuted(int size) { + return themed("INPUT_MUTED_LOCAL", size, Icons::paintMicLocalMuted); + } + /** A channel commander that is not talking. */ public static ImageIcon clientCommander(int size) { return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); @@ -296,6 +323,35 @@ public final class Icons { g.drawLine(2, 2, 14, 14); } + /** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */ + private static void paintMicDisabled(Graphics2D g) { + g.setColor(Theme.IDLE_CLIENT); + g.fillRoundRect(6, 2, 4, 7, 2, 2); + g.setStroke(new BasicStroke(1.4f)); + g.drawArc(4, 6, 8, 6, 200, 140); + g.drawLine(8, 12, 8, 14); + g.setStroke(new BasicStroke(2f)); + g.drawLine(2, 2, 14, 14); + } + + /** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */ + private static void paintSpeakerDisabled(Graphics2D g) { + g.setColor(Theme.IDLE_CLIENT); + g.fillRect(2, 6, 3, 4); + int[] xs = {5, 9, 9, 5}; + int[] ys = {6, 3, 13, 10}; + g.fillPolygon(xs, ys, 4); + g.setStroke(new BasicStroke(2f)); + g.drawLine(2, 2, 14, 14); + } + + /** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */ + private static void paintMicLocalMuted(Graphics2D g) { + paintMicMuted(g); + g.setColor(Theme.ACCENT); + g.fillOval(11, 10, 4, 4); + } + private static void paintConnect(Graphics2D g) { g.setColor(new Color(0x2E8B57)); g.setStroke(new BasicStroke(2f)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index 0aca914..6531c51 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -96,8 +96,10 @@ public final class InfoPanel extends JScrollPane { if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform)); if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version)); if (cl.away) row(sb, "Status", "Away"); - if (cl.inputMuted) row(sb, "Microphone", "muted"); - if (cl.outputMuted) row(sb, "Speakers", "muted"); + if (!cl.inputHardware) row(sb, "Microphone", "disabled"); + else if (cl.inputMuted) row(sb, "Microphone", "muted"); + if (!cl.outputHardware) row(sb, "Speakers", "disabled"); + else if (cl.outputMuted) row(sb, "Speakers", "muted"); if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId)); if (cl.description != null && !cl.description.isEmpty()) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java index e09c852..204c3c2 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java @@ -12,6 +12,7 @@ enum SelfState { DISCONNECTED("Not connected"), DEAFENED("Speakers muted"), MIC_MUTED("Microphone muted"), + MIC_LOCAL_MUTED("Microphone locally muted"), AWAY("Away"), COMMANDER_TALKING("Talking (channel commander)"), COMMANDER("Channel commander"), @@ -37,6 +38,8 @@ enum SelfState { return Icons.speakerMuted(size); case MIC_MUTED: return Icons.micMuted(size); + case MIC_LOCAL_MUTED: + return Icons.micLocalMuted(size); case AWAY: return Icons.clientAway(size); case COMMANDER_TALKING: diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index bd4c4b1..e32ddf8 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -49,6 +49,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { private String identityId = ""; private boolean micMuted; + private boolean micLocalMuted; private boolean deafened; private boolean away; /** Away message currently published, empty when away carries no message. */ @@ -139,6 +140,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { return micMuted; } + boolean isMicLocalMuted() { + return micLocalMuted; + } + boolean isDeafened() { return deafened; } @@ -160,6 +165,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { if (!conn.isConnected()) return SelfState.DISCONNECTED; if (deafened) return SelfState.DEAFENED; if (micMuted) return SelfState.MIC_MUTED; + if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED; if (away) return SelfState.AWAY; ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); boolean talking = self != null && self.talking; @@ -231,6 +237,13 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active."); } + /** TS3's "Local Mic Mute": silences capture without publishing a status change. */ + void setMicLocalMuted(boolean muted) { + micLocalMuted = muted; + conn.setMicLocalMuted(muted); + chatPanel.appendSystem(muted ? "Microphone locally muted." : "Microphone locally unmuted."); + } + void setDeafened(boolean deaf) { deafened = deaf; conn.setDeafened(deaf); @@ -484,6 +497,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { connecting = false; treePanel.setSelfClientId(conn.getSelfClientId()); micMuted = false; + micLocalMuted = false; deafened = false; away = false; awayMessage = ""; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 7835fdd..4d7c56c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -650,7 +650,9 @@ public final class ServerTreePanel extends JScrollPane { /** The client's state, in the order the official client gives them priority. */ private ImageIcon iconFor(ClientEntry cl) { if (cl.isQuery()) return Icons.clientQuery(); + if (!cl.outputHardware) return Icons.speakerDisabled(); if (cl.outputMuted) return Icons.speakerMuted(); + if (!cl.inputHardware) return Icons.micDisabled(); if (cl.inputMuted) return Icons.micMuted(); if (cl.away) return Icons.clientAway(); if (cl.channelCommander) {