Add SysRS-303/304, SysDes-149/150/151, SRS-204/205/206/207, SAD-081/082/083, SDD-094/095/096/097, DEC-029/030. Captures the v1 lifecycle redesign: - Drop manual Start-audio button; audio engine is bound to voice-channel join/leave (ensure_running on first join, shutdown_if_idle on last leave). Output stream opens regardless of mic-permission state so listen-only is a first-class flow. - TransmitMode enum (Ptt / Continuous / VoiceActivity-reserved). Default Ptt on fresh install. Persisted per identity. - PTT release tail: 200 ms default (0-500 ms configurable) before transmit gate closes, avoiding clipped trailing syllables. - Hard-mute toggle overrides transmit gate regardless of mode/PTT. - Bridge surface: drop start_audio/stop_audio; add voice_join(channel_id) / voice_leave() and BridgeEvent::VoiceState. DEC-029 rejects Flutter global-hotkey packages (hotkey_manager, super_hot_key) for PTT: they wrap RegisterHotKey/RegisterEventHotKey which consume the key and don't fire key-up, wrong primitive for PTT. Native Rust DesktopPttBackend (SDD-083/084/085) stays authoritative. DEC-030 defers Voice Activity Detection to P1. RMS / WebRTC VAD / Silero VAD trade-off review (binary-size, dependency-surface, CPU profile) postponed; TransmitMode::VoiceActivity reserved on the enum surface so a P1 increment is non-breaking. Validator clean: 304/151/207/83/97 IDs, strict layered sourcing preserved, no new warnings beyond the pre-existing 35 old-package-name filenames.
17 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:
WindowsRawInputBackend— preferred. UsesRegisterRawInputDeviceswithRIDEV_INPUTSINKto 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. ReportsL2GlobalHoldToTalk(keyboard) orL3GlobalWithMouseButtons(when a mouse side button is bound).WindowsHookBackend— fallback. InstallsWH_KEYBOARD_LLandWH_MOUSE_LLhooks on its own thread. Used when Raw Input registration fails (some constrained environments). ReportsL2GlobalHoldToTalk/L3GlobalWithMouseButtons.FocusedPttBackend— final fallback. ReportsL0Focused.
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:
MacOSEventTapBackend— preferred when Input Monitoring (or Accessibility, depending on macOS version) permission isGranted. Creates aCGEventTapon the main run loop, filtered to keyboard and mouse-button events. ReportsL2GlobalHoldToTalk/L3GlobalWithMouseButtons.FocusedPttBackend— fallback when permission isDenied,Undetermined, or revoked at runtime. ReportsL0Focused.
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 equalwayland) andXDG_CURRENT_DESKTOP(must containgnomeorgnome-flashback). - Probes
org.freedesktop.portal.GlobalShortcutsvia a blockingversionproperty 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):
start(gate, binding)spawns a workertokio::spawntask. The worker owns its own asynczbus::Connectionand aGlobalShortcutsProxy.- The worker calls
CreateSessionwith freshhandle_token/session_handle_tokenvalues (randomu32per process). The portal returns aRequestobject path; the worker subscribes to theResponsesignal on that path and awaits the session handle from theresultsdict. - 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 atL0Focusedwhile the dialog is open (the descriptor watch publishes the transition once the portal returns). - Once
BindShortcutsresolves:- On
response_code == 0: classify thetrigger_descriptionsubstring heuristically (mouse→mouse-side-button; otherwisekeyboard), publishdescriptor() = L2GlobalHoldToTalk(orL3GlobalWithMouseButtonsfor 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 tokeyboardwhenever the portal's description does not containmouse. - On
response_code == 1(user cancelled) or> 1(other failure): publishdescriptor() = L0Focusedthrough the watch sender. The user can retry via the UI "Configure" button.
- On
- The worker then enters its long-lived loop, multiplexing on the command channel (
Rebind/Stop) and the portal'sActivated/Deactivatedsignals. Signal payloads scoped to a different session handle or shortcut id are ignored. MatchingActivatedcallsgate.set(true); matchingDeactivatedcallsgate.set(false). rebind(binding)sends a command to the worker which re-runsBindShortcutson the same session. The portal opens its dialog again; the user can pick a new key.stop()(or backend Drop) signals the worker, which callsRequest::Closeon the session handle, callsgate.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
PttSanitizerlog-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 strper implementation), and the bound input class ("keyboard","mouse-side-button").
7. Audio Gate Rule
Per SRS-201:
capture_active: AtomicBoolis 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 writecapture_active.transmit_active: AtomicBoollives insideAudioTransmitGate. 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) forcestransmit_activeto 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
MissedKeyUpWatchdogtask per audio session. - It subscribes to
AudioTransmitGate'swatch::Receiver<bool>and notes the timestamp of eachfalse -> truetransition. - On each
true -> falsetransition it clears the timestamp. - If a
truelifetime exceeds the configured ceiling (default 30 s, owner-tunable through a future setting), the watchdog callsAudioTransmitGate::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. |
8. v0.9.5 lifecycle and mode-picker addendum
This addendum captures the v1 audio + PTT lifecycle refactor recorded in SysRS-303..304, SysDes-149..151, SRS-204..207, SAD-081..083, and SDD-094..097. It does not change the desktop PTT backend design captured in §1–§7; it constrains how the backends are owned and how the rest of the audio path is exposed to the UI.
- No "Start audio" button anywhere. The audio engine lifecycle is bound to voice-channel membership. Input and output streams open on the user's first voice-channel join of the session and close on the last voice-channel leave. The bridge surface drops
start_audio/stop_audioand exposesvoice_join(channel_id) -> Result<(), VoiceError>andvoice_leave() -> Result<(), VoiceError>;AudioEngine::ensure_running()is invoked implicitly byvoice_joinand is idempotent. The Voice Bar renders fromBridgeEvent::VoiceState { in_channel, transmit_mode, mute, release_tail_ms }and contains no manual-start affordance. Microphone permission is requested at app first launch rather than at first channel join; the output stream opens regardless of permission state, so listen-only is a first-class flow. - Transmit modes for v1 are
PttandContinuous;VoiceActivityis reserved but not implemented.TransmitModeis a#[repr(u8)]enum mirrored across the bridge; the default on fresh install isPtt; the setting is persisted per identity in the identity store. The settings page rendersVoiceActivityas a disabled "coming soon" radio row until DEC-030 is resolved and a VAD backend is allocated. - 200 ms PTT release tail (configurable 0–500 ms). A
ReleaseTailTimertokio adapter sits between theDesktopPttBackendtransmitsignal andAudioTransmitGate::set(). Key-down forwardstrueimmediately; key-up starts a sleep ofrelease_tail_ms(default 200, matching the TeamSpeak / Mumble default) before the gate closes; a key-down within the sleep window cancels the pending close. The tail affects onlytransmit_active;capture_activeis unaffected. The adapter is independent of the missed-key-up watchdog (SAD-079 / SDD-092). - Hard-mute overrides everything. The Voice Bar hard-mute toggle is applied by the
TransmitModeSelectoras a final clamp ontransmit_activeand takes precedence over the active transmit mode, the PTT key state, and every other internal signal. Hard-mute is in-session only and resets on the next voice-channel join. - Cross-references. DEC-029 locks the v1 PTT capture path to the native Rust
DesktopPttBackendtrait + per-OS implementations (SDD-083 / SDD-084 / SDD-085) and rejects Flutter global-hotkey packages such ashotkey_managerandsuper_hot_key, which wrap the OSRegisterHotKeysemantics, consume the key, drop key-up events, and skip mouse side-buttons. DEC-030 defers Voice Activity Detection to P1 pending a backend trade-off review (RMS vs WebRTC VAD vs Silero VAD); the enum surface is preserved so a P1 increment is non-breaking.
| Version | Date | Description |
|---|---|---|
| 0.9.5 | 2026-05-15 | Added the v0.9.5 lifecycle and mode-picker addendum capturing the no-manual-start audio engine bound to voice-channel membership, the v1 transmit-mode set (Ptt default + Continuous, with VoiceActivity reserved per DEC-030), the 200 ms (0–500 ms) PTT release tail, and the hard-mute override. Cross-references DEC-029 (native PTT backend locked) and DEC-030 (VAD deferred to P1). |