Skip to content

Stop mute and deafen firing the moment they are assigned - #409

Merged
OhMyGuus merged 3 commits into
OhMyGuus:nightlyfrom
greluc:fix/keybind-assign-toggle
Sep 8, 2026
Merged

Stop mute and deafen firing the moment they are assigned#409
OhMyGuus merged 3 commits into
OhMyGuus:nightlyfrom
greluc:fix/keybind-assign-toggle

Conversation

@greluc

@greluc greluc commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Replaces #407, which was opened from a fork I have retired. Same branch, same commits, nothing rewritten — reopened from the fork I now work out of. #407 is closed.

The branch is merged up to current nightly. #404 and #405 reworked these same handlers and fixed half of what #407 originally claimed; the description below is the corrected, narrower one.

Assigning the mute or deafen shortcut fires it immediately.

Why

node-keyboard-watcher polls. AddKeyHandler seeds a key as up, and the thread compares that seed against GetAsyncKeyState every 60 ms, emitting a transition:

void AddKeyHandler(int keyId) {
	if (key_map.count(keyId) == 0)
		key_map.insert(std::pair<int, keystate>(keyId, keystate::Up));
}
void ClearKeyHooks() { key_map.clear(); }

KeybindsSection commits a binding on onKeyDown, while the key is still held, and setShortcut sends RESET_KEYHOOKS in the same tick. resetKeyHooks wipes the map, so the key the player is holding is re-seeded up — and the next sweep reports a keydown nobody made. The real release then completes a press the app invented, and the toggle fires.

What is already fixed, and not by this PR

releaseHeldKeys() from #404/#405 lets go of whatever was held before the hooks are rebuilt, which removes the stranded-microphone class entirely. Measured with the harness below: over 20 000 random gestures that end by releasing everything and settling, the pre-#404 code left the microphone open in 54 runs and current nightly in none. That half is yours, and this PR keeps it.

What is left

Two changes.

resetKeyHooks no longer calls clearKeyHooks. Wiping the map is what fabricates the press. addKeyHook ignores a key already in the map, so re-applying the shortcuts leaves every key exactly as the watcher last saw it. The cost: a key that stops being a shortcut keeps being polled until exit — it matches no binding, so nothing acts on it. The addon has no way to unhook one key (RemoveKeyHandler is empty), which is why this wiped everything.

The toggles are answered from what the key was granted when it went down, rather than from the bindings as they stand at keyup. A binding can be reassigned to the very key being held — which is precisely what assigning one of these shortcuts does, and it is why the release of the assigning press would otherwise fire the shortcut it had just created.

And the settings field commits on the DOM keyup instead of the keydown. The renderer is the only part of the app that knows the key is up; the main process cannot ask. Hooking it in a state the watcher agrees with is the point.

Verification

The watcher was modelled from keyhandler.cpp — seed-up-on-add, clear-on-reset, edge-triggered emission on a 60 ms sweep — and used to drive the real handler bodies extracted from hook.ts, so the same script runs against either revision.

nightly this branch
S1 assigning mute must not fire it FAIL MUTE ok
S2 assigning over the push-to-talk key FAIL MUTE ok
S3 ordinary push-to-talk ok ok
S4 pressing mute later ok ok
S5 unrelated reset while push-to-talk held ok ok
S6 impostor radio across the end of a round ok ok
S7 released before the next sweep ok ok
S8 rebinding away from a held key ok ok
20 000 random gestures, settled ok, 0 open ok, 0 open

Only the two rows this PR is about change. npm run typecheck, eslint and prettier --check are clean.

What changes for a user

Holding a key over the field and clicking away without releasing it now binds nothing, where before it bound the key and fired the shortcut. The binding is recorded on release rather than on press, so the field updates a few tens of milliseconds later.

A key unbound while still held will still toggle on release, because its press was granted. Same rule as everything else here, and it cannot leave anything held.

Limits, stated plainly

The watcher model is my transcription of keyhandler.cpp, not the C++ itself, and nothing ran against a real keyboard or a built addon — this is Windows-only native code. emit_event uses a ThreadSafeFunction NonBlockingCall, so delivery to the main thread is asynchronous while my model delivers synchronously per sweep. I do not believe that can reorder a keydown after its own keyup, but I have not proved it.

The harness is not in this PR — there is no test runner here and adding one does not belong in a bug fix. Happy to contribute it separately.

Disclaimer

