From 9b1bd482dd2257b8556ffc853c035aa933f33f2f Mon Sep 17 00:00:00 2001 From: phyce Date: Fri, 22 May 2026 10:31:00 +0000 Subject: [PATCH] Suppress NPC overhead while NPC dialog is open When you talk to an NPC, the dialog widget plays through TTS while the same NPC's overhead chatter often fires from the same event tick. This causes duplicate audio for the same line. Add a config toggle (default on) that skips the NPC overhead path while DIALOG_NPC_TEXT is visible. --- .../naturalspeech/SpeechEventHandler.java | 46 +++++++++++++++++++ .../configs/NaturalSpeechConfig.java | 12 +++++ 2 files changed, 58 insertions(+) diff --git a/src/main/java/dev/phyce/naturalspeech/SpeechEventHandler.java b/src/main/java/dev/phyce/naturalspeech/SpeechEventHandler.java index 78faa24..752659a 100644 --- a/src/main/java/dev/phyce/naturalspeech/SpeechEventHandler.java +++ b/src/main/java/dev/phyce/naturalspeech/SpeechEventHandler.java @@ -13,7 +13,9 @@ import dev.phyce.naturalspeech.tts.VoiceID; import dev.phyce.naturalspeech.tts.VoiceManager; import dev.phyce.naturalspeech.utils.TextUtil; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; import lombok.extern.slf4j.Slf4j; import net.runelite.api.ChatMessageType; import net.runelite.api.Client; @@ -21,6 +23,7 @@ import net.runelite.api.Player; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.OverheadTextChanged; +import net.runelite.api.events.WidgetClosed; import net.runelite.api.events.WidgetLoaded; import net.runelite.api.widgets.ComponentID; import net.runelite.api.widgets.InterfaceID; @@ -41,6 +44,10 @@ public class SpeechEventHandler { private final ClientThread clientThread; + // NPC names that have spoken in the current dialog (tag-stripped, lowercased). + // Multi-NPC dialogs append; cleared when no dialog widget remains. Client thread only. + private final Set currentDialogNpcNames = new HashSet<>(); + @Inject public SpeechEventHandler(Client client, TextToSpeech textToSpeech, NaturalSpeechConfig config, VoiceManager voiceManager, MuteManager muteManager, SpamDetection spamDetection, ClientThread clientThread) { @@ -155,6 +162,22 @@ private void onWidgetLoaded(WidgetLoaded event) { textToSpeech.speak(voiceID, text, 0, MagicUsernames.DIALOG); }); } else if (event.getGroupId() == InterfaceID.DIALOG_NPC) { + // Add the NPC synchronously so the very first OverheadTextChanged in this tick + // is suppressed. The name widget isn't filled in yet, but the player's interaction + // target was set the moment they clicked "Talk-to". + Player local = client.getLocalPlayer(); + if (local != null && local.getInteracting() instanceof NPC) { + String n = ((NPC) local.getInteracting()).getName(); + if (n != null) currentDialogNpcNames.add(Text.removeTags(n).toLowerCase()); + } + // Backup: read the dialog name widget at tick end, in case interacting wasn't this NPC + // (e.g. multi-NPC scripted conversation where the talking NPC changes). + clientThread.invokeAtTickEnd(() -> { + Widget nw = client.getWidget(ComponentID.DIALOG_NPC_NAME); + if (nw != null && nw.getText() != null) { + currentDialogNpcNames.add(Text.removeTags(nw.getText()).toLowerCase()); + } + }); if (!config.npcDialogEnabled()) return; // InvokeAtTickEnd to wait until the text has loaded in clientThread.invokeAtTickEnd(() -> { @@ -205,6 +228,7 @@ private void onOverheadTextChanged(OverheadTextChanged event) { if (!config.npcOverheadEnabled()) return; if (isAreaDisabled()) return; NPC npc = (NPC) event.getActor(); + if (config.suppressOverheadDuringDialog() && isInDialogWith(npc)) return; if (!muteManager.isNpcAllowed(npc)) return; int distance = PluginHelper.getActorDistance(event.getActor()); @@ -457,6 +481,28 @@ private boolean checkMuteLevelThreshold(ChatMessage message) { } + private boolean isInDialogWith(NPC npc) { + if (currentDialogNpcNames.isEmpty()) return false; + String npcName = npc.getName(); + if (npcName == null) return false; + return currentDialogNpcNames.contains(Text.removeTags(npcName).toLowerCase()); + } + + @Subscribe + private void onWidgetClosed(WidgetClosed event) { + int group = event.getGroupId(); + if (group != InterfaceID.DIALOG_NPC && group != InterfaceID.DIALOG_PLAYER) return; + // A pane closing may just be a transition to the other pane (NPC line ↔ player reply); + // defer to tick end so the new pane has had a chance to load before we decide. + clientThread.invokeAtTickEnd(() -> { + Widget npcText = client.getWidget(ComponentID.DIALOG_NPC_TEXT); + Widget playerText = client.getWidget(ComponentID.DIALOG_PLAYER_TEXT); + boolean stillOpen = (npcText != null && !npcText.isHidden()) + || (playerText != null && !playerText.isHidden()); + if (!stillOpen) currentDialogNpcNames.clear(); + }); + } + private static int getGroupId(int component) { return component >> 16; } diff --git a/src/main/java/dev/phyce/naturalspeech/configs/NaturalSpeechConfig.java b/src/main/java/dev/phyce/naturalspeech/configs/NaturalSpeechConfig.java index 5b2785c..6d747c5 100644 --- a/src/main/java/dev/phyce/naturalspeech/configs/NaturalSpeechConfig.java +++ b/src/main/java/dev/phyce/naturalspeech/configs/NaturalSpeechConfig.java @@ -46,6 +46,7 @@ final class ConfigKeys { public static final String HOLD_SHIFT_RIGHT_CLICK_MENU = "holdShiftRightClickMenu"; public static final String MUTE_GRAND_EXCHANGE_NPC_SPAM = "muteGrandExchangeNpcSpam"; public static final String FRIENDS_VOLUME_BOOST = "friendsVolumeBoost"; + public static final String SUPPRESS_OVERHEAD_DURING_DIALOG = "suppressOverheadDuringDialog"; } // @@ -159,6 +160,17 @@ default boolean holdShiftRightClickMenu() { return false; } + @ConfigItem( + position=10, + keyName=ConfigKeys.SUPPRESS_OVERHEAD_DURING_DIALOG, + name="Mute overhead text during dialogs", + description="Silence overhead chatter when dialog is open", + section=generalSettingsSection + ) + default boolean suppressOverheadDuringDialog() { + return true; + } + // @ConfigItem( // position=5, // keyName="playbackSpeed",