feat(audio,bridge,flutter): v1 audio + PTT lifecycle implementation (SDD-094..097)

Implement the SDD-094 / SDD-095 / SDD-096 / SDD-097 detailed designs
committed in dfa84ee.

Rust side
- chanora_audio::TransmitMode enum (Ptt/Continuous/VoiceActivity) with
  serde-friendly u8 repr (SDD-095).
- chanora_audio::TransmitModeSelector: lock-free Atomic-backed selector
  that is the sole writer of transmit_active (per SAD-083), applying
  hard_mute as a final clamp. VoiceActivity falls through to Continuous
  for v1 (DEC-030 placeholder).
- chanora_audio::ReleaseTailTimer: tokio-task-owning struct driving the
  selector's ptt_held input; default 200 ms tail, configurable 0–500 ms
  with AtomicU32 hot read; pending JoinHandle held in a std::sync::Mutex
  touched only on PTT edge transitions (SDD-096).
- chanora_storage: AudioMeta persisted as audio_meta.json next to
  identity.dek; get/set_transmit_mode + get/set_release_tail_ms with
  0..=500 clamp on write.
- chanora_core::ChanoraSession: voice_join(channel, password) and
  voice_leave() are the new lifecycle entry points; ensure_audio_running
  and shutdown_audio_if_idle are private helpers around the existing
  Option<AudioEngine> field. SessionEvent::VoiceState carries the
  in_channel / transmit_mode / mute / release_tail_ms tuple. Selector
  state survives reconnect; supervisor rewires it to each fresh engine
  gate.
- chanora_bridge: drop start_audio; add voice_join, voice_leave,
  set/get_transmit_mode, set/get_release_tail_ms, set_hard_mute.
  BridgeEvent::VoiceState mirrors the core event. AudioStarted/Stopped
  kept for backwards compat but Flutter ignores them in the new UI.

Flutter side
- New apps/chanora_flutter/lib/widgets/voice_bar.dart replaces the
  legacy _AudioControls widget. Renders channel pill, mode badge,
  mute toggle, level meter, PttCapabilityBadge, leave button. No
  manual Start affordance anywhere.
- New apps/chanora_flutter/lib/widgets/voice_settings.dart dialog with
  TransmitMode radio group (VoiceActivity disabled with 'Coming soon'
  trailing label per DEC-030), bind-key button, release-tail slider
  0–500 ms step 25.
- main.dart: state fields _inChannel, _transmitMode, _hardMute,
  _releaseTailMs driven by BridgeEvent_VoiceState. Channel-tap now
  calls voiceJoin instead of moveToChannel. Removed _onStartAudio,
  _audioStarted-gated branch, and the FilledButton.
- l10n: 11 new strings in app_en.arb + app_zh.arb.

Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored
  (chanora_audio: +12 new tests for TransmitMode/Selector/ReleaseTail;
  chanora_storage: +2 new tests for audio_meta round-trip).
- flutter analyze: 0 errors, 0 warnings; 6 infos are the Flutter 3.32
  Radio.groupValue deprecation (pre-existing API usage).
- FRB Dart/Rust bindings regenerated via flutter_rust_bridge_codegen.

Follow-up (intentionally deferred)
- PttController and per-platform PTT backends still drive AudioTransmitGate
  directly via the legacy set_ptt path; routing those key edges through
  ChanoraSession::release_tail_timer().{key_down,key_up} so the tail
  applies to native PTT input is a contained wiring change in a follow-up.
