Files
chanora/docs/architecture/desktop-ptt-architecture.md
T
EdisonJwa 03f5d6bca3 fix(p0): close three P0 coverage gaps after rc.5 audit
Audited every `Priority: P0` row in `docs/requirements/{sysrs,srs}.md`
against the live code. Three items needed work; this commit closes
all three.

Gap A — SysRS-262 + SysRS-282 (screen-reader semantics + accessible
labels for the PTT control)
-------------------------------------------------------------------

The Flutter PTT control is a custom `Listener` over a `Container`
— not a built-in `Button`, so the platform accessibility tree had
no idea it was an interactive control. Screen readers
(VoiceOver, TalkBack, NVDA, Orca) would have read the visible text
without announcing the control role or its toggled state.

Wrap the Listener in a `Semantics(button: true, toggled: _pressed,
label: …, hint: …, excludeSemantics: true)` so the platform
accessibility tree carries the right role, the current state
("Hold to talk" / "Transmitting"), and a usage hint. The
`excludeSemantics: true` argument suppresses the duplicate child
nodes the Container + Row + Icon + Text would otherwise generate
on top of our explicit label.

SysRS-263 (no colour-only state) is preserved: the visible label
and the mic icon already differentiate the two states without
relying on the colour transition.

New ARB key `pttHoldToTalkSemanticsHint` in `app_en.arb` and
`app_zh.arb`.

Gap B — SRS-198 (macOS async permission re-check)
-------------------------------------------------

The macOS backend queried `query_permission()` once at
construction and never re-checked. That violates SRS-198's
"upgrade to the appropriate Global level only after the user
grants the required permission" — once Chanora is running, a
runtime grant must lift the descriptor from `L0Focused` to a
Global level without an app restart.

Substantive rewrite of `crates/chanora_audio/src/ptt_backends/macos.rs`:

  * `permission: PermissionState` becomes `permission: Arc<AtomicU8>`,
    enabling cross-thread updates without a Mutex.
    `PermissionState::{to_u8, from_u8}` carry the encoding.
  * The backend owns a `tokio::sync::watch::Sender<PttBackendDescriptor>`
    and overrides `DesktopPttBackend::descriptor_watch()` to hand
    out subscribers; `chanora_core::ChanoraSession::start_audio`
    already forwards transitions to `SessionEvent::PttCapability`.
  * `start()` spawns a `chanora-perm-watch` OS thread that polls
    `query_permission()` every 1.5 s and republishes the
    descriptor on every transition. Polling rather than KVO /
    notifications because Input-Monitoring has no public
    change-notification API on macOS; 1.5 s is sufficient for a
    user grant + return-to-Chanora cycle.
  * `rebind()` also republishes the descriptor so a
    `keyboard → mouse-side-button` change updates the badge.
  * Six new unit tests on the platform-independent
    `build_descriptor` and the atomic encoding contract. They
    only compile under `target_os = "macos"` (consistent with
    the rest of the module), so the Linux dev-host workspace
    test count is unchanged.

`query_permission()` itself still returns `Undetermined` until
the IOKit live link lands in the macOS platform-verification
commit; the re-query loop will engage the upgrade path
automatically the moment that function returns real values.

Gap C — SRS-200 (Linux mouse-side-button portal-dependence)
-----------------------------------------------------------

`desktop-ptt-architecture.md` §5.3 already described the
heuristic classifier. Added one explicit sentence stating that
Linux mouse-side-button support is *portal-dependent*: Chanora
never claims a fixed Mouse4/Mouse5 binding on Linux; the portal
decides what inputs it accepts in the current session, and the
classifier degrades to `keyboard` whenever the portal's
description does not contain "mouse". This matches the SRS-200
text verbatim and removes the ambiguity over what "Linux
support follows the portal" means in practice.

Verification
------------

  * `cargo test --workspace` (with `CHANORA_DISABLE_KEYRING=1`):
    all 67 Linux-side tests green (unchanged). The new macOS
    unit tests count under `target_os = "macos"` only — they
    will report once the macOS reference host runs `cargo test`.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `flutter analyze`: clean (no new accessibility warnings).
  * Linux release bundle builds clean.

P0 audit summary
----------------

