feat(ptt): full desktop backend ladder + missed-key-up watchdog (gen2 v0.9.3 follow-up)
Lands SDD-081..088 + SDD-092 implementations on top of v1.0.0-rc.3.
The cross-platform pieces — `AudioTransmitGate`, the per-platform
backend ladder, and the missed-key-up watchdog — are wired into the
audio engine lifecycle. Per-platform live verification on Windows
/ macOS / GNOME-Wayland reference hosts is the remaining work
(RR-PTT-001..006/008 in `release-readiness-go-nogo-record.md`).
`chanora_audio::ptt`
--------------------
* `AudioTransmitGate` now owns an `Arc<AtomicBool>` plus a
`tokio::sync::watch::Sender<bool>` (SAD-075 / SDD-089). The
encoder feed reads the atomic on the hot path; the watchdog
subscribes to the watch channel.
* `MissedKeyUpWatchdog::spawn(gate, timeout)` watches the gate
transitions and self-clears `transmit_active` if the
`false -> true` lifetime exceeds the configured ceiling
(DEC-028, default 30s). Two unit tests cover the timeout-fires
and the no-fire-on-normal-release paths.
`chanora_audio::ptt_backends`
-----------------------------
* `DesktopPttBackend` trait + `PttBinding` value type + `PttInputClass`
enum + `PttBackendError` (SDD-081). `PttBinding` deliberately
carries only `input_class` and an opaque `platform_key`
string; raw key codes never appear in the type surface.
* `select()` factory (SAD-071): runtime ladder evaluation per
OS. Windows → Raw Input → low-level hook → Focused; macOS →
Event Tap → Focused; Linux → GNOME-Wayland portal probe →
Focused.
* `FocusedPttBackend` (SDD-087): universal terminal fallback;
integrates with the existing Flutter Listener-driven PTT.
* `WindowsRawInputBackend` + `WindowsHookBackend` (SDD-083 /
SDD-084): three-rung ladder evaluated once at engine start.
Each backend runs a dedicated worker thread that holds the
OS-level handle; `start`/`stop` lifecycle is honest. Live
`RegisterRawInputDevices` / `SetWindowsHookEx` wiring is
platform-verification work — the scaffolding lets the
descriptor + watchdog + capability event be exercised
end-to-end now.
* `MacOSEventTapBackend` (SDD-085): two-rung ladder with
explicit `PermissionState` (Granted / Denied / Undetermined).
`Undetermined` resolves to `L0Focused` so capability
advertising matches actual runtime behaviour even before
Input Monitoring is granted. Live `CGEventTap` + `IOHIDCheckAccess`
wiring is platform-verification work.
* `LinuxGnomeWaylandBackend` (SDD-086): probes GNOME-on-Wayland
via `XDG_SESSION_TYPE` + `XDG_CURRENT_DESKTOP`, then verifies
the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface
is reachable by reading the `version` property over a
blocking zbus session. Reports `gnome-wayland-portal` /
`L2GlobalHoldToTalk`. Other Linux environments fall through
to the universal Focused backend (DEC-025).
`chanora_audio::engine`
-----------------------
* Engine now owns `transmit_gate: AudioTransmitGate` and
threads a `flag_arc()` clone into the existing capture
state for the cheap hot-path read. `set_transmit_active` /
`transmit_active()` go through the gate so subscribers see
every transition.
* `start_audio` selects the highest-capability backend via
`ptt_backends::select()`, calls `backend.start(gate, none())`,
and spawns the watchdog. Both are released in `stop()` and
on Drop.
* New `engine.rebind_ptt(binding) -> PttBackendDescriptor`
drives the binding-capture flow without restarting the engine.
* New `engine.ptt_descriptor()` returns the privacy-safe
descriptor for the initial UI render before the first
capability event arrives.
`chanora_core`
--------------
* Re-exports `PttBinding` + `PttInputClass`.
* New `ChanoraSession::set_ptt_binding(binding)` — calls
`audio.rebind_ptt` and broadcasts the freshly-published
`SessionEvent::PttCapability` so the UI badge updates live.
* New `ChanoraSession::ptt_descriptor()` for the initial render.
`chanora_bridge`
----------------
* New `BridgePttInputClass` enum + `set_ptt_binding(input_class,
platform_key)` async function. The `platform_key` string is
opaque to the bridge and never logged.
* New `ptt_descriptor()` async accessor returning the
`(level, backend_id, bound_input_class)` triple.
Flutter
-------
* `_AudioControls` now has a "Configure" button next to the
capability badge; `_PttBindingCaptureDialog` captures the
next key press (via `Focus.onKeyEvent`) or mouse side button
(via `Listener.onPointerDown` filtered to button bitmasks
`0x08` / `0x10`). The captured value is the platform-neutral
`LogicalKeyboardKey.keyLabel` or `mouse-side-button:{button}`.
* The dialog explicitly tells the user that the actual key
value never leaves it (DEC-027).
* New ARB keys: `pttConfigureAction`, `pttConfigureTitle`,
`pttConfigurePrompt`, `pttConfigureWaiting`,
`pttConfigureCaptured`, `pttConfigurePrivacyNote`,
`pttConfigureSaveAction` (en + zh-Hans).
Dependencies
------------
* `chanora_audio` adds (Linux only) `zbus = "5"` with the
`tokio` runtime selector + `blocking-api` feature for the
GlobalShortcuts portal probe.
* `chanora_audio` adds `tokio` `test-util` to dev-deps for
`start_paused` watchdog tests (the live watchdog tests use
multi-threaded real time).
Verification
------------
* `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`:
57 tests green (was 53). chanora_audio rises from 4 to 8.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
sources ok.
* `cargo about generate --offline`: regenerates
`docs/security/license-inventory.{md,html}`. The crate count
rises from 364 to 383 with the addition of the zbus tree.
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
LICENSE (unchanged).
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` + `flutter build
linux --release`: clean Linux x86_64 bundle.
Documentation
-------------
* `docs/release/release-readiness-go-nogo-record.md` flips
RR-PTT-007 (missed-key-up watchdog) to Done with a pointer
to the two passing unit tests; bumps to v0.9.4. Live
per-platform traces (RR-PTT-001..005, RR-PTT-008) remain
open and are blocked only on platform reference hosts.
Per-platform live verification (Raw Input registration, Event Tap
creation under granted permission, GlobalShortcuts CreateSession +
BindShortcuts) is queued for the platform owners' reference hosts
per `staged-release-plan.md`.
This commit is contained in:
@@ -83,7 +83,7 @@ pub struct AudioEngine {
|
||||
/// Windows / macOS / Linux global backends) drive this flag
|
||||
/// through [`Self::set_transmit_active`]; nothing else is
|
||||
/// permitted to flip it (SAD-075 / SDD-089).
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
transmit_gate: crate::ptt::AudioTransmitGate,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
frames_received: Arc<AtomicU32>,
|
||||
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
|
||||
@@ -108,6 +108,14 @@ pub struct AudioEngine {
|
||||
/// denied microphone permission), PTT becomes a no-op and
|
||||
/// `frames_sent` stays at 0.
|
||||
capture_active: bool,
|
||||
/// Active desktop PTT backend (SAD-071 / SDD-081). Stored
|
||||
/// inside a `Mutex<Option<_>>` so `stop()` can move it out
|
||||
/// and release OS-level resources before the engine is
|
||||
/// dropped. The value is always `Some` between `start_audio`
|
||||
/// and `stop`.
|
||||
ptt_backend: Mutex<Option<Box<dyn crate::ptt_backends::DesktopPttBackend>>>,
|
||||
/// Missed-key-up watchdog. Dropping aborts the task.
|
||||
ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog>,
|
||||
}
|
||||
|
||||
// cpal::Stream is not Send. We keep the engine pinned to the thread
|
||||
@@ -187,7 +195,8 @@ impl AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
let transmit_active = Arc::new(AtomicBool::new(cfg.ptt_initial));
|
||||
let transmit_gate = crate::ptt::AudioTransmitGate::new(cfg.ptt_initial);
|
||||
let transmit_flag_for_capture = transmit_gate.flag_arc();
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
let frames_received = Arc::new(AtomicU32::new(0));
|
||||
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
|
||||
@@ -202,7 +211,7 @@ impl AudioEngine {
|
||||
let capture_result = try_open_capture(
|
||||
&in_dev,
|
||||
voice_out_tx,
|
||||
transmit_active.clone(),
|
||||
transmit_flag_for_capture,
|
||||
frames_sent.clone(),
|
||||
cfg.mic_gain,
|
||||
);
|
||||
@@ -298,8 +307,44 @@ impl AudioEngine {
|
||||
}
|
||||
});
|
||||
|
||||
// Select and arm the desktop PTT backend (SAD-071,
|
||||
// SDD-081). This call is the only place that talks to the
|
||||
// platform-input layer; the rest of the engine consumes
|
||||
// the typed `AudioTransmitGate`. We always have a backend
|
||||
// because the cross-platform factory falls back to
|
||||
// `FocusedPttBackend` (SDD-087).
|
||||
let mut ptt_backend = crate::ptt_backends::select();
|
||||
let initial_binding = crate::ptt_backends::PttBinding::none();
|
||||
match ptt_backend.start(transmit_gate.clone(), initial_binding) {
|
||||
Ok(()) => {
|
||||
let d = ptt_backend.descriptor();
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
capability_level = %d.level,
|
||||
backend_id = d.backend_id,
|
||||
bound_input_class = ?d.bound_input_class,
|
||||
"ptt backend armed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"ptt backend start failed; engine continues with Focused fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the missed-key-up watchdog. The task aborts on
|
||||
// Drop of `MissedKeyUpWatchdog`, so the engine's `stop`
|
||||
// / Drop chain releases it without explicit cleanup.
|
||||
let ptt_watchdog = crate::ptt::MissedKeyUpWatchdog::spawn(
|
||||
transmit_gate.clone(),
|
||||
crate::ptt::MissedKeyUpWatchdog::DEFAULT_TIMEOUT,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
transmit_active,
|
||||
transmit_gate,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
@@ -308,6 +353,8 @@ impl AudioEngine {
|
||||
_output_stream: Mutex::new(Some(output_stream)),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
capture_active,
|
||||
ptt_backend: Mutex::new(Some(ptt_backend)),
|
||||
ptt_watchdog: Some(ptt_watchdog),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,6 +363,17 @@ impl AudioEngine {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
// Release the active PTT backend's OS resources before
|
||||
// dropping the streams; the backend may hold a worker
|
||||
// thread (Raw Input message loop, Event Tap run loop, etc.)
|
||||
// that needs an explicit stop() to wind down cleanly.
|
||||
if let Ok(mut guard) = self.ptt_backend.lock() {
|
||||
if let Some(mut backend) = guard.take() {
|
||||
backend.stop();
|
||||
}
|
||||
}
|
||||
// Aborting the watchdog cancels its tokio task.
|
||||
self.ptt_watchdog.take();
|
||||
// Drop the streams, which stops their callback threads.
|
||||
let _ = self._input_stream.lock().unwrap().take();
|
||||
let _ = self._output_stream.lock().unwrap().take();
|
||||
@@ -330,12 +388,53 @@ impl AudioEngine {
|
||||
/// per-platform global backends in a follow-up — call this
|
||||
/// method exclusively. No-op when capture is inactive.
|
||||
pub fn set_transmit_active(&self, active: bool) {
|
||||
self.transmit_active.store(active, Ordering::Relaxed);
|
||||
self.transmit_gate.set(active);
|
||||
}
|
||||
|
||||
/// Current transmit gate state.
|
||||
pub fn transmit_active(&self) -> bool {
|
||||
self.transmit_active.load(Ordering::Relaxed)
|
||||
self.transmit_gate.load()
|
||||
}
|
||||
|
||||
/// Shared handle to the underlying transmit gate (SAD-075 /
|
||||
/// SDD-089). Returned for diagnostics and integration tests
|
||||
/// only; never mutate the underlying atomic directly — use
|
||||
/// [`Self::set_transmit_active`] instead.
|
||||
pub fn transmit_gate(&self) -> &crate::ptt::AudioTransmitGate {
|
||||
&self.transmit_gate
|
||||
}
|
||||
|
||||
/// Privacy-safe descriptor of the currently active PTT
|
||||
/// backend (SDD-081 / SDD-091). Returns the universal Focused
|
||||
/// fallback descriptor when the backend slot is empty
|
||||
/// (typically only between `stop()` and Drop).
|
||||
pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor {
|
||||
if let Ok(guard) = self.ptt_backend.lock() {
|
||||
if let Some(b) = guard.as_ref() {
|
||||
return b.descriptor();
|
||||
}
|
||||
}
|
||||
crate::ptt::PttBackendDescriptor::focused()
|
||||
}
|
||||
|
||||
/// Replace the PTT binding on the active backend. Returns the
|
||||
/// freshly-published descriptor so callers can re-emit the
|
||||
/// capability event.
|
||||
pub fn rebind_ptt(
|
||||
&self,
|
||||
binding: crate::ptt_backends::PttBinding,
|
||||
) -> Result<crate::ptt::PttBackendDescriptor, AudioError> {
|
||||
let mut guard = self
|
||||
.ptt_backend
|
||||
.lock()
|
||||
.map_err(|_| AudioError::Backend("ptt_backend mutex poisoned".to_string()))?;
|
||||
let backend = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| AudioError::Backend("ptt backend not armed".to_string()))?;
|
||||
backend
|
||||
.rebind(binding)
|
||||
.map_err(|e| AudioError::Backend(format!("rebind: {e}")))?;
|
||||
Ok(backend.descriptor())
|
||||
}
|
||||
|
||||
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
|
||||
|
||||
Reference in New Issue
Block a user