@tduncan / Cortana / commits / e109e90

feat: add real-time voice feedback system with sound cues, mic level visualization, and UI refinements to the voice mode overlay.

abdulmominsakib committed Jul 29, 2026 at 15:51 UTC e109e9004fc83b1a646fdddc66be3d8f162b52f8
14 files changed +961 -424
assets/sounds/connected.mp3
Binary files /dev/null and b/assets/sounds/connected.mp3 differ
assets/sounds/disconnected.mp3
Binary files /dev/null and b/assets/sounds/disconnected.mp3 differ
assets/sounds/generating.mp3
Binary files /dev/null and b/assets/sounds/generating.mp3 differ
assets/sounds/start_listening.mp3
Binary files /dev/null and b/assets/sounds/start_listening.mp3 differ
lib/features/stt/providers/stt_providers.dart
+12
@@ -3,6 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:speech_to_text/speech_to_text.dart';
4 import '../../../core/logger/app_logger.dart';
5
6 +/// Double in dB-ish units (iOS) or 0..1-ish (Android) reported by the
7 +/// speech_to_text plugin via `onSoundLevelChange`. Higher = louder.
8 +typedef SoundLevelChange = void Function(double level);
9 +
10 class SttState {
11 final bool isListening;
12 final bool isAvailable;
@@ -89,6 +93,7 @@ class SttNotifier extends Notifier<SttState> {
93 Future<void> startListening({
94 required void Function(String) onResult,
95 void Function(String)? onFinal,
96 + SoundLevelChange? onSoundLevelChange,
97 }) async {
98 final available = await initSpeech();
99 if (!available) {
@@ -115,6 +120,13 @@ class SttNotifier extends Notifier<SttState> {
120 onFinal(result.recognizedWords);
121 }
122 },
123 + onSoundLevelChange: (level) {
124 + // iOS reports negative dB (-2 quiet, 10 loud), Android ~0..1.
125 + final normalised = level <= 0
126 + ? ((level + 2) / 12).clamp(0.0, 1.0)
127 + : level.clamp(0.0, 1.0);
128 + onSoundLevelChange?.call(normalised);
129 + },
130 listenOptions: SpeechListenOptions(
131 listenFor: const Duration(seconds: 30),
132 pauseFor: const Duration(seconds: 5),
lib/features/tts/providers/tts_providers.dart
+21
@@ -39,6 +39,13 @@ class TtsState {
39 final bool isSeekPending;
40 final double playbackSpeed;
41
42 + /// Text chunks currently being read aloud, in order. Cleared when
43 + /// playback stops. Useful for word-level progress UI.
44 + final List<String> chunks;
45 +
46 + /// Index of the chunk currently being read. -1 when nothing is playing.
47 + final int currentChunkIndex;
48 +
49 const TtsState({
50 this.isSpeaking = false,
51 this.isPaused = false,
@@ -54,6 +61,8 @@ class TtsState {
61 this.canSeek = false,
62 this.isSeekPending = false,
63 this.playbackSpeed = 1.0,
64 + this.chunks = const [],
65 + this.currentChunkIndex = -1,
66 });
67
68 TtsState copyWith({
@@ -73,6 +82,8 @@ class TtsState {
82 double? playbackSpeed,
83 bool clearPlayingTarget = false,
84 bool resetProgress = false,
85 + List<String>? chunks,
86 + int? currentChunkIndex,
87 }) {
88 return TtsState(
89 isSpeaking: isSpeaking ?? this.isSpeaking,
@@ -92,6 +103,8 @@ class TtsState {
103 canSeek: canSeek ?? this.canSeek,
104 isSeekPending: isSeekPending ?? this.isSeekPending,
105 playbackSpeed: playbackSpeed ?? this.playbackSpeed,
106 + chunks: chunks ?? this.chunks,
107 + currentChunkIndex: currentChunkIndex ?? this.currentChunkIndex,
108 );
109 }
110 }
@@ -191,6 +204,7 @@ class TtsNotifier extends Notifier<TtsState> {
204 _currentIndexSubscription = _player.currentIndexStream.listen((index) {
205 if (index != null && index != _currentChunkIndex) {
206 _currentChunkIndex = index;
207 + state = state.copyWith(currentChunkIndex: index);
208 _updateChunkOffset();
209 _onChunkChanged.add(null);
210 _enqueueNextChunks();
@@ -419,6 +433,8 @@ class TtsNotifier extends Notifier<TtsState> {
433 isPreview: false,
434 clearPlayingTarget: true,
435 resetProgress: true,
436 + chunks: const [],
437 + currentChunkIndex: -1,
438 canSeek: false,
439 );
440 // Hand the iOS audio session back to record so a follow-up
@@ -707,6 +723,7 @@ class TtsNotifier extends Notifier<TtsState> {
723 _resetProgress();
724 _chunks = _splitText(text);
725 _currentChunkIndex = 0;
726 + state = state.copyWith(chunks: _chunks, currentChunkIndex: 0);
727 _currentVoice = resolvedVoiceId;
728 _synthesizedChunks.clear();
729 _lastEnqueuedChunk = -1;
@@ -886,6 +903,7 @@ class TtsNotifier extends Notifier<TtsState> {
903 await _player.seek(Duration.zero);
904 }
905 _currentChunkIndex = targetChunk;
906 + state = state.copyWith(currentChunkIndex: targetChunk);
907 _updateChunkOffset();
908 _updatePlaybackProgress();
909 } finally {
@@ -990,6 +1008,8 @@ class TtsNotifier extends Notifier<TtsState> {
1008 isPreview: false,
1009 clearPlayingTarget: true,
1010 resetProgress: true,
1011 + chunks: const [],
1012 + currentChunkIndex: -1,
1013 canSeek: false,
1014 );
1015 _configureAudioSessionForSpeech();
@@ -1037,6 +1057,7 @@ class TtsNotifier extends Notifier<TtsState> {
1057 _resetProgress();
1058 _chunks = List<String>.from(cache.chunks);
1059 _currentChunkIndex = 0;
1060 + state = state.copyWith(chunks: _chunks, currentChunkIndex: 0);
1061 _currentVoice = cache.voice;
1062 _synthesizedChunks.clear();
1063 _synthesizedChunks.addAll(cache.synthesizedChunks);
lib/features/voice_mode/providers/voice_mode_provider.dart
+44 -1
@@ -3,6 +3,7 @@ import 'dart:async';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4
5 import '../../../core/logger/app_logger.dart';
6 +import '../../../services/voice_feedback_service.dart';
7 import '../../chat/providers/chat_providers.dart';
8 import '../../stt/providers/stt_providers.dart';
9 import '../../tts/providers/tts_providers.dart';
@@ -34,6 +35,10 @@ class VoiceModeState {
35 final bool isMuted;
36 final String? error;
37
38 + /// Live microphone input level in 0..1, set from the STT
39 + /// `onSoundLevelChange` callback. 0 when not listening.
40 + final double micLevel;
41 +
42 const VoiceModeState({
43 this.isActive = false,
44 this.phase = VoiceModePhase.idle,
@@ -42,6 +47,7 @@ class VoiceModeState {
47 this.autoListen = true,
48 this.isMuted = false,
49 this.error,
50 + this.micLevel = 0,
51 });
52
53 VoiceModeState copyWith({
@@ -53,6 +59,7 @@ class VoiceModeState {
59 bool? isMuted,
60 String? error,
61 bool clearError = false,
62 + double? micLevel,
63 }) {
64 return VoiceModeState(
65 isActive: isActive ?? this.isActive,
@@ -62,6 +69,7 @@ class VoiceModeState {
69 autoListen: autoListen ?? this.autoListen,
70 isMuted: isMuted ?? this.isMuted,
71 error: clearError ? null : (error ?? this.error),
72 + micLevel: micLevel ?? this.micLevel,
73 );
74 }
75 }
@@ -78,6 +86,12 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
86 bool _active = false;
87 bool _isSendingTranscript = false;
88
89 + /// Whether the "generating" feedback cue has already fired for the
90 + /// current streamed response. Reset on session start; consumed once
91 + /// in `_handleStreamingComplete` so haptic only fires on the first
92 + /// chunk arrival, not on every token.
93 + bool _generatingFired = false;
94 +
95 @override
96 VoiceModeState build() {
97 // Listen to chat streaming state changes.
@@ -101,6 +115,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
115 phase: VoiceModePhase.error,
116 error: _mapSttError(next),
117 );
118 + ref.read(voiceFeedbackProvider).playDisconnected();
119 },
120 );
121
@@ -114,7 +129,9 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
129 /// Begin the voice mode session. Called when the overlay opens.
130 void startSession() {
131 _active = true;
132 + _generatingFired = false;
133 state = const VoiceModeState(isActive: true, phase: VoiceModePhase.idle);
134 + ref.read(voiceFeedbackProvider).playConnected();
135 // Auto-start listening.
136 startListening();
137 }
@@ -129,9 +146,12 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
146 phase: VoiceModePhase.listening,
147 transcript: '',
148 response: '',
149 + micLevel: 0,
150 clearError: true,
151 );
152
153 + ref.read(voiceFeedbackProvider).playListening();
154 +
155 final stt = ref.read(sttProvider.notifier);
156 await stt.startListening(
157 onResult: (words) {
@@ -151,6 +171,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
171 _isSendingTranscript = true;
172 await stopListeningAndSend();
173 },
174 + onSoundLevelChange: (level) {
175 + if (!_active) return;
176 + // Skip the rebuild when the value hasn't materially changed
177 + // (riverpod notifies listeners on every distinct copyWith).
178 + if ((state.micLevel - level).abs() < 0.01) return;
179 + state = state.copyWith(micLevel: level);
180 + },
181 );
182 }
183
@@ -171,7 +198,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
198 return;
199 }
200
174 - state = state.copyWith(phase: VoiceModePhase.processing, response: '');
201 + // Reset the mic level so the waveform visualizer doesn't keep
202 + // dancing on a stale value once we leave the listening phase.
203 + state = state.copyWith(
204 + phase: VoiceModePhase.processing,
205 + response: '',
206 + micLevel: 0,
207 + );
208
209 // Send the transcript as a regular chat message. The streaming
210 // listener will handle the transition to speaking phase.
@@ -197,6 +230,9 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
230 Future<void> endSession() async {
231 _active = false;
232 _isSendingTranscript = false;
233 + _generatingFired = false;
234 +
235 + ref.read(voiceFeedbackProvider).playDisconnected();
236
237 // Stop STT if still listening.
238 try {
@@ -272,6 +308,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
308 response: responseText,
309 );
310
311 + // Fire the generating cue only on the first chunk of this response —
312 + // repeated taps during streaming feel glitchy.
313 + if (!_generatingFired) {
314 + _generatingFired = true;
315 + ref.read(voiceFeedbackProvider).playGenerating();
316 + }
317 +
318 // Trigger TTS to speak the response. Pass message/conversation IDs so
319 // TTS can cache and resume playback against the right message.
320 final tts = ref.read(ttsProvider.notifier);
lib/features/voice_mode/views/components/voice_mode_controls.dart
+66 -66
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
2 import 'package:hugeicons/hugeicons.dart';
3
4 import '../../providers/voice_mode_provider.dart';
5 +import '../../voice_mode_palette.dart';
6
7 /// Bottom control bar for voice mode overlay:
7 -/// end call button, mute toggle, auto-listen toggle.
8 +/// auto-listen toggle, end-call button, primary mic action.
9 class VoiceModeControls extends StatelessWidget {
10 final VoiceModePhase phase;
11 final bool autoListen;
@@ -27,33 +28,30 @@ class VoiceModeControls extends StatelessWidget {
28
29 @override
30 Widget build(BuildContext context) {
30 - final theme = Theme.of(context);
31 - final isDark = theme.brightness == Brightness.dark;
31 + final isDark = Theme.of(context).brightness == Brightness.dark;
32
33 return Padding(
34 - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
34 + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 8),
35 child: Row(
36 - mainAxisAlignment: MainAxisAlignment.spaceEvenly,
36 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
37 + crossAxisAlignment: CrossAxisAlignment.center,
38 children: [
38 - // Auto-listen toggle.
39 - _ControlButton(
39 + _PillControl(
40 icon: autoListen
41 ? HugeIcons.strokeRoundedRepeat
42 : HugeIcons.strokeRoundedRepeatOff,
43 label: autoListen ? 'Auto' : 'Manual',
44 isActive: autoListen,
45 - activeColor: isDark
46 - ? const Color(0xFF6366F1)
47 - : const Color(0xFF4F46E5),
48 - isDark: isDark,
45 + activeColor: VoiceModePalette.accentFor(
46 + VoiceModePhase.speaking,
47 + isDark: isDark,
48 + ),
49 onTap: onToggleAutoListen,
50 ),
51
52 - // Main action — end session (red).
52 _EndCallButton(onTap: onEnd),
53
55 - // Send / interrupt based on phase.
56 - _ControlButton(
54 + _PillControl(
55 icon: phase == VoiceModePhase.listening
56 ? HugeIcons.strokeRoundedSent
57 : phase == VoiceModePhase.speaking
@@ -66,10 +64,10 @@ class VoiceModeControls extends StatelessWidget {
64 : 'Speak',
65 isActive: phase == VoiceModePhase.listening ||
66 phase == VoiceModePhase.speaking,
69 - activeColor: isDark
70 - ? const Color(0xFF34D399)
71 - : const Color(0xFF059669),
72 - isDark: isDark,
67 + activeColor: VoiceModePalette.accentFor(
68 + VoiceModePhase.listening,
69 + isDark: isDark,
70 + ),
71 onTap: onTapCenter,
72 ),
73 ],
@@ -88,15 +86,15 @@ class _EndCallButton extends StatelessWidget {
86 return GestureDetector(
87 onTap: onTap,
88 child: Container(
91 - width: 64,
92 - height: 64,
89 + width: 68,
90 + height: 68,
91 decoration: BoxDecoration(
92 color: const Color(0xFFEF4444),
93 shape: BoxShape.circle,
94 boxShadow: [
95 BoxShadow(
98 - color: const Color(0xFFEF4444).withValues(alpha: 0.4),
99 - blurRadius: 16,
96 + color: const Color(0xFFEF4444).withValues(alpha: 0.45),
97 + blurRadius: 18,
98 spreadRadius: 2,
99 ),
100 ],
@@ -104,7 +102,7 @@ class _EndCallButton extends StatelessWidget {
102 child: const Center(
103 child: HugeIcon(
104 icon: HugeIcons.strokeRoundedCallEnd01,
107 - size: 28,
105 + size: 30,
106 color: Colors.white,
107 ),
108 ),
@@ -113,76 +111,78 @@ class _EndCallButton extends StatelessWidget {
111 }
112 }
113
116 -class _ControlButton extends StatelessWidget {
114 +class _PillControl extends StatelessWidget {
115 final List<List<dynamic>> icon;
116 final String label;
117 final bool isActive;
118 final Color activeColor;
121 - final bool isDark;
119 final VoidCallback onTap;
120
124 - const _ControlButton({
121 + const _PillControl({
122 required this.icon,
123 required this.label,
124 required this.isActive,
125 required this.activeColor,
129 - required this.isDark,
126 required this.onTap,
127 });
128
129 @override
130 Widget build(BuildContext context) {
131 + final isDark = Theme.of(context).brightness == Brightness.dark;
132 final bgColor = isActive
136 - ? activeColor.withValues(alpha: 0.15)
133 + ? activeColor.withValues(alpha: 0.14)
134 : (isDark
138 - ? Colors.white.withValues(alpha: 0.06)
139 - : Colors.black.withValues(alpha: 0.05));
135 + ? Colors.white.withValues(alpha: 0.05)
136 + : Colors.black.withValues(alpha: 0.04));
137
138 final iconColor = isActive
139 ? activeColor
140 : (isDark
144 - ? Colors.white.withValues(alpha: 0.6)
145 - : Colors.black.withValues(alpha: 0.5));
141 + ? Colors.white.withValues(alpha: 0.7)
142 + : Colors.black.withValues(alpha: 0.55));
143
144 return GestureDetector(
145 + behavior: HitTestBehavior.opaque,
146 onTap: onTap,
149 - child: Column(
150 - mainAxisSize: MainAxisSize.min,
151 - children: [
152 - Container(
153 - width: 52,
154 - height: 52,
155 - decoration: BoxDecoration(
156 - color: bgColor,
157 - shape: BoxShape.circle,
158 - border: Border.all(
159 - color: isActive
160 - ? activeColor.withValues(alpha: 0.3)
161 - : Colors.transparent,
162 - width: 1.5,
163 - ),
147 + child: AnimatedContainer(
148 + duration: const Duration(milliseconds: 220),
149 + curve: Curves.easeOut,
150 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
151 + decoration: BoxDecoration(
152 + color: bgColor,
153 + borderRadius: BorderRadius.circular(999),
154 + border: Border.all(
155 + color: isActive
156 + ? activeColor.withValues(alpha: 0.3)
157 + : Colors.transparent,
158 + width: 1.2,
159 + ),
160 + ),
161 + child: Column(
162 + mainAxisSize: MainAxisSize.min,
163 + children: [
164 + HugeIcon(
165 + icon: icon,
166 + size: 22,
167 + color: iconColor,
168 ),
165 - child: Center(
166 - child: HugeIcon(
167 - icon: icon,
168 - size: 24,
169 - color: iconColor,
169 + const SizedBox(height: 4),
170 + Text(
171 + label,
172 + style: TextStyle(
173 + fontSize: 11,
174 + fontWeight: FontWeight.w600,
175 + letterSpacing: 0.2,
176 + color: isActive
177 + ? activeColor
178 + : (isDark
179 + ? Colors.white.withValues(alpha: 0.55)
180 + : Colors.black.withValues(alpha: 0.5)),
181 ),
182 ),
172 - ),
173 - const SizedBox(height: 6),
174 - Text(
175 - label,
176 - style: TextStyle(
177 - fontSize: 11,
178 - fontWeight: FontWeight.w500,
179 - color: isDark
180 - ? Colors.white.withValues(alpha: 0.5)
181 - : Colors.black.withValues(alpha: 0.45),
182 - ),
183 - ),
184 - ],
183 + ],
184 + ),
185 ),
186 );
187 }
188 -}
188 +}
\ No newline at end of file
lib/features/voice_mode/views/components/voice_transcript.dart
+352 -65
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
4 import '../../../tts/providers/tts_providers.dart';
5 import '../../providers/voice_mode_provider.dart';
6 +import '../../voice_mode_palette.dart';
7
7 -/// Displays the current voice phase badge and auto-scrolling response text container.
8 +/// Displays the current voice phase badge and auto-scrolling response text
9 +/// container. Designed to sit below the waveform visualizer.
10 class VoiceTranscript extends ConsumerStatefulWidget {
11 final VoiceModePhase phase;
12 final String transcript;
@@ -55,50 +57,43 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
57
58 @override
59 Widget build(BuildContext context) {
58 - final theme = Theme.of(context);
59 - final isDark = theme.brightness == Brightness.dark;
60 + final isDark = Theme.of(context).brightness == Brightness.dark;
61 final isTtsInitializing = ref.watch(
62 ttsProvider.select((s) => s.isInitializing),
63 );
64
64 - final (label, badgeColor, isSynthesizing) = _resolveStatusBadge(
65 - isTtsInitializing,
66 - );
65 + final (label, badgeColor, isSynthesizing) =
66 + _resolveStatusBadge(isTtsInitializing);
67
68 return Column(
69 mainAxisSize: MainAxisSize.min,
70 children: [
71 - // Status Badge Pill
71 AnimatedSwitcher(
72 duration: const Duration(milliseconds: 300),
73 + transitionBuilder: (child, animation) => FadeTransition(
74 + opacity: animation,
75 + child: ScaleTransition(
76 + scale: Tween<double>(begin: 0.92, end: 1.0).animate(animation),
77 + child: child,
78 + ),
79 + ),
80 child: Container(
81 key: ValueKey(label),
82 padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
83 decoration: BoxDecoration(
78 - color: badgeColor.withValues(alpha: isDark ? 0.15 : 0.1),
79 - borderRadius: BorderRadius.circular(20),
84 + color: badgeColor.withValues(alpha: isDark ? 0.16 : 0.12),
85 + borderRadius: BorderRadius.circular(999),
86 border: Border.all(
81 - color: badgeColor.withValues(alpha: isDark ? 0.3 : 0.25),
87 + color: badgeColor.withValues(alpha: isDark ? 0.32 : 0.28),
88 width: 1,
89 ),
90 ),
91 child: Row(
92 mainAxisSize: MainAxisSize.min,
93 children: [
88 - Container(
89 - width: 7,
90 - height: 7,
91 - decoration: BoxDecoration(
92 - shape: BoxShape.circle,
93 - color: badgeColor,
94 - boxShadow: [
95 - BoxShadow(
96 - color: badgeColor.withValues(alpha: 0.6),
97 - blurRadius: 6,
98 - spreadRadius: 1,
99 - ),
100 - ],
101 - ),
94 + _PulsingDot(
95 + color: badgeColor,
96 + active: widget.phase != VoiceModePhase.idle,
97 ),
98 const SizedBox(width: 8),
99 Text(
@@ -106,7 +101,7 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
101 style: TextStyle(
102 fontSize: 11,
103 fontWeight: FontWeight.w700,
109 - letterSpacing: 1.3,
104 + letterSpacing: 1.4,
105 color: badgeColor,
106 ),
107 ),
@@ -115,29 +110,26 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
110 ),
111 ),
112
118 - const SizedBox(height: 20),
119 -
120 - // Main text content — auto-scrolling square container for RESPONDING/PROCESSING,
121 - // hidden when SPEAKING.
122 - if (widget.phase != VoiceModePhase.speaking)
123 - AnimatedSwitcher(
124 - duration: const Duration(milliseconds: 300),
125 - switchInCurve: Curves.easeOut,
126 - switchOutCurve: Curves.easeIn,
127 - transitionBuilder: (child, animation) {
128 - return FadeTransition(
129 - opacity: animation,
130 - child: SlideTransition(
131 - position: Tween<Offset>(
132 - begin: const Offset(0, 0.08),
133 - end: Offset.zero,
134 - ).animate(animation),
135 - child: child,
136 - ),
137 - );
138 - },
139 - child: _buildTextContent(context, isDark, isSynthesizing),
140 - ),
113 + const SizedBox(height: 18),
114 +
115 + AnimatedSwitcher(
116 + duration: const Duration(milliseconds: 300),
117 + switchInCurve: Curves.easeOut,
118 + switchOutCurve: Curves.easeIn,
119 + transitionBuilder: (child, animation) {
120 + return FadeTransition(
121 + opacity: animation,
122 + child: SlideTransition(
123 + position: Tween<Offset>(
124 + begin: const Offset(0, 0.08),
125 + end: Offset.zero,
126 + ).animate(animation),
127 + child: child,
128 + ),
129 + );
130 + },
131 + child: _buildTextContent(context, isDark, isSynthesizing),
132 + ),
133 ],
134 );
135 }
@@ -147,29 +139,37 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
139 return ('SYNTHESIZING VOICE', const Color(0xFFF59E0B), true);
140 }
141
142 + final isDark = Theme.of(context).brightness == Brightness.dark;
143 +
144 switch (widget.phase) {
145 case VoiceModePhase.listening:
152 - return ('LISTENING', const Color(0xFF10B981), false);
146 + return ('LISTENING',
147 + VoiceModePalette.accentFor(VoiceModePhase.listening, isDark: isDark),
148 + false);
149 case VoiceModePhase.processing:
150 final hasResponse = widget.response.trim().isNotEmpty;
151 if (isTtsInitializing) {
152 return ('SYNTHESIZING VOICE', const Color(0xFFF59E0B), true);
153 }
154 return (
159 - hasResponse ? 'RESPONDING' : 'PROCESSING',
160 - const Color(0xFF8B5CF6),
155 + hasResponse ? 'RESPONDING' : 'THINKING',
156 + VoiceModePalette.accentFor(VoiceModePhase.processing, isDark: isDark),
157 false,
158 );
159 case VoiceModePhase.speaking:
164 - return ('SPEAKING', const Color(0xFF6366F1), false);
160 + return ('SPEAKING',
161 + VoiceModePalette.accentFor(VoiceModePhase.speaking, isDark: isDark),
162 + false);
163 case VoiceModePhase.idle:
166 - return ('READY', Colors.grey, false);
164 + return ('READY',
165 + VoiceModePalette.accentFor(VoiceModePhase.idle, isDark: isDark),
166 + false);
167 case VoiceModePhase.error:
168 final errorText = widget.error ?? '';
169 final isSpeechError = errorText.toLowerCase().contains('speech');
170 return (
171 isSpeechError ? 'NO SPEECH DETECTED' : 'ERROR',
172 - const Color(0xFFEF4444),
172 + VoiceModePalette.accentFor(VoiceModePhase.error, isDark: isDark),
173 false,
174 );
175 }
@@ -200,7 +200,6 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
200 );
201 }
202
203 - // Auto-scrolling square container for generating text
203 return Container(
204 key: const ValueKey('responding-container'),
205 width: double.infinity,
@@ -226,8 +225,8 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
225 fontSize: 14,
226 fontWeight: FontWeight.w400,
227 color: isDark
229 - ? Colors.white.withValues(alpha: 0.7)
230 - : Colors.black.withValues(alpha: 0.65),
228 + ? Colors.white.withValues(alpha: 0.75)
229 + : Colors.black.withValues(alpha: 0.7),
230 height: 1.45,
231 ),
232 textAlign: TextAlign.start,
@@ -240,18 +239,21 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
239 widget.transcript.isEmpty ? 'Listening...' : widget.transcript;
240 return Text(
241 displayText,
243 - key: ValueKey('listening-${displayText.hashCode}'),
242 + key: const ValueKey('listening'),
243 style: TextStyle(
245 - fontSize: 18,
244 + fontSize: 19,
245 fontWeight: FontWeight.w500,
246 color: isDark
248 - ? Colors.white.withValues(alpha: 0.85)
249 - : Colors.black.withValues(alpha: 0.8),
247 + ? Colors.white.withValues(alpha: 0.9)
248 + : Colors.black.withValues(alpha: 0.85),
249 height: 1.4,
250 ),
251 textAlign: TextAlign.center,
252 );
253
254 + case VoiceModePhase.speaking:
255 + return _buildSpeakingContent(isDark);
256 +
257 case VoiceModePhase.idle:
258 return Text(
259 'Tap to start speaking',
@@ -276,15 +278,300 @@ class _VoiceTranscriptState extends ConsumerState<VoiceTranscript> {
278 key: ValueKey('error-${errorText.hashCode}'),
279 style: TextStyle(
280 fontSize: 16,
279 - fontWeight: FontWeight.w400,
280 - color: const Color(0xFFEF4444).withValues(alpha: 0.9),
281 + fontWeight: FontWeight.w500,
282 + color: const Color(0xFFEF4444).withValues(alpha: 0.95),
283 height: 1.4,
284 ),
285 textAlign: TextAlign.center,
286 );
287 + }
288 + }
289
286 - case VoiceModePhase.speaking:
287 - return const SizedBox.shrink();
290 + /// Renders the assistant's response with the currently-spoken word
291 + /// highlighted. Estimate per-chunk timing proportionally from chunk
292 + /// character counts (TTS exposes real per-chunk durations internally,
293 + /// but not via [TtsState], so character-ratio is a good approximation).
294 + Widget _buildSpeakingContent(bool isDark) {
295 + final response = widget.response;
296 + if (response.isEmpty) {
297 + return Text(
298 + '...',
299 + key: const ValueKey('speaking'),
300 + style: TextStyle(
301 + fontSize: 16,
302 + fontWeight: FontWeight.w400,
303 + color: isDark
304 + ? Colors.white.withValues(alpha: 0.7)
305 + : Colors.black.withValues(alpha: 0.7),
306 + height: 1.45,
307 + ),
308 + textAlign: TextAlign.center,
309 + );
310 }
311 +
312 + final tts = ref.watch(ttsProvider);
313 + final accent = VoiceModePalette.accentFor(
314 + VoiceModePhase.speaking,
315 + isDark: isDark,
316 + );
317 + final mutedColor = isDark
318 + ? Colors.white.withValues(alpha: 0.7)
319 + : Colors.black.withValues(alpha: 0.7);
320 +
321 + final highlightedWordIndex = _activeWordIndex(response, tts);
322 +
323 + final base = TextStyle(
324 + fontSize: 16,
325 + fontWeight: FontWeight.w400,
326 + color: mutedColor,
327 + height: 1.45,
328 + );
329 +
330 + final spans = _buildWordSpans(
331 + response: response,
332 + activeIndex: highlightedWordIndex,
333 + base: base,
334 + active: base.copyWith(
335 + color: accent,
336 + fontWeight: FontWeight.w700,
337 + ),
338 + );
339 +
340 + return RichText(
341 + key: const ValueKey('speaking'),
342 + textAlign: TextAlign.center,
343 + maxLines: 4,
344 + overflow: TextOverflow.ellipsis,
345 + text: TextSpan(style: base, children: spans),
346 + );
347 + }
348 +
349 + /// Returns the index of the word currently being spoken, or -1 if no
350 + /// word is currently active (e.g. between sentences, before TTS has
351 + /// populated `chunks`).
352 + int _activeWordIndex(String response, TtsState tts) {
353 + if (!tts.isSpeaking ||
354 + tts.chunks.isEmpty ||
355 + tts.currentChunkIndex < 0 ||
356 + tts.currentChunkIndex >= tts.chunks.length) {
357 + return -1;
358 + }
359 +
360 + final chunkStartChars = _chunkCharStarts(response, tts.chunks);
361 + final charIdx = _globalCharIndex(
362 + chunkIndex: tts.currentChunkIndex,
363 + chunkStartChars: chunkStartChars,
364 + chunks: tts.chunks,
365 + duration: tts.duration,
366 + position: tts.position,
367 + );
368 + if (charIdx < 0) return -1;
369 +
370 + return _wordIndexAtChar(response, charIdx);
371 + }
372 +
373 + /// Maps each TTS chunk to a starting character offset within the
374 + /// original response string so we can locate the spoken character.
375 + /// Falls back to char-proportional split when the TTS chunks don't
376 + /// map cleanly (e.g. markdown was stripped, text was rephrased).
377 + List<int> _chunkCharStarts(String response, List<String> chunks) {
378 + final starts = <int>[];
379 + int cursor = 0;
380 + for (final chunk in chunks) {
381 + final idx = response.indexOf(chunk, cursor);
382 + starts.add(idx >= 0 ? idx : cursor);
383 + cursor = (idx >= 0 ? idx : cursor) + chunk.length;
384 + }
385 + return starts;
386 + }
387 +
388 + /// Approximates the global character index that's currently being
389 + /// spoken, by combining the chunk-of-the-moment's start offset with
390 + /// the chunk's share of the total `duration` and the live `position`.
391 + int _globalCharIndex({
392 + required int chunkIndex,
393 + required List<int> chunkStartChars,
394 + required List<String> chunks,
395 + required Duration duration,
396 + required Duration position,
397 + }) {
398 + if (chunks.isEmpty) return -1;
399 +
400 + final totalChars = chunks.fold<int>(0, (sum, c) => sum + c.length);
401 + if (totalChars == 0) return -1;
402 +
403 + final prevChunkChars = <int>[0];
404 + for (var i = 0; i < chunks.length; i++) {
405 + prevChunkChars.add(prevChunkChars.last + chunks[i].length);
406 + }
407 +
408 + final safeDuration = duration.inMilliseconds > 0
409 + ? duration.inMilliseconds
410 + : totalChars * 60;
411 + final safePosition = position.inMilliseconds.clamp(0, safeDuration);
412 + final globalCharIdx =
413 + ((safePosition / safeDuration) * totalChars).round().clamp(0, totalChars);
414 +
415 + // Within which chunk does that character fall?
416 + int targetChunk = chunks.length - 1;
417 + for (var i = 0; i < chunks.length; i++) {
418 + if (globalCharIdx < prevChunkChars[i + 1]) {
419 + targetChunk = i;
420 + break;
421 + }
422 + }
423 +
424 + final chunkStart = chunkStartChars[targetChunk];
425 + final charInChunk = (globalCharIdx - prevChunkChars[targetChunk])
426 + .clamp(0, chunks[targetChunk].length);
427 + return chunkStart + charInChunk;
428 + }
429 +
430 + /// Builds the highlighted word index for a text by walking whitespace-
431 + /// separated tokens and returning the index of the one containing
432 + /// [charIdx].
433 + int _wordIndexAtChar(String text, int charIdx) {
434 + if (charIdx < 0 || charIdx >= text.length) return -1;
435 + var inWord = false;
436 + var wordIndex = -1;
437 + for (var i = 0; i < text.length; i++) {
438 + final isWordChar = _isWordChar(text.codeUnitAt(i));
439 + if (isWordChar && !inWord) {
440 + wordIndex++;
441 + inWord = true;
442 + } else if (!isWordChar && inWord) {
443 + inWord = false;
444 + }
445 + if (i == charIdx && inWord) {
446 + return wordIndex;
447 + }
448 + }
449 + // If charIdx is the last char and is inside a word.
450 + return inWord ? wordIndex : -1;
451 + }
452 +
453 + bool _isWordChar(int codeUnit) {
454 + // Letters, digits, and apostrophes (don't break "don't" into two).
455 + final isAlnum = (codeUnit >= 0x30 && codeUnit <= 0x39) || // 0-9
456 + (codeUnit >= 0x41 && codeUnit <= 0x5A) || // A-Z
457 + (codeUnit >= 0x61 && codeUnit <= 0x7A) || // a-z
458 + (codeUnit >= 0xC0 && codeUnit <= 0x024F); // Latin extended
459 + final isApostrophe =
460 + codeUnit == 0x27 || codeUnit == 0x2019; // ' '
461 + return isAlnum || isApostrophe;
462 + }
463 +
464 + List<InlineSpan> _buildWordSpans({
465 + required String response,
466 + required int activeIndex,
467 + required TextStyle base,
468 + required TextStyle active,
469 + }) {
470 + final spans = <InlineSpan>[];
471 + var wordIndex = -1;
472 + var buffer = StringBuffer();
473 + var inWord = false;
474 +
475 + void flushBuffer({required bool trailingWord}) {
476 + if (buffer.isEmpty) return;
477 + final text = buffer.toString();
478 + spans.add(TextSpan(
479 + text: text,
480 + style: trailingWord && wordIndex == activeIndex ? active : base,
481 + ));
482 + buffer = StringBuffer();
483 + }
484 +
485 + for (var i = 0; i < response.length; i++) {
486 + final code = response.codeUnitAt(i);
487 + final isWord = _isWordChar(code);
488 + if (isWord && !inWord) {
489 + // Flush preceding whitespace.
490 + flushBuffer(trailingWord: false);
491 + wordIndex++;
492 + inWord = true;
493 + } else if (!isWord && inWord) {
494 + // End of word — flush with active/inactive style.
495 + flushBuffer(trailingWord: true);
496 + inWord = false;
497 + }
498 + buffer.write(response[i]);
499 + }
500 + if (inWord) {
501 + flushBuffer(trailingWord: true);
502 + } else {
503 + flushBuffer(trailingWord: false);
504 + }
505 + return spans;
506 }
507 }
508 +
509 +/// Tiny pulsing dot used inside the status badge.
510 +class _PulsingDot extends StatefulWidget {
511 + final Color color;
512 + final bool active;
513 +
514 + const _PulsingDot({required this.color, required this.active});
515 +
516 + @override
517 + State<_PulsingDot> createState() => _PulsingDotState();
518 +}
519 +
520 +class _PulsingDotState extends State<_PulsingDot>
521 + with SingleTickerProviderStateMixin {
522 + late final AnimationController _controller;
523 +
524 + @override
525 + void initState() {
526 + super.initState();
527 + _controller = AnimationController(
528 + vsync: this,
529 + duration: const Duration(milliseconds: 1400),
530 + );
531 + if (widget.active) _controller.repeat(reverse: true);
532 + }
533 +
534 + @override
535 + void didUpdateWidget(_PulsingDot oldWidget) {
536 + super.didUpdateWidget(oldWidget);
537 + if (widget.active && !_controller.isAnimating) {
538 + _controller.repeat(reverse: true);
539 + } else if (!widget.active && _controller.isAnimating) {
540 + _controller.stop();
541 + _controller.value = 0;
542 + }
543 + }
544 +
545 + @override
546 + void dispose() {
547 + _controller.dispose();
548 + super.dispose();
549 + }
550 +
551 + @override
552 + Widget build(BuildContext context) {
553 + return AnimatedBuilder(
554 + animation: _controller,
555 + builder: (context, _) {
556 + final t = widget.active ? _controller.value : 0.0;
557 + return Container(
558 + width: 8,
559 + height: 8,
560 + decoration: BoxDecoration(
561 + shape: BoxShape.circle,
562 + color: widget.color,
563 + boxShadow: widget.active
564 + ? [
565 + BoxShadow(
566 + color: widget.color.withValues(alpha: 0.5 + 0.4 * t),
567 + blurRadius: 5 + 5 * t,
568 + spreadRadius: 0.5 + 1.5 * t,
569 + ),
570 + ]
571 + : null,
572 + ),
573 + );
574 + },
575 + );
576 + }
577 +}
\ No newline at end of file
lib/features/voice_mode/views/components/voice_visualizer.dart
+163 -221
@@ -1,20 +1,29 @@
1 -import 'dart:math';
1 +import 'dart:math' as math;
2
3 import 'package:flutter/material.dart';
4
5 import '../../providers/voice_mode_provider.dart';
6 -
7 -/// Animated visual indicator that changes appearance based on the
8 -/// current [VoiceModePhase]. Uses [CustomPainter] for smooth,
9 -/// GPU-friendly rendering.
6 +import '../../voice_mode_palette.dart';
7 +
8 +/// Real-time waveform visualizer.
9 +///
10 +/// While [VoiceModePhase.listening] the bars animate from the live
11 +/// microphone level. While [VoiceModePhase.speaking] (AI playback) we
12 +/// drive a procedural envelope because we can't read amplitude from the
13 +/// TTS audio stream.
14 class VoiceVisualizer extends StatefulWidget {
15 final VoiceModePhase phase;
16 final double size;
17
18 + /// Current microphone input level in 0..1 (only meaningful while
19 + /// listening).
20 + final double micLevel;
21 +
22 const VoiceVisualizer({
23 super.key,
24 required this.phase,
25 this.size = 220,
26 + this.micLevel = 0,
27 });
28
29 @override
@@ -22,88 +31,43 @@ class VoiceVisualizer extends StatefulWidget {
31 }
32
33 class _VoiceVisualizerState extends State<VoiceVisualizer>
25 - with TickerProviderStateMixin {
26 - late AnimationController _pulseController;
27 - late AnimationController _orbitController;
28 - late AnimationController _waveController;
34 + with SingleTickerProviderStateMixin {
35 + /// Always running — even during listening we need per-frame ticks so the
36 + /// procedural noise modulation keeps moving while the user speaks. During
37 + /// speaking the same controller drives the AI envelope.
38 + late final AnimationController _ticker;
39
40 @override
41 void initState() {
42 super.initState();
33 -
34 - _pulseController = AnimationController(
43 + _ticker = AnimationController(
44 vsync: this,
45 duration: const Duration(milliseconds: 1800),
37 - );
38 -
39 - _orbitController = AnimationController(
40 - vsync: this,
41 - duration: const Duration(milliseconds: 2400),
42 - );
43 -
44 - _waveController = AnimationController(
45 - vsync: this,
46 - duration: const Duration(milliseconds: 1200),
47 - );
48 -
49 - _syncAnimations(widget.phase);
50 - }
51 -
52 - @override
53 - void didUpdateWidget(VoiceVisualizer oldWidget) {
54 - super.didUpdateWidget(oldWidget);
55 - if (oldWidget.phase != widget.phase) {
56 - _syncAnimations(widget.phase);
57 - }
58 - }
59 -
60 - void _syncAnimations(VoiceModePhase phase) {
61 - // Stop all first.
62 - _pulseController.stop();
63 - _orbitController.stop();
64 - _waveController.stop();
65 -
66 - switch (phase) {
67 - case VoiceModePhase.listening:
68 - _pulseController.repeat(reverse: true);
69 - case VoiceModePhase.processing:
70 - _orbitController.repeat();
71 - case VoiceModePhase.speaking:
72 - _waveController.repeat();
73 - case VoiceModePhase.idle:
74 - case VoiceModePhase.error:
75 - // Gentle idle pulse.
76 - _pulseController.repeat(reverse: true);
77 - }
46 + )..repeat();
47 }
48
49 @override
50 void dispose() {
82 - _pulseController.dispose();
83 - _orbitController.dispose();
84 - _waveController.dispose();
51 + _ticker.dispose();
52 super.dispose();
53 }
54
55 @override
56 Widget build(BuildContext context) {
57 + final isDark = Theme.of(context).brightness == Brightness.dark;
58 +
59 return SizedBox(
60 width: widget.size,
61 height: widget.size,
62 child: AnimatedBuilder(
94 - animation: Listenable.merge([
95 - _pulseController,
96 - _orbitController,
97 - _waveController,
98 - ]),
63 + animation: _ticker,
64 builder: (context, _) {
65 return CustomPaint(
101 - painter: _VoicePainter(
66 + painter: _WaveformPainter(
67 phase: widget.phase,
103 - pulseValue: _pulseController.value,
104 - orbitValue: _orbitController.value,
105 - waveValue: _waveController.value,
106 - isDark: Theme.of(context).brightness == Brightness.dark,
68 + micLevel: widget.micLevel.clamp(0.0, 1.0),
69 + aiValue: _ticker.value,
70 + isDark: isDark,
71 ),
72 );
73 },
@@ -112,196 +76,174 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
76 }
77 }
78
115 -class _VoicePainter extends CustomPainter {
79 +class _WaveformPainter extends CustomPainter {
80 + static const _barCount = 28;
81 +
82 final VoiceModePhase phase;
117 - final double pulseValue;
118 - final double orbitValue;
119 - final double waveValue;
83 + final double micLevel;
84 + final double aiValue;
85 final bool isDark;
86
122 - _VoicePainter({
87 + // Pre-allocated scratch values to keep the per-frame loop tight.
88 + static final Paint _barPaint = Paint()..strokeCap = StrokeCap.round;
89 + static final Paint _haloPaint = Paint()
90 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 28);
91 + static final Paint _corePaint = Paint()
92 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
93 +
94 + _WaveformPainter({
95 required this.phase,
124 - required this.pulseValue,
125 - required this.orbitValue,
126 - required this.waveValue,
96 + required this.micLevel,
97 + required this.aiValue,
98 required this.isDark,
99 });
100
101 @override
102 void paint(Canvas canvas, Size size) {
103 final center = Offset(size.width / 2, size.height / 2);
133 - final maxRadius = size.width / 2;
104 + final accent = VoiceModePalette.accentFor(phase, isDark: isDark);
105 +
106 + // The halo keeps the surface feeling alive even when there's no audio.
107 + _paintHalo(canvas, center, size, accent);
108
109 switch (phase) {
110 case VoiceModePhase.listening:
137 - _paintListening(canvas, center, maxRadius);
138 - case VoiceModePhase.processing:
139 - _paintProcessing(canvas, center, maxRadius);
111 + _paintBars(canvas, size, accent, amp: micLevel);
112 case VoiceModePhase.speaking:
141 - _paintSpeaking(canvas, center, maxRadius);
113 + _paintBars(canvas, size, accent, amp: _aiEnvelope());
114 + case VoiceModePhase.processing:
115 + _paintProcessingBars(canvas, size, accent);
116 case VoiceModePhase.idle:
143 - _paintIdle(canvas, center, maxRadius);
117 + _paintBars(canvas, size, accent, amp: 0.06 + 0.04 * aiValue);
118 case VoiceModePhase.error:
145 - _paintError(canvas, center, maxRadius);
119 + _paintBars(canvas, size, accent, amp: 0.05);
120 }
121 }
122
149 - void _paintListening(Canvas canvas, Offset center, double maxRadius) {
150 - final baseColor = isDark
151 - ? const Color(0xFF6366F1) // Indigo
152 - : const Color(0xFF4F46E5);
153 -
154 - // Concentric pulsing rings.
155 - for (int i = 0; i < 4; i++) {
156 - final progress = (pulseValue + i * 0.2) % 1.0;
157 - final radius = maxRadius * 0.3 + maxRadius * 0.6 * progress;
158 - final alpha = (1.0 - progress) * 0.4;
159 -
160 - final paint = Paint()
161 - ..color = baseColor.withValues(alpha: alpha)
162 - ..style = PaintingStyle.stroke
163 - ..strokeWidth = 2.5 - (progress * 1.5);
164 -
165 - canvas.drawCircle(center, radius, paint);
123 + void _paintBars(
124 + Canvas canvas,
125 + Size size,
126 + Color color, {
127 + required double amp,
128 + }) {
129 + const half = _barCount ~/ 2;
130 + final width = size.width;
131 + final height = size.height;
132 + final centerY = height / 2;
133 + final maxBarHeight = height * 0.42;
134 + final minBarHeight = height * 0.04;
135 + final barWidth = (width / (_barCount * 2)).clamp(2.0, 4.0);
136 + final gap = (width - barWidth * _barCount * 2) / (_barCount * 2 + 1);
137 + final baseX = centerY - (half * (barWidth + gap)) + gap / 2;
138 + final stride = barWidth + gap;
139 + final t = aiValue * 2 * math.pi;
140 + final ampScale = 0.45 + amp * 1.35;
141 +
142 + _barPaint.strokeWidth = barWidth;
143 +
144 + for (int i = 0; i < _barCount; i++) {
145 + final dist = ((i - half).abs() / half).clamp(0.0, 1.0);
146 + final envelope = math.cos(dist * math.pi / 2);
147 + final noise = _waveNoise(i - half, phase, t);
148 + final normalised = (envelope * ampScale * (0.6 + 0.4 * noise))
149 + .clamp(0.0, 1.0);
150 + final barHeight = minBarHeight + (maxBarHeight - minBarHeight) * normalised;
151 +
152 + _barPaint.color = color.withValues(alpha: 0.35 + 0.55 * normalised);
153 + canvas.drawLine(
154 + Offset(baseX + i * stride, centerY - barHeight / 2),
155 + Offset(baseX + i * stride, centerY + barHeight / 2),
156 + _barPaint,
157 + );
158 }
159
168 - // Inner glowing core.
169 - final corePaint = Paint()
170 - ..color = baseColor.withValues(alpha: 0.6 + pulseValue * 0.3)
171 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
172 - canvas.drawCircle(center, maxRadius * 0.18, corePaint);
173 -
174 - final solidCore = Paint()..color = baseColor;
175 - canvas.drawCircle(center, maxRadius * 0.12, solidCore);
160 + _corePaint.color = color.withValues(alpha: 0.6 + 0.4 * amp);
161 + canvas.drawCircle(
162 + Offset(width / 2, height / 2),
163 + 4 + 6 * amp,
164 + _corePaint,
165 + );
166 }
167
178 - void _paintProcessing(Canvas canvas, Offset center, double maxRadius) {
179 - final baseColor = isDark
180 - ? const Color(0xFFA78BFA) // Violet
181 - : const Color(0xFF7C3AED);
182 -
183 - // Orbiting dots.
184 - const dotCount = 8;
185 - for (int i = 0; i < dotCount; i++) {
186 - final angle = (orbitValue * 2 * pi) + (i * 2 * pi / dotCount);
187 - final orbitRadius = maxRadius * 0.45;
188 - final dotX = center.dx + cos(angle) * orbitRadius;
189 - final dotY = center.dy + sin(angle) * orbitRadius;
190 -
191 - final trailAlpha = 0.3 + (0.7 * (i / dotCount));
192 - final dotRadius = 4.0 + (3.0 * (i / dotCount));
193 -
194 - final paint = Paint()
195 - ..color = baseColor.withValues(alpha: trailAlpha)
196 - ..maskFilter = MaskFilter.blur(BlurStyle.normal, dotRadius * 0.5);
197 -
198 - canvas.drawCircle(Offset(dotX, dotY), dotRadius, paint);
168 + void _paintProcessingBars(Canvas canvas, Size size, Color color) {
169 + const half = _barCount ~/ 2;
170 + final centerY = size.height / 2;
171 + final maxBarHeight = size.height * 0.28;
172 + final minBarHeight = size.height * 0.03;
173 + final barWidth = (size.width / (_barCount * 2)).clamp(2.0, 4.0);
174 + final gap = (size.width - barWidth * _barCount * 2) / (_barCount * 2 + 1);
175 + final baseX = centerY - (half * (barWidth + gap)) + gap / 2;
176 + final stride = barWidth + gap;
177 +
178 + _barPaint.strokeWidth = barWidth;
179 +
180 + for (int i = 0; i < _barCount; i++) {
181 + final dist = ((i - half).abs() / half).clamp(0.0, 1.0);
182 + final progress = ((aiValue + dist * 0.6) % 1.0);
183 + final wave = math.sin(progress * math.pi);
184 + final h = minBarHeight + (maxBarHeight - minBarHeight) * wave;
185 +
186 + _barPaint.color = color.withValues(alpha: 0.25 + 0.55 * wave);
187 + canvas.drawLine(
188 + Offset(baseX + i * stride, centerY - h / 2),
189 + Offset(baseX + i * stride, centerY + h / 2),
190 + _barPaint,
191 + );
192 }
193
201 - // Second ring going the opposite direction.
202 - for (int i = 0; i < 5; i++) {
203 - final angle = -(orbitValue * 1.5 * 2 * pi) + (i * 2 * pi / 5);
204 - final orbitRadius = maxRadius * 0.65;
205 - final dotX = center.dx + cos(angle) * orbitRadius;
206 - final dotY = center.dy + sin(angle) * orbitRadius;
207 -
208 - final trailAlpha = 0.15 + (0.35 * (i / 5));
209 -
210 - final paint = Paint()
211 - ..color = baseColor.withValues(alpha: trailAlpha)
212 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
213 -
214 - canvas.drawCircle(Offset(dotX, dotY), 3, paint);
215 - }
216 -
217 - // Center pulse.
218 - final corePulse = (sin(orbitValue * 2 * pi) + 1) / 2;
219 - final corePaint = Paint()
220 - ..color = baseColor.withValues(alpha: 0.3 + corePulse * 0.2)
221 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
222 - canvas.drawCircle(center, maxRadius * 0.15, corePaint);
194 + final breath = 0.5 + 0.5 * math.sin(aiValue * 2 * math.pi);
195 + _corePaint
196 + ..color = color.withValues(alpha: 0.5 + 0.4 * breath)
197 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
198 + canvas.drawCircle(
199 + Offset(size.width / 2, size.height / 2),
200 + 6 + 4 * breath,
201 + _corePaint,
202 + );
203 + // Restore the original blur radius for the next caller.
204 + _corePaint.maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
205 }
206
225 - void _paintSpeaking(Canvas canvas, Offset center, double maxRadius) {
226 - final baseColor = isDark
227 - ? const Color(0xFF34D399) // Emerald
228 - : const Color(0xFF059669);
229 -
230 - // Equalizer-style bars arranged in a circular pattern.
231 - const barCount = 24;
232 - for (int i = 0; i < barCount; i++) {
233 - final angle = (i * 2 * pi / barCount) - pi / 2;
234 - final phaseOffset = sin(waveValue * 2 * pi + i * 0.5);
235 - final barHeight = maxRadius * 0.15 + maxRadius * 0.3 * ((phaseOffset + 1) / 2);
236 -
237 - final innerRadius = maxRadius * 0.3;
238 - final outerRadius = innerRadius + barHeight;
239 -
240 - final startX = center.dx + cos(angle) * innerRadius;
241 - final startY = center.dy + sin(angle) * innerRadius;
242 - final endX = center.dx + cos(angle) * outerRadius;
243 - final endY = center.dy + sin(angle) * outerRadius;
244 -
245 - final barAlpha = 0.3 + 0.6 * ((phaseOffset + 1) / 2);
246 -
247 - final paint = Paint()
248 - ..color = baseColor.withValues(alpha: barAlpha)
249 - ..strokeWidth = 3.5
250 - ..strokeCap = StrokeCap.round;
251 -
252 - canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
253 - }
254 -
255 - // Inner glow.
256 - final corePaint = Paint()
257 - ..color = baseColor.withValues(alpha: 0.4)
258 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14);
259 - canvas.drawCircle(center, maxRadius * 0.2, corePaint);
260 -
261 - final solidCore = Paint()..color = baseColor.withValues(alpha: 0.8);
262 - canvas.drawCircle(center, maxRadius * 0.1, solidCore);
207 + void _paintHalo(
208 + Canvas canvas,
209 + Offset center,
210 + Size size,
211 + Color color,
212 + ) {
213 + _haloPaint.color = color.withValues(alpha: isDark ? 0.18 : 0.12);
214 + canvas.drawCircle(center, size.width * 0.32, _haloPaint);
215 }
216
265 - void _paintIdle(Canvas canvas, Offset center, double maxRadius) {
266 - final baseColor = isDark
267 - ? Colors.white.withValues(alpha: 0.3)
268 - : Colors.black.withValues(alpha: 0.15);
269 -
270 - // Gentle breathing circle.
271 - final radius = maxRadius * 0.2 + maxRadius * 0.05 * pulseValue;
272 - final paint = Paint()
273 - ..color = baseColor
274 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
275 - canvas.drawCircle(center, radius, paint);
276 -
277 - // Thin outer ring.
278 - final ringPaint = Paint()
279 - ..color = baseColor.withValues(alpha: 0.15 + pulseValue * 0.1)
280 - ..style = PaintingStyle.stroke
281 - ..strokeWidth = 1.5;
282 - canvas.drawCircle(center, maxRadius * 0.5, ringPaint);
217 + /// Two-sine envelope that mimics natural speech rhythm.
218 + double _waveNoise(int barIndex, VoiceModePhase phase, double t) {
219 + final phaseOffset = barIndex * 0.45;
220 + switch (phase) {
221 + case VoiceModePhase.listening:
222 + return 0.5 + 0.5 * math.sin(t * 0.6 + phaseOffset);
223 + case VoiceModePhase.speaking:
224 + final a = math.sin(t * 1.3 + phaseOffset);
225 + final b = math.sin(t * 2.7 + phaseOffset * 1.7);
226 + return 0.5 + 0.35 * a + 0.25 * b;
227 + case VoiceModePhase.idle:
228 + return 0.5 + 0.5 * math.sin(t + phaseOffset);
229 + case VoiceModePhase.error:
230 + case VoiceModePhase.processing:
231 + return 0.5;
232 + }
233 }
234
285 - void _paintError(Canvas canvas, Offset center, double maxRadius) {
286 - final baseColor = const Color(0xFFEF4444);
287 -
288 - // Pulsing red glow.
289 - final radius = maxRadius * 0.2 + maxRadius * 0.05 * pulseValue;
290 - final paint = Paint()
291 - ..color = baseColor.withValues(alpha: 0.4 + pulseValue * 0.2)
292 - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
293 - canvas.drawCircle(center, radius, paint);
294 -
295 - final solidCore = Paint()..color = baseColor.withValues(alpha: 0.7);
296 - canvas.drawCircle(center, maxRadius * 0.1, solidCore);
235 + double _aiEnvelope() {
236 + final t = aiValue;
237 + final wave = 0.55 + 0.45 * math.sin(t * 2 * math.pi * 4);
238 + final breath = 0.4 + 0.6 * math.sin(t * 2 * math.pi);
239 + return (wave * breath).clamp(0.05, 1.0);
240 }
241
242 @override
300 - bool shouldRepaint(covariant _VoicePainter oldDelegate) {
243 + bool shouldRepaint(covariant _WaveformPainter oldDelegate) {
244 return phase != oldDelegate.phase ||
302 - pulseValue != oldDelegate.pulseValue ||
303 - orbitValue != oldDelegate.orbitValue ||
304 - waveValue != oldDelegate.waveValue ||
245 + micLevel != oldDelegate.micLevel ||
246 + aiValue != oldDelegate.aiValue ||
247 isDark != oldDelegate.isDark;
248 }
307 -}
249 +}
\ No newline at end of file
lib/features/voice_mode/views/voice_mode_overlay.dart
+156 -71
@@ -2,6 +2,7 @@ import 'dart:ui';
2
3 import 'package:flutter/material.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +import 'package:go_router/go_router.dart';
6 import 'package:haptic_feedback/haptic_feedback.dart';
7 import 'package:hugeicons/hugeicons.dart';
8
@@ -10,14 +11,15 @@ import '../../../../core/providers/app_providers.dart';
11 import '../../chat/providers/chat_notifier.dart';
12 import '../../tts/views/tts_model_manager_screen.dart';
13 import '../providers/voice_mode_provider.dart';
14 +import '../voice_mode_palette.dart';
15 import 'components/voice_mode_controls.dart';
16 import 'components/voice_transcript.dart';
17 import 'components/voice_visualizer.dart';
18
19 /// Full-screen voice-to-voice conversation overlay.
20 ///
19 -/// Presents an animated visual indicator and transcript text across
20 -/// Listen → Process → Speak phases, looping hands-free when
21 +/// Presents a real-time waveform visualizer and auto-scrolling transcript
22 +/// across Listen → Process → Speak phases, looping hands-free when
23 /// auto-listen is enabled.
24 ///
25 /// Usage:
@@ -33,7 +35,7 @@ class VoiceModeOverlay extends ConsumerStatefulWidget {
35 context: context,
36 barrierDismissible: false,
37 barrierColor: Colors.transparent,
36 - transitionDuration: const Duration(milliseconds: 400),
38 + transitionDuration: const Duration(milliseconds: 420),
39 transitionBuilder: (context, animation, secondaryAnimation, child) {
40 final curve = CurvedAnimation(
41 parent: animation,
@@ -42,7 +44,7 @@ class VoiceModeOverlay extends ConsumerStatefulWidget {
44 return FadeTransition(
45 opacity: curve,
46 child: ScaleTransition(
45 - scale: Tween<double>(begin: 0.92, end: 1.0).animate(curve),
47 + scale: Tween<double>(begin: 0.94, end: 1.0).animate(curve),
48 child: child,
49 ),
50 );
@@ -68,30 +70,24 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
70 }
71
72 Future<void> _endSession() async {
71 - Haptics.vibrate(HapticsType.medium);
73 await ref.read(voiceModeProvider.notifier).endSession();
74 if (mounted) {
74 - Navigator.of(context).pop();
75 + context.pop();
76 }
77 }
78
79 void _handleCenterAction() {
80 final phase = ref.read(voiceModeProvider).phase;
80 - Haptics.vibrate(HapticsType.light);
81
82 switch (phase) {
83 case VoiceModePhase.listening:
84 - // Send current transcript.
84 ref.read(voiceModeProvider.notifier).stopListeningAndSend();
85 case VoiceModePhase.speaking:
87 - // Interrupt and potentially re-listen.
86 ref.read(voiceModeProvider.notifier).interrupt();
87 case VoiceModePhase.idle:
88 case VoiceModePhase.error:
91 - // Start listening.
89 ref.read(voiceModeProvider.notifier).startListening();
90 case VoiceModePhase.processing:
94 - // Can't interrupt processing — do nothing.
91 break;
92 }
93 }
@@ -120,18 +116,18 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
116 builder: (_) => const TtsModelManagerScreen(),
117 );
118 },
123 - borderRadius: BorderRadius.circular(20),
119 + borderRadius: BorderRadius.circular(999),
120 child: Container(
125 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
121 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
122 decoration: BoxDecoration(
123 color: isDark
128 - ? Colors.white.withValues(alpha: 0.08)
129 - : Colors.black.withValues(alpha: 0.06),
130 - borderRadius: BorderRadius.circular(20),
124 + ? Colors.white.withValues(alpha: 0.06)
125 + : Colors.black.withValues(alpha: 0.05),
126 + borderRadius: BorderRadius.circular(999),
127 border: Border.all(
128 color: isDark
133 - ? Colors.white.withValues(alpha: 0.12)
134 - : Colors.black.withValues(alpha: 0.1),
129 + ? Colors.white.withValues(alpha: 0.10)
130 + : Colors.black.withValues(alpha: 0.08),
131 ),
132 ),
133 child: Row(
@@ -142,23 +138,24 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
138 size: 13,
139 color: theme.colorScheme.primary,
140 ),
145 - const SizedBox(width: 5),
141 + const SizedBox(width: 6),
142 Text(
143 label,
144 style: TextStyle(
145 fontSize: 12,
146 fontWeight: FontWeight.w600,
147 + letterSpacing: 0.1,
148 color: isDark
149 ? Colors.white.withValues(alpha: 0.85)
150 : Colors.black87,
151 ),
152 ),
156 - const SizedBox(width: 3),
153 + const SizedBox(width: 2),
154 Icon(
158 - Icons.chevron_right,
159 - size: 14,
155 + Icons.chevron_right_rounded,
156 + size: 16,
157 color: isDark
161 - ? Colors.white.withValues(alpha: 0.4)
158 + ? Colors.white.withValues(alpha: 0.45)
159 : Colors.black.withValues(alpha: 0.4),
160 ),
161 ],
@@ -173,9 +170,9 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
170 final theme = Theme.of(context);
171 final isDark = theme.brightness == Brightness.dark;
172
176 - final bgColor = isDark
177 - ? const Color(0xFF0A0A0A)
178 - : const Color(0xFFFAFAFA);
173 + final bgColor = isDark ? const Color(0xFF0B0B10) : const Color(0xFFF7F7FB);
174 +
175 + final accent = VoiceModePalette.accentFor(state.phase, isDark: isDark);
176
177 return PopScope(
178 canPop: false,
@@ -184,87 +181,109 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
181 },
182 child: Scaffold(
183 backgroundColor: Colors.transparent,
187 - body: Container(
188 - decoration: BoxDecoration(
189 - color: bgColor.withValues(alpha: 0.92),
190 - ),
191 - child: BackdropFilter(
192 - filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
193 - child: SafeArea(
184 + body: Stack(
185 + children: [
186 + // RepaintBoundary so the gradient + blur aren't re-rasterised
187 + // when the lower subtree (transcript/visualizer/controls)
188 + // rebuilds on streamed tokens or mic-level changes.
189 + Positioned.fill(
190 + child: RepaintBoundary(
191 + child: Stack(
192 + children: [
193 + AnimatedContainer(
194 + duration: const Duration(milliseconds: 600),
195 + decoration: BoxDecoration(
196 + gradient: RadialGradient(
197 + center: const Alignment(0, -0.2),
198 + radius: 1.1,
199 + colors: [
200 + accent.withValues(alpha: isDark ? 0.18 : 0.22),
201 + bgColor,
202 + ],
203 + stops: const [0.0, 0.85],
204 + ),
205 + ),
206 + ),
207 + Positioned.fill(
208 + child: BackdropFilter(
209 + filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
210 + child: Container(
211 + color: bgColor.withValues(alpha: 0.55),
212 + ),
213 + ),
214 + ),
215 + ],
216 + ),
217 + ),
218 + ),
219 +
220 + SafeArea(
221 child: Column(
222 children: [
196 - // Top bar — title, TTS chip, close button.
223 Padding(
198 - padding: const EdgeInsets.symmetric(
199 - horizontal: 16,
200 - vertical: 8,
201 - ),
224 + padding: const EdgeInsets.fromLTRB(20, 12, 12, 8),
225 child: Row(
226 children: [
204 - // Title.
227 + Container(
228 + width: 8,
229 + height: 8,
230 + decoration: BoxDecoration(
231 + shape: BoxShape.circle,
232 + color: accent,
233 + boxShadow: [
234 + BoxShadow(
235 + color: accent.withValues(alpha: 0.6),
236 + blurRadius: 8,
237 + spreadRadius: 1,
238 + ),
239 + ],
240 + ),
241 + ),
242 + const SizedBox(width: 10),
243 Text(
244 'Voice Mode',
245 style: TextStyle(
208 - fontSize: 16,
246 + fontSize: 15,
247 fontWeight: FontWeight.w600,
248 + letterSpacing: 0.2,
249 color: isDark
211 - ? Colors.white.withValues(alpha: 0.7)
212 - : Colors.black.withValues(alpha: 0.6),
250 + ? Colors.white.withValues(alpha: 0.85)
251 + : Colors.black.withValues(alpha: 0.8),
252 ),
253 ),
254 const Spacer(),
216 - // TTS Model Chip.
255 _buildTtsChip(context, theme, isDark),
218 - const SizedBox(width: 8),
219 - // Close button.
220 - IconButton(
221 - onPressed: _endSession,
222 - icon: Icon(
223 - Icons.close,
224 - color: isDark
225 - ? Colors.white.withValues(alpha: 0.6)
226 - : Colors.black.withValues(alpha: 0.5),
227 - ),
228 - ),
256 + const SizedBox(width: 4),
257 + _CloseButton(isDark: isDark, onPressed: _endSession),
258 ],
259 ),
260 ),
261
233 - // Expanding space.
262 const Spacer(flex: 2),
263
236 - // Animated visualizer.
264 GestureDetector(
265 onTap: _handleCenterAction,
266 child: VoiceVisualizer(
267 phase: state.phase,
241 - size: 220,
268 + micLevel: state.micLevel,
269 + size: 240,
270 ),
271 ),
272
245 - const SizedBox(height: 32),
273 + const SizedBox(height: 28),
274
247 - // Transcript / status text.
275 Padding(
276 padding: const EdgeInsets.symmetric(horizontal: 32),
250 - child: VoiceTranscript(
277 + child: _StreamingTranscript(
278 phase: state.phase,
279 transcript: state.transcript,
253 - response: state.phase == VoiceModePhase.processing
254 - ? (ref.watch(
255 - chatProvider.select(
256 - (s) => s.streamingMessage?.content,
257 - ),
258 - ) ??
259 - state.response)
260 - : state.response,
280 + response: state.response,
281 error: state.error,
282 ),
283 ),
284
285 const Spacer(flex: 3),
286
267 - // Bottom controls.
287 VoiceModeControls(
288 phase: state.phase,
289 autoListen: state.autoListen,
@@ -274,16 +293,82 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
293 ref.read(voiceModeProvider.notifier).toggleMute();
294 },
295 onToggleAutoListen: () {
277 - Haptics.vibrate(HapticsType.light);
296 ref.read(voiceModeProvider.notifier).toggleAutoListen();
297 },
298 onTapCenter: _handleCenterAction,
299 ),
300
283 - const SizedBox(height: 16),
301 + const SizedBox(height: 18),
302 ],
303 ),
304 ),
305 + ],
306 + ),
307 + ),
308 + );
309 + }
310 +}
311 +
312 +/// Subscribes to the chat streaming message and feeds its content into
313 +/// [VoiceTranscript] during the processing phase. Lives in its own
314 +/// Consumer so streamed tokens only rebuild this subtree, not the
315 +/// gradient/BackdropFilter above it.
316 +class _StreamingTranscript extends ConsumerWidget {
317 + final VoiceModePhase phase;
318 + final String transcript;
319 + final String response;
320 + final String? error;
321 +
322 + const _StreamingTranscript({
323 + required this.phase,
324 + required this.transcript,
325 + required this.response,
326 + required this.error,
327 + });
328 +
329 + @override
330 + Widget build(BuildContext context, WidgetRef ref) {
331 + final streamed = phase == VoiceModePhase.processing
332 + ? (ref.watch(chatProvider.select((s) => s.streamingMessage?.content)) ??
333 + response)
334 + : response;
335 + return VoiceTranscript(
336 + phase: phase,
337 + transcript: transcript,
338 + response: streamed,
339 + error: error,
340 + );
341 + }
342 +}
343 +
344 +class _CloseButton extends StatelessWidget {
345 + final bool isDark;
346 + final VoidCallback onPressed;
347 +
348 + const _CloseButton({required this.isDark, required this.onPressed});
349 +
350 + @override
351 + Widget build(BuildContext context) {
352 + return Material(
353 + color: Colors.transparent,
354 + child: InkWell(
355 + customBorder: const CircleBorder(),
356 + onTap: onPressed,
357 + child: Container(
358 + width: 36,
359 + height: 36,
360 + decoration: BoxDecoration(
361 + shape: BoxShape.circle,
362 + color: isDark
363 + ? Colors.white.withValues(alpha: 0.06)
364 + : Colors.black.withValues(alpha: 0.05),
365 + ),
366 + child: Icon(
367 + Icons.close_rounded,
368 + size: 18,
369 + color: isDark
370 + ? Colors.white.withValues(alpha: 0.7)
371 + : Colors.black.withValues(alpha: 0.55),
372 ),
373 ),
374 ),
lib/features/voice_mode/voice_mode_palette.dart new
+33
@@ -0,0 +1,33 @@
1 +import 'package:flutter/material.dart';
2 +
3 +import 'providers/voice_mode_provider.dart';
4 +
5 +/// Accent color palette used across the voice-mode UI (overlay header,
6 +/// waveform visualizer, transcript badge, control buttons).
7 +///
8 +/// Centralised so the same phase always renders with the same colour
9 +/// regardless of which surface paints it.
10 +class VoiceModePalette {
11 + const VoiceModePalette._();
12 +
13 + static Color accentFor(VoiceModePhase phase, {required bool isDark}) {
14 + switch (phase) {
15 + case VoiceModePhase.listening:
16 + return isDark ? const Color(0xFF34D399) : const Color(0xFF059669);
17 + case VoiceModePhase.processing:
18 + return isDark ? const Color(0xFFA78BFA) : const Color(0xFF7C3AED);
19 + case VoiceModePhase.speaking:
20 + return isDark ? const Color(0xFF818CF8) : const Color(0xFF4F46E5);
21 + case VoiceModePhase.idle:
22 + return isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B);
23 + case VoiceModePhase.error:
24 + return const Color(0xFFEF4444);
25 + }
26 + }
27 +
28 + /// Subtle background used behind the centred phase icon / pill.
29 + static Color subtleBg(BuildContext context, {double opacity = 0.08}) {
30 + final isDark = Theme.of(context).brightness == Brightness.dark;
31 + return (isDark ? Colors.white : Colors.black).withValues(alpha: opacity);
32 + }
33 +}
lib/services/voice_feedback_service.dart new
+113
@@ -0,0 +1,113 @@
1 +import 'dart:async';
2 +
3 +import 'package:audioplayers/audioplayers.dart';
4 +import 'package:flutter/services.dart';
5 +import 'package:flutter_riverpod/flutter_riverpod.dart';
6 +
7 +/// Sound + haptic feedback for the voice-to-voice overlay lifecycle.
8 +///
9 +/// Holds four preloaded [AudioPlayer]s (one per cue) so playback is
10 +/// zero-latency at the moment of a phase transition. Each public method
11 +/// plays its cue and fires the matching haptic in one call.
12 +///
13 +/// All playback errors are swallowed — feedback is best-effort and must
14 +/// never break the voice pipeline.
15 +class VoiceFeedbackService {
16 + final AudioPlayer _connectedPlayer = AudioPlayer();
17 + final AudioPlayer _disconnectedPlayer = AudioPlayer();
18 + final AudioPlayer _listeningPlayer = AudioPlayer();
19 + final AudioPlayer _generatingPlayer = AudioPlayer();
20 +
21 + bool _disposed = false;
22 +
23 + /// Preload every cue into memory so the first call has no asset-load
24 + /// latency. Safe to call multiple times — audioplayers reuses the
25 + /// existing source on subsequent `setSourceAsset` calls.
26 + Future<void> preload() async {
27 + await Future.wait([
28 + _load(_connectedPlayer, 'sounds/connected.mp3'),
29 + _load(_disconnectedPlayer, 'sounds/disconnected.mp3'),
30 + _load(_listeningPlayer, 'sounds/start_listening.mp3'),
31 + _load(_generatingPlayer, 'sounds/generating.mp3'),
32 + ]);
33 + }
34 +
35 + Future<void> _load(AudioPlayer player, String assetPath) async {
36 + if (_disposed) return;
37 + try {
38 + await player.setReleaseMode(ReleaseMode.stop);
39 + await player.setSource(AssetSource(assetPath));
40 + } catch (_) {
41 + // Swallow — see class doc.
42 + }
43 + }
44 +
45 + Future<void> _play(AudioPlayer player) async {
46 + if (_disposed) return;
47 + try {
48 + await player.seek(Duration.zero);
49 + await player.resume();
50 + } catch (_) {
51 + // Swallow — see class doc.
52 + }
53 + }
54 +
55 + /// Session opened, mic is live. Confident, affirming.
56 + Future<void> playConnected() async {
57 + await _play(_connectedPlayer);
58 + await _safeHaptic(HapticFeedback.mediumImpact);
59 + }
60 +
61 + /// Session closed gracefully. Soft, not alarming.
62 + Future<void> playDisconnected() async {
63 + await _play(_disconnectedPlayer);
64 + await _safeHaptic(HapticFeedback.lightImpact);
65 + }
66 +
67 + /// Mic activated, awaiting user speech. Crisp micro-click.
68 + Future<void> playListening() async {
69 + await _play(_listeningPlayer);
70 + await _safeHaptic(HapticFeedback.selectionClick);
71 + }
72 +
73 + /// LLM is streaming. Single tap on first chunk only — repeated
74 + /// haptics during streaming feel glitchy.
75 + Future<void> playGenerating() async {
76 + await _play(_generatingPlayer);
77 + await _safeHaptic(HapticFeedback.lightImpact);
78 + }
79 +
80 + Future<void> _safeHaptic(Future<void> Function() haptic) async {
81 + if (_disposed) return;
82 + try {
83 + await haptic();
84 + } catch (_) {
85 + // Swallow — haptics unsupported on some devices / emulators.
86 + }
87 + }
88 +
89 + /// Release every player. Called by [voiceFeedbackProvider]'s
90 + /// `ref.onDispose`.
91 + Future<void> dispose() async {
92 + if (_disposed) return;
93 + _disposed = true;
94 + await Future.wait([
95 + _connectedPlayer.dispose(),
96 + _disconnectedPlayer.dispose(),
97 + _listeningPlayer.dispose(),
98 + _generatingPlayer.dispose(),
99 + ]);
100 + }
101 +}
102 +
103 +/// Singleton feedback service. Not autoDispose — preloaded sounds stay
104 +/// warm for the whole app lifetime so the first phase transition has no
105 +/// asset-load delay.
106 +final voiceFeedbackProvider = Provider<VoiceFeedbackService>((ref) {
107 + final service = VoiceFeedbackService();
108 + // Fire-and-forget: callers don't block on preload.
109 + // ignore: discarded_futures
110 + service.preload();
111 + ref.onDispose(service.dispose);
112 + return service;
113 +});
\ No newline at end of file
pubspec.yaml
+1
@@ -117,6 +117,7 @@ flutter:
117 assets:
118 - assets/google_fonts/
119 - assets/images/
120 + - assets/sounds/
121 # An image asset can refer to one or more resolution-specific "variants", see
122 # https://flutter.dev/to/resolution-aware-images
123