Skip to content

Add: Global Hotkey Mute Microphone + Floating Overlay mod - #5221

Open
Eliasilyz wants to merge 8 commits into
ramensoftware:mainfrom
Eliasilyz:main
Open

Add: Global Hotkey Mute Microphone + Floating Overlay mod#5221
Eliasilyz wants to merge 8 commits into
ramensoftware:mainfrom
Eliasilyz:main

Conversation

@Eliasilyz

Copy link
Copy Markdown

A customizable floating overlay and global hotkey to toggle and monitor the default microphone's mute state.

Changelog

If this pull request updates an existing mod, describe the changes below:

  • Initial release.

Mod authorship

If this pull request introduces a new mod, please complete the section below.

This mod was created by:

  • The submitter, without AI assistance
  • The submitter, with AI assistance
  • Claude
  • ChatGPT
  • Gemini
  • Another AI (please specify):
  • Other (please specify):

Please select the options that best apply. Your selection does not affect the acceptance criteria, but it helps reviewers understand the context of the code and provide relevant feedback.


Summary

Adds a new Windhawk mod, mic-mute-hotkey-overlay.cpp, that binds a global hotkey to toggle mute on the system's default microphone and shows a floating, always-on-top overlay indicator while doing so.

What it does

  • Global hotkey (default Ctrl+Alt+M, configurable) toggles mute via IAudioEndpointVolume on the default capture device.
  • Layered, always-on-top overlay (GDI+ rendered) shows mic state: green + pulsing animation when unmuted, red with a slash when muted.
  • Configurable: Size, position, auto-hide delay, and hotkey binding are all configurable through mod settings and applied live without reloading.

Testing

  • Compiled and ran via Windhawk on Windows 11, mod loaded into explorer.exe.
  • Verified hotkey toggles mute, overlay shows/hides on toggle, and overlay position/size updates live after changing settings.

Known limitations

  • Synthetic Pulse: Mic-active animation is a synthetic pulse, not driven by real audio peak levels — IAudioMeterInformation isn't fully defined in the Windhawk compiler's SDK headers (forward-declared only), so real peak metering isn't available in this build environment.
  • Single Monitor: Auto position currently only accounts for the primary monitor.
  • No Dragging: No drag-to-reposition support directly on the overlay window.

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 26, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

@windhawk-reviewer

Copy link
Copy Markdown

@Eliasilyz /ai-review can't be applied here: the AI review only handles a pull request that adds or updates a single mod file under mods/, but mods/mic-mute-hotkey-overlay.cpp isn't a mod file.

Comment /ready-for-reviewer to hand this pull request over to a human reviewer directly. See the pull request review process for details.

@Eliasilyz Eliasilyz left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ai-review

Eliasilyz

This comment was marked as spam.

@Eliasilyz Eliasilyz left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ai-review

@Eliasilyz Eliasilyz left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ai-review

@windhawk-reviewer

Copy link
Copy Markdown

@Eliasilyz /ready-for-reviewer can't be applied here: this pull request hasn't been through an AI review yet. Comment /ai-review first.

See the pull request review process for details.

Eliasilyz

This comment was marked as spam.

Eliasilyz

This comment was marked as spam.

@Eliasilyz

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 26, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The concept is nice and the GDI+ overlay rendering is clean, but the mod is injected into explorer.exe without needing to be, and the unload path can leave the worker thread running inside an unmapped module. Those two are the main things to fix.

1. This should be a tool mod (@include windhawk.exe), not an explorer.exe mod.