After this commit every Priority: P0 row in `sysrs.md` and
`srs.md` has a concrete implementation. The remaining open items
are all live verification, not code:

  * Per-platform live PTT traces on Windows / macOS reference
    hosts (RR-PTT-001..003, RR-PTT-008) — hosts unavailable
    locally; queued for platform owners.
  * Linux GNOME-Wayland live trace (RR-PTT-004) — implemented
    in rc.5; awaiting live host trace.
  * Linux non-tested compositor fallback trace (RR-PTT-005) —
    Open.
  * Diagnostic-export key-leak inspection (RR-PTT-006) — Open
    but trivially testable on any host with PTT bound.
  * DEC-012 legal review — engineering hand-off complete since
    rc.2.
2026-05-15 16:50:48 +08:00

13 KiB

Desktop Push-to-Talk Architecture

Field Value
Document type Architecture
Version 0.9.3
Status Baseline Candidate
Language English
Product Chanora
Date 2026-05-15

Owner-resolved review questions from gen2/chanora-desktop-ptt-review-summary-v0.9.2.md are recorded as DEC-023 through DEC-028 in docs/governance/product-decision-register.md.


1. Purpose

Desktop Push-to-Talk (PTT) is the operation by which a user holds a bound keyboard key or mouse button to enable voice transmission and releases it to disable transmission. This document fixes the architecture so the product can describe, implement, verify, and release desktop PTT honestly — every capability claim made to the user is auditable against a live runtime measurement.

The previous architecture treated PTT as a single global behaviour. That over-promised on platforms where the operating system, the user-granted permission set, the display server, or the compositor does not permit unconditional global key capture. This document replaces that with a capability-based model.

2. Architectural Decisions

ID Decision Source
DEC-023 Windows Global PTT is P0 / MVP. Owner ruling 2026-05-15
DEC-024 macOS Global PTT is P0 / MVP with explicit permission UX flow. Owner ruling 2026-05-15
DEC-025 The officially-tested Linux environment for the first public release is GNOME on Wayland (only). Owner ruling 2026-05-15
DEC-026 Mouse side buttons are supported in the first desktop PTT release on Windows and macOS; Linux support follows whatever the GlobalShortcuts portal exposes. Owner ruling 2026-05-15
DEC-027 The diagnostic export carries capability and availability state only — no raw key codes, scan codes, or virtual-key values ever leave the device. Owner ruling 2026-05-15
DEC-028 The missed-key-up watchdog is P0: the audio engine shall self-release transmit_active if the OS suppresses a key-up event. Owner ruling 2026-05-15

3. Capability Ladder

Level Name Meaning
L0Focused Focused PTT PTT works only while the Chanora window has input focus. Mandatory on every desktop platform per SysRS-296.
L1GlobalShortcut Global shortcut activation The OS recognises a global accelerator and notifies the application, but hold-to-talk semantics may be approximated rather than guaranteed.
L2GlobalHoldToTalk Global hold-to-talk Press and release events are delivered while the application is not focused. The common Windows / macOS / GNOME-Wayland-portal MVP target.
L3GlobalWithMouseButtons Global hold-to-talk plus mouse buttons Includes mouse side buttons (Mouse4 / Mouse5).
L4DeviceAware Device-aware PTT Backend can distinguish specific input devices. Reserved. No MVP implementation produces L4DeviceAware.

The reported capability shall match runtime behaviour. A backend that could deliver L2GlobalHoldToTalk but lacks the user-granted permission shall report L0Focused until permission is granted.

4. Component Allocation

+------------------------------------+
| Flutter Voice UI                   |
|   PTT binding-capture sheet        |
|   VoiceBar PttCapabilityBadge      |
+---------------+--------------------+
                |
                |  set_binding(...)
                |  events_stream() -> BridgeEvent::PttCapability
                v
+---------------+--------------------+
| chanora_bridge                     |
|   typed DTOs, no key data crosses  |
+---------------+--------------------+
                |
                v
+------------------------------------+
| chanora_core                       |
|   PttController                    |
|     owns Box<dyn DesktopPttBackend>|
|     owns Arc<AudioTransmitGate>    |
|     publishes PttCapabilityLevel   |
|   MissedKeyUpWatchdog (tokio task) |
+---------------+--------------------+
                |
                v
