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
+7
View File
@@ -312,6 +312,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
rust.BridgeSnapshot? _snapshot;
final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats;
double? _inputLevel;
Timer? _statsTimer;
bool _snapshotRefreshInFlight = false;
bool _snapshotRefreshQueued = false;
@@ -319,6 +320,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
bool _snapshotRefreshQueuedReportErrors = false;
int _connectionEpoch = 0;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
StreamSubscription<double>? _inputLevelSub;
late final PrefetchDebouncer _prefetch;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
@@ -395,6 +397,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer);
_hostCtl.addListener(_onHostEdited);
_eventsSub = rust.eventsStream().listen(_onEvent);
_inputLevelSub = rust.inputLevelStream().listen((level) {
if (mounted) setState(() => _inputLevel = level);
});
// SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged
// events as early as possible so the listen-only banner reflects
// the system state on first frame.
@@ -957,6 +962,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
WidgetsBinding.instance.removeObserver(this);
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_eventsSub?.cancel();
_inputLevelSub?.cancel();
_statsTimer?.cancel();
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
@@ -2378,6 +2384,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_currentVoiceChannelId,
),
audioStats: _audioStats,
inputLevel: _inputLevel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
+16 -2
View File
@@ -261,6 +261,12 @@ Stream<BridgeEvent> eventsStream() =>
Future<BridgeAudioStats> audioStats() =>
RustLib.instance.api.crateApiAudioStats();
/// 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 or the session is dropped.
Stream<double> inputLevelStream() =>
RustLib.instance.api.crateApiInputLevelStream();
/// Apply the P1 audio-processing config.
Future<void> setAudioProcessingConfig({
required BridgeAudioProcessingConfig config,
@@ -681,15 +687,22 @@ class BridgeAudioStats {
/// Current push-to-talk state.
final bool pttActive;
/// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping).
final double inputLevel;
const BridgeAudioStats({
required this.framesSent,
required this.framesReceived,
required this.pttActive,
required this.inputLevel,
});
@override
int get hashCode =>
framesSent.hashCode ^ framesReceived.hashCode ^ pttActive.hashCode;
framesSent.hashCode ^
framesReceived.hashCode ^
pttActive.hashCode ^
inputLevel.hashCode;
@override
bool operator ==(Object other) =>
@@ -698,7 +711,8 @@ class BridgeAudioStats {
runtimeType == other.runtimeType &&
framesSent == other.framesSent &&
framesReceived == other.framesReceived &&
pttActive == other.pttActive;
pttActive == other.pttActive &&
inputLevel == other.inputLevel;
}
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => 281698435;
int get rustContentHash => -20394775;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -123,6 +123,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiInitStorage({required String dir});
Stream<double> crateApiInputLevelStream();
Future<bool> crateApiIsConnected();
Future<BridgeAudioDeviceList> crateApiListAudioDevices();
@@ -762,6 +764,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiInitStorageConstMeta =>
const TaskConstMeta(debugName: "init_storage", argNames: ["dir"]);
@override
Stream<double> crateApiInputLevelStream() {
final sink = RustStreamSink<double>();
unawaited(
handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_StreamSink_f_32_Sse(sink, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiInputLevelStreamConstMeta,
argValues: [sink],
apiImpl: this,
),
),
);
return sink.stream;
}
TaskConstMeta get kCrateApiInputLevelStreamConstMeta =>
const TaskConstMeta(debugName: "input_level_stream", argNames: ["sink"]);
@override
Future<bool> crateApiIsConnected() {
return handler.executeNormal(
@@ -771,7 +805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
funcId: 22,
port: port_,
);
},
@@ -798,7 +832,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 23,
port: port_,
);
},
@@ -825,7 +859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 23,
funcId: 24,
port: port_,
);
},
@@ -849,7 +883,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -879,7 +913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 25,
funcId: 26,
port: port_,
);
},
@@ -909,7 +943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
funcId: 27,
port: port_,
);
},
@@ -936,7 +970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 28,
port: port_,
);
},
@@ -961,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -994,7 +1028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 29,
funcId: 30,
port: port_,
);
},
@@ -1021,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1055,7 +1089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 31,
funcId: 32,
port: port_,
);
},
@@ -1090,7 +1124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 33,
port: port_,
);
},
@@ -1120,7 +1154,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 34,
port: port_,
);
},
@@ -1148,7 +1182,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 35,
port: port_,
);
},
@@ -1176,7 +1210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 35,
funcId: 36,
port: port_,
);
},
@@ -1206,7 +1240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 37,
port: port_,
);
},
@@ -1234,7 +1268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1260,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 38,
funcId: 39,
port: port_,
);
},
@@ -1288,7 +1322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 40,
port: port_,
);
},
@@ -1316,7 +1350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 40,
funcId: 41,
port: port_,
);
},
@@ -1344,7 +1378,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 41,
funcId: 42,
port: port_,
);
},
@@ -1376,7 +1410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 42,
funcId: 43,
port: port_,
);
},
@@ -1406,7 +1440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 43,
funcId: 44,
port: port_,
);
},
@@ -1434,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 44,
funcId: 45,
port: port_,
);
},
@@ -1462,7 +1496,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 45,
funcId: 46,
port: port_,
);
},
@@ -1489,7 +1523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 47,
port: port_,
);
},
@@ -1517,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 47,
funcId: 48,
port: port_,
);
},
@@ -1549,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 48,
funcId: 49,
port: port_,
);
},
@@ -1578,7 +1612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 49,
funcId: 50,
port: port_,
);
},
@@ -1610,6 +1644,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError();
}
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
throw UnimplementedError();
}
@protected
String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1781,12 +1821,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 3)
throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
if (arr.length != 4)
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BridgeAudioStats(
framesSent: dco_decode_u_32(arr[0]),
framesReceived: dco_decode_u_32(arr[1]),
pttActive: dco_decode_bool(arr[2]),
inputLevel: dco_decode_f_32(arr[3]),
);
}
@@ -2271,6 +2312,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError('Unreachable ()');
}
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
throw UnimplementedError('Unreachable ()');
}
@protected
String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2488,10 +2537,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_framesSent = sse_decode_u_32(deserializer);
var var_framesReceived = sse_decode_u_32(deserializer);
var var_pttActive = sse_decode_bool(deserializer);
var var_inputLevel = sse_decode_f_32(deserializer);
return BridgeAudioStats(
framesSent: var_framesSent,
framesReceived: var_framesReceived,
pttActive: var_pttActive,
inputLevel: var_inputLevel,
);
}
@@ -3199,6 +3250,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(
self.setupAndSerialize(
codec: SseCodec(
decodeSuccessData: sse_decode_f_32,
decodeErrorData: sse_decode_AnyhowException,
),
),
serializer,
);
}
@protected
void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3380,6 +3448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_32(self.framesSent, serializer);
sse_encode_u_32(self.framesReceived, serializer);
sse_encode_bool(self.pttActive, serializer);
sse_encode_f_32(self.inputLevel, serializer);
}
@protected
@@ -27,6 +27,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@@ -210,6 +213,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@@ -439,6 +447,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@@ -29,6 +29,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
RustStreamSink<double> dco_decode_StreamSink_f_32_Sse(dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@@ -212,6 +215,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<double> sse_decode_StreamSink_f_32_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@@ -441,6 +449,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_f_32_Sse(
RustStreamSink<double> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@@ -33,6 +33,7 @@ class VoiceBar extends StatelessWidget {
required this.onConfigure,
required this.onPttHeldChanged,
this.talkPowerBlocked = false,
this.inputLevel,
});
final bool inChannel;
@@ -53,6 +54,10 @@ class VoiceBar extends StatelessWidget {
/// level meter. Pass `null` to render an idle meter.
final rust.BridgeAudioStats? audioStats;
/// Real-time input level from the 30 Hz stream (dBFS).
/// When non-null, takes precedence over `audioStats.inputLevel`.
final double? inputLevel;
/// PTT capability badge inputs — passed through to
/// [`PttCapabilityBadge`].
final String pttLevel;
@@ -196,7 +201,7 @@ class VoiceBar extends StatelessWidget {
),
const SizedBox(height: 6),
// Row 4: level meter
VoiceLevelMeter(active: levelActive),
VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
const SizedBox(height: 4),
if (stats != null)
Text(
@@ -7,7 +7,7 @@
// release-tail are surfaced inline (radio buttons + slider) inside
// the modal.
import 'dart:async' show Timer, unawaited;
import 'dart:async' show StreamSubscription, Timer, unawaited;
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
@@ -455,6 +455,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
int _rateTickCount = 0;
late final AudioProcessingConfigState _audioProcessing;
double? _streamLevel;
StreamSubscription<double>? _levelSub;
@override
void initState() {
@@ -463,8 +465,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
widget.initialAudioConfig,
);
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
_levelSub = rust.inputLevelStream().listen((level) {
if (mounted) setState(() => _streamLevel = level);
});
// Poll audio stats at 250 ms so TX/RX counters update in real time
// while the sheet is open.
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
@@ -472,7 +478,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
setState(() {
_stats = s;
_rateTickCount++;
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived;
@@ -487,6 +492,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
@override
void dispose() {
_levelSub?.cancel();
_statsTimer?.cancel();
super.dispose();
}
@@ -625,7 +631,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 12),
// 4) Level meter + live TX/RX stats.
VoiceLevelMeter(active: levelActive),
VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel),
const SizedBox(height: 6),
_StatsRow(
txRate: _txRate,
@@ -1,28 +1,78 @@
import 'dart:math' show max;
import 'package:flutter/material.dart';
/// Shared compact level meter used by voice surfaces.
class VoiceLevelMeter extends StatelessWidget {
const VoiceLevelMeter({super.key, required this.active});
///
/// When [level] is null (no stats available yet), falls back to [active]
/// for a binary indicator. When [level] is provided it is interpreted as
/// dBFS and mapped to a 01 fill fraction via [dbfsToFraction] (floors
/// at -60 dBFS).
class VoiceLevelMeter extends StatefulWidget {
const VoiceLevelMeter({super.key, this.active = false, this.level});
/// Binary fallback when no dBFS value is available.
final bool active;
/// Real input level in dBFS (-120 = silence, 0 = clipping).
/// Null means stats are not yet available; [active] is used instead.
final double? level;
/// Map dBFS [-60, 0] → [0.0, 1.0].
static double dbfsToFraction(double dbfs) {
const floor = -60.0;
if (dbfs <= floor) return 0.0;
if (dbfs >= 0.0) return 1.0;
return (dbfs - floor) / -floor;
}
@override
State<VoiceLevelMeter> createState() => _VoiceLevelMeterState();
}
class _VoiceLevelMeterState extends State<VoiceLevelMeter> {
double _previousFill = 0.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final double fill;
final Color color;
if (widget.level != null) {
fill = VoiceLevelMeter.dbfsToFraction(widget.level!);
color = fill > 0.0
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
} else {
fill = widget.active ? 0.75 : 0.05;
color = widget.active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant;
}
final begin = _previousFill;
_previousFill = fill;
return Container(
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: active ? 0.75 : 0.05,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: begin, end: fill),
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
builder: (context, animatedFill, child) {
return FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: max(animatedFill, 0.02),
child: child,
);
},
child: Container(
decoration: BoxDecoration(
color: active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
color: color,
borderRadius: BorderRadius.circular(4),
),
),