Stop mute and deafen firing the moment they are assigned - #409
Merged
Conversation
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>
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Assigning the mute or deafen shortcut fires it immediately.
Why
node-keyboard-watcherpolls.AddKeyHandlerseeds a key as up, and the thread compares that seed againstGetAsyncKeyStateevery 60 ms, emitting a transition:KeybindsSectioncommits a binding ononKeyDown, while the key is still held, andsetShortcutsendsRESET_KEYHOOKSin the same tick.resetKeyHookswipes 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.
resetKeyHooksno longer callsclearKeyHooks. Wiping the map is what fabricates the press.addKeyHookignores 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 (RemoveKeyHandleris 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 fromhook.ts, so the same script runs against either revision.MUTEMUTEOnly the two rows this PR is about change.
npm run typecheck,eslintandprettier --checkare 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_eventuses aThreadSafeFunctionNonBlockingCall, 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