24 files changed
+1659
-73
README.md
+3
-3
@@ -59,11 +59,11 @@ LocalMind is built with modern, scalable technologies:
59
60
LocalMind is rapidly evolving. Here is what's coming next:
61
62
-- [ ] **AI Voice (Text-to-Speech)**: Listen to your AI's responses with high-quality TTS.
62
+- [x] **AI Voice (Text-to-Speech)**: Listen to your AI's responses with high-quality TTS.
63
- [ ] **Context Smart Replies**: Integrated suggested follow-ups to keep the conversation flowing.
64
-- [ ] **Multimodal Support**: Attach images and documents for vision-capable models.
64
+- [x] **Multimodal Support**: Attach images and documents for vision-capable models.
65
- [ ] **Tablet/Desktop Optimization**: A split-view layout for large-screen productivity.
66
-- [ ] **Export Options**: Export your chats to Markdown or PDF to share or archive.
66
+- [x] **Export Options**: Export your chats to Markdown or PDF to share or archive.
67
- [ ] **Quick Shortcuts**: OS-level widgets and shortcuts for launching new chats instantly.
68
69
## 🛠️ Getting Started
assets/tts/config.json
new
+29
@@ -0,0 +1,29 @@
1
+{
2
+ "name": "Kitten TTS Nano",
3
+ "version": "0.8",
4
+ "type": "ONNX2",
5
+ "model": "kitten-tts-nano-0.8",
6
+ "voices" : "voices.npz",
7
+ "model_file": "kitten_tts_nano_v0_8.onnx",
8
+ "speed_priors": {
9
+ "expr-voice-2-f": 0.8,
10
+ "expr-voice-2-m": 0.8,
11
+ "expr-voice-3-m": 0.8,
12
+ "expr-voice-3-f": 0.8,
13
+ "expr-voice-4-m": 0.9,
14
+ "expr-voice-4-f": 0.8,
15
+ "expr-voice-5-m": 0.8,
16
+ "expr-voice-5-f": 0.8
17
+ },
18
+ "voice_aliases": {
19
+ "Bella": "expr-voice-2-f",
20
+ "Jasper": "expr-voice-2-m",
21
+ "Luna": "expr-voice-3-f",
22
+ "Bruno": "expr-voice-3-m",
23
+ "Rosie": "expr-voice-4-f",
24
+ "Hugo": "expr-voice-4-m",
25
+ "Kiki": "expr-voice-5-f",
26
+ "Leo": "expr-voice-5-m"
27
+ }
28
+
29
+}
assets/tts/kitten_tts_nano_v0_8.onnx
Binary files /dev/null and b/assets/tts/kitten_tts_nano_v0_8.onnx differ
assets/tts/voices.npz
Binary files /dev/null and b/assets/tts/voices.npz differ
lib/core/models/enums.dart
+34
@@ -12,6 +12,40 @@ enum EngineStatus { notLoaded, loading, loaded, error }
12
13
enum LiteLmBackendType { cpu, gpu, npu }
14
15
+enum TtsEngine { system, kitten }
16
+
17
+enum KittenTtsVoice {
18
+ bella,
19
+ jasper,
20
+ luna,
21
+ bruno,
22
+ rosie,
23
+ hugo,
24
+ kiki,
25
+ leo;
26
+
27
+ String get displayName {
28
+ switch (this) {
29
+ case KittenTtsVoice.bella:
30
+ return 'Bella';
31
+ case KittenTtsVoice.jasper:
32
+ return 'Jasper';
33
+ case KittenTtsVoice.luna:
34
+ return 'Luna';
35
+ case KittenTtsVoice.bruno:
36
+ return 'Bruno';
37
+ case KittenTtsVoice.rosie:
38
+ return 'Rosie';
39
+ case KittenTtsVoice.hugo:
40
+ return 'Hugo';
41
+ case KittenTtsVoice.kiki:
42
+ return 'Kiki';
43
+ case KittenTtsVoice.leo:
44
+ return 'Leo';
45
+ }
46
+ }
47
+}
48
+
49
enum OnDeviceModelState {
50
notDownloaded,
51
downloading,
lib/core/providers/app_providers.dart
+6
@@ -83,6 +83,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
83
_update(state.copyWith(codeThemeLight: value));
84
void setPreferredBackend(LiteLmBackendType value) =>
85
_update(state.copyWith(preferredBackend: value));
86
+ void setTtsEngine(TtsEngine value) =>
87
+ _update(state.copyWith(ttsEngine: value));
88
+ void setKittenTtsVoice(KittenTtsVoice value) =>
89
+ _update(state.copyWith(kittenTtsVoice: value));
90
+ void setKittenTtsSpeed(double value) =>
91
+ _update(state.copyWith(kittenTtsSpeed: value));
92
93
Future<void> _update(AppSettings updated) async {
94
state = updated;
lib/core/storage/entities.dart
+16
@@ -175,6 +175,10 @@ class ConversationEntity {
175
int messageCount;
176
String? lastMessagePreview;
177
String? systemPrompt;
178
+ double? temperature;
179
+ double? topP;
180
+ int? maxTokens;
181
+ int? contextLength;
182
183
@Backlink()
184
final messages = ToMany<MessageEntity>();
@@ -192,6 +196,10 @@ class ConversationEntity {
196
this.messageCount = 0,
197
this.lastMessagePreview,
198
this.systemPrompt,
199
+ this.temperature,
200
+ this.topP,
201
+ this.maxTokens,
202
+ this.contextLength,
203
});
204
205
factory ConversationEntity.fromDomain(Conversation conversation) {
@@ -207,6 +215,10 @@ class ConversationEntity {
215
messageCount: conversation.messageCount,
216
lastMessagePreview: conversation.lastMessagePreview,
217
systemPrompt: conversation.systemPrompt,
218
+ temperature: conversation.temperature,
219
+ topP: conversation.topP,
220
+ maxTokens: conversation.maxTokens,
221
+ contextLength: conversation.contextLength,
222
);
223
}
224
@@ -223,6 +235,10 @@ class ConversationEntity {
235
messageCount: messageCount,
236
lastMessagePreview: lastMessagePreview,
237
systemPrompt: systemPrompt,
238
+ temperature: temperature,
239
+ topP: topP,
240
+ maxTokens: maxTokens,
241
+ contextLength: contextLength,
242
);
243
}
244
}
lib/features/chat/data/kitten_tts_service.dart
new
+195
@@ -0,0 +1,195 @@
1
+import 'dart:io';
2
+
3
+import 'package:audioplayers/audioplayers.dart';
4
+import 'package:flutter/services.dart';
5
+import 'package:kitten_tts_flutter/kitten_tts_flutter.dart';
6
+import 'package:path_provider/path_provider.dart';
7
+
8
+import '../../../core/logger/app_logger.dart';
9
+import '../../../core/models/enums.dart';
10
+import 'phonemizer_service.dart';
11
+
12
+/// Service that wraps the KittenTTS Flutter package for neural text-to-speech.
13
+///
14
+/// On first initialization, copies the required model files from app assets
15
+/// to the filesystem (since the ONNX runtime requires absolute file paths).
16
+///
17
+/// Usage:
18
+/// ```dart
19
+/// final service = KittenTtsService();
20
+/// await service.initialize();
21
+/// await service.speak('Hello world', voice: KittenTtsVoice.bella);
22
+/// await service.dispose();
23
+/// ```
24
+class KittenTtsService {
25
+ KittenTtsFlutter? _tts;
26
+ AudioPlayer? _audioPlayer;
27
+ final PhonemizerService _phonemizer = PhonemizerService();
28
+
29
+ bool _isInitialized = false;
30
+ bool _isSpeaking = false;
31
+ Future<void>? _initializeFuture;
32
+
33
+ // Asset paths
34
+ static const String _assetConfig = 'assets/tts/config.json';
35
+ static const String _assetModel = 'assets/tts/kitten_tts_nano_v0_8.onnx';
36
+ static const String _assetVoices = 'assets/tts/voices.npz';
37
+
38
+ // File names in the app support directory
39
+ static const String _fileConfig = 'config.json';
40
+ static const String _fileModel = 'kitten_tts_nano_v0_8.onnx';
41
+ static const String _fileVoices = 'voices.npz';
42
+
43
+ bool get isInitialized => _isInitialized;
44
+ bool get isSpeaking => _isSpeaking;
45
+
46
+ /// Initialize the KittenTTS engine.
47
+ ///
48
+ /// Copies model files from assets to the filesystem on first run,
49
+ /// then loads the ONNX session.
50
+ Future<void> initialize() async {
51
+ if (_isInitialized) return;
52
+ if (_initializeFuture != null) return _initializeFuture;
53
+
54
+ _initializeFuture = _doInitialize();
55
+ return _initializeFuture;
56
+ }
57
+
58
+ Future<void> _doInitialize() async {
59
+ try {
60
+ final supportDir = await getApplicationSupportDirectory();
61
+ final ttsDir = Directory('${supportDir.path}/kitten_tts');
62
+ if (!await ttsDir.exists()) {
63
+ await ttsDir.create(recursive: true);
64
+ }
65
+
66
+ // Copy assets to filesystem if not already present
67
+ final configPath = await _ensureAssetFile(ttsDir, _fileConfig, _assetConfig);
68
+ final modelPath = await _ensureAssetFile(ttsDir, _fileModel, _assetModel);
69
+ final voicesPath = await _ensureAssetFile(ttsDir, _fileVoices, _assetVoices);
70
+
71
+ Log.info(
72
+ 'KittenTTS assets ready: config=$configPath, model=$modelPath, voices=$voicesPath',
73
+ );
74
+
75
+ // Initialize KittenTTS engine
76
+ _tts ??= KittenTtsFlutter();
77
+ await _tts!.init(
78
+ configPath: configPath,
79
+ modelPath: modelPath,
80
+ voicesPath: voicesPath,
81
+ );
82
+
83
+ _audioPlayer ??= AudioPlayer();
84
+ _audioPlayer!.onPlayerComplete.listen((_) {
85
+ _isSpeaking = false;
86
+ });
87
+
88
+ _isInitialized = true;
89
+ Log.info('KittenTTS initialized successfully');
90
+ } catch (e, st) {
91
+ Log.error('Failed to initialize KittenTTS: $e\n$st');
92
+ rethrow;
93
+ } finally {
94
+ _initializeFuture = null;
95
+ }
96
+ }
97
+
98
+ /// Generate WAV bytes and play them for the given text.
99
+ ///
100
+ /// [text] should be plain English text. It will be phonemized internally.
101
+ /// [voice] selects one of the built-in voices.
102
+ /// [speed] controls the playback rate (0.5 to 2.0).
103
+ Future<void> speak(
104
+ String text, {
105
+ KittenTtsVoice voice = KittenTtsVoice.bella,
106
+ double speed = 1.0,
107
+ }) async {
108
+ await initialize();
109
+ if (text.trim().isEmpty) return;
110
+
111
+ _isSpeaking = true;
112
+
113
+ try {
114
+ final phonemized = "${_phonemizer.phonemize(text)} "; // Add trailing silence
115
+ Log.debug('Phonemized: "$text" → "$phonemized" (speed: $speed)');
116
+
117
+ Uint8List wavBytes;
118
+ try {
119
+ wavBytes = await _tts!.generateWavBytes(
120
+ phonemizedText: phonemized,
121
+ language: 'en',
122
+ voice: voice.displayName,
123
+ speed: speed,
124
+ );
125
+ } catch (e) {
126
+ // If session is not initialized, try one re-initialization
127
+ if (e.toString().contains('Session not initialized')) {
128
+ Log.warning('KittenTTS session not initialized, attempting recovery...');
129
+ _isInitialized = false;
130
+ await initialize();
131
+ wavBytes = await _tts!.generateWavBytes(
132
+ phonemizedText: phonemized,
133
+ language: 'en',
134
+ voice: voice.displayName,
135
+ speed: speed,
136
+ );
137
+ } else {
138
+ rethrow;
139
+ }
140
+ }
141
+
142
+ // Play the WAV bytes
143
+ await _audioPlayer!.stop();
144
+ await _audioPlayer!.play(BytesSource(wavBytes));
145
+ } catch (e, st) {
146
+ Log.error('KittenTTS speak error: $e\n$st');
147
+ _isSpeaking = false;
148
+ rethrow;
149
+ }
150
+ }
151
+
152
+ /// Stop any ongoing audio playback.
153
+ Future<void> stop() async {
154
+ await _audioPlayer?.stop();
155
+ _isSpeaking = false;
156
+ }
157
+
158
+ /// Pause audio playback.
159
+ Future<void> pause() async {
160
+ await _audioPlayer?.pause();
161
+ _isSpeaking = false;
162
+ }
163
+
164
+ /// Release the ONNX session and audio player.
165
+ Future<void> dispose() async {
166
+ try {
167
+ _tts?.release();
168
+ } catch (_) {}
169
+ try {
170
+ await _audioPlayer?.dispose();
171
+ } catch (_) {}
172
+ _tts = null;
173
+ _audioPlayer = null;
174
+ _isInitialized = false;
175
+ _isSpeaking = false;
176
+ }
177
+
178
+ /// Ensures an asset file exists on the filesystem.
179
+ /// If missing, copies it from app assets.
180
+ Future<String> _ensureAssetFile(
181
+ Directory targetDir,
182
+ String fileName,
183
+ String assetPath,
184
+ ) async {
185
+ final file = File('${targetDir.path}/$fileName');
186
+ if (await file.exists()) {
187
+ return file.path;
188
+ }
189
+
190
+ Log.info('Copying asset $assetPath to ${file.path}');
191
+ final byteData = await rootBundle.load(assetPath);
192
+ await file.writeAsBytes(byteData.buffer.asUint8List());
193
+ return file.path;
194
+ }
195
+}
lib/features/chat/data/phonemizer_service.dart
new
+218
@@ -0,0 +1,218 @@
1
+/// A basic English grapheme-to-phoneme (G2P) converter.
2
+///
3
+/// Converts English text into IPA-style phonemes suitable for KittenTTS.
4
+/// This is a rule-based approach and is not perfect — it handles common
5
+/// English pronunciation patterns. For production-grade accuracy, eSpeak
6
+/// or a neural G2P model should be used instead.
7
+class PhonemizerService {
8
+ static final Map<String, String> _consonants = {
9
+ 'b': 'b', 'c': 'k', 'd': 'd', 'f': 'f', 'g': 'g',
10
+ 'h': 'h', 'j': 'dʒ', 'k': 'k', 'l': 'l', 'm': 'm',
11
+ 'n': 'n', 'p': 'p', 'q': 'k', 'r': 'ɹ', 's': 's',
12
+ 't': 't', 'v': 'v', 'w': 'w', 'x': 'ks', 'y': 'j',
13
+ 'z': 'z',
14
+ // Common digraphs
15
+ 'ch': 'tʃ', 'sh': 'ʃ', 'th': 'θ', 'gh': '', 'ph': 'f',
16
+ 'wh': 'w', 'ng': 'ŋ', 'qu': 'kw', 'ck': 'k',
17
+ };
18
+
19
+ static final Map<String, String> _vowelRules = {
20
+ 'a': 'æ', // cat
21
+ 'e': 'ɛ', // bed
22
+ 'i': 'ɪ', // sit
23
+ 'o': 'ɒ', // hot (BrE)
24
+ 'u': 'ʌ', // cup
25
+ // Long vowels / diphthongs
26
+ 'ay': 'eɪ', 'ai': 'eɪ',
27
+ 'ee': 'iː', 'ea': 'iː',
28
+ 'ie': 'iː',
29
+ 'oa': 'oʊ', 'ow': 'oʊ',
30
+ 'oo': 'uː',
31
+ 'ue': 'uː',
32
+ 'ou': 'aʊ',
33
+ 'oi': 'ɔɪ', 'oy': 'ɔɪ',
34
+ 'au': 'ɔː',
35
+ 'aw': 'ɔː',
36
+ 'ar': 'ɑː',
37
+ 'er': 'ɜː',
38
+ 'ir': 'ɜː',
39
+ 'or': 'ɔː',
40
+ 'ur': 'ɜː',
41
+ 'igh': 'aɪ',
42
+ };
43
+
44
+ /// Convert English text to phonemized string for KittenTTS.
45
+ ///
46
+ /// Handles basic cleaning, punctuation removal, and rule-based phonemization.
47
+ /// Words are separated by spaces.
48
+ String phonemize(String text) {
49
+ final cleaned = text
50
+ .replaceAll(RegExp(r"[^\w\s']"), ' ')
51
+ .replaceAll(RegExp(r'\s+'), ' ')
52
+ .trim()
53
+ .toLowerCase();
54
+
55
+ final words = cleaned.split(' ');
56
+ final phonemizedWords = <String>[];
57
+
58
+ for (final word in words) {
59
+ if (word.isEmpty) continue;
60
+ final phonemes = _phonemizeWord(word);
61
+ phonemizedWords.add(phonemes);
62
+ }
63
+
64
+ return phonemizedWords.join(' ');
65
+ }
66
+
67
+ String _phonemizeWord(String word) {
68
+ // Handle very common words first
69
+ if (_commonWords.containsKey(word)) {
70
+ return _commonWords[word]!;
71
+ }
72
+
73
+ final buffer = StringBuffer();
74
+ int i = 0;
75
+
76
+ while (i < word.length) {
77
+ // Try digraphs first
78
+ if (i < word.length - 1) {
79
+ final two = word.substring(i, i + 2);
80
+ final three = i < word.length - 2 ? word.substring(i, i + 3) : null;
81
+
82
+ if (three != null && _vowelRules.containsKey(three)) {
83
+ buffer.write(_vowelRules[three]);
84
+ i += 3;
85
+ continue;
86
+ }
87
+
88
+ if (_vowelRules.containsKey(two)) {
89
+ buffer.write(_vowelRules[two]);
90
+ i += 2;
91
+ continue;
92
+ }
93
+
94
+ if (_consonants.containsKey(two)) {
95
+ buffer.write(_consonants[two]);
96
+ i += 2;
97
+ continue;
98
+ }
99
+ }
100
+
101
+ // Single letter
102
+ final char = word[i];
103
+ if (_vowelRules.containsKey(char)) {
104
+ buffer.write(_vowelRules[char]);
105
+ } else if (_consonants.containsKey(char)) {
106
+ buffer.write(_consonants[char]);
107
+ }
108
+ i++;
109
+ }
110
+
111
+ return buffer.toString();
112
+ }
113
+
114
+ static final Map<String, String> _commonWords = {
115
+ 'the': 'ðə',
116
+ 'a': 'ə',
117
+ 'an': 'æn',
118
+ 'and': 'ænd',
119
+ 'of': 'ɒv',
120
+ 'to': 'tuː',
121
+ 'in': 'ɪn',
122
+ 'is': 'ɪz',
123
+ 'you': 'juː',
124
+ 'that': 'ðæt',
125
+ 'it': 'ɪt',
126
+ 'he': 'hiː',
127
+ 'was': 'wɒz',
128
+ 'for': 'fɔː',
129
+ 'on': 'ɒn',
130
+ 'are': 'ɑː',
131
+ 'as': 'æz',
132
+ 'with': 'wɪð',
133
+ 'his': 'hɪz',
134
+ 'they': 'ðeɪ',
135
+ 'at': 'æt',
136
+ 'be': 'biː',
137
+ 'this': 'ðɪs',
138
+ 'have': 'hæv',
139
+ 'from': 'frɒm',
140
+ 'or': 'ɔː',
141
+ 'one': 'wʌn',
142
+ 'had': 'hæd',
143
+ 'by': 'baɪ',
144
+ 'word': 'wɜːd',
145
+ 'but': 'bʌt',
146
+ 'not': 'nɒt',
147
+ 'what': 'wɒt',
148
+ 'all': 'ɔːl',
149
+ 'were': 'wɜː',
150
+ 'we': 'wiː',
151
+ 'when': 'wen',
152
+ 'your': 'jɔː',
153
+ 'can': 'kæn',
154
+ 'said': 'sed',
155
+ 'there': 'ðeə',
156
+ 'use': 'juːz',
157
+ 'each': 'iːtʃ',
158
+ 'which': 'wɪtʃ',
159
+ 'she': 'ʃiː',
160
+ 'do': 'duː',
161
+ 'how': 'haʊ',
162
+ 'their': 'ðeə',
163
+ 'if': 'ɪf',
164
+ 'will': 'wɪl',
165
+ 'up': 'ʌp',
166
+ 'other': 'ʌðə',
167
+ 'about': 'əbaʊt',
168
+ 'out': 'aʊt',
169
+ 'many': 'meni',
170
+ 'then': 'ðen',
171
+ 'them': 'ðem',
172
+ 'these': 'ðiːz',
173
+ 'so': 'səʊ',
174
+ 'some': 'sʌm',
175
+ 'her': 'hɜː',
176
+ 'would': 'wʊd',
177
+ 'make': 'meɪk',
178
+ 'like': 'laɪk',
179
+ 'into': 'ɪntuː',
180
+ 'him': 'hɪm',
181
+ 'has': 'hæz',
182
+ 'two': 'tuː',
183
+ 'more': 'mɔː',
184
+ 'go': 'gəʊ',
185
+ 'way': 'weɪ',
186
+ 'could': 'kʊd',
187
+ 'my': 'maɪ',
188
+ 'than': 'ðæn',
189
+ 'first': 'fɜːst',
190
+ 'water': 'wɔːtə',
191
+ 'been': 'biːn',
192
+ 'call': 'kɔːl',
193
+ 'who': 'huː',
194
+ 'now': 'naʊ',
195
+ 'find': 'faɪnd',
196
+ 'long': 'lɒŋ',
197
+ 'down': 'daʊn',
198
+ 'day': 'deɪ',
199
+ 'did': 'dɪd',
200
+ 'get': 'get',
201
+ 'come': 'kʌm',
202
+ 'made': 'meɪd',
203
+ 'may': 'meɪ',
204
+ 'i': 'aɪ',
205
+ 'am': 'æm',
206
+ 'yes': 'jes',
207
+ 'no': 'nəʊ',
208
+ 'hello': 'hɛloʊ',
209
+ 'hi': 'haɪ',
210
+ 'goodbye': 'gʊdbaɪ',
211
+ 'thanks': 'θæŋks',
212
+ 'thank': 'θæŋk',
213
+ 'please': 'pliːz',
214
+ 'sorry': 'sɒri',
215
+ 'okay': 'əʊkeɪ',
216
+ 'ok': 'əʊkeɪ',
217
+ };
218
+}
lib/features/chat/providers/chat_providers.dart
+15
@@ -175,11 +175,26 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
175
final settings = ref.watch(settingsProvider);
176
final activeConv = ref.watch(conv.activeConversationProvider);
177
178
+ // Start with app-level defaults
179
double temperature = settings.temperature;
180
double topP = settings.topP;
181
int maxTokens = settings.maxTokens;
182
int contextLength = settings.contextLength;
183
184
+ // Apply per-conversation overrides if they exist
185
+ if (activeConv?.temperature != null) {
186
+ temperature = activeConv!.temperature!;
187
+ }
188
+ if (activeConv?.topP != null) {
189
+ topP = activeConv!.topP!;
190
+ }
191
+ if (activeConv?.maxTokens != null) {
192
+ maxTokens = activeConv!.maxTokens!;
193
+ }
194
+ if (activeConv?.contextLength != null) {
195
+ contextLength = activeConv!.contextLength!;
196
+ }
197
+
198
String? systemPrompt;
199
if (activeConv?.personaId != null) {
200
final personaId = activeConv!.personaId;
lib/features/chat/providers/tts_providers.dart
new
+155
@@ -0,0 +1,155 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+
3
+import '../../../core/models/enums.dart';
4
+import '../../../core/providers/app_providers.dart';
5
+import '../data/kitten_tts_service.dart';
6
+import '../data/tts_service.dart';
7
+
8
+/// Provider for the system TTS service (flutter_tts).
9
+final systemTtsProvider = Provider<TtsService>((ref) {
10
+ return TtsService();
11
+});
12
+
13
+/// Provider for the KittenTTS neural TTS service.
14
+///
15
+/// This is a lazy-initialized provider. The service copies model files
16
+/// from assets to the filesystem on first use.
17
+final kittenTtsServiceProvider = Provider<KittenTtsService>((ref) {
18
+ return KittenTtsService();
19
+});
20
+
21
+/// Notifier that manages the currently active TTS engine state.
22
+///
23
+/// Delegates to either [TtsService] (system) or [KittenTtsService]
24
+/// based on the user's settings preference.
25
+final unifiedTtsProvider =
26
+ NotifierProvider<UnifiedTtsNotifier, UnifiedTtsState>(() {
27
+ return UnifiedTtsNotifier();
28
+ });
29
+
30
+class UnifiedTtsState {
31
+ final bool isSpeaking;
32
+ final bool isInitializing;
33
+ final String? error;
34
+ final TtsEngine activeEngine;
35
+
36
+ const UnifiedTtsState({
37
+ this.isSpeaking = false,
38
+ this.isInitializing = false,
39
+ this.error,
40
+ this.activeEngine = TtsEngine.system,
41
+ });
42
+
43
+ UnifiedTtsState copyWith({
44
+ bool? isSpeaking,
45
+ bool? isInitializing,
46
+ String? error,
47
+ TtsEngine? activeEngine,
48
+ }) {
49
+ return UnifiedTtsState(
50
+ isSpeaking: isSpeaking ?? this.isSpeaking,
51
+ isInitializing: isInitializing ?? this.isInitializing,
52
+ error: error,
53
+ activeEngine: activeEngine ?? this.activeEngine,
54
+ );
55
+ }
56
+}
57
+
58
+class UnifiedTtsNotifier extends Notifier<UnifiedTtsState> {
59
+ TtsService? _systemTts;
60
+ KittenTtsService? _kittenTts;
61
+
62
+ @override
63
+ UnifiedTtsState build() {
64
+ final settings = ref.watch(settingsProvider);
65
+ return UnifiedTtsState(activeEngine: settings.ttsEngine);
66
+ }
67
+
68
+ TtsService get _systemTtsInstance {
69
+ _systemTts ??= ref.read(systemTtsProvider);
70
+ return _systemTts!;
71
+ }
72
+
73
+ KittenTtsService get _kittenTtsInstance {
74
+ _kittenTts ??= ref.read(kittenTtsServiceProvider);
75
+ return _kittenTts!;
76
+ }
77
+
78
+ /// Speak the given text using the currently selected TTS engine.
79
+ Future<void> speak(String text) async {
80
+ if (text.trim().isEmpty) return;
81
+
82
+ final settings = ref.read(settingsProvider);
83
+ final engine = settings.ttsEngine;
84
+
85
+ state = state.copyWith(
86
+ isSpeaking: true,
87
+ isInitializing: engine == TtsEngine.kitten,
88
+ error: null,
89
+ );
90
+
91
+ try {
92
+ if (engine == TtsEngine.kitten) {
93
+ await _kittenTtsInstance.speak(
94
+ text,
95
+ voice: settings.kittenTtsVoice,
96
+ speed: settings.kittenTtsSpeed,
97
+ );
98
+ } else {
99
+ await _systemTtsInstance.speak(text);
100
+ }
101
+ } catch (e) {
102
+ state = state.copyWith(
103
+ isSpeaking: false,
104
+ isInitializing: false,
105
+ error: 'TTS error: ${e.toString()}',
106
+ );
107
+ } finally {
108
+ state = state.copyWith(
109
+ isSpeaking: false,
110
+ isInitializing: false,
111
+ );
112
+ }
113
+ }
114
+
115
+ /// Stop any ongoing speech.
116
+ Future<void> stop() async {
117
+ final settings = ref.read(settingsProvider);
118
+ if (settings.ttsEngine == TtsEngine.kitten) {
119
+ await _kittenTtsInstance.stop();
120
+ } else {
121
+ await _systemTtsInstance.stop();
122
+ }
123
+ state = state.copyWith(isSpeaking: false);
124
+ }
125
+
126
+ /// Pause ongoing speech.
127
+ Future<void> pause() async {
128
+ final settings = ref.read(settingsProvider);
129
+ if (settings.ttsEngine == TtsEngine.kitten) {
130
+ await _kittenTtsInstance.pause();
131
+ } else {
132
+ await _systemTtsInstance.pause();
133
+ }
134
+ state = state.copyWith(isSpeaking: false);
135
+ }
136
+
137
+ /// Dispose all TTS resources.
138
+ Future<void> dispose() async {
139
+ try {
140
+ await _kittenTtsInstance.dispose();
141
+ } catch (_) {}
142
+ try {
143
+ await _systemTtsInstance.dispose();
144
+ } catch (_) {}
145
+ }
146
+
147
+ /// Check if any TTS engine is currently speaking.
148
+ bool get isSpeaking {
149
+ final settings = ref.read(settingsProvider);
150
+ if (settings.ttsEngine == TtsEngine.kitten) {
151
+ return _kittenTtsInstance.isSpeaking;
152
+ }
153
+ return _systemTtsInstance.isSpeaking;
154
+ }
155
+}
lib/features/chat/views/chat_screen.dart
+8
-3
@@ -14,6 +14,7 @@ import '../data/models/message.dart';
14
import '../providers/chat_providers.dart';
15
import 'components/chat_bubble.dart';
16
import 'components/chat_input_bar.dart';
17
+import 'components/chat_filter_sheet.dart';
18
import 'components/mcp_config_sheet.dart';
19
import 'components/notification_permission_banner.dart';
20
import '../../conversations/data/models/conversation.dart';
@@ -143,9 +144,13 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
144
},
145
),
146
IconButton(
146
- icon: const Icon(Icons.settings_outlined),
147
- onPressed: () => context.push(AppRoutes.settings),
148
- tooltip: 'Settings',
147
+ icon: HugeIcon(
148
+ icon: HugeIcons.strokeRoundedFilterHorizontal,
149
+ size: 24,
150
+ color: isDark ? Colors.white70 : Colors.black87,
151
+ ),
152
+ onPressed: () => showChatFilterSheet(context),
153
+ tooltip: 'Chat Parameters',
154
),
155
PopupMenuButton<String>(
156
icon: const Icon(Icons.more_vert),
lib/features/chat/views/components/chat_filter_sheet.dart
new
+385
@@ -0,0 +1,385 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:hugeicons/hugeicons.dart';
4
+import '../../../../core/providers/app_providers.dart';
5
+import '../../../conversations/providers/conversation_providers.dart' as conv;
6
+
7
+class ChatFilterSheet extends ConsumerWidget {
8
+ const ChatFilterSheet({super.key});
9
+
10
+ @override
11
+ Widget build(BuildContext context, WidgetRef ref) {
12
+ final settings = ref.watch(settingsProvider);
13
+ final activeConv = ref.watch(conv.activeConversationProvider);
14
+ final theme = Theme.of(context);
15
+ final isDark = theme.brightness == Brightness.dark;
16
+
17
+ // Use per-conversation values if set, otherwise fall back to app defaults
18
+ final temperature = activeConv?.temperature ?? settings.temperature;
19
+ final topP = activeConv?.topP ?? settings.topP;
20
+ final maxTokens = activeConv?.maxTokens ?? settings.maxTokens;
21
+ final contextLength = activeConv?.contextLength ?? settings.contextLength;
22
+
23
+ final hasOverrides = activeConv?.temperature != null ||
24
+ activeConv?.topP != null ||
25
+ activeConv?.maxTokens != null ||
26
+ activeConv?.contextLength != null;
27
+
28
+ return Container(
29
+ padding: const EdgeInsets.all(16),
30
+ decoration: BoxDecoration(
31
+ color: isDark ? const Color(0xFF1F1F1F) : Colors.white,
32
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
33
+ ),
34
+ child: SafeArea(
35
+ child: Column(
36
+ mainAxisSize: MainAxisSize.min,
37
+ crossAxisAlignment: CrossAxisAlignment.start,
38
+ children: [
39
+ Center(
40
+ child: Container(
41
+ width: 40,
42
+ height: 4,
43
+ decoration: BoxDecoration(
44
+ color: isDark
45
+ ? const Color(0xFF3A3A3A)
46
+ : const Color(0xFFE5E5E5),
47
+ borderRadius: BorderRadius.circular(2),
48
+ ),
49
+ ),
50
+ ),
51
+ const SizedBox(height: 16),
52
+ Row(
53
+ children: [
54
+ HugeIcon(
55
+ icon: HugeIcons.strokeRoundedFilterHorizontal,
56
+ size: 24,
57
+ color: theme.colorScheme.primary,
58
+ ),
59
+ const SizedBox(width: 8),
60
+ Text(
61
+ 'Chat Parameters',
62
+ style: TextStyle(
63
+ fontSize: 18,
64
+ fontWeight: FontWeight.bold,
65
+ color: isDark ? Colors.white : Colors.black,
66
+ ),
67
+ ),
68
+ const Spacer(),
69
+ if (hasOverrides)
70
+ TextButton.icon(
71
+ onPressed: () => _resetToDefaults(ref, activeConv?.id),
72
+ icon: const Icon(Icons.restore, size: 16),
73
+ label: const Text('Reset'),
74
+ ),
75
+ ],
76
+ ),
77
+ const SizedBox(height: 8),
78
+ Text(
79
+ 'These parameters control how the model responds. Tap Reset to use app defaults.',
80
+ style: TextStyle(
81
+ fontSize: 12,
82
+ color: isDark
83
+ ? const Color(0xFF666666)
84
+ : const Color(0xFF999999),
85
+ ),
86
+ ),
87
+ const SizedBox(height: 16),
88
+ Flexible(
89
+ child: SingleChildScrollView(
90
+ child: Column(
91
+ mainAxisSize: MainAxisSize.min,
92
+ children: [
93
+ _SliderParam(
94
+ label: 'Temperature',
95
+ value: temperature,
96
+ min: 0.0,
97
+ max: 2.0,
98
+ divisions: 20,
99
+ description:
100
+ 'Controls randomness. Lower = focused, Higher = creative.',
101
+ onChanged: (v) => _updateParam(
102
+ ref,
103
+ activeConv?.id,
104
+ temperature: v,
105
+ ),
106
+ isDark: isDark,
107
+ ),
108
+ _SliderParam(
109
+ label: 'Top P',
110
+ value: topP,
111
+ min: 0.0,
112
+ max: 1.0,
113
+ divisions: 10,
114
+ description: 'Nucleus sampling threshold. Lower = more focused.',
115
+ onChanged: (v) => _updateParam(
116
+ ref,
117
+ activeConv?.id,
118
+ topP: v,
119
+ ),
120
+ isDark: isDark,
121
+ ),
122
+ _IntParam(
123
+ label: 'Max Tokens',
124
+ value: maxTokens,
125
+ description: 'Maximum response length in tokens.',
126
+ onChanged: (v) => _updateParam(
127
+ ref,
128
+ activeConv?.id,
129
+ maxTokens: v,
130
+ ),
131
+ isDark: isDark,
132
+ ),
133
+ _IntParam(
134
+ label: 'Context Length',
135
+ value: contextLength,
136
+ description: 'Conversation history window in tokens.',
137
+ onChanged: (v) => _updateParam(
138
+ ref,
139
+ activeConv?.id,
140
+ contextLength: v,
141
+ ),
142
+ isDark: isDark,
143
+ ),
144
+ ],
145
+ ),
146
+ ),
147
+ ),
148
+ const SizedBox(height: 16),
149
+ ],
150
+ ),
151
+ ),
152
+ );
153
+ }
154
+
155
+ void _updateParam(
156
+ WidgetRef ref,
157
+ String? conversationId, {
158
+ double? temperature,
159
+ double? topP,
160
+ int? maxTokens,
161
+ int? contextLength,
162
+ }) {
163
+ if (conversationId == null) {
164
+ // No active conversation — update app-level defaults instead
165
+ final notifier = ref.read(settingsProvider.notifier);
166
+ if (temperature != null) notifier.setTemperature(temperature);
167
+ if (topP != null) notifier.setTopP(topP);
168
+ if (maxTokens != null) notifier.setMaxTokens(maxTokens);
169
+ if (contextLength != null) notifier.setContextLength(contextLength);
170
+ return;
171
+ }
172
+
173
+ ref.read(conv.conversationsProvider.notifier).updateChatParams(
174
+ conversationId,
175
+ temperature: temperature,
176
+ topP: topP,
177
+ maxTokens: maxTokens,
178
+ contextLength: contextLength,
179
+ );
180
+ }
181
+
182
+ void _resetToDefaults(WidgetRef ref, String? conversationId) {
183
+ if (conversationId == null) return;
184
+ ref.read(conv.conversationsProvider.notifier).updateChatParams(
185
+ conversationId,
186
+ clearTemperature: true,
187
+ clearTopP: true,
188
+ clearMaxTokens: true,
189
+ clearContextLength: true,
190
+ );
191
+ }
192
+}
193
+
194
+class _SliderParam extends StatelessWidget {
195
+ const _SliderParam({
196
+ required this.label,
197
+ required this.value,
198
+ required this.min,
199
+ required this.max,
200
+ required this.divisions,
201
+ required this.description,
202
+ required this.onChanged,
203
+ required this.isDark,
204
+ });
205
+
206
+ final String label;
207
+ final double value;
208
+ final double min;
209
+ final double max;
210
+ final int divisions;
211
+ final String description;
212
+ final ValueChanged<double> onChanged;
213
+ final bool isDark;
214
+
215
+ @override
216
+ Widget build(BuildContext context) {
217
+ return Padding(
218
+ padding: const EdgeInsets.symmetric(vertical: 8),
219
+ child: Column(
220
+ crossAxisAlignment: CrossAxisAlignment.start,
221
+ children: [
222
+ Row(
223
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
224
+ children: [
225
+ Text(
226
+ label,
227
+ style: TextStyle(
228
+ fontSize: 14,
229
+ fontWeight: FontWeight.w500,
230
+ color: isDark ? Colors.white : Colors.black,
231
+ ),
232
+ ),
233
+ Text(
234
+ value.toStringAsFixed(2),
235
+ style: TextStyle(
236
+ fontSize: 14,
237
+ fontWeight: FontWeight.w600,
238
+ color: isDark
239
+ ? const Color(0xFF3B82F6)
240
+ : const Color(0xFF2563EB),
241
+ ),
242
+ ),
243
+ ],
244
+ ),
245
+ SliderTheme(
246
+ data: SliderThemeData(
247
+ trackHeight: 4,
248
+ thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
249
+ overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
250
+ activeTrackColor: isDark
251
+ ? const Color(0xFF3B82F6)
252
+ : const Color(0xFF2563EB),
253
+ thumbColor: isDark
254
+ ? const Color(0xFF3B82F6)
255
+ : const Color(0xFF2563EB),
256
+ inactiveTrackColor: isDark
257
+ ? const Color(0xFF3A3A3A)
258
+ : const Color(0xFFE5E5E5),
259
+ ),
260
+ child: Slider(
261
+ value: value.clamp(min, max),
262
+ min: min,
263
+ max: max,
264
+ divisions: divisions,
265
+ onChanged: onChanged,
266
+ ),
267
+ ),
268
+ Text(
269
+ description,
270
+ style: TextStyle(
271
+ fontSize: 12,
272
+ color: isDark
273
+ ? const Color(0xFF666666)
274
+ : const Color(0xFF999999),
275
+ ),
276
+ ),
277
+ ],
278
+ ),
279
+ );
280
+ }
281
+}
282
+
283
+class _IntParam extends StatefulWidget {
284
+ const _IntParam({
285
+ required this.label,
286
+ required this.value,
287
+ required this.description,
288
+ required this.onChanged,
289
+ required this.isDark,
290
+ });
291
+
292
+ final String label;
293
+ final int value;
294
+ final String description;
295
+ final ValueChanged<int> onChanged;
296
+ final bool isDark;
297
+
298
+ @override
299
+ State<_IntParam> createState() => _IntParamState();
300
+}
301
+
302
+class _IntParamState extends State<_IntParam> {
303
+ late TextEditingController _controller;
304
+
305
+ @override
306
+ void initState() {
307
+ super.initState();
308
+ _controller = TextEditingController(text: widget.value.toString());
309
+ }
310
+
311
+ @override
312
+ void didUpdateWidget(_IntParam old) {
313
+ super.didUpdateWidget(old);
314
+ if (old.value != widget.value) {
315
+ _controller.text = widget.value.toString();
316
+ }
317
+ }
318
+
319
+ @override
320
+ void dispose() {
321
+ _controller.dispose();
322
+ super.dispose();
323
+ }
324
+
325
+ @override
326
+ Widget build(BuildContext context) {
327
+ return Padding(
328
+ padding: const EdgeInsets.symmetric(vertical: 8),
329
+ child: Column(
330
+ crossAxisAlignment: CrossAxisAlignment.start,
331
+ children: [
332
+ Text(
333
+ widget.label,
334
+ style: TextStyle(
335
+ fontSize: 14,
336
+ fontWeight: FontWeight.w500,
337
+ color: widget.isDark ? Colors.white : Colors.black,
338
+ ),
339
+ ),
340
+ const SizedBox(height: 8),
341
+ SizedBox(
342
+ width: 120,
343
+ child: TextField(
344
+ controller: _controller,
345
+ keyboardType: TextInputType.number,
346
+ decoration: InputDecoration(
347
+ isDense: true,
348
+ contentPadding: const EdgeInsets.symmetric(
349
+ horizontal: 12,
350
+ vertical: 10,
351
+ ),
352
+ border: OutlineInputBorder(
353
+ borderRadius: BorderRadius.circular(8),
354
+ ),
355
+ ),
356
+ onChanged: (text) {
357
+ final val = int.tryParse(text);
358
+ if (val != null && val > 0) widget.onChanged(val);
359
+ },
360
+ ),
361
+ ),
362
+ const SizedBox(height: 4),
363
+ Text(
364
+ widget.description,
365
+ style: TextStyle(
366
+ fontSize: 12,
367
+ color: widget.isDark
368
+ ? const Color(0xFF666666)
369
+ : const Color(0xFF999999),
370
+ ),
371
+ ),
372
+ ],
373
+ ),
374
+ );
375
+ }
376
+}
377
+
378
+void showChatFilterSheet(BuildContext context) {
379
+ showModalBottomSheet(
380
+ context: context,
381
+ isScrollControlled: true,
382
+ backgroundColor: Colors.transparent,
383
+ builder: (context) => const ChatFilterSheet(),
384
+ );
385
+}
lib/features/chat/views/components/message_action_bar.dart
+41
-22
@@ -1,9 +1,10 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter/services.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:shadcn_ui/shadcn_ui.dart';
4
-import '../../data/tts_service.dart';
5
+import '../../providers/tts_providers.dart';
6
6
-class MessageActionBar extends StatefulWidget {
7
+class MessageActionBar extends ConsumerStatefulWidget {
8
const MessageActionBar({
9
super.key,
10
required this.content,
@@ -22,32 +23,43 @@ class MessageActionBar extends StatefulWidget {
23
final VoidCallback? onShare;
24
25
@override
25
- State<MessageActionBar> createState() => _MessageActionBarState();
26
+ ConsumerState<MessageActionBar> createState() => _MessageActionBarState();
27
}
28
28
-class _MessageActionBarState extends State<MessageActionBar> {
29
- final _tts = TtsService();
29
+class _MessageActionBarState extends ConsumerState<MessageActionBar> {
30
bool _isSpeaking = false;
31
-
32
- @override
33
- void dispose() {
34
- _tts.dispose();
35
- super.dispose();
36
- }
31
+ bool _isInitializing = false;
32
33
void _toggleTts() async {
39
- if (_isSpeaking) {
40
- await _tts.stop();
41
- setState(() => _isSpeaking = false);
34
+ final ttsNotifier = ref.read(unifiedTtsProvider.notifier);
35
+ final ttsState = ref.read(unifiedTtsProvider);
36
+
37
+ if (_isSpeaking || ttsState.isSpeaking) {
38
+ await ttsNotifier.stop();
39
+ if (mounted) setState(() => _isSpeaking = false);
40
} else {
43
- setState(() => _isSpeaking = true);
44
- await _tts.speak(widget.content);
45
- setState(() => _isSpeaking = false);
41
+ setState(() {
42
+ _isInitializing = true;
43
+ _isSpeaking = true;
44
+ });
45
+ try {
46
+ await ttsNotifier.speak(widget.content);
47
+ } finally {
48
+ if (mounted) {
49
+ setState(() {
50
+ _isSpeaking = false;
51
+ _isInitializing = false;
52
+ });
53
+ }
54
+ }
55
}
56
}
57
58
@override
59
Widget build(BuildContext context) {
60
+ final ttsState = ref.watch(unifiedTtsProvider);
61
+ final isTtsActive = _isSpeaking || ttsState.isSpeaking;
62
+
63
return Row(
64
mainAxisSize: MainAxisSize.min,
65
children: [
@@ -98,9 +110,13 @@ class _MessageActionBarState extends State<MessageActionBar> {
110
],
111
const SizedBox(width: 4),
112
_ActionButton(
101
- icon: _isSpeaking ? Icons.stop_circle : Icons.volume_up,
102
- label: _isSpeaking ? 'Stop' : 'Read Aloud',
103
- onTap: _toggleTts,
113
+ icon: isTtsActive
114
+ ? Icons.stop_circle
115
+ : (_isInitializing ? Icons.hourglass_top : Icons.volume_up),
116
+ label: isTtsActive
117
+ ? 'Stop'
118
+ : (_isInitializing ? 'Loading TTS...' : 'Read Aloud'),
119
+ onTap: _isInitializing ? null : _toggleTts,
120
),
121
const SizedBox(width: 4),
122
_ActionButton(
@@ -136,6 +152,9 @@ class _MessageActionBarState extends State<MessageActionBar> {
152
}
153
154
void _showMoreOptions(BuildContext context) {
155
+ final ttsState = ref.read(unifiedTtsProvider);
156
+ final isTtsActive = _isSpeaking || ttsState.isSpeaking;
157
+
158
showShadSheet(
159
context: context,
160
builder: (context) => ShadSheet(
@@ -155,8 +174,8 @@ class _MessageActionBarState extends State<MessageActionBar> {
174
},
175
),
176
ListTile(
158
- leading: Icon(_isSpeaking ? Icons.stop : Icons.volume_up),
159
- title: Text(_isSpeaking ? 'Stop Reading' : 'Read Aloud'),
177
+ leading: Icon(isTtsActive ? Icons.stop : Icons.volume_up),
178
+ title: Text(isTtsActive ? 'Stop Reading' : 'Read Aloud'),
179
onTap: () {
180
Navigator.of(context).pop();
181
_toggleTts();
lib/features/conversations/data/models/conversation.dart
+20
@@ -10,6 +10,10 @@ class Conversation {
10
final int messageCount;
11
final String? lastMessagePreview;
12
final String? systemPrompt;
13
+ final double? temperature;
14
+ final double? topP;
15
+ final int? maxTokens;
16
+ final int? contextLength;
17
18
Conversation({
19
required this.id,
@@ -23,6 +27,10 @@ class Conversation {
27
this.messageCount = 0,
28
this.lastMessagePreview,
29
this.systemPrompt,
30
+ this.temperature,
31
+ this.topP,
32
+ this.maxTokens,
33
+ this.contextLength,
34
});
35
36
Conversation copyWith({
@@ -39,6 +47,14 @@ class Conversation {
47
String? lastMessagePreview,
48
String? systemPrompt,
49
bool clearSystemPrompt = false,
50
+ double? temperature,
51
+ bool clearTemperature = false,
52
+ double? topP,
53
+ bool clearTopP = false,
54
+ int? maxTokens,
55
+ bool clearMaxTokens = false,
56
+ int? contextLength,
57
+ bool clearContextLength = false,
58
}) {
59
return Conversation(
60
id: id ?? this.id,
@@ -52,6 +68,10 @@ class Conversation {
68
messageCount: messageCount ?? this.messageCount,
69
lastMessagePreview: lastMessagePreview ?? this.lastMessagePreview,
70
systemPrompt: clearSystemPrompt ? null : (systemPrompt ?? this.systemPrompt),
71
+ temperature: clearTemperature ? null : (temperature ?? this.temperature),
72
+ topP: clearTopP ? null : (topP ?? this.topP),
73
+ maxTokens: clearMaxTokens ? null : (maxTokens ?? this.maxTokens),
74
+ contextLength: clearContextLength ? null : (contextLength ?? this.contextLength),
75
);
76
}
77
}
lib/features/conversations/providers/conversation_providers.dart
+44
@@ -206,6 +206,50 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
206
}
207
}
208
209
+ Future<void> updateChatParams(
210
+ String id, {
211
+ double? temperature,
212
+ bool clearTemperature = false,
213
+ double? topP,
214
+ bool clearTopP = false,
215
+ int? maxTokens,
216
+ bool clearMaxTokens = false,
217
+ int? contextLength,
218
+ bool clearContextLength = false,
219
+ }) async {
220
+ final db = ref.read(databaseProvider);
221
+ final conversations = state.value ?? [];
222
+ final existingIndex = conversations.indexWhere((c) => c.id == id);
223
+ if (existingIndex != -1) {
224
+ final existing = conversations[existingIndex];
225
+ final updated = existing.copyWith(
226
+ temperature: temperature,
227
+ clearTemperature: clearTemperature,
228
+ topP: topP,
229
+ clearTopP: clearTopP,
230
+ maxTokens: maxTokens,
231
+ clearMaxTokens: clearMaxTokens,
232
+ contextLength: contextLength,
233
+ clearContextLength: clearContextLength,
234
+ updatedAt: DateTime.now(),
235
+ );
236
+
237
+ final query = db.conversationBox
238
+ .query(ConversationEntity_.id.equals(id))
239
+ .build();
240
+ final existingEntity = query.findFirst();
241
+ query.close();
242
+
243
+ final entity = ConversationEntity.fromDomain(updated);
244
+ if (existingEntity != null) {
245
+ entity.internalId = existingEntity.internalId;
246
+ }
247
+ db.conversationBox.put(entity);
248
+
249
+ state = AsyncData(await _loadAll());
250
+ }
251
+ }
252
+
253
Future<void> deleteAll() async {
254
final db = ref.read(databaseProvider);
255
db.messageBox.removeAll();
lib/features/settings/data/models/app_settings.dart
+18
@@ -30,6 +30,9 @@ class AppSettings {
30
final SyntaxThemeName codeThemeDark;
31
final SyntaxThemeName codeThemeLight;
32
final LiteLmBackendType preferredBackend;
33
+ final TtsEngine ttsEngine;
34
+ final KittenTtsVoice kittenTtsVoice;
35
+ final double kittenTtsSpeed;
36
37
AppSettings({
38
this.temperature = 0.7,
@@ -52,6 +55,9 @@ class AppSettings {
55
this.codeThemeDark = SyntaxThemeName.dark,
56
this.codeThemeLight = SyntaxThemeName.light,
57
this.preferredBackend = LiteLmBackendType.cpu,
58
+ this.ttsEngine = TtsEngine.system,
59
+ this.kittenTtsVoice = KittenTtsVoice.bella,
60
+ this.kittenTtsSpeed = 1.0,
61
});
62
63
AppSettings copyWith({
@@ -75,6 +81,9 @@ class AppSettings {
81
SyntaxThemeName? codeThemeDark,
82
SyntaxThemeName? codeThemeLight,
83
LiteLmBackendType? preferredBackend,
84
+ TtsEngine? ttsEngine,
85
+ KittenTtsVoice? kittenTtsVoice,
86
+ double? kittenTtsSpeed,
87
}) {
88
return AppSettings(
89
temperature: temperature ?? this.temperature,
@@ -100,6 +109,9 @@ class AppSettings {
109
codeThemeDark: codeThemeDark ?? this.codeThemeDark,
110
codeThemeLight: codeThemeLight ?? this.codeThemeLight,
111
preferredBackend: preferredBackend ?? this.preferredBackend,
112
+ ttsEngine: ttsEngine ?? this.ttsEngine,
113
+ kittenTtsVoice: kittenTtsVoice ?? this.kittenTtsVoice,
114
+ kittenTtsSpeed: kittenTtsSpeed ?? this.kittenTtsSpeed,
115
);
116
}
117
@@ -125,6 +137,9 @@ class AppSettings {
137
'codeThemeDark': codeThemeDark.index,
138
'codeThemeLight': codeThemeLight.index,
139
'preferredBackend': preferredBackend.index,
140
+ 'ttsEngine': ttsEngine.index,
141
+ 'kittenTtsVoice': kittenTtsVoice.index,
142
+ 'kittenTtsSpeed': kittenTtsSpeed,
143
};
144
}
145
@@ -150,6 +165,9 @@ class AppSettings {
165
codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
166
codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
167
preferredBackend: LiteLmBackendType.values[map['preferredBackend'] ?? 0],
168
+ ttsEngine: TtsEngine.values[map['ttsEngine'] ?? 0],
169
+ kittenTtsVoice: KittenTtsVoice.values[map['kittenTtsVoice'] ?? 0],
170
+ kittenTtsSpeed: map['kittenTtsSpeed']?.toDouble() ?? 1.0,
171
);
172
}
173
lib/features/settings/screens/settings_screen.dart
+246
-40
@@ -31,46 +31,6 @@ class SettingsScreen extends ConsumerWidget {
31
body: ListView(
32
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
33
children: [
34
- _SectionHeader(title: 'Chat Parameters'),
35
- _SliderSetting(
36
- label: 'Temperature',
37
- value: settings.temperature,
38
- min: 0.0,
39
- max: 2.0,
40
- divisions: 20,
41
- description:
42
- 'Controls randomness. Lower = focused, Higher = creative.',
43
- onChanged: (v) =>
44
- ref.read(settingsProvider.notifier).setTemperature(v),
45
- isDark: isDark,
46
- ),
47
- _SliderSetting(
48
- label: 'Top P',
49
- value: settings.topP,
50
- min: 0.0,
51
- max: 1.0,
52
- divisions: 10,
53
- description: 'Nucleus sampling threshold. Lower = more focused.',
54
- onChanged: (v) => ref.read(settingsProvider.notifier).setTopP(v),
55
- isDark: isDark,
56
- ),
57
- _IntInputSetting(
58
- label: 'Max Tokens',
59
- value: settings.maxTokens,
60
- description: 'Maximum response length in tokens.',
61
- onChanged: (v) =>
62
- ref.read(settingsProvider.notifier).setMaxTokens(v),
63
- isDark: isDark,
64
- ),
65
- _IntInputSetting(
66
- label: 'Context Length',
67
- value: settings.contextLength,
68
- description: 'Conversation history window in tokens.',
69
- onChanged: (v) =>
70
- ref.read(settingsProvider.notifier).setContextLength(v),
71
- isDark: isDark,
72
- ),
73
- const Divider(height: 32),
34
_SectionHeader(title: 'Appearance'),
35
_ThemeToggle(current: settings.themeMode, ref: ref),
36
_SliderSetting(
@@ -101,6 +61,30 @@ class SettingsScreen extends ConsumerWidget {
61
isDark: isDark,
62
),
63
const Divider(height: 32),
64
+ _SectionHeader(title: 'Text-to-Speech'),
65
+ _TtsEngineToggle(current: settings.ttsEngine, ref: ref, isDark: isDark),
66
+ if (settings.ttsEngine == TtsEngine.kitten) ...[
67
+ const SizedBox(height: 8),
68
+ _KittenVoiceDropdown(
69
+ current: settings.kittenTtsVoice,
70
+ onChanged: (v) =>
71
+ ref.read(settingsProvider.notifier).setKittenTtsVoice(v),
72
+ isDark: isDark,
73
+ ),
74
+ _SliderSetting(
75
+ label: 'Kitten TTS Speed',
76
+ value: settings.kittenTtsSpeed,
77
+ min: 0.5,
78
+ max: 2.0,
79
+ divisions: 15,
80
+ description: 'Adjust the playback rate of neural TTS.',
81
+ onChanged: (v) =>
82
+ ref.read(settingsProvider.notifier).setKittenTtsSpeed(v),
83
+ isDark: isDark,
84
+ valueFormat: (v) => '${v.toStringAsFixed(2)}x',
85
+ ),
86
+ ],
87
+ const Divider(height: 32),
88
_SectionHeader(title: 'Behavior'),
89
_ToggleSetting(
90
label: 'Streaming Responses',
@@ -892,6 +876,228 @@ class _DangerousAction extends StatelessWidget {
876
}
877
}
878
879
+class _TtsEngineToggle extends StatelessWidget {
880
+ const _TtsEngineToggle({
881
+ required this.current,
882
+ required this.ref,
883
+ required this.isDark,
884
+ });
885
+
886
+ final TtsEngine current;
887
+ final WidgetRef ref;
888
+ final bool isDark;
889
+
890
+ @override
891
+ Widget build(BuildContext context) {
892
+ final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
893
+
894
+ return Padding(
895
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
896
+ child: Column(
897
+ crossAxisAlignment: CrossAxisAlignment.start,
898
+ children: [
899
+ Text(
900
+ 'TTS Engine',
901
+ style: TextStyle(
902
+ fontSize: 14,
903
+ fontWeight: FontWeight.w500,
904
+ color: isDark ? Colors.white : Colors.black,
905
+ ),
906
+ ),
907
+ const SizedBox(height: 8),
908
+ Row(
909
+ children: [
910
+ Expanded(
911
+ child: GestureDetector(
912
+ onTap: () => ref
913
+ .read(settingsProvider.notifier)
914
+ .setTtsEngine(TtsEngine.system),
915
+ child: Container(
916
+ padding: const EdgeInsets.symmetric(vertical: 10),
917
+ decoration: BoxDecoration(
918
+ color: current == TtsEngine.system
919
+ ? accent.withValues(alpha: 0.15)
920
+ : (isDark
921
+ ? const Color(0xFF1F1F1F)
922
+ : const Color(0xFFF5F5F5)),
923
+ borderRadius: BorderRadius.circular(8),
924
+ border: Border.all(
925
+ color: current == TtsEngine.system
926
+ ? accent
927
+ : (isDark
928
+ ? const Color(0xFF3A3A3A)
929
+ : const Color(0xFFE5E5E5)),
930
+ ),
931
+ ),
932
+ child: Column(
933
+ children: [
934
+ Icon(
935
+ Icons.record_voice_over,
936
+ size: 20,
937
+ color: current == TtsEngine.system
938
+ ? accent
939
+ : (isDark
940
+ ? const Color(0xFF888888)
941
+ : const Color(0xFF999999)),
942
+ ),
943
+ const SizedBox(height: 4),
944
+ Text(
945
+ 'System TTS',
946
+ style: TextStyle(
947
+ fontSize: 11,
948
+ fontWeight: current == TtsEngine.system
949
+ ? FontWeight.w600
950
+ : FontWeight.normal,
951
+ color: current == TtsEngine.system
952
+ ? accent
953
+ : (isDark
954
+ ? const Color(0xFF888888)
955
+ : const Color(0xFF999999)),
956
+ ),
957
+ ),
958
+ ],
959
+ ),
960
+ ),
961
+ ),
962
+ ),
963
+ const SizedBox(width: 8),
964
+ Expanded(
965
+ child: GestureDetector(
966
+ onTap: () => ref
967
+ .read(settingsProvider.notifier)
968
+ .setTtsEngine(TtsEngine.kitten),
969
+ child: Container(
970
+ padding: const EdgeInsets.symmetric(vertical: 10),
971
+ decoration: BoxDecoration(
972
+ color: current == TtsEngine.kitten
973
+ ? accent.withValues(alpha: 0.15)
974
+ : (isDark
975
+ ? const Color(0xFF1F1F1F)
976
+ : const Color(0xFFF5F5F5)),
977
+ borderRadius: BorderRadius.circular(8),
978
+ border: Border.all(
979
+ color: current == TtsEngine.kitten
980
+ ? accent
981
+ : (isDark
982
+ ? const Color(0xFF3A3A3A)
983
+ : const Color(0xFFE5E5E5)),
984
+ ),
985
+ ),
986
+ child: Column(
987
+ children: [
988
+ Icon(
989
+ Icons.auto_awesome,
990
+ size: 20,
991
+ color: current == TtsEngine.kitten
992
+ ? accent
993
+ : (isDark
994
+ ? const Color(0xFF888888)
995
+ : const Color(0xFF999999)),
996
+ ),
997
+ const SizedBox(height: 4),
998
+ Text(
999
+ 'Kitten TTS',
1000
+ style: TextStyle(
1001
+ fontSize: 11,
1002
+ fontWeight: current == TtsEngine.kitten
1003
+ ? FontWeight.w600
1004
+ : FontWeight.normal,
1005
+ color: current == TtsEngine.kitten
1006
+ ? accent
1007
+ : (isDark
1008
+ ? const Color(0xFF888888)
1009
+ : const Color(0xFF999999)),
1010
+ ),
1011
+ ),
1012
+ ],
1013
+ ),
1014
+ ),
1015
+ ),
1016
+ ),
1017
+ ],
1018
+ ),
1019
+ const SizedBox(height: 4),
1020
+ Text(
1021
+ current == TtsEngine.system
1022
+ ? 'Uses your device\'s built-in text-to-speech engine.'
1023
+ : 'Uses the Kitten neural TTS model running locally on your device.',
1024
+ style: TextStyle(
1025
+ fontSize: 12,
1026
+ color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
1027
+ ),
1028
+ ),
1029
+ ],
1030
+ ),
1031
+ );
1032
+ }
1033
+}
1034
+
1035
+class _KittenVoiceDropdown extends StatelessWidget {
1036
+ const _KittenVoiceDropdown({
1037
+ required this.current,
1038
+ required this.onChanged,
1039
+ required this.isDark,
1040
+ });
1041
+
1042
+ final KittenTtsVoice current;
1043
+ final ValueChanged<KittenTtsVoice> onChanged;
1044
+ final bool isDark;
1045
+
1046
+ @override
1047
+ Widget build(BuildContext context) {
1048
+ return Padding(
1049
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1050
+ child: Column(
1051
+ crossAxisAlignment: CrossAxisAlignment.start,
1052
+ children: [
1053
+ Text(
1054
+ 'Neural Voice',
1055
+ style: TextStyle(
1056
+ fontSize: 14,
1057
+ fontWeight: FontWeight.w500,
1058
+ color: isDark ? Colors.white : Colors.black,
1059
+ ),
1060
+ ),
1061
+ const SizedBox(height: 8),
1062
+ Container(
1063
+ decoration: BoxDecoration(
1064
+ color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
1065
+ borderRadius: BorderRadius.circular(8),
1066
+ border: Border.all(
1067
+ color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
1068
+ ),
1069
+ ),
1070
+ child: DropdownButtonHideUnderline(
1071
+ child: DropdownButton<KittenTtsVoice>(
1072
+ value: current,
1073
+ isExpanded: true,
1074
+ padding: const EdgeInsets.symmetric(horizontal: 12),
1075
+ borderRadius: BorderRadius.circular(8),
1076
+ dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
1077
+ items: KittenTtsVoice.values.map((voice) {
1078
+ return DropdownMenuItem(
1079
+ value: voice,
1080
+ child: Text(
1081
+ voice.displayName,
1082
+ style: TextStyle(
1083
+ fontSize: 14,
1084
+ color: isDark ? Colors.white : Colors.black,
1085
+ ),
1086
+ ),
1087
+ );
1088
+ }).toList(),
1089
+ onChanged: (v) {
1090
+ if (v != null) onChanged(v);
1091
+ },
1092
+ ),
1093
+ ),
1094
+ ),
1095
+ ],
1096
+ ),
1097
+ );
1098
+ }
1099
+}
1100
+
1101
class _OnDeviceEngineStatus extends ConsumerWidget {
1102
const _OnDeviceEngineStatus();
1103
lib/features/sidebar/sidebar_widget.dart
+14
-1
@@ -22,10 +22,12 @@ class SidebarWidget extends ConsumerWidget {
22
final theme = Theme.of(context);
23
final location = GoRouterState.of(context).uri.toString();
24
25
+ final isHistory = location.startsWith(AppRoutes.chatHistory);
26
final isServers = location.startsWith(AppRoutes.servers);
27
final isPersonas = location.startsWith(AppRoutes.personas);
28
final themeMode = ref.watch(themeModeProvider);
29
final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
30
+ final isSettings = location.startsWith(AppRoutes.settings);
31
32
return Container(
33
width: 300,
@@ -68,7 +70,7 @@ class SidebarWidget extends ConsumerWidget {
70
DrawerNavItem(
71
iconData: HugeIcons.strokeRoundedChatting01,
72
label: 'History',
71
- isSelected: isServers,
73
+ isSelected: isHistory,
74
onTap: () {
75
if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
76
Navigator.pop(context);
@@ -112,6 +114,17 @@ class SidebarWidget extends ConsumerWidget {
114
const SizedBox(height: 8),
115
const Divider(height: 1, indent: 16, endIndent: 16),
116
const SizedBox(height: 8),
117
+ DrawerNavItem(
118
+ iconData: HugeIcons.strokeRoundedSettings01,
119
+ label: 'Settings',
120
+ isSelected: isSettings,
121
+ onTap: () {
122
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
123
+ Navigator.pop(context);
124
+ }
125
+ context.go(AppRoutes.settings);
126
+ },
127
+ ),
128
DrawerNavItem(
129
iconData: _getThemeIcon(themeMode),
130
label: 'Appearance: ${_getThemeLabel(themeMode)}',
lib/main.dart
+8
-1
@@ -6,6 +6,7 @@ import 'app.dart';
6
import 'core/providers/highlighter_provider.dart';
7
import 'core/providers/storage_providers.dart';
8
import 'core/storage/objectbox_store.dart';
9
+import 'features/chat/providers/tts_providers.dart';
10
import 'features/on_device/providers/on_device_providers.dart';
11
12
Future<void> main() async {
@@ -23,8 +24,14 @@ Future<void> main() async {
24
],
25
);
26
26
- // Initialize notification service
27
+ // Initialize services
28
await container.read(downloadNotificationServiceProvider).init();
29
+
30
+ // Pre-initialize KittenTTS (neural engine)
31
+ // Don't await it to avoid blocking app startup, but start the process
32
+ container.read(kittenTtsServiceProvider).initialize().catchError((e) {
33
+ print('Failed to pre-initialize KittenTTS: $e');
34
+ });
35
36
runApp(
37
UncontrolledProviderScope(
lib/objectbox-model.json
+21
-1
@@ -5,7 +5,7 @@
5
"entities": [
6
{
7
"id": "1:6060443518060866028",
8
- "lastPropertyId": "12:768672924450595967",
8
+ "lastPropertyId": "16:9154328066006661329",
9
"name": "ConversationEntity",
10
"properties": [
11
{
@@ -70,6 +70,26 @@
70
"id": "12:768672924450595967",
71
"name": "systemPrompt",
72
"type": 9
73
+ },
74
+ {
75
+ "id": "13:2024052842140577753",
76
+ "name": "temperature",
77
+ "type": 8
78
+ },
79
+ {
80
+ "id": "14:4666617501498182545",
81
+ "name": "topP",
82
+ "type": 8
83
+ },
84
+ {
85
+ "id": "15:6140239983637101227",
86
+ "name": "maxTokens",
87
+ "type": 6
88
+ },
89
+ {
90
+ "id": "16:9154328066006661329",
91
+ "name": "contextLength",
92
+ "type": 6
93
}
94
],
95
"relations": []
lib/objectbox.g.dart
+74
-2
@@ -22,7 +22,7 @@ final _entities = <obx_int.ModelEntity>[
22
obx_int.ModelEntity(
23
id: const obx_int.IdUid(1, 6060443518060866028),
24
name: 'ConversationEntity',
25
- lastPropertyId: const obx_int.IdUid(12, 768672924450595967),
25
+ lastPropertyId: const obx_int.IdUid(16, 9154328066006661329),
26
flags: 0,
27
properties: <obx_int.ModelProperty>[
28
obx_int.ModelProperty(
@@ -98,6 +98,30 @@ final _entities = <obx_int.ModelEntity>[
98
type: 9,
99
flags: 0,
100
),
101
+ obx_int.ModelProperty(
102
+ id: const obx_int.IdUid(13, 2024052842140577753),
103
+ name: 'temperature',
104
+ type: 8,
105
+ flags: 0,
106
+ ),
107
+ obx_int.ModelProperty(
108
+ id: const obx_int.IdUid(14, 4666617501498182545),
109
+ name: 'topP',
110
+ type: 8,
111
+ flags: 0,
112
+ ),
113
+ obx_int.ModelProperty(
114
+ id: const obx_int.IdUid(15, 6140239983637101227),
115
+ name: 'maxTokens',
116
+ type: 6,
117
+ flags: 0,
118
+ ),
119
+ obx_int.ModelProperty(
120
+ id: const obx_int.IdUid(16, 9154328066006661329),
121
+ name: 'contextLength',
122
+ type: 6,
123
+ flags: 0,
124
+ ),
125
],
126
relations: <obx_int.ModelRelation>[],
127
backlinks: <obx_int.ModelBacklink>[
@@ -476,7 +500,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
500
final systemPromptOffset = object.systemPrompt == null
501
? null
502
: fbb.writeString(object.systemPrompt!);
479
- fbb.startTable(13);
503
+ fbb.startTable(17);
504
fbb.addInt64(0, object.internalId);
505
fbb.addOffset(1, idOffset);
506
fbb.addOffset(2, titleOffset);
@@ -489,6 +513,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
513
fbb.addInt64(9, object.messageCount);
514
fbb.addOffset(10, lastMessagePreviewOffset);
515
fbb.addOffset(11, systemPromptOffset);
516
+ fbb.addFloat64(12, object.temperature);
517
+ fbb.addFloat64(13, object.topP);
518
+ fbb.addInt64(14, object.maxTokens);
519
+ fbb.addInt64(15, object.contextLength);
520
fbb.finish(fbb.endTable());
521
return object.internalId;
522
},
@@ -540,6 +568,26 @@ obx_int.ModelDefinition getObjectBoxModel() {
568
final systemPromptParam = const fb.StringReader(
569
asciiOptimization: true,
570
).vTableGetNullable(buffer, rootOffset, 26);
571
+ final temperatureParam = const fb.Float64Reader().vTableGetNullable(
572
+ buffer,
573
+ rootOffset,
574
+ 28,
575
+ );
576
+ final topPParam = const fb.Float64Reader().vTableGetNullable(
577
+ buffer,
578
+ rootOffset,
579
+ 30,
580
+ );
581
+ final maxTokensParam = const fb.Int64Reader().vTableGetNullable(
582
+ buffer,
583
+ rootOffset,
584
+ 32,
585
+ );
586
+ final contextLengthParam = const fb.Int64Reader().vTableGetNullable(
587
+ buffer,
588
+ rootOffset,
589
+ 34,
590
+ );
591
final object = ConversationEntity(
592
internalId: internalIdParam,
593
id: idParam,
@@ -553,6 +601,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
601
messageCount: messageCountParam,
602
lastMessagePreview: lastMessagePreviewParam,
603
systemPrompt: systemPromptParam,
604
+ temperature: temperatureParam,
605
+ topP: topPParam,
606
+ maxTokens: maxTokensParam,
607
+ contextLength: contextLengthParam,
608
);
609
obx_int.InternalToManyAccess.setRelInfo<ConversationEntity>(
610
object.messages,
@@ -981,6 +1033,26 @@ class ConversationEntity_ {
1033
_entities[0].properties[11],
1034
);
1035
1036
+ /// See [ConversationEntity.temperature].
1037
+ static final temperature = obx.QueryDoubleProperty<ConversationEntity>(
1038
+ _entities[0].properties[12],
1039
+ );
1040
+
1041
+ /// See [ConversationEntity.topP].
1042
+ static final topP = obx.QueryDoubleProperty<ConversationEntity>(
1043
+ _entities[0].properties[13],
1044
+ );
1045
+
1046
+ /// See [ConversationEntity.maxTokens].
1047
+ static final maxTokens = obx.QueryIntegerProperty<ConversationEntity>(
1048
+ _entities[0].properties[14],
1049
+ );
1050
+
1051
+ /// See [ConversationEntity.contextLength].
1052
+ static final contextLength = obx.QueryIntegerProperty<ConversationEntity>(
1053
+ _entities[0].properties[15],
1054
+ );
1055
+
1056
/// see [ConversationEntity.messages]
1057
static final messages =
1058
obx.QueryBacklinkToMany<MessageEntity, ConversationEntity>(
pubspec.lock
+104
@@ -25,6 +25,14 @@ packages:
25
url: "https://pub.dev"
26
source: hosted
27
version: "2.1.2"
28
+ archive:
29
+ dependency: transitive
30
+ description:
31
+ name: archive
32
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
33
+ url: "https://pub.dev"
34
+ source: hosted
35
+ version: "4.0.9"
36
args:
37
dependency: transitive
38
description:
@@ -41,6 +49,62 @@ packages:
49
url: "https://pub.dev"
50
source: hosted
51
version: "2.13.1"
52
+ audioplayers:
53
+ dependency: "direct main"
54
+ description:
55
+ name: audioplayers
56
+ sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
57
+ url: "https://pub.dev"
58
+ source: hosted
59
+ version: "6.6.0"
60
+ audioplayers_android:
61
+ dependency: transitive
62
+ description:
63
+ name: audioplayers_android
64
+ sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
65
+ url: "https://pub.dev"
66
+ source: hosted
67
+ version: "5.2.1"
68
+ audioplayers_darwin:
69
+ dependency: transitive
70
+ description:
71
+ name: audioplayers_darwin
72
+ sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
73
+ url: "https://pub.dev"
74
+ source: hosted
75
+ version: "6.4.0"
76
+ audioplayers_linux:
77
+ dependency: transitive
78
+ description:
79
+ name: audioplayers_linux
80
+ sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
81
+ url: "https://pub.dev"
82
+ source: hosted
83
+ version: "4.2.1"
84
+ audioplayers_platform_interface:
85
+ dependency: transitive
86
+ description:
87
+ name: audioplayers_platform_interface
88
+ sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
89
+ url: "https://pub.dev"
90
+ source: hosted
91
+ version: "7.1.1"
92
+ audioplayers_web:
93
+ dependency: transitive
94
+ description:
95
+ name: audioplayers_web
96
+ sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
97
+ url: "https://pub.dev"
98
+ source: hosted
99
+ version: "5.2.0"
100
+ audioplayers_windows:
101
+ dependency: transitive
102
+ description:
103
+ name: audioplayers_windows
104
+ sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
105
+ url: "https://pub.dev"
106
+ source: hosted
107
+ version: "4.3.0"
108
boolean_selector:
109
dependency: transitive
110
description:
@@ -379,6 +443,14 @@ packages:
443
url: "https://pub.dev"
444
source: hosted
445
version: "1.0.7"
446
+ flutter_onnxruntime:
447
+ dependency: transitive
448
+ description:
449
+ name: flutter_onnxruntime
450
+ sha256: "72fad60739e01f2278c266f4ec386b392c5ecdfc33d44b4a155c609366f6ece5"
451
+ url: "https://pub.dev"
452
+ source: hosted
453
+ version: "1.7.0"
454
flutter_plugin_android_lifecycle:
455
dependency: transitive
456
description:
@@ -605,6 +677,14 @@ packages:
677
url: "https://pub.dev"
678
source: hosted
679
version: "4.11.0"
680
+ kitten_tts_flutter:
681
+ dependency: "direct main"
682
+ description:
683
+ name: kitten_tts_flutter
684
+ sha256: "54896fa5d96c20edaff63973e80add6f4f23fd2abb3c631aa2a976f477df6a8b"
685
+ url: "https://pub.dev"
686
+ source: hosted
687
+ version: "0.0.1"
688
leak_tracker:
689
dependency: transitive
690
description:
@@ -877,6 +957,14 @@ packages:
957
url: "https://pub.dev"
958
source: hosted
959
version: "1.5.2"
960
+ posix:
961
+ dependency: transitive
962
+ description:
963
+ name: posix
964
+ sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
965
+ url: "https://pub.dev"
966
+ source: hosted
967
+ version: "6.5.0"
968
pub_semver:
969
dependency: transitive
970
description:
@@ -1098,6 +1186,14 @@ packages:
1186
url: "https://pub.dev"
1187
source: hosted
1188
version: "1.4.1"
1189
+ synchronized:
1190
+ dependency: transitive
1191
+ description:
1192
+ name: synchronized
1193
+ sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
1194
+ url: "https://pub.dev"
1195
+ source: hosted
1196
+ version: "3.4.0+1"
1197
syntax_highlight:
1198
dependency: "direct main"
1199
description:
@@ -1243,6 +1339,14 @@ packages:
1339
url: "https://pub.dev"
1340
source: hosted
1341
version: "3.1.5"
1342
+ uuid:
1343
+ dependency: transitive
1344
+ description:
1345
+ name: uuid
1346
+ sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
1347
+ url: "https://pub.dev"
1348
+ source: hosted
1349
+ version: "4.5.3"
1350
vector_graphics:
1351
dependency: transitive
1352
description:
pubspec.yaml
+5
@@ -59,6 +59,8 @@ dependencies:
59
flutter_litert_lm: ^0.3.0
60
wakelock_plus: ^1.2.8
61
flutter_local_notifications: ^18.0.1
62
+ kitten_tts_flutter: ^0.0.1
63
+ audioplayers: ^6.4.0
64
65
dev_dependencies:
66
flutter_test:
@@ -88,6 +90,9 @@ flutter:
90
assets:
91
- assets/google_fonts/
92
- assets/images/
93
+ - assets/tts/config.json
94
+ - assets/tts/kitten_tts_nano_v0_8.onnx
95
+ - assets/tts/voices.npz
96
97
# An image asset can refer to one or more resolution-specific "variants", see
98
# https://flutter.dev/to/resolution-aware-images