* 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.
277 lines
11 KiB
Dart
277 lines
11 KiB
Dart
// Voice bar widget (SDD-097). Replaces the legacy `_AudioControls`
|
|
// block that used to live in `main.dart`. Driven by the
|
|
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
|
// core's transmit-mode selector + release-tail timer.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import 'ptt_capability_badge.dart';
|
|
import 'voice_compact.dart';
|
|
import 'voice_level_meter.dart';
|
|
import 'voice_platform.dart';
|
|
import 'voice_status_summary.dart';
|
|
import '../src/rust/api.dart' as rust;
|
|
|
|
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
|
/// toggle, level meter, and a leave-channel affordance.
|
|
class VoiceBar extends StatelessWidget {
|
|
/// Construct a voice bar.
|
|
const VoiceBar({
|
|
super.key,
|
|
required this.inChannel,
|
|
required this.transmitMode,
|
|
required this.hardMute,
|
|
required this.outputMuted,
|
|
required this.releaseTailMs,
|
|
required this.channelName,
|
|
required this.audioStats,
|
|
required this.pttLevel,
|
|
required this.pttBackendId,
|
|
required this.pttBoundInputClass,
|
|
required this.pttBoundKeyLabel,
|
|
required this.onConfigure,
|
|
required this.onPttHeldChanged,
|
|
this.talkPowerBlocked = false,
|
|
this.inputLevel,
|
|
});
|
|
|
|
final bool inChannel;
|
|
final rust.BridgeTransmitMode transmitMode;
|
|
final bool hardMute;
|
|
final bool outputMuted;
|
|
final bool talkPowerBlocked;
|
|
|
|
/// Configured release-tail in milliseconds (0..=500). Surfaced as
|
|
/// a hint underneath the mode badge.
|
|
final int releaseTailMs;
|
|
|
|
/// Channel display name to show in the channel-name pill. Empty
|
|
/// string suppresses the pill (typically when `!inChannel`).
|
|
final String channelName;
|
|
|
|
/// Audio statistics (TX/RX frames + ptt_active) to drive the
|
|
/// 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;
|
|
|
|
/// Backend id, e.g. `focused`, `windows-raw-input`.
|
|
final String pttBackendId;
|
|
|
|
/// Coarse bound input class.
|
|
final String pttBoundInputClass;
|
|
|
|
/// Platform-neutral key label captured by the binding dialog.
|
|
final String pttBoundKeyLabel;
|
|
|
|
/// Open the voice settings dialog. This is the SINGLE entry point
|
|
/// for transmit-mode selection, PTT key binding, and release-tail
|
|
/// configuration. The capability badge below is information-only
|
|
/// and intentionally does NOT have its own configure affordance.
|
|
final VoidCallback onConfigure;
|
|
|
|
/// Drive the press/release edges of the on-screen PTT button on
|
|
/// touch-only mobile platforms (iOS / iPadOS / Android). On
|
|
/// desktop platforms this callback is wired but never invoked
|
|
/// because the on-screen button is only rendered on mobile.
|
|
/// The callee should map `true` to `setPtt(active: true)` and
|
|
/// `false` to `setPtt(active: false)`; the Rust release-tail
|
|
/// timer handles the trailing tail (SDD-096).
|
|
final ValueChanged<bool> onPttHeldChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final stats = audioStats;
|
|
final levelActive = stats?.pttActive ?? false;
|
|
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
|
final summary = voiceStatusSummary(
|
|
l10n: l10n,
|
|
transmitMode: transmitMode,
|
|
releaseTailMs: releaseTailMs,
|
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
|
isTouchOnly: isTouchOnlyPttHost,
|
|
inputMuted: hardMute,
|
|
outputMuted: outputMuted,
|
|
pttActive: stats?.pttActive ?? false,
|
|
talkPower: talkPowerBlocked ? 0 : null,
|
|
neededTalkPower: talkPowerBlocked ? 1 : null,
|
|
talkPowerGranted: false,
|
|
);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 0),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: summary.talkPowerBlocked
|
|
? Colors.amber.withValues(alpha: 0.18)
|
|
: summary.muted
|
|
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
|
: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: summary.talkPowerBlocked
|
|
? Colors.amber.shade700
|
|
: summary.muted
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.outlineVariant,
|
|
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Row 1: mode badge
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
transmitMode == rust.BridgeTransmitMode.ptt
|
|
? Icons.radio_button_checked
|
|
: Icons.podcasts,
|
|
size: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
summary.modeLabel,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.tune, size: 16),
|
|
label: Text(l10n.voiceSettingsTitle),
|
|
onPressed: onConfigure,
|
|
),
|
|
],
|
|
),
|
|
// Status row: talk power / mic / speaker state.
|
|
if (inChannel) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
summary.talkPowerBlocked
|
|
? 'Insufficient talk power'
|
|
: summary.statusText,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: summary.talkPowerBlocked
|
|
? Colors.amber.shade700
|
|
: summary.muted
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
// Row 3: PTT-only secondary content.
|
|
//
|
|
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
|
// Web) this is a one-line bound-key + release-tail hint
|
|
// sitting right under the mode badge.
|
|
//
|
|
// On touch-only hosts (iOS / iPadOS / Android) the
|
|
// on-screen Push to Talk button is rendered AT THE
|
|
// BOTTOM of the Voice Bar (see below) so it sits
|
|
// closest to the user's thumb when the Voice Bar is
|
|
// pinned to the bottom of a narrow-layout screen. The
|
|
// release-tail value is folded into the small print
|
|
// under the button rather than shown here.
|
|
if (isPtt && !isTouchOnlyPttHost)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 22, top: 2),
|
|
child: Text(
|
|
'${l10n.voiceModePtt}: '
|
|
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
|
' · '
|
|
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
// Row 4: level meter
|
|
VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel),
|
|
const SizedBox(height: 4),
|
|
if (stats != null)
|
|
Text(
|
|
l10n.audioStatsLine(
|
|
stats.framesSent,
|
|
stats.framesReceived,
|
|
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
|
),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 6),
|
|
// PTT capability badge — only relevant when PTT mode is
|
|
// active. Hidden for Continuous / Voice Activity since
|
|
// there's no key binding to surface a capability for.
|
|
// The badge is information-only; the user reaches the
|
|
// bind-key flow through the Voice Bar's settings gear
|
|
// (single configuration entry point — see the comment
|
|
// on `onConfigure`).
|
|
//
|
|
// Still shown on touch-only mobile hosts because iOS P0
|
|
// acceptance requires an explicit `L0Focused` badge and
|
|
// explanation that global hotkeys are not available in
|
|
// the iOS sandbox.
|
|
if (isPtt)
|
|
PttCapabilityBadge(
|
|
level: pttLevel,
|
|
backendId: pttBackendId,
|
|
boundInputClass: pttBoundInputClass,
|
|
),
|
|
// On touch-only mobile hosts the Push to Talk button is
|
|
// the LAST element of the Voice Bar so it lands closest
|
|
// to the user's thumb when the Voice Bar is pinned to
|
|
// the bottom of a narrow-layout screen. The release-
|
|
// tail value sits above the button so the user sees
|
|
// how long their voice continues after they let go.
|
|
if (isPtt && isTouchOnlyPttHost) ...[
|
|
const SizedBox(height: 4),
|
|
Center(
|
|
child: Text(
|
|
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
VoicePttButton(
|
|
active: levelActive,
|
|
onHeldChanged: onPttHeldChanged,
|
|
height: 64,
|
|
borderRadius: 12,
|
|
iconSize: 24,
|
|
iconGap: 10,
|
|
blurRadius: 12,
|
|
spreadRadius: 0,
|
|
listenForPan: true,
|
|
labelLetterSpacing: null,
|
|
),
|
|
],
|
|
// Leave-voice button intentionally absent: TeamSpeak's
|
|
// model is "user is always in some channel", not
|
|
// Discord's join/leave-voice. To stop being heard /
|
|
// hearing others, mute mic and/or speaker via the
|
|
// icons at the top of the bar. To physically move,
|
|
// tap a different channel in the tree below.
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|