Report mute/hardware status correctly, add Local Mic Mute, fix stale talking indicator

- Mute/deafen status now goes out through clientupdate instead of
  clientedit, which silently rejected client_input_muted/output_muted
  since they are runtime status, not editable client properties.
- Publish client_input_hardware so other clients see "Microphone
  Disabled" instead of silence while another tab holds the capture
  device; track input/output hardware flags for other clients too,
  and show a distinct grey "disabled" icon instead of reusing the red
  "muted" one for both the tree and the info panel.
- Implement TS3's Enable/Disable/Toggle Local Mic Mute hotkeys: they
  silence capture like a real mute, but never touch the published
  mute status or play a sound.
- A speaker's "talking" indicator only ever cleared when its
  zero-length end-of-burst voice packet arrived; if that one UDP
  packet was lost, the indicator stuck until their next burst. Add a
  watchdog that clears it after 200ms of silence from that speaker
  regardless.
This commit is contained in:
2026-08-17 08:43:22 +00:00
parent e228dd4ad0
commit 75ae6b89b2
12 changed files with 198 additions and 15 deletions

View File

@@ -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);

View File

@@ -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),

View File

@@ -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 = "";

View File

@@ -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<String, String> 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"