feat(ptt): code-side initial split — transmit_active / capability badge / sanitizer

Implements the gen2 v0.9.3 doc baseline's first slice of code work:

  * SRS-201: split the audio engine's `ptt` AtomicBool into the
    authoritative `transmit_active` flag. The legacy `set_ptt` /
    `ptt` accessors are retained as `#[doc(hidden)]` thin wrappers
    so the existing bridge command and the existing Flutter
    hold-to-talk UI keep compiling.
  * SAD-075 / SDD-089 acknowledged at the type level: only
    `AudioEngine::set_transmit_active` (or its legacy alias)
    mutates the flag; the encoder feed reads it once per outbound
    frame and never writes.
  * SDD-082: new `chanora_audio::ptt` module ships the
    `PttCapabilityLevel` enum (`L0Focused`, `L1GlobalShortcut`,
    `L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`,
    `L4DeviceAware` reserved) with a stable `as_str` mapping and
    an `is_global` classifier.
  * SDD-087: `PttBackendDescriptor::focused()` constant value for
    the universal Focused-PTT fallback. The struct shape carries
    only privacy-safe fields (`level`, `backend_id`,
    `bound_input_class`) — a key code cannot fit through this
    surface by construction (DEC-027).
  * SAD-077 / SDD-090: `RedactingLogLayer` now hosts the
    `PttBanCheckVisitor` and the `PTT_BANNED_FIELDS` constant
    (`key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`,
    `keysym_string`, `key_sequence`, `key_press_history`,
    `key_timing`). Any record whose field set names a banned key
    is dropped before reaching the in-memory log sink or the
    user-initiated diagnostic export. The check is structural and
    runs ahead of formatting / redaction.
  * `SessionEvent::PttCapability` carries the diagnostics-safe
    descriptor through the broadcast event stream;
    `chanora_core::ChanoraSession::start_audio` publishes the
    Focused-PTT descriptor when the audio engine starts (SRS-196
    / SDD-091).
  * `BridgeEvent::PttCapability` mirrors the event across the
    FFI boundary. flutter_rust_bridge codegen regenerated.
  * Flutter `_AudioControls` renders a capability badge above the
    PTT button: a globe icon for Global levels, a focus-frame
    icon for `L0Focused`, plus a Tooltip exposing the bound input
    class. New ARB key `pttCapabilityBadge(level, backend)` in
    `app_en.arb` and `app_zh.arb`.

Per-platform global PTT backends (`WindowsRawInputBackend`,
`MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) and the
`MissedKeyUpWatchdog` task land in a separate follow-up commit;
this milestone ships only PTT-L0 universally so the application's
runtime capability reporting is honest from day one.

Tests
-----

* `chanora_audio` rises from 1 to 4 unit tests covering
  `PttCapabilityLevel::as_str`, `is_global`, and the
  `PttBackendDescriptor::focused()` shape contract.
* `chanora_diagnostics` rises from 9 to 11 unit tests covering
  the new `PttBanCheckVisitor` over every banned field name and
  the `PTT_BANNED_FIELDS` stability assertion.
* Workspace total: 53 unit + integration tests, all green with
  `CHANORA_DISABLE_KEYRING=1` (was 49 at v1.0.0-rc.2).
* `flutter analyze`: clean.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate`: zero warnings (license inventory
  regenerated).
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
  LICENSE.
* Linux x86_64 release bundle builds clean.

No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
This commit is contained in:
EdisonJwa
2026-05-15 15:02:03 +08:00
parent 02ffadfa52
commit 7b21916049
16 changed files with 642 additions and 31 deletions
+49 -18
View File
@@ -77,7 +77,13 @@ impl Default for AudioEngineConfig {
/// Running audio engine. Drop = stop.
pub struct AudioEngine {
ptt: Arc<AtomicBool>,
/// `transmit_active` is the authoritative gate for outbound
/// voice — the Opus encoder feed consults this flag once per
/// 20 ms frame. PTT subsystems (focused widget, future
/// 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>,
frames_sent: Arc<AtomicU32>,
frames_received: Arc<AtomicU32>,
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
@@ -181,7 +187,7 @@ impl AudioEngine {
}
}
let ptt = Arc::new(AtomicBool::new(cfg.ptt_initial));
let transmit_active = Arc::new(AtomicBool::new(cfg.ptt_initial));
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()));
@@ -196,7 +202,7 @@ impl AudioEngine {
let capture_result = try_open_capture(
&in_dev,
voice_out_tx,
ptt.clone(),
transmit_active.clone(),
frames_sent.clone(),
cfg.mic_gain,
);
@@ -293,7 +299,7 @@ impl AudioEngine {
});
Ok(Self {
ptt,
transmit_active,
frames_sent,
frames_received,
output_gain,
@@ -316,19 +322,41 @@ impl AudioEngine {
info!(target: "chanora_audio", "audio engine stopped");
}
/// Set the push-to-talk active state. When false, captured audio
/// is discarded before encoding. No-op if capture is inactive.
pub fn set_ptt(&self, active: bool) {
self.ptt.store(active, Ordering::Relaxed);
/// Set the **transmission gate** (SRS-201). When true the
/// encoder feed is allowed to emit Opus frames; when false the
/// captured audio is discarded before encoding. This is the
/// only writer permitted on `transmit_active` (SAD-075 /
/// SDD-089). Push-to-Talk subsystems — focused PTT today,
/// 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);
}
/// Current PTT state.
/// Current transmit gate state.
pub fn transmit_active(&self) -> bool {
self.transmit_active.load(Ordering::Relaxed)
}
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
/// the existing bridge `set_ptt` command and the existing
/// Flutter UI continue to compile during the v0.9.3 PTT
/// migration (SRS-201 splits the conceptual `ptt` flag into
/// `transmit_active` / `capture_active`).
#[doc(hidden)]
pub fn set_ptt(&self, active: bool) {
self.set_transmit_active(active);
}
/// Legacy alias for [`Self::transmit_active`].
#[doc(hidden)]
pub fn ptt(&self) -> bool {
self.ptt.load(Ordering::Relaxed)
self.transmit_active()
}
/// True if the capture stream opened. When false, the engine
/// runs in playback-only mode and PTT is a no-op.
/// runs in playback-only mode and the transmit gate is a
/// no-op (no frames will ever be encoded).
pub fn capture_active(&self) -> bool {
self.capture_active
}
@@ -380,7 +408,7 @@ impl Drop for AudioEngine {
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
ptt: Arc<AtomicBool>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<cpal::Stream, AudioError> {
@@ -405,7 +433,7 @@ fn try_open_capture(
in_channels,
mic_gain,
voice_out_tx,
ptt,
transmit_active,
frames_sent,
)));
@@ -433,7 +461,9 @@ struct CaptureState {
resample_pos: f64,
opus_out: [u8; MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
ptt: Arc<AtomicBool>,
/// The PTT transmission gate. Read once per outbound frame; the
/// CaptureState never mutates this flag.
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
}
@@ -444,7 +474,7 @@ impl CaptureState {
in_channels: usize,
mic_gain: f32,
voice_out_tx: mpsc::Sender<OutPacket>,
ptt: Arc<AtomicBool>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
) -> Self {
Self {
@@ -456,15 +486,16 @@ impl CaptureState {
resample_pos: 0.0,
opus_out: [0u8; MAX_OPUS_FRAME],
voice_out_tx,
ptt,
transmit_active,
frames_sent,
}
}
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send on PTT.
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.ptt.load(Ordering::Relaxed) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
return;