+------------------------------------+
| chanora_audio                      |
|   DesktopPttBackend trait          |
|     WindowsRawInputBackend         |
|     WindowsHookBackend             |
|     MacOSEventTapBackend           |
|     LinuxGnomeWaylandBackend       |
|     FocusedPttBackend              |
|   AudioTransmitGate                |
|   capture_active / transmit_active |
+------------------------------------+
                |
                v
+------------------------------------+
| chanora_diagnostics                |
|   RedactingLogLayer                |
|   PttSanitizer (drops banned keys) |
+------------------------------------+

The audio engine reads transmit_active once per outbound Opus frame. No code path other than AudioTransmitGate::set flips the value.

5. Per-Platform Strategy

5.1 Windows

Three-level ladder evaluated once at audio-engine start:

  1. WindowsRawInputBackend — preferred. Uses RegisterRawInputDevices with RIDEV_INPUTSINK to receive keyboard and mouse events even when Chanora is not focused. Runs a message-only window on its own OS thread so the WndProc is non-blocking. Reports L2GlobalHoldToTalk (keyboard) or L3GlobalWithMouseButtons (when a mouse side button is bound).
  2. WindowsHookBackend — fallback. Installs WH_KEYBOARD_LL and WH_MOUSE_LL hooks on its own thread. Used when Raw Input registration fails (some constrained environments). Reports L2GlobalHoldToTalk / L3GlobalWithMouseButtons.
  3. FocusedPttBackend — final fallback. Reports L0Focused.

The chosen rung is fixed for the lifetime of the audio engine; restart of the engine re-evaluates the ladder.

5.2 macOS

Two-level ladder with explicit permission gating:

  1. MacOSEventTapBackend — preferred when Input Monitoring (or Accessibility, depending on macOS version) permission is Granted. Creates a CGEventTap on the main run loop, filtered to keyboard and mouse-button events. Reports L2GlobalHoldToTalk / L3GlobalWithMouseButtons.
  2. FocusedPttBackend — fallback when permission is Denied, Undetermined, or revoked at runtime. Reports L0Focused.

The audio engine starts immediately on user request; the permission state is queried in parallel and the capability level is upgraded asynchronously through BridgeEvent::PttCapability if the user grants the permission. This avoids blocking voice functionality on a permission prompt.

5.3 Linux

Two-level ladder restricted to the officially-tested environment per DEC-025.

Selection at try_select() time:

  • Reads XDG_SESSION_TYPE (must equal wayland) and XDG_CURRENT_DESKTOP (must contain gnome or gnome-flashback).
  • Probes org.freedesktop.portal.GlobalShortcuts via a blocking version property read.

If either check fails the factory returns None and the caller falls back to FocusedPttBackend at L0Focused.

Live session lifecycle (Q5b — lazy, single backend instance):

  1. start(gate, binding) spawns a worker tokio::spawn task. The worker owns its own async zbus::Connection and a GlobalShortcutsProxy.
  2. The worker calls CreateSession with fresh handle_token / session_handle_token values (random u32 per process). The portal returns a Request object path; the worker subscribes to the Response signal on that path and awaits the session handle from the results dict.
  3. The worker then calls BindShortcuts(session_handle, [("chanora-ptt", { description: "Chanora push-to-talk" })], "", {}). The portal opens its own system-managed dialog asking the user to choose a key — Chanora does not read raw key events. The audio engine continues at L0Focused while the dialog is open (the descriptor watch publishes the transition once the portal returns).
  4. Once BindShortcuts resolves:
    • On response_code == 0: classify the trigger_description substring heuristically (mousemouse-side-button; otherwise keyboard), publish descriptor() = L2GlobalHoldToTalk (or L3GlobalWithMouseButtons for the mouse case) through the watch sender. The trigger_description string itself is never logged. Mouse-side-button support on Linux is portal-dependent (SRS-200 / DEC-026): the backend never offers a fixed "Mouse4 / Mouse5" capture path; the portal's own dialog decides which inputs it accepts in the current session, and Chanora honours whatever it returns. The classifier degrades to keyboard whenever the portal's description does not contain mouse.
    • On response_code == 1 (user cancelled) or > 1 (other failure): publish descriptor() = L0Focused through the watch sender. The user can retry via the UI "Configure" button.
  5. The worker then enters its long-lived loop, multiplexing on the command channel (Rebind / Stop) and the portal's Activated / Deactivated signals. Signal payloads scoped to a different session handle or shortcut id are ignored. Matching Activated calls gate.set(true); matching Deactivated calls gate.set(false).
  6. rebind(binding) sends a command to the worker which re-runs BindShortcuts on the same session. The portal opens its dialog again; the user can pick a new key.
  7. stop() (or backend Drop) signals the worker, which calls Request::Close on the session handle, calls gate.set(false), and exits.

