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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -51,6 +51,7 @@ public final class DesktopVoiceInput implements VoiceInput {
|
||||
|
||||
private final ConcurrentLinkedQueue<byte[]> 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;
|
||||
|
||||
@@ -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<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(() -> {
|
||||
try {
|
||||
s.line.drain();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (s.decoder != null) s.decoder.reset();
|
||||
markTalking(s, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
|
||||
@@ -226,6 +262,7 @@ public final class DesktopVoiceOutput implements VoiceOutput {
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
watchdog.shutdownNow();
|
||||
for (ClientStream s : streams.values()) {
|
||||
s.close();
|
||||
}
|
||||
|
||||
@@ -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<ServerTab> targets, boolean muted) {
|
||||
for (ServerTab tab : targets) tab.setMicLocalMuted(muted);
|
||||
frame.refreshAfterHotkey();
|
||||
}
|
||||
|
||||
private void setDeafened(List<ServerTab> 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<ServerTab> tabs) {
|
||||
return tabs.stream().anyMatch(ServerTab::isMicLocalMuted);
|
||||
}
|
||||
|
||||
private static boolean anyDeafened(List<ServerTab> tabs) {
|
||||
return tabs.stream().anyMatch(ServerTab::isDeafened);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user