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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user