This change was written with AI assistance (Claude Code). The author reviewed it, and every claim was checked against the actual sources — including reading the addon's C++ — with behaviour produced by running code rather than reasoned about. Several earlier designs were discarded because testing showed they reintroduced a stuck microphone by another route. Please review it as you would any patch from a stranger; if something here is wrong I would rather hear it than have it merged.

🤖 Generated with Claude Code

greluc and others added 3 commits September 7, 2026 21:27
Assigning the mute or deafen shortcut fires it immediately, and the same
mechanism can leave the microphone open until the app restarts.

The key watcher polls. `AddKeyHandler` seeds a key as up, and the thread compares
that seed against GetAsyncKeyState every 60ms, emitting a transition. So wiping
its map has two effects that both look like input nobody gave:

  * a key that is physically down at that moment is re-seeded up, so the next
    sweep reports a keydown the user never made, and the real release then
    completes a press the app invents. That is what fires mute or deafen the
    instant one of them is bound, because binding is committed on keydown while
    the key is still held and immediately sends RESET_KEYHOOKS.
  * a key the watcher was already tracking loses the keyup that would have closed
    it. Nothing decrements `speaking`, PUSH_TO_TALK false is never sent again, and
    the microphone stays open for the rest of the session.

Three changes, and they only work together.

`resetKeyHooks` no longer calls `clearKeyHooks`. addKeyHook ignores a key already
in the map, so re-applying the shortcuts leaves every key exactly as the watcher
last saw it and nothing is fabricated or lost. The cost is that a key which stops
being a shortcut keeps being polled until exit; it matches no binding, so nothing
acts on it. The addon has no way to unhook one key -- RemoveKeyHandler is an
empty function -- which is why this used to wipe everything.

The settings field commits on the DOM keyup instead of the keydown. The renderer
is the only part of the app that knows the key is up; the main process cannot
ask. Hooking it in a state the watcher agrees with is the whole point.

And every release is now answered from what its press was granted, recorded per
key at keydown, instead of by asking the bindings again. The answer can change
while a key is held -- a round ending, or a binding reassigned to the very key
being held, which is exactly what assigning a shortcut does.

Verified against a model of the watcher transcribed from keyhandler.cpp, driving
the real handler bodies extracted from this file. Eight scenarios: on nightly
four fail, including the reported bug and two ways to strand the microphone; on
this branch all eight pass. A search over 20000 random gestures that ends by
releasing everything and settling finds 54 runs on nightly where the microphone
is left open, and none here.

One visible change beyond the fix: holding a key over the field and clicking away
without releasing it now binds nothing, where before it bound the key and fired
the shortcut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lucas Greuloch (greluc) <lucas.greuloch@gmail.com>
OhMyGuus#404 and OhMyGuus#405 reworked the same handlers this branch changes, and the rework
covers half of what this branch was for. `speaking` is gone, replaced by
`pushToTalkHeld` and `impostorRadioHeld`, and `resetKeyHooks` now begins with
`releaseHeldKeys()`, which lets go of whatever was held before the hooks are
rebuilt. That removes the stranded-microphone class outright: the harness on this
branch finds 0 of 20000 random gestures ending with the microphone open on
upstream nightly, where the pre-OhMyGuus#404 code left it open in 54.

So the conflicted handlers are resolved wholly in upstream's favour and this
branch keeps only what nightly still gets wrong -- assigning mute or deafen fires
it, which the harness reproduces on nightly as S1 and S2.

What remains is two things. `resetKeyHooks` still does not call `clearKeyHooks`,
because wiping the watcher's map is what fabricates the press: a key that is
physically down is re-seeded up, so the next sweep reports a keydown nobody made
and the real release completes it. And the mute and deafen toggles are answered
from what the key was granted when it went down, rather than from the bindings as
they stand at keyup -- a binding can be reassigned to the very key being held,
which is exactly what assigning one of these shortcuts does.

The keyId-matching for push-to-talk and the impostor radio that this branch used
to carry is dropped: `releaseHeldKeys` already covers it, so it was redundant.

All eight scenarios pass here; two fail on nightly. typecheck, eslint and
prettier are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lucas Greuloch (greluc) <lucas.greuloch@gmail.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Download the artifacts for this pull request:


This service is provided by nightly.link. These artifacts will expire in 90 days and will not be available for download after that time.

@OhMyGuus
OhMyGuus merged commit a89bb04 into OhMyGuus:nightly Sep 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants