main
dart 112 lines 3.48 KB
Raw
1 import 'dart:async';
2
3 import 'package:audioplayers/audioplayers.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6 import '../core/services/app_haptics.dart';
7
8 /// Sound + haptic feedback for the voice-to-voice overlay lifecycle.
9 ///
10 /// Holds four preloaded [AudioPlayer]s (one per cue) so playback is
11 /// zero-latency at the moment of a phase transition. Each public method
12 /// plays its cue and fires the matching haptic in one call.
13 ///
14 /// All playback errors are swallowed — feedback is best-effort and must
15 /// never break the voice pipeline.
16 class VoiceFeedbackService {
17 VoiceFeedbackService(this._haptics);
18
19 final AppHaptics _haptics;
20 final AudioPlayer _connectedPlayer = AudioPlayer();
21 final AudioPlayer _disconnectedPlayer = AudioPlayer();
22 final AudioPlayer _listeningPlayer = AudioPlayer();
23 final AudioPlayer _generatingPlayer = AudioPlayer();
24
25 bool _disposed = false;
26
27 /// Preload every cue into memory so the first call has no asset-load
28 /// latency. Safe to call multiple times — audioplayers reuses the
29 /// existing source on subsequent `setSourceAsset` calls.
30 Future<void> preload() async {
31 await Future.wait([
32 _load(_connectedPlayer, 'sounds/connected.mp3'),
33 _load(_disconnectedPlayer, 'sounds/disconnected.mp3'),
34 _load(_listeningPlayer, 'sounds/start_listening.mp3'),
35 _load(_generatingPlayer, 'sounds/generating.mp3'),
36 ]);
37 }
38
39 Future<void> _load(AudioPlayer player, String assetPath) async {
40 if (_disposed) return;
41 try {
42 await player.setReleaseMode(ReleaseMode.stop);
43 await player.setSource(AssetSource(assetPath));
44 } catch (_) {
45 // Swallow — see class doc.
46 }
47 }
48
49 Future<void> _play(AudioPlayer player) async {
50 if (_disposed) return;
51 try {
52 await player.seek(Duration.zero);
53 await player.resume();
54 } catch (_) {
55 // Swallow — see class doc.
56 }
57 }
58
59 /// Session opened, mic is live. Confident, affirming.
60 Future<void> playConnected() async {
61 if (_disposed) return;
62 await _play(_connectedPlayer);
63 await _haptics.medium();
64 }
65
66 /// Session closed gracefully. Soft, not alarming.
67 Future<void> playDisconnected() async {
68 if (_disposed) return;
69 await _play(_disconnectedPlayer);
70 await _haptics.light();
71 }
72
73 /// Mic activated, awaiting user speech. Crisp micro-click.
74 Future<void> playListening() async {
75 if (_disposed) return;
76 await _play(_listeningPlayer);
77 await _haptics.selection();
78 }
79
80 /// LLM is streaming. Single tap on first chunk only — repeated
81 /// haptics during streaming feel glitchy.
82 Future<void> playGenerating() async {
83 if (_disposed) return;
84 await _play(_generatingPlayer);
85 await _haptics.light();
86 }
87
88 /// Release every player. Called by [voiceFeedbackProvider]'s
89 /// `ref.onDispose`.
90 Future<void> dispose() async {
91 if (_disposed) return;
92 _disposed = true;
93 await Future.wait([
94 _connectedPlayer.dispose(),
95 _disconnectedPlayer.dispose(),
96 _listeningPlayer.dispose(),
97 _generatingPlayer.dispose(),
98 ]);
99 }
100 }
101
102 /// Singleton feedback service. Not autoDispose — preloaded sounds stay
103 /// warm for the whole app lifetime so the first phase transition has no
104 /// asset-load delay.
105 final voiceFeedbackProvider = Provider<VoiceFeedbackService>((ref) {
106 final service = VoiceFeedbackService(ref.read(appHapticsProvider));
107 // Fire-and-forget: callers don't block on preload.
108 // ignore: discarded_futures
109 service.preload();
110 ref.onDispose(service.dispose);
111 return service;
112 });