fix(ui,voice): display 'Space' (not blank) in bind dialog; cut PTT poll latency

Issue 1: tapping Space in the PTT binding capture dialog set
_captured to LogicalKeyboardKey.space.keyLabel which is ' ' (a single
space character), rendering as a blank string in the 'Captured: '
display. Same problem for Enter, Tab, Backspace, etc. — Flutter's
keyLabel returns the printable representation, not a readable name.
Added _displayLabelForKey() with a table that maps whitespace and
common special keys to canonical English labels matching the
entries in crates/chanora_audio/src/ptt_backends/windows_keymap.rs
(so the bridge resolves to the right VK_* on Windows). Pure
modifier keys (shift / ctrl / alt / meta / caps / num / scroll
lock) return null so they don't accidentally bind on their own.

Issue 2: physical key press -> 'PTT=on' in the Voice Bar lagged by
up to ~500 ms because audioStats was polled at 500 ms intervals.
The Rust-side transition is microsecond-fast; the visible delay is
purely the Flutter poll interval. Cut to 80 ms (~12 Hz), well below
the perceptual lag threshold. Adds ~12 small FFI calls per second,
trivially cheap. A push-based BridgeEvent::TransmitActiveChanged
would let us drop the poll entirely; noted as a follow-up.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
This commit is contained in:
EdisonJwa
2026-05-16 00:31:57 +08:00
parent 87cff52c0a
commit 7596f8a9dc
+71 -6
View File
@@ -246,7 +246,16 @@ class _BetaHomeState extends State<_BetaHome> {
void _ensureStatsTimer() {
if (_statsTimer != null) return;
_statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
// Poll at 80 ms (~12 Hz) — this is the loop that drives the
// Voice Bar's "PTT=on/off" indicator and the level meter, so
// it needs to be fast enough that users don't perceive a lag
// between physically pressing the bound key and seeing the
// UI change. 80 ms is well below the ~150 ms perceptual
// delay threshold and adds only a dozen tiny FFI calls per
// second to the load. A future push-based BridgeEvent for
// transmit-active transitions would let us drop this poll
// entirely.
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
@@ -1273,17 +1282,73 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
// Skip modifier-only presses so the user can chord into the
// real binding.
final keyLabel = event.logicalKey.keyLabel;
if (keyLabel.isEmpty) return KeyEventResult.ignored;
final label = _displayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
_captured = keyLabel;
_captured = label;
_capturedClass = rust.BridgePttInputClass.keyboard;
});
return KeyEventResult.handled;
}
/// Translate a [LogicalKeyboardKey] into the platform-neutral
/// label string the bridge expects (matching the entries in
/// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`).
///
/// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty
/// for pure modifiers (shift / ctrl / alt / meta), and localised
/// strings for some special keys; we normalise to the canonical
/// English label so the badge displays something readable AND so
/// the Windows backend's keymap can resolve to a `VK_*`. Pure
/// modifier keys are intentionally rejected — chording into the
/// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the
/// individual modifier-down events.
String? _displayLabelForKey(LogicalKeyboardKey k) {
// Whitespace / common control keys whose keyLabel is unhelpful.
if (k == LogicalKeyboardKey.space) return 'Space';
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'Enter';
}
if (k == LogicalKeyboardKey.tab) return 'Tab';
if (k == LogicalKeyboardKey.escape) return 'Escape';
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
if (k == LogicalKeyboardKey.delete) return 'Delete';
if (k == LogicalKeyboardKey.insert) return 'Insert';
if (k == LogicalKeyboardKey.home) return 'Home';
if (k == LogicalKeyboardKey.end) return 'End';
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
// Pure modifier keys are not bindable on their own (user can
// still chord by pressing a non-modifier while holding them).
if (k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.capsLock ||
k == LogicalKeyboardKey.numLock ||
k == LogicalKeyboardKey.scrollLock) {
return null;
}
// Fall back to keyLabel for letters, digits, function keys,
// numpad digits, and punctuation. Trim whitespace as a final
// belt-and-braces guard.
final fallback = k.keyLabel.trim();
if (fallback.isEmpty) return null;
return fallback;
}
void _captureMouseSideButton(int button) {
setState(() {
_captured = 'mouse-side-button:$button';