- Real audio-level RMS in BridgeAudioStats (current meter is binary).
- VoiceActivity backend (DEC-030).
This commit is contained in:
EdisonJwa
2026-05-15 23:05:37 +08:00
parent dfa84ee7bb
commit ba444d94bd
25 changed files with 2519 additions and 178 deletions
+215 -6
View File
@@ -48,7 +48,8 @@ use tracing::{info, warn};
pub mod ptt;
pub use chanora_audio::{
AudioEngine, AudioEngineConfig, PttBackendDescriptor, PttCapabilityLevel,
AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor,
PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector,
};
pub use chanora_audio::{PttBinding, PttInputClass};
pub use chanora_diagnostics::{
@@ -156,6 +157,21 @@ pub enum SessionEvent {
/// no binding is active.
bound_input_class: String,
},
/// Voice subsystem state snapshot (SDD-094). Emitted on
/// `voice_join` / `voice_leave`, transmit-mode changes,
/// hard-mute toggles, and release-tail edits.
VoiceState {
/// True when the user has joined a voice channel via
/// `voice_join` and the audio engine is running.
in_channel: bool,
/// Active transmit mode encoded as
/// [`chanora_audio::TransmitMode::as_u8`].
transmit_mode: u8,
/// True when the hard-mute clamp is engaged.
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
},
}
/// Coarse OS-reported network state. Populated by the Flutter side
@@ -231,6 +247,16 @@ pub struct ChanoraSession {
/// extension). Lives alongside the identity file. Wired by
/// [`Self::init_storage`].
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
/// Single transmit-mode selector for the whole session
/// lifetime (SAD-083). Re-wired to a fresh
/// [`AudioTransmitGate`] every time the audio engine starts;
/// in between it caches the user's chosen mode and hard-mute
/// so that `set_transmit_mode` / `set_hard_mute` work even
/// before any audio is running.
voice_selector: Arc<TransmitModeSelector>,
/// Release-tail timer (SDD-096). Drives the selector's
/// `ptt_held` input from PTT key edges.
release_tail: Arc<ReleaseTailTimer>,
}
impl ChanoraSession {
@@ -238,12 +264,24 @@ impl ChanoraSession {
pub fn new() -> Self {
let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let (network_tx, _) = watch::channel(NetworkState::Unknown);
// Initial selector wired to a standalone gate. Once the
// audio engine starts, `start_audio` constructs a fresh
// selector wired to the engine's gate and migrates the
// cached mode + hard-mute into it.
let initial_gate = AudioTransmitGate::new(false);
let selector = Arc::new(TransmitModeSelector::new(initial_gate));
let release_tail = Arc::new(ReleaseTailTimer::new(
selector.clone(),
chanora_audio::DEFAULT_TAIL_MS,
));
Self {
inner: Arc::new(Mutex::new(None)),
events_tx,
network_tx,
identity_store: Arc::new(Mutex::new(None)),
bookmark_store: Arc::new(Mutex::new(None)),
voice_selector: selector,
release_tail,
}
}
@@ -277,6 +315,13 @@ impl ChanoraSession {
}
};
let encrypts = bookmarks.encrypts_passwords();
// Restore persisted v1 audio settings (SDD-095/096).
let persisted_mode = store.get_transmit_mode();
if let Some(m) = TransmitMode::from_u8(persisted_mode) {
self.voice_selector.set_mode(m);
}
self.release_tail
.set_tail_ms(store.get_release_tail_ms().min(chanora_audio::MAX_TAIL_MS));
*self.identity_store.lock().await = Some(store);
*self.bookmark_store.lock().await = Some(bookmarks);
info!(
@@ -408,6 +453,7 @@ impl ChanoraSession {
cancel_rx,
sup_inner.clone(),
self.network_tx.subscribe(),
self.voice_selector.clone(),
));
let _ = self.events_tx.send(SessionEvent::Connected {
@@ -461,8 +507,14 @@ impl ChanoraSession {
.protocol
.take_voice_in()
.ok_or(CoreError::Invariant("voice_in already taken"))?;
let engine = chanora_audio::AudioEngine::start(cfg.clone(), voice_out, voice_in)?;
let gate = engine.transmit_gate().clone();
// Build a fresh gate, give it to the engine, and rewire
// the session's long-lived selector to it (SAD-083). The
// selector retains cached mode / hard-mute / ptt_held so
// settings set before audio-start take effect immediately.
let gate = AudioTransmitGate::new(cfg.ptt_initial);
let engine =
chanora_audio::AudioEngine::start_with_gate(cfg.clone(), voice_out, voice_in, gate.clone())?;
self.voice_selector.replace_gate(gate.clone());
state.audio = Some(engine);
// Wire the PTT controller (SDD-088). It owns the platform
@@ -611,6 +663,161 @@ impl ChanoraSession {
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
}
// ---------- v1 audio + PTT lifecycle (SDD-094/095/096) ----------
/// Idempotent helper that ensures the audio engine is running
/// (SDD-094). Starts a fresh engine with the default
/// [`AudioEngineConfig`] if none is active; otherwise leaves
/// the current engine in place.
async fn ensure_audio_running(&self) -> Result<(), CoreError> {
let need_start = {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
state.audio.is_none()
};
if need_start {
self.start_audio(AudioEngineConfig::default()).await?;
}
Ok(())
}
/// Tear down the audio engine when it is no longer needed
/// (SDD-094). Called by `voice_leave`. The PTT controller is
/// torn down with it.
async fn shutdown_audio_if_idle(&self) {
let mut guard = self.inner.lock().await;
if let Some(state) = guard.as_mut() {
if let Some(controller) = state.ptt_controller.take() {
controller.stop().await;
}
if let Some(mut engine) = state.audio.take() {
engine.stop();
let _ = self.events_tx.send(SessionEvent::AudioStopped);
}
// Record that audio is no longer desired so the
// supervisor does not re-arm it on the next reconnect.
let mut sup = state.sup_inner.lock().await;
sup.audio_running = false;
}
}
/// Join a voice channel (SDD-094). Moves the user to
/// `channel_id`, starts the audio engine if needed, marks the
/// transmit-mode selector as in-channel, and emits
/// [`SessionEvent::VoiceState`].
///
/// `password` is optional; empty string is treated as none.
pub async fn voice_join(
&self,
channel_id: u64,
password: Option<String>,
) -> Result<(), CoreError> {
self.move_to_channel(channel_id, password).await?;
self.ensure_audio_running().await?;
self.voice_selector.set_in_channel(true);
self.emit_voice_state(true).await;
Ok(())
}
/// Leave the current voice channel (SDD-094). Marks the
/// selector as out-of-channel (which clamps `transmit_active`
/// to false), tears down the audio engine, and emits
/// [`SessionEvent::VoiceState`].
pub async fn voice_leave(&self) -> Result<(), CoreError> {
self.voice_selector.set_in_channel(false);
self.shutdown_audio_if_idle().await;
self.emit_voice_state(false).await;
Ok(())
}
/// Update the active transmit mode (SDD-095). Persists the new
/// value to the identity store when one is wired, and re-emits
/// [`SessionEvent::VoiceState`].
pub async fn set_transmit_mode(&self, mode: TransmitMode) -> Result<(), CoreError> {
self.voice_selector.set_mode(mode);
if let Some(store) = self.identity_store.lock().await.as_ref() {
// Persist best-effort; a missing meta file is a recoverable error.
if let Err(e) = store.set_transmit_mode(mode.as_u8()) {
warn!(
target: "chanora_core",
error = %e,
"failed to persist transmit_mode"
);
}
}
let in_channel = self.voice_selector.in_channel();
self.emit_voice_state(in_channel).await;
Ok(())
}
/// Read the active transmit mode.
pub fn transmit_mode(&self) -> TransmitMode {
self.voice_selector.mode()
}
/// Engage or release the hard-mute clamp (SDD-094). Hard-mute
/// is the final clamp applied by the [`TransmitModeSelector`]
/// — when engaged, no audio is transmitted regardless of
/// channel or PTT state.
pub async fn set_hard_mute(&self, muted: bool) -> Result<(), CoreError> {
self.voice_selector.set_hard_mute(muted);
let in_channel = self.voice_selector.in_channel();
self.emit_voice_state(in_channel).await;
Ok(())
}
/// True if hard-mute is currently engaged.
pub fn hard_mute(&self) -> bool {
self.voice_selector.hard_mute()
}
/// Update the release-tail (SDD-096). Clamped to `0..=500` ms
/// inclusive. Persists best-effort and re-emits voice state.
pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> {
let clamped = ms.min(chanora_audio::MAX_TAIL_MS);
self.release_tail.set_tail_ms(clamped);
if let Some(store) = self.identity_store.lock().await.as_ref() {
if let Err(e) = store.set_release_tail_ms(clamped) {
warn!(
target: "chanora_core",
error = %e,
"failed to persist release_tail_ms"
);
}
}
let in_channel = self.voice_selector.in_channel();
self.emit_voice_state(in_channel).await;
Ok(())
}
/// Current release-tail in milliseconds.
pub fn release_tail_ms(&self) -> u32 {
self.release_tail.tail_ms()
}
/// Shared handle to the session's [`TransmitModeSelector`].
/// Exposed for diagnostics / tests; the bridge mutates via the
/// dedicated `set_*` methods above.
pub fn transmit_selector(&self) -> Arc<TransmitModeSelector> {
self.voice_selector.clone()
}
/// Shared handle to the session's [`ReleaseTailTimer`]. PTT
/// input backends drive this timer's `key_down` / `key_up`
/// edges (SDD-088).
pub fn release_tail_timer(&self) -> Arc<ReleaseTailTimer> {
self.release_tail.clone()
}
async fn emit_voice_state(&self, in_channel: bool) {
let _ = self.events_tx.send(SessionEvent::VoiceState {
in_channel,
transmit_mode: self.voice_selector.mode().as_u8(),
mute: self.voice_selector.hard_mute(),
release_tail_ms: self.release_tail.tail_ms(),
});
}
/// Disconnect from the server. No-op if not connected.
pub async fn disconnect(&self) -> Result<(), CoreError> {
let mut guard = self.inner.lock().await;
@@ -679,6 +886,7 @@ async fn supervisor_loop(
mut cancel_rx: oneshot::Receiver<()>,
sup_inner: Arc<Mutex<SupervisorInner>>,
mut network_rx: watch::Receiver<NetworkState>,
voice_selector: Arc<TransmitModeSelector>,
) {
let mut lost_rx = initial_lost_rx;
let mut probe = initial_probe;
@@ -959,12 +1167,13 @@ async fn supervisor_loop(
if let Some(state) = guard.as_mut() {
let voice_out = state.protocol.voice_out();
if let Some(voice_in) = state.protocol.take_voice_in() {
match chanora_audio::AudioEngine::start(
audio_cfg, voice_out, voice_in,
let gate = chanora_audio::AudioTransmitGate::new(audio_cfg.ptt_initial);
match chanora_audio::AudioEngine::start_with_gate(
audio_cfg, voice_out, voice_in, gate.clone(),
) {
Ok(engine) => {
let gate = engine.transmit_gate().clone();
state.audio = Some(engine);
voice_selector.replace_gate(gate.clone());
// Re-arm the PTT controller against
// the new engine's gate (SDD-088).
let controller = ptt::PttController::new(gate);