@tduncan / Cortana / commits / dc723d2

feat: add neural KittenTTS engine with voice support and per-conversation inference parameter settings

abdulmominsakib committed Apr 27, 2026 at 19:52 UTC dc723d28727377deaf7373137d81644506ec4d64
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