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>
This commit is contained in:
@@ -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<VoiceState> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user