The mod installs no function hooks and no symbol hooks at all — it starts its own thread, creates its own window, calls RegisterHotKey, and talks to Core Audio. None of that needs code injection into the shell. Injecting anyway costs you two concrete things:

  • Multiple explorer.exe processes. There can be more than one (the "launch folder windows in a separate process" option, and other cases). Each instance runs ModThreadProc, so you get N worker threads, N COM+GDI+ initializations and N overlay windows. Only the first RegisterHotKey(hwnd, 1, ...) succeeds — the rest fail silently (the return value isn't checked), so those instances have a dead hotkey. With alwaysShow enabled you get N identical topmost overlays stacked on the same pixels.
  • Shell stability. Any fault in the render loop or the COM path takes down the whole shell.

This is exactly the case the wiki describes: Mods as tools: Running mods in a dedicated process. Change @include to windhawk.exe, rename Wh_ModInit / Wh_ModSettingsChanged / Wh_ModUninit to WhTool_*, and paste the launcher snippet from the wiki verbatim at the end of the file.

Very close prior art to model on — lock-keys-notifier.wh.cpp is architecturally the same mod (tool mod, worker thread, layered GDI+ toast overlay driven by a keyboard event). Also always-on-top.wh.cpp (tool mod + RegisterHotKey), mic-tray-control.wh.cpp (tool mod + default-mic mute), and explorer-folder-hover-menu.wh.cpp for a verbatim copy of the launcher boilerplate.

2. Wh_ModUninit can return while the worker thread is still running — that crashes the host.

Windhawk FreeLibrarys the mod as soon as Wh_ModUninit returns. If the thread is still inside GetMessage, its instruction pointer and return address are in the now-unmapped image. There are two ways to reach that here:

  • g_hOverlay is NULL when Wh_ModUninit runs — either the worker hasn't reached CreateOverlayWindow() yet (a real race: disable/settings-reload right after enabling), or RegisterClassEx/CreateWindowEx failed (neither return value is checked). Then no WM_QUIT is ever posted, the thread loops forever, and the wait just burns 3 seconds before returning anyway.
  • Even on the happy path, WaitForSingleObject(g_hThread, 3000) is a "give up and crash later" timeout.

Also, g_hOverlay is written on the worker thread and read from the Windhawk thread with no synchronization.

Post WM_QUIT to the thread (not the window) with a retry — PostThreadMessage fails until the target thread has a message queue — and then wait INFINITE:

DWORD WINAPI ModThreadProc(LPVOID) {
    MSG msg;
    // Force the message queue to exist so PostThreadMessageW can't be dropped.
    PeekMessageW(&msg, nullptr, WM_USER, WM_USER, PM_NOREMOVE);
    ...
}

void Wh_ModUninit() {
    if (g_threadId) {
        while (!PostThreadMessageW(g_threadId, WM_QUIT, 0, 0) &&
               WaitForSingleObject(g_hThread, 10) == WAIT_TIMEOUT) {
        }
    }
    if (g_hThread) {
        WaitForSingleObject(g_hThread, INFINITE);
        CloseHandle(g_hThread);
        g_hThread = nullptr;
    }
}

References: aero-flip3d-recreation.wh.cpp#L3529-L3547 for the retry-then-INFINITE pattern, and caps-ime-switcher.wh.cpp#L342-L344 for the PeekMessageW queue-creation trick.

Note this also matters for UnregisterClass: if the thread never exits cleanly, the class stays registered with an lpfnWndProc pointing into unmapped memory, and the next load either fails with ERROR_CLASS_ALREADY_EXISTS or dispatches to a dangling pointer.

3. The default capture device is resolved once and never refreshed.

InitAudio() grabs GetDefaultAudioEndpoint(eCapture, eConsole, ...) at startup and holds that IAudioEndpointVolume forever. When the user changes the default mic (plugs in a headset, switches in Sound settings), the hotkey keeps muting the old device and the overlay shows the old device's state — silently doing the wrong thing, which for a mic-mute indicator is the worst failure mode.

Related: if InitAudio() fails at startup (no capture device present yet, or Core Audio not ready when explorer starts), it's never retried — the mod is dead until it's reloaded.

Register an IMMNotificationClient and re-resolve on OnDefaultDeviceChanged(eCapture, eConsole, ...), or just re-resolve the endpoint on every toggle. See mic-tray-control.wh.cpp#L498-L525. Note the notification callbacks arrive on an MTA thread, so post to your overlay window rather than touching the interfaces directly.

4. hotkeyModifiers: 0 registers a bare key system-wide.

Nothing validates the mask. With hotkeyModifiers set to 0, mods ends up as just MOD_NOREPEAT, and RegisterHotKey happily registers plain M (or whatever hotkeyVK is) as a global hotkey — swallowing that key in every application on the system, with no obvious way for the user to connect the symptom to this mod. Given the setting is a raw decimal bitmask, 0 is an easy thing for a user to type.

Reject a zero mask (and a zero/invalid VK), and check the RegisterHotKey result so a conflict with another app is at least visible:

if (!RegisterHotKey(hwnd, 1, mods, g_settings.hotkeyVK)) {
    Wh_Log(L"RegisterHotKey failed: %u", GetLastError());
}

5. clickThrough and alwaysShow have no effect on a settings change.

The WM_APP + 1 handler re-registers the hotkey, repositions and re-renders — but:

  • WS_EX_TRANSPARENT is only applied in CreateOverlayWindow, so toggling clickThrough does nothing until the mod is reloaded.
  • Enabling alwaysShow doesn't show the overlay (nothing calls ShowOverlayTemporarily/ShowWindow, and UpdateLayeredWindow doesn't unhide a hidden window), so it appears to do nothing until the next hotkey press.

Both should be applied in the WM_APP + 1 handler, e.g.:

LONG_PTR ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
ex = g_settings.clickThrough ? (ex | WS_EX_TRANSPARENT) : (ex & ~WS_EX_TRANSPARENT);
SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex);

if (g_settings.alwaysShow && !g_overlayVisible) {
    ShowOverlayTemporarily(hwnd);
}

6. Positioning ignores the work area, secondary monitors, and DPI.

ComputeOverlayRect uses GetSystemMetrics(SM_CXSCREEN/SM_CYSCREEN), which is the primary monitor's full bounds. Consequences:

  • In auto mode the overlay is placed at screenHeight - size - margin, i.e. underneath the taskbar on a default Windows setup. Use MONITORINFO::rcWork.
  • Auto placement can never target a secondary monitor. And because "auto" is encoded as any negative value, a monitor positioned to the left of / above the primary (negative virtual-screen coordinates) can't be targeted manually either — use a separate overlayAutoPosition boolean instead of overloading the sign.
  • overlaySize is raw pixels, so a 72 px badge is visually half-size at 200% scaling. Scale by the target monitor's effective DPI.

lock-keys-notifier.wh.cpp#L1006-L1032 has ready-made WorkAreaForTarget() / MonitorDpi() helpers for exactly this, and #L1389-L1400 shows the SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) call the worker thread needs so UpdateLayeredWindow coordinates aren't virtualized on mixed-scale setups. GetDpiForMonitor needs -lshcore.

7. The window class uses explorer.exe's HINSTANCE.

wc.hInstance = GetModuleHandle(nullptr) returns the host executable, not the mod. The class ends up owned by explorer.exe while its lpfnWndProc lives in the mod DLL. Use the mod's own module handle for RegisterClassEx, CreateWindowEx and UnregisterClass:

HINSTANCE GetCurrentModuleHandle() {
    HINSTANCE hInst = nullptr;
    GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                          GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                      (LPCWSTR)&GetCurrentModuleHandle, &hInst);
    return hInst;
}

Note GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT — do not take a reference on the mod's own module. See audio-scroll-switcher.wh.cpp#L125-L134. While you're there, check the RegisterClassEx and CreateWindowEx return values (see item 2 — a NULL window handle currently means a thread that can never be told to quit).

8. The 30 fps render loop never stops while alwaysShow is on.

Every WM_TIMER tick (33 ms) allocates a fresh DIB section, re-runs the whole GDI+ path (3 ellipses + a GraphicsPath + several pens), calls UpdateLayeredWindow, and makes a COM GetMute() call. With alwaysShow enabled KillTimer is never reached, so this runs forever — including in the muted state, where GetAnimLevel returns 0, nothing animates, and every frame is byte-identical to the previous one.

Only run the timer while something is actually animating, and stop it once the visual is static; re-render on state change instead of polling. Mute-state changes can be event-driven too via IAudioEndpointVolume::RegisterControlChangeNotify + IAudioEndpointVolumeCallback::OnNotify, which removes the 30 Hz GetMute() polling entirely.

