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:
Edison Jwa
2026-06-05 20:57:16 +09:00
committed by GitHub
parent 2c7b68e21e
commit 82441f3d97
25 changed files with 519 additions and 258 deletions
+86 -30
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
// Section: executor
@@ -738,6 +738,42 @@ fn wire__crate__api__init_storage_impl(
},
)
}
fn wire__crate__api__input_level_stream_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "input_level_stream",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_sink =
<StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec>>::sse_decode(
&mut deserializer,
);
deserializer.end();
move |context| {
transform_result_sse::<_, crate::BridgeError>((move || {
let output_ok = crate::api::input_level_stream(api_sink)?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__is_connected_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1790,6 +1826,14 @@ impl SseDecode
}
}
impl SseDecode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <String>::sse_decode(deserializer);
return StreamSink::deserialize(inner);
}
}
impl SseDecode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1962,10 +2006,12 @@ impl SseDecode for crate::api::BridgeAudioStats {
let mut var_framesSent = <u32>::sse_decode(deserializer);
let mut var_framesReceived = <u32>::sse_decode(deserializer);
let mut var_pttActive = <bool>::sse_decode(deserializer);
let mut var_inputLevel = <f32>::sse_decode(deserializer);
return crate::api::BridgeAudioStats {
frames_sent: var_framesSent,
frames_received: var_framesReceived,
ptt_active: var_pttActive,
input_level: var_inputLevel,
};
}
}
@@ -2755,33 +2801,34 @@ fn pde_ffi_dispatcher_primary_impl(
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
36 => {
21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
37 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2803,10 +2850,10 @@ fn pde_ffi_dispatcher_sync_impl(
data_len,
),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2984,6 +3031,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioStats {
self.frames_sent.into_into_dart().into_dart(),
self.frames_received.into_into_dart().into_dart(),
self.ptt_active.into_into_dart().into_dart(),
self.input_level.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -3652,6 +3700,13 @@ impl SseEncode
}
}
impl SseEncode for StreamSink<f32, flutter_rust_bridge::for_generated::SseCodec> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
unimplemented!("")
}
}
impl SseEncode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3781,6 +3836,7 @@ impl SseEncode for crate::api::BridgeAudioStats {
<u32>::sse_encode(self.frames_sent, serializer);
<u32>::sse_encode(self.frames_received, serializer);
<bool>::sse_encode(self.ptt_active, serializer);
<f32>::sse_encode(self.input_level, serializer);
}
}