UX implication on Linux: the in-app _PttBindingCaptureDialog (used by Windows and macOS in MVP) is skipped when the active backend is gnome-wayland-portal. The Flutter "Configure" button calls setPttBinding directly with a sentinel platform_key = "portal" and shows a SnackBar telling the user their desktop will open its own shortcut dialog. This matches the portal's design (DEC-027 / Q3a).

Per DEC-025 the application does not claim Global PTT support on an untested Linux environment. The UI capability badge explicitly notes "Focused PTT — untested compositor for Global PTT" when the user runs Chanora outside GNOME-on-Wayland.

6. Privacy Rule

Per SysRS-302 and SRS-202:

  • Raw key codes, scan codes, virtual-key values, keysyms, and key-press timing sequences shall not be logged, persisted, or included in any user-initiated diagnostic export.
  • The PttSanitizer log-sink decorator enforces this at write time by inspecting field names and dropping records whose field names match a banned list (key_code, scan_code, virtual_key, vk, keysym, keysym_string, key_sequence). The check is structural — it does not rely on a content scan.
  • The diagnostic export shall name only the capability level (PttCapabilityLevel::as_str()), the backend identifier (a fixed &'static str per implementation), and the bound input class ("keyboard", "mouse-side-button").

7. Audio Gate Rule

Per SRS-201:

  • capture_active: AtomicBool is set by the audio engine on input-stream lifecycle transitions (stream opened or closed) and by the platform input-permission state. The PTT subsystem does not write capture_active.
  • transmit_active: AtomicBool lives inside AudioTransmitGate. The Opus encoder feed reads it once per outbound frame; the gate is the only mutator path.
  • A muted self-input (per set_input_muted) forces transmit_active to false regardless of the PTT subsystem's wish; this preserves the existing self-mute semantics.

8. Missed-Key-Up Watchdog

Per SRS-203 / DEC-028:

  • The audio engine spawns one MissedKeyUpWatchdog task per audio session.
  • It subscribes to AudioTransmitGate's watch::Receiver<bool> and notes the timestamp of each false -> true transition.
  • On each true -> false transition it clears the timestamp.
  • If a true lifetime exceeds the configured ceiling (default 30 s, owner-tunable through a future setting), the watchdog calls AudioTransmitGate::set(false) and emits a sanitised diagnostic line naming only the capability level and backend identifier.

9. Release Readiness

Per SysDes-148 and the release-readiness record:

  • Every release artefact carries a per-platform capability-evidence row listing the detected PttCapabilityLevel, the active backend identifier, and whether the Focused fallback was exercised during verification.
  • Release notes shall mirror that evidence and shall not over-claim Global PTT support.

10. Traceability

SysRS-296..302
  -> SysDes-142..148
  -> SRS-195..203
  -> SAD-071..079
  -> SDD-081..092

Verification coverage:

SDD-081..092 -> SWE4-UV-035..039
SAD-071..079 + SDD-081..092 -> SWE5-IV-015
SRS-195..203 -> SWE6-SV-017
SysDes-142..148 -> SYS4-SIV-016

11. Change History

Version Date Description
0.9.3 2026-05-15 Initial baseline-candidate architecture for capability-based desktop Push-to-Talk. Codifies the owner rulings for PTT-OPEN-001 through PTT-OPEN-006 as DEC-023 through DEC-028.
0.9.4 2026-05-15 Promoted the Linux GNOME-Wayland backend from probe-only to the live CreateSession + BindShortcuts + Activated / Deactivated session flow. Owns an async tokio task with a dedicated zbus::Connection; publishes descriptor transitions through a watch::Sender consumed by chanora_core::ChanoraSession::start_audio. Adds the privacy-safe trigger_description classifier and the cancellation / failure path (downgrade to L0Focused + re-emit). Updates the Flutter UI to skip the in-app capture dialog on the portal backend (Q3a) and surface a SnackBar redirecting the user to the desktop's own dialog.