9. README/metadata.

  • @description is in Indonesian ("Global hotkey untuk mute/unmute mic default..."). Primary user-facing strings should be English; you can add the Indonesian version via localization, e.g. @description:id-ID. Same applies to any settings $name/$description you localize later.
  • The mod's whole point is a visible overlay, but the README has no image. Please add a screenshot or a short GIF of the overlay in both states — i.imgur.com and raw.githubusercontent.com are the allowed image hosts.
Optional improvements

Minor polish — none of this affects users, so it's your call.

  • Unused build dependencies. -loleaut32, -ldwmapi and -lpropsys aren't used by any code in the file. Likewise #include <audioclient.h> and #include <algorithm> are unused, and #pragma comment(lib, "gdiplus.lib") is an MSVC-ism that's redundant next to -lgdiplus.
  • The SDK version blocks are dead code. Windhawk already compiles mods with WINVER=0x0A00, _WIN32_WINNT=0x0A00, NTDDI_VERSION=0x0A000008 (see .vscode/c_cpp_properties.json in this repo), so the #ifndef blocks at the top never fire. If they ever did, they'd pin the headers to Vista and hide the per-monitor DPI APIs from item 6. Just delete them.
  • Drop the log prefix. Wh_Log(L"MicMuteOverlay: failed to init audio endpoint") — Windhawk already prefixes log lines with the mod name, so Wh_Log(L"Failed to init audio endpoint") is enough.
  • g_settings is written and read across threads. LoadSettings() runs on the Windhawk thread during Wh_ModSettingsChanged while the worker thread is reading the struct mid-render. Only reachable on a settings change, so low stakes, but the cleanest fix is to load into a local ModSettings and hand it to the worker via the WM_APP + 1 message (or guard the struct with a lock / make the individual fields atomic). Related: RenderOverlay reads g_settings.overlaySize into size, then ComputeOverlayRect reads it again — the two can disagree if it changes in between.
  • overlayDurationMs isn't clamped. A negative value is cast to DWORD in the comparison, becomes ~4.3 billion, and the overlay never auto-hides. Clamp it the way overlaySize is clamped.
  • The hotkey settings are hard to use. A decimal bitmask plus a decimal VK code is a rough UX for a hotkey. Consider a single string setting like "Ctrl+Alt+M" parsed at load time — keyboard-shortcut-actions.wh.cpp does exactly this — or $options dropdowns for the modifiers and the key.
  • WM_APP + 1. It's your own window class so a collision is unlikely, but RegisterWindowMessage(L"WH_MicMuteOverlay_SettingsChanged") is free and rules it out entirely.
  • IsMicMuted() is called twice in the WM_HOTKEY handler (RenderOverlay(hwnd, GetAnimLevel(IsMicMuted()), IsMicMuted())) — two COM calls where one local bool would do. That render call is also redundant with the timer tick that ShowOverlayTemporarily just scheduled.
  • Positioning relative to existing mods. Nothing in the catalog currently does hotkey → mic mute, so there's room for this — but it's worth a line in the README saying how it differs from mic-tray-control (tray-click mute of the default mic). Also consider whether a ACTION_MIC_MUTE contributed to keyboard-shortcut-actions — which is the established global-hotkey framework here, and currently only handles eRender devices — would reach more users than a standalone hotkey.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • Real peak metering is achievable. The README lists the synthetic pulse as a limitation because IAudioMeterInformation is only forward-declared in the compiler's headers. That's routinely worked around in this repo by declaring the interface yourself with MIDL_INTERFACE and its IID — see audio-scroll-switcher.wh.cpp#L76 for the pattern. IMMDevice::Activate(__uuidof(IAudioMeterInformation), ...) then gives you GetPeakValue() and a genuinely reactive ring animation. Worth doing if you keep the animation at all — a pulse that ignores whether the user is actually talking is arguably worse than no animation.
  • A topmost overlay in the shell process won't render over exclusive-fullscreen apps. For a mic indicator the most valuable moment is usually during a call or a game, so it's worth mentioning the limitation in the README.
  • GetTickCount() works correctly here (the DWORD subtraction is wrap-safe), but GetTickCount64() is the modern spelling if you're touching that code anyway.
  • Drag-to-reposition and per-monitor auto placement are listed as known limitations — both fall out naturally once item 6 is done, since you'll already have the monitor and DPI in hand. WM_NCHITTEST returning HTCAPTION gives you dragging for free when clickThrough is off (which would also make clickThrough: false do something useful — right now the non-click-through mode just blocks clicks and does nothing with them).


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 26, 2026
@Eliasilyz

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 27, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The tool-mod conversion, the event-driven mute/device notifications, the UNCHANGED_REFCOUNT module handle and the join-before-unload teardown are all done right. Three things worth fixing:

1. The 30 fps timer runs forever whenever the mic is unmuted, even with the overlay hidden.

EnsureTimerState (line 563) keys the timer off the mute state, not off visibility:

bool needTimer = !g_micMuted || (g_overlayVisible && !g_settings.alwaysShow);

With the default settings (alwaysShow: false) and an unmuted mic — i.e. the normal state for most of the session — !g_micMuted is true, so the timer never stops. Every 33 ms WM_TIMER (line 655) does a full GetMicPeak() call into the audio engine, a CreateDIBSection + GDI+ antialiased redraw, and an UpdateLayeredWindow — on a window that isn't even on screen. That's a dedicated windhawk.exe process spinning at 30 fps 24/7, which keeps the CPU out of idle states and costs battery for nothing.

Nothing in the hidden state uses the polled peak: activeSignal in WM_TIMER only extends the visible time, it never shows the overlay. So the timer is only needed while the overlay is actually visible:

bool needTimer =
    g_overlayVisible && (!g_micMuted || !g_settings.alwaysShow);

and WM_TIMER should skip RenderCurrentState when !g_overlayVisible. ShowOverlayTemporarily already calls EnsureTimerState indirectly at every show site, so the timer restarts correctly when the overlay comes back.

2. The hotkey settings are a raw bitmask plus a decimal virtual-key code.

