Compare commits

...

2 Commits

Author SHA1 Message Date
bvandeusen ad20c9f9d4 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#23) from dev into main 2026-04-15 02:54:47 +00:00
bvandeusen 48c134ce6a 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 <noreply@anthropic.com>
2026-04-14 22:46:48 -04:00
5 changed files with 60 additions and 55 deletions
+1
View File
@@ -653,6 +653,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
VoiceMicButton( VoiceMicButton(
mode: ref.watch(voiceProvider).mode, mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive, voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
amplitude: ref.watch(voiceProvider).amplitude,
onTap: _toggleCaptureMic, onTap: _toggleCaptureMic,
), ),
IconButton( IconButton(
+21 -4
View File
@@ -59,22 +59,29 @@ class VoiceState {
final VoiceMode mode; final VoiceMode mode;
final bool voiceModeActive; final bool voiceModeActive;
final bool available; final bool available;
/// Normalized mic amplitude 0.01.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({ const VoiceState({
this.mode = VoiceMode.idle, this.mode = VoiceMode.idle,
this.voiceModeActive = false, this.voiceModeActive = false,
this.available = true, this.available = true,
this.amplitude = 0.0,
}); });
VoiceState copyWith({ VoiceState copyWith({
VoiceMode? mode, VoiceMode? mode,
bool? voiceModeActive, bool? voiceModeActive,
bool? available, bool? available,
double? amplitude,
}) => }) =>
VoiceState( VoiceState(
mode: mode ?? this.mode, mode: mode ?? this.mode,
voiceModeActive: voiceModeActive ?? this.voiceModeActive, voiceModeActive: voiceModeActive ?? this.voiceModeActive,
available: available ?? this.available, available: available ?? this.available,
amplitude: amplitude ?? this.amplitude,
); );
} }
@@ -267,14 +274,24 @@ class VoiceNotifier extends Notifier<VoiceState> {
void _onAmplitude(Amplitude event) { void _onAmplitude(Amplitude event) {
if (!state.voiceModeActive) return; 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 = final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs; DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return; if (elapsed < _minRecordingMs) return;
final db = event.current; final isSilent = !validDb || db < _silenceThresholdDb;
// 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;
if (isSilent) { if (isSilent) {
_silenceMs += 200; _silenceMs += 200;
@@ -376,6 +376,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
VoiceMicButton( VoiceMicButton(
mode: voiceState.mode, mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive, voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode, onTap: _toggleVoiceMode,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
+1
View File
@@ -267,6 +267,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
VoiceMicButton( VoiceMicButton(
mode: voiceState.mode, mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive, voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode, onTap: _toggleVoiceMode,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
+36 -51
View File
@@ -5,51 +5,29 @@ import '../providers/voice_provider.dart';
/// Animated mic button that reflects the current [VoiceMode]. /// Animated mic button that reflects the current [VoiceMode].
/// ///
/// - idle: muted background, mic_none icon /// - 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 /// - transcribing: indigo with spinner
/// - playing: indigo with volume_up icon /// - playing: indigo with volume_up icon
class VoiceMicButton extends StatefulWidget { class VoiceMicButton extends StatelessWidget {
final VoiceMode mode; final VoiceMode mode;
final bool voiceModeActive; final bool voiceModeActive;
/// Live mic amplitude 0.01.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; final VoidCallback? onTap;
const VoiceMicButton({ const VoiceMicButton({
super.key, super.key,
required this.mode, required this.mode,
required this.voiceModeActive, required this.voiceModeActive,
this.amplitude = 0.0,
this.onTap, this.onTap,
}); });
@override
State<VoiceMicButton> createState() => _VoiceMicButtonState();
}
class _VoiceMicButtonState extends State<VoiceMicButton>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.25).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
Color _bgColor(BuildContext context) { Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return switch (widget.mode) { return switch (mode) {
VoiceMode.recording => const Color(0xFFEF4444), VoiceMode.recording => const Color(0xFFEF4444),
VoiceMode.transcribing || VoiceMode.playing => cs.primary, VoiceMode.transcribing || VoiceMode.playing => cs.primary,
VoiceMode.idle => cs.surfaceContainerHighest, VoiceMode.idle => cs.surfaceContainerHighest,
@@ -58,11 +36,10 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
Widget _icon(BuildContext context) { Widget _icon(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final iconColor = widget.mode == VoiceMode.idle final iconColor =
? cs.onSurfaceVariant mode == VoiceMode.idle ? cs.onSurfaceVariant : Colors.white;
: Colors.white;
return switch (widget.mode) { return switch (mode) {
VoiceMode.transcribing => SizedBox( VoiceMode.transcribing => SizedBox(
width: 18, width: 18,
height: 18, height: 18,
@@ -78,14 +55,14 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isRecording = widget.mode == VoiceMode.recording; final isRecording = mode == VoiceMode.recording;
final button = Material( final button = Material(
color: _bgColor(context), color: _bgColor(context),
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: widget.onTap, onTap: onTap,
child: SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
@@ -96,22 +73,30 @@ class _VoiceMicButtonState extends State<VoiceMicButton>
if (!isRecording) return button; if (!isRecording) return button;
return AnimatedBuilder( // Base pulse so silence still breathes (0.1 floor), scale + glow climb
animation: _pulseAnimation, // linearly with live amplitude.
builder: (_, child) => Container( final amp = amplitude.clamp(0.0, 1.0);
decoration: BoxDecoration( final pulse = 0.1 + amp * 0.9;
shape: BoxShape.circle,
boxShadow: [ return AnimatedContainer(
BoxShadow( duration: const Duration(milliseconds: 120),
color: const Color(0xFFEF4444).withValues(alpha: 0.35), curve: Curves.easeOut,
blurRadius: 8 * _pulseAnimation.value, decoration: BoxDecoration(
spreadRadius: 2 * _pulseAnimation.value, shape: BoxShape.circle,
), boxShadow: [
], BoxShadow(
), color: const Color(0xFFEF4444).withValues(alpha: 0.2 + pulse * 0.3),
child: child, 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,
); );
} }
} }