feat(voice): real-time mic input level metering at 30 Hz (#25)
* feat(voice): add real-time mic input level metering at 30 Hz
Expose input RMS from the audio engine through the bridge as a
dedicated Rust→Dart Stream<double>, replacing the binary on/off
indicator with a proportional dBFS level meter.
Rust side:
- chanora_audio: add set_input_dbfs/input_dbfs accessors to
SharedAudioProcessingStats; restructure CaptureState::ingest()
to compute dBFS from mono buffer before the PTT guard so the
meter shows mic activity even when not transmitting.
- chanora_core: widen audio_stats() return to include f32 input
level.
- chanora_bridge: add input_level: f32 to BridgeAudioStats and
new input_level_stream(sink: StreamSink<f32>) that pushes at
~30 Hz via tokio interval task.
- Update frb_generated.rs serialization for the new field.
Flutter side:
- VoiceLevelMeter: accept optional double level (dBFS), map
-60..0 dBFS to 0..1 fill fraction, animate with
TweenAnimationBuilder for smooth transitions.
- voice_compact.dart: subscribe to inputLevelStream in the voice
details sheet for 30 Hz meter updates, keeping 250 ms poll for
TX/RX counters.
- voice_bar.dart: accept optional inputLevel from the stream.
- main.dart: subscribe to inputLevelStream, pass to VoiceBar.
* chore: sync Flutter build config and dependency updates
- Add Flutter migrator flags to gradle.properties (builtInKotlin, newDsl)
- Add FlutterGeneratedPluginSwiftPackage to iOS/macOS Xcode projects
- Update meta 1.17→1.18, test_api 0.7.10→0.7.11
- Rebuild chanora_bridge framework for macOS
- Update Podfile.lock for iOS and macOS
* fix(voice): correct meter animation, pre-gain dBFS, stream lifecycle, and protocol warnings
B1: Convert VoiceLevelMeter to StatefulWidget tracking previous fill
as Tween begin so the meter animates smoothly instead of resetting
to zero on every frame.
B2: Compute dBFS from pre-gain mono samples in CaptureState::ingest()
so the level meter reflects raw mic input, matching mobile paths.
B4: End input_level_stream after 10 consecutive session errors instead
of emitting -120 dBFS forever when the session is gone.
Also fixes all 13 clippy warnings in chanora_protocol: collapsed
nested if-let patterns, replaced .ok() + Some matching with Ok, used
? operator, and introduced EventChannels struct to reduce the four
helper functions below the 7-argument threshold.
* fix(voice): use MissedTickBehavior::Skip for level meter stream and align dBFS doc
Set MissedTickBehavior::Skip on the input_level_stream tokio interval
so slow audio_stats() calls skip missed ticks instead of bursting,
preventing CPU spikes on the UI meter thread.
Align VoiceLevelMeter class doc: the mapping floors at -60 dBFS
(via dbfsToFraction), not the full -120 range.
This commit is contained in:
@@ -1038,6 +1038,8 @@ pub struct BridgeAudioStats {
|
||||
pub frames_received: u32,
|
||||
/// Current push-to-talk state.
|
||||
pub ptt_active: bool,
|
||||
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
|
||||
pub input_level: f32,
|
||||
}
|
||||
|
||||
/// Bridge route class for P1 audio-processing policy.
|
||||
@@ -2096,7 +2098,7 @@ pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
|
||||
|
||||
/// Read audio statistics. Errors if no connection or audio not started.
|
||||
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
let (s, r, p) = runtime()
|
||||
let (s, r, p, lvl) = runtime()
|
||||
.spawn(async { session().audio_stats().await })
|
||||
.await
|
||||
.map_err(|e| task_join_error("audio_stats", e))??;
|
||||
@@ -2104,9 +2106,42 @@ pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
|
||||
frames_sent: s,
|
||||
frames_received: r,
|
||||
ptt_active: p,
|
||||
input_level: lvl,
|
||||
})
|
||||
}
|
||||
|
||||
/// Subscribe to real-time microphone input level at ~30 Hz.
|
||||
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
||||
/// when the Dart subscriber cancels, the session is dropped, or
|
||||
/// the session becomes persistently unavailable.
|
||||
pub fn input_level_stream(sink: StreamSink<f32>) -> Result<(), BridgeError> {
|
||||
runtime().spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(33));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut consecutive_errors = 0u32;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let level = match session().audio_stats().await {
|
||||
Ok((_, _, _, lvl)) => {
|
||||
consecutive_errors = 0;
|
||||
lvl
|
||||
}
|
||||
Err(_) => {
|
||||
consecutive_errors += 1;
|
||||
if consecutive_errors >= 10 {
|
||||
return;
|
||||
}
|
||||
-120.0
|
||||
}
|
||||
};
|
||||
if sink.add(level).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the P1 audio-processing config.
|
||||
pub async fn set_audio_processing_config(
|
||||
config: BridgeAudioProcessingConfig,
|
||||
|
||||
Reference in New Issue
Block a user