- hotkeyModifiers: 3
  $description: Bitmask: 1=Alt, 2=Ctrl, 4=Shift, 8=Win. ...
- hotkeyVK: 77
  $description: "Decimal VK code, default 77 = 'M'"

To rebind this to, say, Win+Shift+F9 a user has to add 8+4 and look up VK_F9 = 120 on MSDN. The repo's established format for this is a single readable string — see keyboard-shortcut-actions.wh.cpp, which takes Ctrl+Alt+D and parses it in FromStringHotKey (modifier map + VK name map + numeric fallback). Replacing both settings with one hotkey: "Ctrl+Alt+M" string read via WindhawkUtils::StringSetting would be a straight improvement, and the "at least one modifier required" validation you already have carries over unchanged.

3. Overlap with existing mods — please state the differentiation.

Two mods already cover parts of this:

  • mic-tray-control.wh.cpp — also a windhawk.exe tool mod, toggles mute on the default capture device and shows a mute-state indicator (tray icon rather than a floating overlay).
  • keyboard-shortcut-actions.wh.cpp — global hotkeys bound to actions, already including ACTION_MUTE (system volume). A Mute microphone action there would be a very natural addition and would cover the hotkey half of this mod for everyone using it.

The floating peak-driven overlay is genuinely new, so this isn't a straight duplicate — but the maintainer's strong preference is to extend an existing mod rather than add a near-neighbour. Worth either contributing the mic-mute action upstream to keyboard-shortcut-actions (development happens at https://github.com/m417z/my-windhawk-mods) and keeping this mod focused on the overlay, or adding a short "how this differs from X" line to the README so users can pick.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • Leftover review-artifact comments. Lines 144 (NOT GetModuleHandle(nullptr) — see item 7 of the review), 387 (item 6: work area, multi-monitor, DPI awareness) and 562 (never spins forever in the muted + alwaysShow case (item 8)) reference a review the reader of the merged mod can't see. Keep the technical explanation, drop the "item N" references.

  • g_audioLock doesn't protect anything. Every EnterCriticalSection call site — ResolveAudioEndpoint, CleanupAudio, ToggleMicMute, GetMicPeak — runs on the overlay thread, and the two COM callbacks deliberately only PostMessage. The critical section (and InitializeCriticalSection/DeleteCriticalSection) can go, or if you'd rather keep it as a guard against future call sites, a comment saying so would help.

  • g_settings is written from a different thread than it's read from. WhTool_ModSettingsChanged calls LoadSettings() on the Windhawk thread while the overlay thread is reading g_settings in ComputeOverlayRect/EnsureTimerState/WM_TIMER. Worst case is one frame rendered with a half-applied config, so it's cosmetic, but it's cleanly avoidable — post kMsgSettingsChanged first and call LoadSettings() inside that handler, on the overlay thread.

  • The DIB section and memory DC are recreated on every frame. RenderOverlay (lines 446-459, 531-544) does CreateDIBSection + CreateCompatibleDC + DeleteObject/DeleteDC per tick. Caching them and only rebuilding when size changes removes 30 GDI object create/destroy pairs per second while the overlay is up.

  • ToggleMicMute re-resolves the whole endpoint chain on every hotkey press. ResolveAudioEndpoint releases and re-activates IMMDevice + IAudioEndpointVolume + the meter, and re-registers the volume callback, on each toggle. Since IMMNotificationClient::OnDefaultDeviceChanged already keeps the endpoint fresh, this is redundant work (and cross-process RPC) on the hotkey path. Keeping it as a fallback only when g_pEndpointVolume is null would be enough.

  • #pragma comment(lib, "gdiplus.lib") (line 113) is an MSVC-ism and redundant with -lgdiplus in @compilerOptions.

  • IID_IAudioMeterInformation2 (line 137) duplicates the GUID already given to MIDL_INTERFACE; __uuidof(IAudioMeterInformation2) works directly. Also worth re-checking the premise — if the current toolchain's endpointvolume.h does declare IAudioMeterInformation in full, the private copy can be dropped entirely.

  • The tool-mod boilerplate has been reformatted. It's functionally identical to the wiki snippet, but the line wrapping differs in ~5 places (GetModuleFileName, the commandLine declaration, the CreateProcessInternalW_t typedef, the GetProcAddress and pCreateProcessInternalW calls). The convention is a verbatim copy so the boilerplate can be diffed across mods — see explorer-folder-hover-menu.wh.cpp. The file is also missing a trailing newline.

  • @version 2.0.0 on an initial release is a bit confusing next to the "Initial release" changelog; 1.0.0 would read better.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • Auto-hide never fires while the mic picks up sound. activeSignal = !g_micMuted && peak > 0.03f blocks the hide branch, so during a call the overlay stays on screen for the entire call even with alwaysShow: false and overlayDurationMs: 1500. That may well be the intent given the mod's description, but the setting is named "Overlay auto-hide delay after toggle" and gives no hint of it — worth documenting, or making it a separate keepVisibleWhileActive option.

  • The synthetic fallback pulse makes the badge colour blink. When peak metering isn't available, GetMicPeak returns (sin(2πφ)*0.5+0.5)*0.5, which dips below the peak > 0.03f threshold used for colActive vs colIdle (line 492) for roughly two ticks out of every ~22. The result is a grey flicker about 1.4 times per second. Using a separate "is the mic live" flag for the colour, and the pulse only for the ring geometry, would avoid it.

  • Unmuted-but-idle renders grey. IAudioMeterInformation on a capture endpoint reports 0 unless some application actually has a capture stream open, so with real metering an unmuted mic that nothing is using shows colIdle (grey/blue). Users may read grey as "disabled". Consider a distinct colour for unmuted-idle vs. muted, or basing the base colour on the mute state only and letting the rings carry the level.

  • clickThrough: false has no upside as implemented. The overlay has no click, drag or context-menu behaviour, so turning click-through off only makes it swallow clicks on whatever is underneath. Either drop the setting or pair it with the drag-to-reposition feature listed in the README's limitations.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 27, 2026
@Eliasilyz

Copy link
Copy Markdown
Author

/ready-for-reviewer

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-reviewer Ready for a human reviewer, and in the queue for one. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 27, 2026
@m417z

m417z commented Aug 29, 2026

Copy link
Copy Markdown
Member

How about addressing the first and second notes of the review (performance, hotkey settings)?

I'd also consider addressing this:

  • The DIB section and memory DC are recreated on every frame. RenderOverlay (lines 446-459, 531-544) does CreateDIBSection + CreateCompatibleDC + DeleteObject/DeleteDC per tick. Caching them and only rebuilding when size changes removes 30 GDI object create/destroy pairs per second while the overlay is up.

  • clickThrough: false has no upside as implemented. The overlay has no click, drag or context-menu behaviour, so turning click-through off only makes it swallow clicks on whatever is underneath. Either drop the setting or pair it with the drag-to-reposition feature listed in the README's limitations.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 29, 2026
@Eliasilyz

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Overall this is in good shape: correct tool-mod structure with the launcher boilerplate intact, proper thread join in WhTool_ModUninit, UnregisterClass on teardown, COM released on the thread that created it, event-driven mute/device tracking, DPI-aware positioning, and a README with screenshots. The items below are the ones worth fixing.

1. The 30 fps render loop never stops while the mic is unmuted — even with the overlay hidden.

bool needTimer =
    !g_micMuted || (g_overlayVisible && !g_settings.alwaysShow);

!g_micMuted is the normal state, so the TIMER_ANIM timer stays armed permanently. Every 33 ms WM_TIMER does a COM GetPeakValue(), MonitorFromPoint/GetMonitorInfo/GetDpiForMonitor, a full GDI+ repaint of ~8 shapes, and an UpdateLayeredWindow — all of it while the overlay is invisible and nothing is looking at it. That's continuous CPU + DWM work for the entire time the mod is enabled, which is exactly the kind of always-on polling that gets flagged (the comment above EnsureTimerState claims it "never spins forever", but it does in the far more common unmuted case).

Nothing needs to animate while the window is hidden — peak level never triggers a show, only the hotkey and mute-change messages do. Gate the timer on visibility:

bool needTimer =
    g_overlayVisible && (!g_micMuted || !g_settings.alwaysShow);

2. Changing the mic's input volume pops up the mute overlay.

IAudioEndpointVolumeCallback::OnNotify fires for volume changes as well as mute changes, and OnNotify posts kMsgMuteChanged unconditionally:

STDMETHODIMP OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA pNotify) override {
    if (g_hOverlay && pNotify) {
        PostMessage(g_hOverlay, kMsgMuteChanged, pNotify->bMuted ? 1 : 0, 0);
    }

The handler then always calls ShowOverlayTemporarily. So moving the mic slider in Sound settings — or any app that adjusts the capture endpoint volume (some conferencing apps do this for AGC) — flashes the overlay even though the mute state didn't change. Filter on an actual transition:

case kMsgMuteChanged: {
    bool muted = wParam != 0;
    if (muted == g_micMuted) {
        return 0;  // volume-only notification, mute unchanged
    }
    g_micMuted = muted;
    ShowOverlayTemporarily(hwnd);
    ...
}

As a bonus this also drops the duplicate show/render that currently happens after every hotkey press (your own SetMute triggers OnNotify right after ToggleMicMute already rendered).

3. WM_MOVE can't distinguish a user drag from a programmatic move, so auto-position silently stops working when click-through is off.

case WM_MOVE: {
    if (!g_settings.clickThrough) {
        g_manualPos.x = (short)LOWORD(lParam);
        ...
        g_manualPosition = true;
    }

SetWindowPos (in ApplyOverlayPositionAndSize) and UpdateLayeredWindow with a pptDst both generate WM_WINDOWPOSCHANGEDWM_MOVE. In kMsgSettingsChanged you clear g_manualPosition, then immediately call ApplyOverlayPositionAndSize, which synchronously re-sets it to true. From then on ComputeOverlayRect takes the manual branch, so the overlay no longer re-anchors to rcWork when the work area changes (taskbar size/auto-hide toggled, resolution change, primary monitor change) — the overlayAutoPosition setting is effectively frozen at whatever it computed once.

Capture the position only at the end of a real drag instead:

case WM_EXITSIZEMOVE: {
    if (!g_settings.clickThrough) {
        RECT wr;
        GetWindowRect(hwnd, &wr);
        g_manualPos = {wr.left, wr.top};
        g_manualPosition = true;
    }
    return 0;
}

(and drop the WM_MOVE handler).

4. Please state how this differs from the existing mods in this area.

The closest ones are mic-tray-control and mutealert (both surface default-mic mute state and let you toggle it, via the tray/taskbar rather than a hotkey), and keyboard-shortcut-actions, which is a general hotkey→action framework using the same Ctrl+Alt+M string format and already ships a "Mute system volume" action. The floating overlay is a genuine differentiator, but the hotkey half overlaps keyboard-shortcut-actions closely enough that a "Mute microphone" action there might serve users better than a second hotkey parser. Worth a sentence in the PR description on why a separate mod is the right call — the maintainer generally prefers extending an existing mod over a partial-overlap new one.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • RegisterGlobalHotkey re-derives the modifier mask from raw bit values:

    if (g_settings.hotkeyModifiers & 1) mods |= MOD_ALT;
    if (g_settings.hotkeyModifiers & 2) mods |= MOD_CONTROL;
    ...

    ParseHotkeyString already stores MOD_* flags, and this only round-trips correctly because MOD_ALT/MOD_CONTROL/MOD_SHIFT/MOD_WIN happen to be 1/2/4/8. Just use UINT mods = g_settings.hotkeyModifiers;.

  • g_settings is written by LoadSettings() on Windhawk's callback thread while the overlay thread reads it (WM_TIMER, ComputeOverlayRect, EnsureTimerState), and g_hOverlay is written on the worker thread and read from the COM callback threads. Benign in practice, but the clean fix is to have WhTool_ModSettingsChanged only post kMsgSettingsChanged and call LoadSettings() from the message handler on the owning thread (keeping the direct call for the not-yet-created-window case).

  • g_audioLock is never actually contended: ResolveAudioEndpoint, ToggleMicMute, GetMicPeak and CleanupAudio are all reached only from the overlay thread, and the two COM callbacks deliberately only PostMessage. Either drop the critical section or add a comment saying it's defensive — as written it reads like there's cross-thread access that doesn't exist.

  • Wh_GetStringSetting never returns NULL (it returns L"" on error/unset), so the if (hotkeyStr) guard is dead. WindhawkUtils::StringSetting (from <windhawk_utils.h>) is the idiomatic RAII form and removes the manual Wh_FreeStringSetting.

  • #pragma comment(lib, "gdiplus.lib") is an MSVC-ism and redundant next to -lgdiplus in @compilerOptions — drop it.

  • IAudioMeterInformation2: mutealert solves the same MinGW gap while keeping the canonical name, guarded so it disappears once the toolchain header catches up:

    #ifndef __IAudioMeterInformation_INTERFACE_DEFINED__
    #define __IAudioMeterInformation_INTERFACE_DEFINED__
    MIDL_INTERFACE("c02216f6-8c67-4b5b-9d00-d008e73e0064")
    IAudioMeterInformation : public IUnknown { ... };
    #ifdef __CRT_UUID_DECL
    __CRT_UUID_DECL(IAudioMeterInformation, 0xc02216f6, ...)
    #endif
    #endif

    That also lets you use __uuidof(...) instead of the hand-written IID_IAudioMeterInformation2 constant.

  • Comments referencing a previous review's numbering — // Module handle (NOT GetModuleHandle(nullptr) — see item 7 of the review), (item 6: work area, multi-monitor, DPI awareness), (item 8) — are meaningless to anyone reading the file later. Same for the two comments in ToggleMicMute: one says the endpoint is "Cheap enough to call on every toggle", the other says "a full re-resolve here isn't needed on the hot path", and the code does the latter.

  • GdiplusStartup's return value isn't checked; if it fails, g_gdiplusToken stays 0 and every render silently draws nothing. A Wh_Log on failure would make that diagnosable.

  • overlayMargin is clamped at the low end only, so a large value pushes the overlay off-screen with no way to see it again. Consider clamping it against the work-area size.

  • The launcher block at the bottom is functionally identical to the wiki snippet but re-wrapped by clang-format in three places. Keeping it byte-identical (and adding the missing trailing newline at EOF) makes it easier to diff against the template when it's updated.

  • Version 2.0.0 for an initial release is a little surprising given the changelog says "Initial release" — not a problem, just noting it in case it was unintentional.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The unmuted state usually renders grey, not green. IAudioMeterInformation::GetPeakValue on a capture endpoint reports ~0 unless some app has an active capture stream, and the fill color is peak > 0.03f ? colActive : colIdle — so pressing the hotkey to unmute while nothing is recording shows a grey mic, which doesn't read as "on". The fallback synthetic pulse only kicks in when metering is unavailable, not when it reads 0. Consider using the green fill for "unmuted" and reserving the pulse rings for actual input level, so the icon color always answers "am I muted?" at a glance.

  • The overlay won't auto-hide while there's any mic input. activeSignal = !g_micMuted && peak > 0.03f blocks the auto-hide check entirely, so during a call the overlay stays up indefinitely regardless of overlayDurationMs. That may well be intended, but it isn't mentioned in the README or the setting description — worth documenting, or making it a separate option.

  • Auto-position always uses MonitorFromPoint({0,0}, MONITOR_DEFAULTTOPRIMARY), i.e. the primary monitor only. Following the monitor with the foreground window (or the cursor) would be more useful on multi-monitor setups; the settings description does say "primary monitor", so this is just an enhancement idea.

  • While dragging (click-through off), the WM_TIMER render calls UpdateLayeredWindow with a pptDst every 33 ms, which repositions the window in parallel with the modal move loop. It tracks because g_manualPos is updated from the move messages, but it may look jittery — passing nullptr for pptDst and letting SetWindowPos/the move loop own the position would avoid the two mechanisms fighting.

  • overlaySize and overlayMargin are scaled by the target monitor's DPI, but overlayPosX/overlayPosY are used as raw physical coordinates. That's documented, but it means the manual position doesn't move when DPI changes while the size does.

  • With click-through off, WM_NCHITTEST returns HTCAPTION for the whole window, so the overlay is one big drag handle and a double-click / right-click on it goes through the system-menu path. Restricting the drag area (or requiring a modifier) would leave room for future click actions on the overlay itself.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 29, 2026
@Eliasilyz

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Overall this is a well-structured tool mod: correct WhTool_* usage, a dedicated overlay thread that owns the window/COM/GDI+ and joins cleanly in WhTool_ModUninit, event-driven mute/device tracking that only PostMessages from the COM callback threads, and a settings block that matches the code exactly. A few things to fix:

1. Drag-to-reposition fights the render timer

RenderOverlay passes pptDst to UpdateLayeredWindow, which moves the window, and it runs from WM_TIMER at ~30 fps:

POINT ptDst = {rc.left, rc.top};
...
UpdateLayeredWindow(hwnd, nullptr, &ptDst, &sizeWnd, g_memDC, &ptSrc, 0, &blend, ULW_ALPHA);

The modal move loop entered from WM_NCHITTESTHTCAPTION keeps dispatching WM_TIMER, so while the user drags, every animation frame yanks the window back to the settings-computed position (g_manualPosition is only set at WM_EXITSIZEMOVE). The result is a jittering overlay, and the position recorded on release is whichever SetWindowPos happened last — so the drop position is unreliable. Since the timer runs whenever the overlay is visible (except muted + always-show), this affects the normal case.

Fix: track the drag and don't reposition during it — nullptr for pptDst updates the content while leaving the window where the move loop put it:

case WM_ENTERSIZEMOVE:
    g_dragging = true;
    return 0;
...
UpdateLayeredWindow(hwnd, nullptr, g_dragging ? nullptr : &ptDst, &sizeWnd,
                    g_memDC, &ptSrc, 0, &blend, ULW_ALPHA);

neko-cat does exactly this — an isDragging flag set in WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE, and the animation tick syncs from GetWindowRect instead of moving the window while it's set.

2. GetMute/SetMute failures are ignored, so the indicator can show the wrong state

BOOL muted = FALSE;
g_pEndpointVolume->GetMute(&muted);
g_pEndpointVolume->SetMute(!muted, nullptr);
g_micMuted = !muted;

If SetMute fails (endpoint without ENDPOINT_HARDWARE_SUPPORT_MUTE, device removed mid-call, transient audio-service error), g_micMuted is still flipped, and since no OnNotify follows, nothing corrects it — the overlay claims "muted" while the mic is live, which is the worst failure mode for this kind of indicator. Same for a failing GetMute (muted stays FALSE). Only commit the state on success:

BOOL muted = FALSE;
if (SUCCEEDED(g_pEndpointVolume->GetMute(&muted)) &&
    SUCCEEDED(g_pEndpointVolume->SetMute(!muted, nullptr))) {
    g_micMuted = !muted;
}

3. Overlap with keyboard-shortcut-actions

The README's comparison is accurate — mic-tray-control and mutealert are tray/taskbar based with no hotkey, and keyboard-shortcut-actions mutes render endpoints, not capture. Still worth flagging for the maintainer's call: the hotkey half of this mod is a re-implementation of that mod's core (down to the same Ctrl+Alt+M string format), and adding a "mute microphone" action there would be a small change. The floating animated overlay is the genuine differentiator, so if the maintainer prefers consolidation, the natural split is a new action in keyboard-shortcut-actions plus this mod focused on the indicator.

Optional improvements

Minor polish — none of this affects users much, so it's your call.

  • Re-assert topmost when showing. ShowOverlayTemporarily only calls ShowWindow; topmost windows created since the overlay was last shown will sit above it. SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) on show fixes that.
  • Position isn't refreshed when the timer is off. With alwaysShow on and the mic muted, EnsureTimerState kills the timer, so a resolution change, monitor rearrangement, or taskbar/work-area change leaves the overlay stranded until the next mute toggle. Handling WM_DISPLAYCHANGE (and WM_SETTINGCHANGE/SPI_SETWORKAREA) with ApplyOverlayPositionAndSize + RenderCurrentState would cover it.
  • WindhawkUtils::StringSetting is the RAII form of the raw Wh_GetStringSetting + Wh_FreeStringSetting pair in LoadSettings (#include <windhawk_utils.h>): WindhawkUtils::StringSetting hotkey = WindhawkUtils::StringSetting::make(L"hotkey");.
  • Tool-mod boilerplate was reformatted. The pasted launcher differs from the wiki snippet only in line wrapping (GetModuleFileName indent, the commandLine array, the CreateProcessInternalW_t typedef and call). Keeping it byte-identical makes it easy to diff against future revisions of the snippet. The file is also missing a trailing newline.
  • Hotkey parser gaps. Punctuation and numpad keys (Ctrl+Alt+/, Ctrl+Alt+Num0) can't be expressed; a VkKeyScanW fallback for single non-alphanumeric characters would cover most of them cheaply. Also, a token longer than 31 characters is silently split and the remainder parsed as another token, which can reset vk to 0.
  • Render before showing. ShowOverlayTemporarily shows the window before RenderCurrentState runs, so the first frame after a toggle briefly shows the previous state's bitmap. Rendering first and then showing avoids it.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • Auto-hide effectively never fires while the mic hears anything. In WM_TIMER, activeSignal = !g_micMuted && peak > 0.03f suppresses the hide, so during a call (any app holding a capture stream with signal) the overlay stays on screen indefinitely and keeps re-rendering + UpdateLayeredWindow-ing at 30 fps for the whole call — while the setting is labelled "Overlay auto-hide delay after toggle (ms)" and alwaysShow is off by default. It's defensible as a "mic is live" indicator, but it's surprising from the setting name; consider making it an explicit option ("keep visible while the mic is active") and/or skipping the redraw when the rendered state hasn't meaningfully changed. Note the same threshold makes the fallback path erratic: the synthetic pulse (sinf(...) * 0.5f + 0.5f) * 0.5f spans 0–0.5, so on systems where IAudioMeterInformation fails it sits above 0.03f for most of its ~0.7 s cycle and the hide only lands on the rare tick that dips below.
  • Peak metering only moves while an app is actually capturing. GetPeakValue on the endpoint reads ~0 when no stream is open, so the "active" green + rings appear only during real capture, and the idle-but-unmuted state is the grey colIdle. That's arguably the more useful behavior, but the README's "reflects actual mic input" reads like it responds to room noise at all times.
  • A dragged position is temporary. kMsgSettingsChanged resets g_manualPosition = false, so changing any unrelated setting snaps the overlay back, and the position is lost entirely when the tool process restarts (mod reload, sign-out). Persisting it with Wh_SetIntValue(L"manualPosX", ...) / Wh_GetIntValue would make dragging stick; at minimum, the setting description could say the drag is not persisted.
  • Auto-position is primary-monitor only. MonitorFromPoint({0,0}, MONITOR_DEFAULTTOPRIMARY) pins the overlay to the primary display. Using the monitor under the cursor or of the foreground window would put the indicator where the user is looking on a multi-monitor setup.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 29, 2026
@Eliasilyz

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Overall this is a well-structured tool mod — the dedicated-process pattern, COM object ownership (everything created and released on the overlay thread), the join-in-WhTool_ModUninit teardown, window class unregistration and the IAudioMeterInformation workaround all look right. Two behavioral issues are worth fixing:

1. Auto-hide fires in the middle of a drag, and leaves almost no window to start one

WM_TIMER hides the overlay purely on elapsed time, with no exception for an in-progress move loop:

if (!g_settings.alwaysShow && g_overlayVisible) {
    bool activeSignal = !g_micMuted && peak > 0.03f;
    DWORD elapsed = GetTickCount() - g_lastActivityTick;
    if (!activeSignal && elapsed > (DWORD)g_settings.overlayDurationMs) {
        ShowWindow(hwnd, SW_HIDE);
        g_overlayVisible = false;
    }
}

The move loop started from WM_NCHITTESTHTCAPTION pumps messages, so WM_TIMER keeps firing during the drag. With the defaults (alwaysShow: false, overlayDurationMs: 1500) and a muted or silent mic, the overlay disappears 1.5 s into the drag while the user is still holding the mouse button. And since the overlay is only on screen for 1.5 s after a toggle in the first place, there's barely time to grab it at all — drag-to-reposition effectively only works if alwaysShow is enabled, which isn't what the setting description implies.

Suppress the hide while dragging and restart the countdown when the drag ends:

    if (!g_settings.alwaysShow && g_overlayVisible && !g_dragging) {
        ...
    }
case WM_EXITSIZEMOVE: {
    g_dragging = false;
    g_lastActivityTick = GetTickCount();  // don't hide right after the drop
    ...
}

2. overlayDurationMs doesn't do what its name and description say

activeSignal blocks the auto-hide for as long as the mic reports peak > 0.03, so once the user unmutes during a call the overlay stays on screen for the entire call — the "Overlay auto-hide delay after toggle (ms)" setting is silently bypassed, and alwaysShow: false no longer means "transient". This is presumably intentional (the mod is described as animating "while the mic is active"), but nothing in the settings UI says so, and with clickThrough: false it also means a permanently click-blocking 72 px square for the duration of the call.

Either make it explicit in the $description of overlayDurationMs / alwaysShow, or better, gate it behind its own setting (e.g. keepVisibleWhileActive, default off) so a user who asks for a 1.5 s indicator gets one.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • g_hOverlay is written on the overlay thread and read from Windhawk's callback thread (WhTool_ModSettingsChanged) and from the two COM notification threads, without synchronization; std::atomic<HWND> would make that well-defined. Related: the else branch of WhTool_ModSettingsChanged calls LoadSettings() on Windhawk's thread, which can overlap with CreateOverlayWindow()ComputeOverlayRect() reading g_settings on the overlay thread. The window is tiny and it can only happen on a settings change, but the comment claiming a single writer isn't quite accurate.

  • g_audioLock guards state that, as the comment above it says, is only ever touched from the overlay thread (ResolveAudioEndpoint, ToggleMicMute, GetMicPeak, CleanupAudio) — the notification callbacks deliberately only PostMessage. Since nothing else can contend it, dropping the critical section (and its Initialize/Delete pair) would remove ~20 lines and one thing to reason about.

  • Wh_GetStringSetting + manual Wh_FreeStringSetting can be replaced by WindhawkUtils::StringSetting (RAII), which would also let you drop the fixed hotkeyRaw[64] copy that only exists for the log message. See taskbar-clock-customization.wh.cpp#L523.

  • When GdiplusStartup fails the thread carries on and still constructs Gdiplus::Bitmap/Graphics on every frame and calls UpdateLayeredWindow with an uninitialized DIB. Since the mod is useless without GDI+, it'd be cleaner to bail out of ModThreadProc (or skip rendering entirely) rather than log and continue.

  • EnsureRenderTarget doesn't keep the SelectObject return value, so the original bitmap is never selected back before DeleteDC. It works in practice, but restoring it is the documented contract.

  • The tool-mod launcher block at the bottom is functionally identical to the wiki snippet but not byte-identical — continuation lines are indented one extra space. The rest of the file has the same off-by-one continuation indent, so it looks like it wasn't formatted with the repo's .clang-format (BasedOnStyle: Chromium, IndentWidth: 4). Running clang-format with the repo config would fix both, and keeps the boilerplate diffable against the wiki version — see explorer-folder-hover-menu.wh.cpp for a verbatim copy. The file also has no trailing newline.

  • MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY){0, 0} is on the primary monitor by definition, so MonitorFromPoint isn't really needed there; MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY) or just the point lookup without the flag reads the same.

  • Design note, not a request to change anything: the README's comparison section is appreciated and the overlay is a genuine differentiator over mic-tray-control and mutealert. Worth being aware, though, that the hotkey half is close to keyboard-shortcut-actions, which already has the same Ctrl+Alt+M-style parser and a Mute action — it just targets eRender. A capture-device action there would be a small addition, so the maintainer may still prefer that split (hotkey action there, overlay here).

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The synthetic-pulse fallback leaks into decisions that aren't about animation. When IAudioMeterInformation isn't available, GetMicPeak() returns a sine wave in [0, 0.5], which then feeds both the background colour (peak > 0.03f ? colActive : colIdle) and activeSignal — so on such a system the overlay flickers between "mic active" green and idle grey, and delays auto-hide, on a completely silent mic. Consider returning the real/fallback distinction, e.g. float GetMicPeak(bool* hasRealPeak), and using the synthetic value only for the ring geometry.

  • The 33 ms timer re-renders and calls UpdateLayeredWindow even when the frame is identical (muted, or unmuted with peak ≈ 0 — the rings are skipped and the colour is colIdle). With alwaysShow on and the mic unmuted this runs indefinitely. Skipping the render when the inputs (muted, quantized peak, rect) haven't changed since the last frame would make the idle case free.

  • The dragged position lives only in g_manualPos — it's discarded on any settings change (by design, per the comment) and on every mod reload/restart, so the user has to re-drag each time. Wh_SetIntValue(L"overlayPosX", ...) / Wh_GetIntValue would let it survive, without touching anything outside Windhawk's own storage.

  • Auto-position is primary-monitor only, which the setting description does say. If you want to lift the limitation later, MonitorFromPoint(cursorPos, MONITOR_DEFAULTTONEAREST) at show time (or a monitor-index setting) would place it on the active display instead.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 29, 2026
@Eliasilyz

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

This pull request has already had 3 AI reviews in the last 24 hours, which is the limit, so no review was posted this time.

Comment /ai-review again after 2026-08-30 11:54 UTC (in 23 hours) to get another one.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

@Eliasilyz /ready-for-reviewer can't be applied here: the most recent AI review covers 4488ebd, but the current head of this pull request is feb69eb. Comment /ai-review to get a review of the current code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-author The author's turn: request an AI review, or respond to one that was posted.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants