From 48c134ce6af55e6760edd13438d8fa61147f2ca3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 14 Apr 2026 22:46:48 -0400 Subject: [PATCH] feat(voice): pulse mic button with live amplitude VoiceState now carries a normalized mic amplitude (0..1) updated from the existing onAmplitudeChanged subscription, with a 0.02 change threshold so we don't spam rebuilds. VoiceMicButton swaps the constant-rate AnimationController for an AnimatedScale + animated glow driven by the live amplitude. 0.1 floor keeps the button breathing on silence; 120ms ease-out smooths between 200ms samples. Co-Authored-By: Claude Opus 4.6 --- lib/app.dart | 1 + lib/providers/voice_provider.dart | 25 +++++-- lib/screens/briefing/briefing_screen.dart | 1 + lib/screens/chat/chat_screen.dart | 1 + lib/widgets/voice_mic_button.dart | 87 ++++++++++------------- 5 files changed, 60 insertions(+), 55 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 9d55a01..272acb4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -653,6 +653,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { VoiceMicButton( mode: ref.watch(voiceProvider).mode, voiceModeActive: ref.watch(voiceProvider).voiceModeActive, + amplitude: ref.watch(voiceProvider).amplitude, onTap: _toggleCaptureMic, ), IconButton( diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 5e55ad5..6c533b4 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -59,22 +59,29 @@ class VoiceState { final VoiceMode mode; final bool voiceModeActive; final bool available; + /// Normalized mic amplitude 0.0–1.0 while recording. Drives the live + /// pulse on VoiceMicButton so the user has obvious feedback that audio + /// is actually being picked up. + final double amplitude; const VoiceState({ this.mode = VoiceMode.idle, this.voiceModeActive = false, this.available = true, + this.amplitude = 0.0, }); VoiceState copyWith({ VoiceMode? mode, bool? voiceModeActive, bool? available, + double? amplitude, }) => VoiceState( mode: mode ?? this.mode, voiceModeActive: voiceModeActive ?? this.voiceModeActive, available: available ?? this.available, + amplitude: amplitude ?? this.amplitude, ); } @@ -267,14 +274,24 @@ class VoiceNotifier extends Notifier { void _onAmplitude(Amplitude event) { if (!state.voiceModeActive) return; + final db = event.current; + final validDb = !(db.isNaN || db.isInfinite); + + // Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet, + // 0dB is peak; we clamp and bias so the button visibly breathes even + // on soft speech without exploding on loud input. + if (validDb) { + final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0); + if ((norm - state.amplitude).abs() > 0.02) { + state = state.copyWith(amplitude: norm); + } + } + final elapsed = DateTime.now().millisecondsSinceEpoch - _recordingStartMs; if (elapsed < _minRecordingMs) return; - final db = event.current; - // Guard against NaN / ±Infinity which can arrive on some Android devices - // when the recorder is initialising. Treat invalid readings as silence. - final isSilent = db.isNaN || db.isInfinite || db < _silenceThresholdDb; + final isSilent = !validDb || db < _silenceThresholdDb; if (isSilent) { _silenceMs += 200; diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index df71caf..574cff8 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -376,6 +376,7 @@ class _BriefingScreenState extends ConsumerState VoiceMicButton( mode: voiceState.mode, voiceModeActive: voiceState.voiceModeActive, + amplitude: voiceState.amplitude, onTap: _toggleVoiceMode, ), const SizedBox(width: 6), diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 987155f..89347f9 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -267,6 +267,7 @@ class _ChatScreenState extends ConsumerState VoiceMicButton( mode: voiceState.mode, voiceModeActive: voiceState.voiceModeActive, + amplitude: voiceState.amplitude, onTap: _toggleVoiceMode, ), const SizedBox(width: 6), diff --git a/lib/widgets/voice_mic_button.dart b/lib/widgets/voice_mic_button.dart index 1476993..d59c550 100644 --- a/lib/widgets/voice_mic_button.dart +++ b/lib/widgets/voice_mic_button.dart @@ -5,51 +5,29 @@ import '../providers/voice_provider.dart'; /// Animated mic button that reflects the current [VoiceMode]. /// /// - idle: muted background, mic_none icon -/// - recording: red with pulsing shadow ring +/// - recording: red, pulses with live [amplitude] for real-time feedback /// - transcribing: indigo with spinner /// - playing: indigo with volume_up icon -class VoiceMicButton extends StatefulWidget { +class VoiceMicButton extends StatelessWidget { final VoiceMode mode; final bool voiceModeActive; + /// Live mic amplitude 0.0–1.0 while recording. Drives the button scale + /// and glow so the user has obvious feedback that audio is being picked + /// up. Ignored when not in [VoiceMode.recording]. + final double amplitude; final VoidCallback? onTap; const VoiceMicButton({ super.key, required this.mode, required this.voiceModeActive, + this.amplitude = 0.0, this.onTap, }); - @override - State createState() => _VoiceMicButtonState(); -} - -class _VoiceMicButtonState extends State - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(reverse: true); - _pulseAnimation = Tween(begin: 1.0, end: 1.25).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - Color _bgColor(BuildContext context) { final cs = Theme.of(context).colorScheme; - return switch (widget.mode) { + return switch (mode) { VoiceMode.recording => const Color(0xFFEF4444), VoiceMode.transcribing || VoiceMode.playing => cs.primary, VoiceMode.idle => cs.surfaceContainerHighest, @@ -58,11 +36,10 @@ class _VoiceMicButtonState extends State Widget _icon(BuildContext context) { final cs = Theme.of(context).colorScheme; - final iconColor = widget.mode == VoiceMode.idle - ? cs.onSurfaceVariant - : Colors.white; + final iconColor = + mode == VoiceMode.idle ? cs.onSurfaceVariant : Colors.white; - return switch (widget.mode) { + return switch (mode) { VoiceMode.transcribing => SizedBox( width: 18, height: 18, @@ -78,14 +55,14 @@ class _VoiceMicButtonState extends State @override Widget build(BuildContext context) { - final isRecording = widget.mode == VoiceMode.recording; + final isRecording = mode == VoiceMode.recording; final button = Material( color: _bgColor(context), shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), - onTap: widget.onTap, + onTap: onTap, child: SizedBox( width: 40, height: 40, @@ -96,22 +73,30 @@ class _VoiceMicButtonState extends State if (!isRecording) return button; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (_, child) => Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: const Color(0xFFEF4444).withValues(alpha: 0.35), - blurRadius: 8 * _pulseAnimation.value, - spreadRadius: 2 * _pulseAnimation.value, - ), - ], - ), - child: child, + // Base pulse so silence still breathes (0.1 floor), scale + glow climb + // linearly with live amplitude. + final amp = amplitude.clamp(0.0, 1.0); + final pulse = 0.1 + amp * 0.9; + + return AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFEF4444).withValues(alpha: 0.2 + pulse * 0.3), + blurRadius: 6 + pulse * 14, + spreadRadius: 1 + pulse * 5, + ), + ], + ), + child: AnimatedScale( + scale: 1.0 + pulse * 0.18, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: button, ), - child: button, ); } }