18 files changed
+1363
-1082
README.md
+1
-1
@@ -62,7 +62,7 @@ LocalMind is rapidly evolving. Here is what's coming next:
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
- [x] **Multimodal Support**: Attach images and documents for vision-capable models.
65
-- [ ] **Tablet/Desktop Optimization**: A split-view layout for large-screen productivity.
65
+- [x] **Tablet/Desktop Optimization**: A split-view layout for large-screen productivity.
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
assets/espeak-data.zip
Binary files /dev/null and b/assets/espeak-data.zip differ
assets/espeak-ng-data.zip
Binary files a/assets/espeak-ng-data.zip and /dev/null differ
lib/core/models/enums.dart
+110
-56
@@ -1,4 +1,113 @@
1
-import 'package:neural_tts/neural_tts.dart';
1
+enum EngineId { system, kitten, kokoro, piper }
2
+
3
+class EngineMeta {
4
+ final String name;
5
+ final String tagline;
6
+ final int sizeMb;
7
+ final int ramMb;
8
+ final int voiceCount;
9
+ final int accentColor;
10
+
11
+ const EngineMeta({
12
+ required this.name,
13
+ required this.tagline,
14
+ required this.sizeMb,
15
+ required this.ramMb,
16
+ required this.voiceCount,
17
+ required this.accentColor,
18
+ });
19
+
20
+ static const system = EngineMeta(
21
+ name: 'System TTS',
22
+ tagline: 'Built-in device engine',
23
+ sizeMb: 0,
24
+ ramMb: 0,
25
+ voiceCount: 0,
26
+ accentColor: 0xFF9E9E9E,
27
+ );
28
+
29
+ static const kitten = EngineMeta(
30
+ name: 'Kitten TTS',
31
+ tagline: 'High-speed neural TTS',
32
+ sizeMb: 57,
33
+ ramMb: 120,
34
+ voiceCount: 8,
35
+ accentColor: 0xFFFF9800,
36
+ );
37
+
38
+ static const kokoro = EngineMeta(
39
+ name: 'Kokoro TTS',
40
+ tagline: 'Premium neural voices',
41
+ sizeMb: 170,
42
+ ramMb: 250,
43
+ voiceCount: 22,
44
+ accentColor: 0xFFE91E63,
45
+ );
46
+
47
+ static EngineMeta forEngine(EngineId engine) {
48
+ switch (engine) {
49
+ case EngineId.kitten:
50
+ return kitten;
51
+ case EngineId.kokoro:
52
+ return kokoro;
53
+ default:
54
+ return system;
55
+ }
56
+ }
57
+}
58
+
59
+class Voice {
60
+ final String id;
61
+ final String name;
62
+ final EngineId engine;
63
+ final String? language;
64
+ final String gender;
65
+
66
+ const Voice({
67
+ required this.id,
68
+ required this.name,
69
+ required this.engine,
70
+ this.language,
71
+ required this.gender,
72
+ });
73
+}
74
+
75
+List<Voice> voicesForEngine(EngineId engine) {
76
+ switch (engine) {
77
+ case EngineId.kitten:
78
+ return kittenVoices;
79
+ case EngineId.kokoro:
80
+ return kokoroVoices;
81
+ default:
82
+ return [];
83
+ }
84
+}
85
+
86
+const List<Voice> kittenVoices = [
87
+ Voice(id: 'Bella', name: 'Bella', engine: EngineId.kitten, language: 'en', gender: 'f'),
88
+ Voice(id: 'Jasper', name: 'Jasper', engine: EngineId.kitten, language: 'en', gender: 'm'),
89
+ Voice(id: 'Luna', name: 'Luna', engine: EngineId.kitten, language: 'en', gender: 'f'),
90
+ Voice(id: 'Bruno', name: 'Bruno', engine: EngineId.kitten, language: 'en', gender: 'm'),
91
+ Voice(id: 'Rosie', name: 'Rosie', engine: EngineId.kitten, language: 'en', gender: 'f'),
92
+ Voice(id: 'Hugo', name: 'Hugo', engine: EngineId.kitten, language: 'en', gender: 'm'),
93
+ Voice(id: 'Kiki', name: 'Kiki', engine: EngineId.kitten, language: 'en', gender: 'f'),
94
+ Voice(id: 'Leo', name: 'Leo', engine: EngineId.kitten, language: 'en', gender: 'm'),
95
+];
96
+
97
+const List<Voice> kokoroVoices = [
98
+ Voice(id: 'af_heart', name: 'Heart', engine: EngineId.kokoro, language: 'en', gender: 'f'),
99
+ Voice(id: 'af_bella', name: 'Bella', engine: EngineId.kokoro, language: 'en', gender: 'f'),
100
+ Voice(id: 'af_nicole', name: 'Nicole', engine: EngineId.kokoro, language: 'en', gender: 'f'),
101
+ Voice(id: 'af_aoihana', name: 'Aoihana', engine: EngineId.kokoro, language: 'en', gender: 'f'),
102
+ Voice(id: 'af_sarah', name: 'Sarah', engine: EngineId.kokoro, language: 'en', gender: 'f'),
103
+ Voice(id: 'af_sky', name: 'Sky', engine: EngineId.kokoro, language: 'en', gender: 'f'),
104
+ Voice(id: 'am_adam', name: 'Adam', engine: EngineId.kokoro, language: 'en', gender: 'm'),
105
+ Voice(id: 'am_michael', name: 'Michael', engine: EngineId.kokoro, language: 'en', gender: 'm'),
106
+ Voice(id: 'bf_isabelle', name: 'Isabelle', engine: EngineId.kokoro, language: 'en', gender: 'f'),
107
+ Voice(id: 'bf_alice', name: 'Alice', engine: EngineId.kokoro, language: 'en', gender: 'f'),
108
+ Voice(id: 'bm_george', name: 'George', engine: EngineId.kokoro, language: 'en', gender: 'm'),
109
+ Voice(id: 'bm_lewis', name: 'Lewis', engine: EngineId.kokoro, language: 'en', gender: 'm'),
110
+];
111
112
enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
113
@@ -14,67 +123,12 @@ enum OnDeviceEngineStatus { notLoaded, loading, loaded, error }
123
124
enum LiteLmBackendType { cpu, gpu, npu }
125
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
- String get id {
49
- switch (this) {
50
- case KittenTtsVoice.bella:
51
- return 'expr-voice-2-f';
52
- case KittenTtsVoice.jasper:
53
- return 'expr-voice-2-m';
54
- case KittenTtsVoice.luna:
55
- return 'expr-voice-3-f';
56
- case KittenTtsVoice.bruno:
57
- return 'expr-voice-3-m';
58
- case KittenTtsVoice.rosie:
59
- return 'expr-voice-4-f';
60
- case KittenTtsVoice.hugo:
61
- return 'expr-voice-4-m';
62
- case KittenTtsVoice.kiki:
63
- return 'expr-voice-5-f';
64
- case KittenTtsVoice.leo:
65
- return 'expr-voice-5-m';
66
- }
67
- }
68
-}
69
-
126
EngineId engineIdFromString(String value) {
127
switch (value) {
128
case 'kitten':
129
return EngineId.kitten;
130
case 'kokoro':
131
return EngineId.kokoro;
76
- case 'supertonic':
77
- return EngineId.supertonic;
132
default:
133
return EngineId.system;
134
}
lib/core/providers/app_providers.dart
+3
-6
@@ -1,9 +1,9 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
-import 'package:neural_tts/neural_tts.dart';
2
3
import '../../features/settings/data/models/app_settings.dart';
4
import '../models/enums.dart';
5
import '../../features/tts/data/kitten_tts_model.dart';
6
+import '../../features/tts/data/kokoro_tts_model.dart';
7
import 'storage_providers.dart';
8
import '../theme/app_theme.dart';
9
@@ -95,13 +95,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
95
void setTtsSpeed(double value) => _update(state.copyWith(ttsSpeed: value));
96
void setKittenTtsModelVariant(KittenTtsModelVariant value) =>
97
_update(state.copyWith(kittenTtsModelVariant: value));
98
- void setSupertonicSteps(int value) =>
99
- _update(state.copyWith(supertonicSteps: value));
98
+ void setKokoroTtsModelVariant(KokoroTtsModelVariant value) =>
99
+ _update(state.copyWith(kokoroTtsModelVariant: value));
100
void setAutoSpeakEnabled(bool value) =>
101
_update(state.copyWith(autoSpeakEnabled: value));
102
- void setUsePhonemizer(bool value) =>
103
- _update(state.copyWith(usePhonemizer: value));
104
- void setUseEspeak(bool value) => _update(state.copyWith(useEspeak: value));
102
103
Future<void> _update(AppSettings updated) async {
104
state = updated;
lib/features/chat/views/components/message_action_bar.dart
+16
-25
@@ -27,38 +27,29 @@ class MessageActionBar extends ConsumerStatefulWidget {
27
}
28
29
class _MessageActionBarState extends ConsumerState<MessageActionBar> {
30
- bool _isSpeaking = false;
31
- bool _isInitializing = false;
30
31
void _toggleTts() async {
32
final ttsNotifier = ref.read(tts.ttsProvider.notifier);
33
final ttsState = ref.read(tts.ttsProvider);
34
+ final isThisPlaying = ttsState.playingContent == widget.content && ttsState.isSpeaking;
35
37
- if (_isSpeaking || ttsState.isSpeaking) {
36
+ if (isThisPlaying) {
37
await ttsNotifier.stop();
39
- if (mounted) setState(() => _isSpeaking = false);
38
} else {
41
- setState(() {
42
- _isInitializing = true;
43
- _isSpeaking = true;
44
- });
39
+ if (ttsState.isSpeaking) {
40
+ await ttsNotifier.stop();
41
+ }
42
try {
43
await ttsNotifier.speak(widget.content);
47
- } finally {
48
- if (mounted) {
49
- setState(() {
50
- _isSpeaking = false;
51
- _isInitializing = false;
52
- });
53
- }
54
- }
44
+ } catch (_) {}
45
}
46
}
47
48
@override
49
Widget build(BuildContext context) {
50
final ttsState = ref.watch(tts.ttsProvider);
61
- final isTtsActive = _isSpeaking || ttsState.isSpeaking;
51
+ final isThisPlaying = ttsState.playingContent == widget.content && ttsState.isSpeaking;
52
+ final isThisInitializing = ttsState.playingContent == widget.content && ttsState.isInitializing;
53
54
return Row(
55
mainAxisSize: MainAxisSize.min,
@@ -110,13 +101,13 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
101
],
102
const SizedBox(width: 4),
103
_ActionButton(
113
- icon: isTtsActive
104
+ icon: isThisPlaying
105
? Icons.stop_circle
115
- : (_isInitializing ? Icons.hourglass_top : Icons.volume_up),
116
- label: isTtsActive
106
+ : (isThisInitializing ? Icons.hourglass_top : Icons.volume_up),
107
+ label: isThisPlaying
108
? 'Stop'
118
- : (_isInitializing ? 'Loading TTS...' : 'Read Aloud'),
119
- onTap: _isInitializing ? null : _toggleTts,
109
+ : (isThisInitializing ? 'Loading TTS...' : 'Read Aloud'),
110
+ onTap: isThisInitializing ? null : _toggleTts,
111
),
112
const SizedBox(width: 4),
113
_ActionButton(
@@ -153,7 +144,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
144
145
void _showMoreOptions(BuildContext context) {
146
final ttsState = ref.read(tts.ttsProvider);
156
- final isTtsActive = _isSpeaking || ttsState.isSpeaking;
147
+ final isThisPlaying = ttsState.playingContent == widget.content && ttsState.isSpeaking;
148
149
showShadSheet(
150
context: context,
@@ -174,8 +165,8 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
165
},
166
),
167
ListTile(
177
- leading: Icon(isTtsActive ? Icons.stop : Icons.volume_up),
178
- title: Text(isTtsActive ? 'Stop Reading' : 'Read Aloud'),
168
+ leading: Icon(isThisPlaying ? Icons.stop : Icons.volume_up),
169
+ title: Text(isThisPlaying ? 'Stop Reading' : 'Read Aloud'),
170
onTap: () {
171
Navigator.of(context).pop();
172
_toggleTts();
lib/features/settings/data/models/app_settings.dart
+19
-21
@@ -1,10 +1,10 @@
1
import 'dart:convert';
2
3
import 'package:flutter/material.dart';
4
-import 'package:neural_tts/neural_tts.dart';
4
5
import '../../../../core/models/enums.dart';
6
import '../../../../features/tts/data/kitten_tts_model.dart';
7
+import '../../../../features/tts/data/kokoro_tts_model.dart';
8
9
enum SyntaxThemeName { light, dark }
10
@@ -34,10 +34,8 @@ class AppSettings {
34
final String? ttsVoiceId;
35
final double ttsSpeed;
36
final KittenTtsModelVariant kittenTtsModelVariant;
37
- final int supertonicSteps;
37
+ final KokoroTtsModelVariant kokoroTtsModelVariant;
38
final bool autoSpeakEnabled;
39
- final bool usePhonemizer;
40
- final bool useEspeak;
39
40
AppSettings({
41
this.temperature = 0.7,
@@ -65,10 +63,8 @@ class AppSettings {
63
this.ttsVoiceId,
64
this.ttsSpeed = 1.0,
65
this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
68
- this.supertonicSteps = 5,
66
+ this.kokoroTtsModelVariant = KokoroTtsModelVariant.int8,
67
this.autoSpeakEnabled = false,
70
- this.usePhonemizer = true,
71
- this.useEspeak = true,
68
});
69
70
AppSettings copyWith({
@@ -97,10 +93,8 @@ class AppSettings {
93
String? ttsVoiceId,
94
double? ttsSpeed,
95
KittenTtsModelVariant? kittenTtsModelVariant,
100
- int? supertonicSteps,
96
+ KokoroTtsModelVariant? kokoroTtsModelVariant,
97
bool? autoSpeakEnabled,
102
- bool? usePhonemizer,
103
- bool? useEspeak,
98
}) {
99
return AppSettings(
100
temperature: temperature ?? this.temperature,
@@ -132,10 +126,9 @@ class AppSettings {
126
ttsSpeed: ttsSpeed ?? this.ttsSpeed,
127
kittenTtsModelVariant:
128
kittenTtsModelVariant ?? this.kittenTtsModelVariant,
135
- supertonicSteps: supertonicSteps ?? this.supertonicSteps,
129
+ kokoroTtsModelVariant:
130
+ kokoroTtsModelVariant ?? this.kokoroTtsModelVariant,
131
autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
137
- usePhonemizer: usePhonemizer ?? this.usePhonemizer,
138
- useEspeak: useEspeak ?? this.useEspeak,
132
);
133
}
134
@@ -166,10 +159,8 @@ class AppSettings {
159
'ttsVoiceId': ttsVoiceId,
160
'ttsSpeed': ttsSpeed,
161
'kittenTtsModelVariant': kittenTtsModelVariant.name,
169
- 'supertonicSteps': supertonicSteps,
162
+ 'kokoroTtsModelVariant': kokoroTtsModelVariant.name,
163
'autoSpeakEnabled': autoSpeakEnabled,
171
- 'usePhonemizer': usePhonemizer,
172
- 'useEspeak': useEspeak,
164
};
165
}
166
@@ -199,11 +190,9 @@ class AppSettings {
190
ttsEngine: _parseEngine(map['ttsEngine']),
191
ttsVoiceId: map['ttsVoiceId'],
192
ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
202
- kittenTtsModelVariant: _parseVariant(map['kittenTtsModelVariant']),
203
- supertonicSteps: map['supertonicSteps'] ?? 5,
193
+ kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
194
+ kokoroTtsModelVariant: _parseKokoroVariant(map['kokoroTtsModelVariant']),
195
autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
205
- usePhonemizer: map['usePhonemizer'] ?? true,
206
- useEspeak: map['useEspeak'] ?? true,
196
);
197
}
198
@@ -217,7 +206,7 @@ class AppSettings {
206
return EngineId.system;
207
}
208
220
- static KittenTtsModelVariant _parseVariant(dynamic value) {
209
+ static KittenTtsModelVariant _parseKittenVariant(dynamic value) {
210
if (value is String) {
211
try {
212
return KittenTtsModelVariant.values.byName(value);
@@ -226,6 +215,15 @@ class AppSettings {
215
return KittenTtsModelVariant.nanoInt8;
216
}
217
218
+ static KokoroTtsModelVariant _parseKokoroVariant(dynamic value) {
219
+ if (value is String) {
220
+ try {
221
+ return KokoroTtsModelVariant.values.byName(value);
222
+ } catch (_) {}
223
+ }
224
+ return KokoroTtsModelVariant.int8;
225
+ }
226
+
227
String toJson() => json.encode(toMap());
228
229
factory AppSettings.fromJson(String source) =>
lib/features/settings/views/settings_screen.dart
-21
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:go_router/go_router.dart';
4
import 'package:localmind/features/sidebar/sidebar_widget.dart';
5
-import 'package:neural_tts/neural_tts.dart';
5
import '../../../core/providers/app_providers.dart';
6
import '../../../core/models/enums.dart';
7
import '../../../core/routes/app_routes.dart';
@@ -100,21 +99,6 @@ class SettingsViews extends ConsumerWidget {
99
isDark: isDark,
100
valueFormat: (v) => '${v.toStringAsFixed(2)}x',
101
),
103
- if (settings.ttsEngine == EngineId.supertonic)
104
- _SliderSetting(
105
- label: 'Quality Steps',
106
- value: settings.supertonicSteps.toDouble(),
107
- min: 1,
108
- max: 20,
109
- divisions: 5,
110
- description:
111
- 'Diffusion steps. Higher = better quality, slower.',
112
- onChanged: (v) => ref
113
- .read(settingsProvider.notifier)
114
- .setSupertonicSteps(v.round()),
115
- isDark: isDark,
116
- valueFormat: (v) => v.toStringAsFixed(0),
117
- ),
102
],
103
const Divider(height: 32),
104
_SectionHeader(title: 'Behavior'),
@@ -976,11 +960,6 @@ class _EngineDropdown extends StatelessWidget {
960
'Kokoro TTS',
961
Icons.auto_awesome,
962
),
979
- _engineItem(
980
- EngineId.supertonic,
981
- 'Supertonic TTS',
982
- Icons.auto_awesome,
983
- ),
963
],
964
onChanged: (v) {
965
if (v != null) onChanged(v);
lib/features/sidebar/sidebar_widget.dart
+171
-2
@@ -8,6 +8,7 @@ import '../../core/routes/app_routes.dart';
8
import '../../core/providers/app_providers.dart';
9
import '../../core/theme/app_theme.dart';
10
import '../chat/providers/chat_providers.dart';
11
+import '../tts/providers/tts_providers.dart' as tts;
12
import 'components/active_server_indicator.dart';
13
import 'components/conversation_drawer_header.dart';
14
import 'components/drawer_nav_item.dart';
@@ -155,6 +156,10 @@ class SidebarWidget extends ConsumerWidget {
156
),
157
),
158
159
+ // TTS Mini Player (only visible when TTS is active)
160
+ const _TtsPlayerBar(),
161
+ const SizedBox(height: 4),
162
+
163
// Bottom Section
164
const GitHubRepoCard(),
165
const Divider(height: 1),
@@ -193,6 +198,170 @@ class SidebarWidget extends ConsumerWidget {
198
}
199
}
200
201
+/// Mini TTS player bar shown in the sidebar when speech is playing or
202
+/// initializing. Displays a waveform icon, a content preview, and a stop button.
203
+class _TtsPlayerBar extends ConsumerWidget {
204
+ const _TtsPlayerBar();
205
+
206
+ @override
207
+ Widget build(BuildContext context, WidgetRef ref) {
208
+ final ttsState = ref.watch(tts.ttsProvider);
209
+ ref.listen(tts.ttsProvider, (prev, next) {
210
+ if (next.error != null && prev?.error != next.error) {
211
+ ScaffoldMessenger.of(context).showSnackBar(
212
+ SnackBar(
213
+ content: Text(next.error!),
214
+ backgroundColor: Colors.red,
215
+ duration: const Duration(seconds: 3),
216
+ ),
217
+ );
218
+ }
219
+ });
220
+ final theme = Theme.of(context);
221
+ final isDark = theme.brightness == Brightness.dark;
222
+
223
+ if (!ttsState.isSpeaking && !ttsState.isInitializing) {
224
+ return const SizedBox.shrink();
225
+ }
226
+
227
+ final preview = _truncateContent(ttsState.playingContent);
228
+
229
+ return AnimatedSize(
230
+ duration: const Duration(milliseconds: 200),
231
+ curve: Curves.easeInOut,
232
+ child: Container(
233
+ margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
234
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
235
+ decoration: BoxDecoration(
236
+ color: isDark ? const Color(0xFF1E293B) : const Color(0xFFEFF6FF),
237
+ borderRadius: BorderRadius.circular(10),
238
+ border: Border.all(
239
+ color: isDark ? const Color(0xFF334155) : const Color(0xFFBFDBFE),
240
+ ),
241
+ ),
242
+ child: Row(
243
+ children: [
244
+ _AnimatedWaveIndicator(isActive: ttsState.isSpeaking),
245
+ const SizedBox(width: 10),
246
+ Expanded(
247
+ child: Column(
248
+ crossAxisAlignment: CrossAxisAlignment.start,
249
+ mainAxisSize: MainAxisSize.min,
250
+ children: [
251
+ Text(
252
+ preview ??
253
+ (ttsState.isInitializing ? 'Loading...' : 'Playing'),
254
+ style: TextStyle(
255
+ fontSize: 12,
256
+ fontWeight: FontWeight.w500,
257
+ color: isDark ? Colors.white70 : Colors.black87,
258
+ ),
259
+ maxLines: 1,
260
+ overflow: TextOverflow.ellipsis,
261
+ ),
262
+ if (ttsState.isInitializing)
263
+ Text(
264
+ 'Initializing TTS...',
265
+ style: TextStyle(
266
+ fontSize: 10,
267
+ color: isDark ? Colors.white38 : Colors.black45,
268
+ ),
269
+ ),
270
+ ],
271
+ ),
272
+ ),
273
+ const SizedBox(width: 8),
274
+ GestureDetector(
275
+ onTap: () => ref.read(tts.ttsProvider.notifier).stop(),
276
+ child: Tooltip(
277
+ message: 'Stop',
278
+ child: Container(
279
+ padding: const EdgeInsets.all(4),
280
+ decoration: BoxDecoration(
281
+ color: Colors.red.withValues(alpha: 0.1),
282
+ borderRadius: BorderRadius.circular(6),
283
+ ),
284
+ child: Icon(
285
+ Icons.stop_circle,
286
+ size: 20,
287
+ color: isDark ? Colors.red[300] : Colors.red[600],
288
+ ),
289
+ ),
290
+ ),
291
+ ),
292
+ ],
293
+ ),
294
+ ),
295
+ );
296
+ }
297
+
298
+ String? _truncateContent(String? content) {
299
+ if (content == null || content.isEmpty) return null;
300
+ if (content.length <= 50) return content;
301
+ return '${content.substring(0, 47)}...';
302
+ }
303
+}
304
+
305
+/// An animated audio wave indicator with a subtle bounce effect.
306
+class _AnimatedWaveIndicator extends StatefulWidget {
307
+ final bool isActive;
308
+ const _AnimatedWaveIndicator({required this.isActive});
309
+
310
+ @override
311
+ State<_AnimatedWaveIndicator> createState() => _AnimatedWaveIndicatorState();
312
+}
313
+
314
+class _AnimatedWaveIndicatorState extends State<_AnimatedWaveIndicator>
315
+ with SingleTickerProviderStateMixin {
316
+ late AnimationController _controller;
317
+
318
+ @override
319
+ void initState() {
320
+ super.initState();
321
+ _controller = AnimationController(
322
+ duration: const Duration(milliseconds: 800),
323
+ vsync: this,
324
+ );
325
+ if (widget.isActive) {
326
+ _controller.repeat(reverse: true);
327
+ }
328
+ }
329
+
330
+ @override
331
+ void didUpdateWidget(_AnimatedWaveIndicator oldWidget) {
332
+ super.didUpdateWidget(oldWidget);
333
+ if (widget.isActive && !_controller.isAnimating) {
334
+ _controller.repeat(reverse: true);
335
+ } else if (!widget.isActive && _controller.isAnimating) {
336
+ _controller.stop();
337
+ }
338
+ }
339
+
340
+ @override
341
+ void dispose() {
342
+ _controller.dispose();
343
+ super.dispose();
344
+ }
345
+
346
+ @override
347
+ Widget build(BuildContext context) {
348
+ return AnimatedBuilder(
349
+ animation: _controller,
350
+ builder: (context, child) {
351
+ final scale = widget.isActive ? 1.0 + _controller.value * 0.15 : 1.0;
352
+ return Transform.scale(
353
+ scale: scale,
354
+ child: Icon(
355
+ Icons.graphic_eq,
356
+ size: 18,
357
+ color: widget.isActive ? const Color(0xFF3B82F6) : Colors.grey,
358
+ ),
359
+ );
360
+ },
361
+ );
362
+ }
363
+}
364
+
365
/// A sidebar navigation item that accepts any Widget as an icon.
366
/// Used when HugeIcons doesn't have a suitable icon.
367
class _SidebarNavItem extends StatelessWidget {
@@ -223,8 +392,8 @@ class _SidebarNavItem extends StatelessWidget {
392
decoration: BoxDecoration(
393
color: isSelected
394
? (isDark
226
- ? theme.colorScheme.primary.withAlpha(30)
227
- : theme.colorScheme.primary.withAlpha(20))
395
+ ? theme.colorScheme.primary.withAlpha(30)
396
+ : theme.colorScheme.primary.withAlpha(20))
397
: Colors.transparent,
398
borderRadius: BorderRadius.circular(12),
399
),
lib/features/tts/data/kokoro_tts_downloader.dart
new
+278
@@ -0,0 +1,278 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:dio/dio.dart';
5
+import 'package:path_provider/path_provider.dart';
6
+import 'package:wakelock_plus/wakelock_plus.dart';
7
+
8
+import '../../../core/logger/app_logger.dart';
9
+import 'kokoro_tts_model.dart';
10
+
11
+/// Downloads Kokoro TTS model files (kokoro.onnx + voice .bin files)
12
+/// from HuggingFace with resume support and per-file progress reporting.
13
+class KokoroTtsDownloader {
14
+ final Dio _dio = Dio();
15
+ final Map<String, CancelToken> _activeDownloads = {};
16
+
17
+ Future<Directory> _getVariantDir(KokoroTtsModelVariant variant) async {
18
+ final supportDir = await getApplicationSupportDirectory();
19
+ final dir = Directory('${supportDir.path}/kokoro_tts/${variant.dirName}');
20
+ if (!await dir.exists()) {
21
+ await dir.create(recursive: true);
22
+ }
23
+ return dir;
24
+ }
25
+
26
+ /// Check whether all files for a variant have been downloaded.
27
+ Future<bool> isVariantDownloaded(KokoroTtsModelVariant variant) async {
28
+ final dir = await _getVariantDir(variant);
29
+ final model = KokoroTtsModel.forVariant(variant);
30
+
31
+ final modelPath = '${dir.path}/${model.modelFile.fileName}';
32
+ if (!await File(modelPath).exists()) return false;
33
+
34
+ for (final voice in model.voiceFiles) {
35
+ final voicePath = '${dir.path}/${voice.fileName}';
36
+ if (!await File(voicePath).exists()) return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ /// Get total downloaded bytes for a variant.
42
+ Future<int> getDownloadedBytes(KokoroTtsModelVariant variant) async {
43
+ final dir = await _getVariantDir(variant);
44
+ int total = 0;
45
+ final entities = await dir.list().toList();
46
+ for (final e in entities) {
47
+ if (e is File) {
48
+ total += await e.length();
49
+ }
50
+ }
51
+ return total;
52
+ }
53
+
54
+ /// Download all files for [variant], emitting per-file progress.
55
+ Stream<KokoroTtsFileProgress> downloadModel(KokoroTtsModelVariant variant) async* {
56
+ final model = KokoroTtsModel.forVariant(variant);
57
+ final dir = await _getVariantDir(variant);
58
+ final variantKey = variant.name;
59
+
60
+ await WakelockPlus.enable();
61
+
62
+ try {
63
+ final allFiles = <_DownloadableFile>[
64
+ _DownloadableFile(
65
+ fileName: model.modelFile.fileName,
66
+ downloadUrl: model.modelFile.downloadUrl,
67
+ sizeBytes: model.modelFile.sizeBytes,
68
+ ),
69
+ for (final voice in model.voiceFiles)
70
+ _DownloadableFile(
71
+ fileName: voice.fileName,
72
+ downloadUrl: voice.downloadUrl,
73
+ sizeBytes: voice.sizeBytes,
74
+ ),
75
+ ];
76
+
77
+ for (final file in allFiles) {
78
+ final cancelToken = CancelToken();
79
+ _activeDownloads[variantKey] = cancelToken;
80
+
81
+ final filePath = '${dir.path}/${file.fileName}';
82
+ final partialPath = '$filePath.part';
83
+ final partialFile = File(partialPath);
84
+
85
+ int receivedBytes = 0;
86
+
87
+ if (await partialFile.exists()) {
88
+ receivedBytes = await partialFile.length();
89
+ Log.info(
90
+ 'Resuming Kokoro ${variant.displayName} ${file.fileName} from $receivedBytes bytes',
91
+ );
92
+ }
93
+
94
+ DateTime lastProgressUpdate = DateTime.now();
95
+
96
+ try {
97
+ final response = await _resolveWithRedirects(
98
+ url: file.downloadUrl,
99
+ startByte: receivedBytes,
100
+ cancelToken: cancelToken,
101
+ );
102
+
103
+ final contentLenHeader =
104
+ response.headers.value(Headers.contentLengthHeader);
105
+ final totalBytes = contentLenHeader != null
106
+ ? int.parse(contentLenHeader) + receivedBytes
107
+ : file.sizeBytes;
108
+
109
+ final IOSink sink = partialFile.openWrite(mode: FileMode.append);
110
+ final stream = response.data?.stream;
111
+
112
+ if (stream == null) {
113
+ await sink.close();
114
+ throw DioException(
115
+ requestOptions: response.requestOptions,
116
+ error: 'Response data stream is null',
117
+ );
118
+ }
119
+
120
+ await for (final List<int> chunk in stream) {
121
+ if (cancelToken.isCancelled) break;
122
+ sink.add(chunk);
123
+ receivedBytes += chunk.length;
124
+
125
+ final now = DateTime.now();
126
+ if (now.difference(lastProgressUpdate).inMilliseconds >= 500) {
127
+ yield KokoroTtsFileProgress(
128
+ fileName: file.fileName,
129
+ variant: variant,
130
+ receivedBytes: receivedBytes,
131
+ totalBytes: totalBytes,
132
+ );
133
+ lastProgressUpdate = now;
134
+ }
135
+ }
136
+
137
+ await sink.flush();
138
+ await sink.close();
139
+
140
+ await partialFile.rename(filePath);
141
+ Log.info(
142
+ 'Kokoro ${variant.displayName} ${file.fileName} downloaded',
143
+ );
144
+
145
+ yield KokoroTtsFileProgress(
146
+ fileName: file.fileName,
147
+ variant: variant,
148
+ receivedBytes: receivedBytes,
149
+ totalBytes: receivedBytes,
150
+ isComplete: true,
151
+ );
152
+ } catch (e) {
153
+ if (e is DioException && CancelToken.isCancel(e)) {
154
+ Log.info('Kokoro download cancelled for $variantKey');
155
+ return;
156
+ }
157
+ rethrow;
158
+ }
159
+ }
160
+ } finally {
161
+ _activeDownloads.remove(variantKey);
162
+ if (_activeDownloads.isEmpty) {
163
+ await WakelockPlus.disable();
164
+ }
165
+ }
166
+ }
167
+
168
+ /// Cancel an in-flight download.
169
+ void cancelDownload(KokoroTtsModelVariant variant) {
170
+ _activeDownloads[variant.name]?.cancel();
171
+ }
172
+
173
+ /// Delete all files for a variant from disk.
174
+ Future<void> deleteVariant(KokoroTtsModelVariant variant) async {
175
+ final dir = await _getVariantDir(variant);
176
+ if (await dir.exists()) {
177
+ await dir.delete(recursive: true);
178
+ }
179
+ Log.info('Kokoro variant ${variant.displayName} deleted');
180
+ }
181
+
182
+ /// Get list of downloaded variants.
183
+ Future<Set<KokoroTtsModelVariant>> getDownloadedVariants() async {
184
+ final result = <KokoroTtsModelVariant>{};
185
+ for (final variant in KokoroTtsModelVariant.values) {
186
+ if (await isVariantDownloaded(variant)) {
187
+ result.add(variant);
188
+ }
189
+ }
190
+ return result;
191
+ }
192
+
193
+ /// Get the absolute file path for a variant's model file.
194
+ Future<String?> getModelPath(KokoroTtsModelVariant variant) async {
195
+ final dir = await _getVariantDir(variant);
196
+ final model = KokoroTtsModel.forVariant(variant);
197
+ final file = File('${dir.path}/${model.modelFile.fileName}');
198
+ if (await file.exists()) return file.path;
199
+ return null;
200
+ }
201
+
202
+ /// Get the directory containing voice .bin files.
203
+ Future<Directory> getVoicesDir(KokoroTtsModelVariant variant) async {
204
+ return _getVariantDir(variant);
205
+ }
206
+
207
+ /// Manually follows redirects to ensure Authorization header safety.
208
+ Future<Response<ResponseBody>> _resolveWithRedirects({
209
+ required String url,
210
+ required int startByte,
211
+ required CancelToken cancelToken,
212
+ }) async {
213
+ String currentUrl = url;
214
+ final Uri originalUri = Uri.parse(url);
215
+
216
+ for (int hop = 0; hop < 5; hop++) {
217
+ final options = Options(
218
+ responseType: ResponseType.stream,
219
+ followRedirects: false,
220
+ validateStatus: (status) => status != null && status < 500,
221
+ headers: {
222
+ if (startByte > 0) 'Range': 'bytes=$startByte-',
223
+ },
224
+ );
225
+
226
+ final currentUri = Uri.parse(currentUrl);
227
+ if (currentUri.host != originalUri.host) {
228
+ options.headers?.remove('Authorization');
229
+ }
230
+
231
+ final response = await _dio.get<ResponseBody>(
232
+ currentUrl,
233
+ options: options,
234
+ cancelToken: cancelToken,
235
+ );
236
+
237
+ if (response.statusCode == 200 || response.statusCode == 206) {
238
+ return response;
239
+ }
240
+
241
+ if (response.statusCode! >= 300 && response.statusCode! < 400) {
242
+ final location = response.headers.value('location');
243
+ if (location == null) {
244
+ throw DioException(
245
+ requestOptions: response.requestOptions,
246
+ error: 'Redirect without location header',
247
+ );
248
+ }
249
+ currentUrl = Uri.parse(currentUrl).resolve(location).toString();
250
+ continue;
251
+ }
252
+
253
+ throw DioException(
254
+ requestOptions: response.requestOptions,
255
+ response: response,
256
+ error: 'Server returned ${response.statusCode}',
257
+ );
258
+ }
259
+
260
+ throw DioException(
261
+ requestOptions: RequestOptions(path: url),
262
+ error: 'Too many redirects',
263
+ );
264
+ }
265
+}
266
+
267
+/// Internal helper for tracking files to download.
268
+class _DownloadableFile {
269
+ final String fileName;
270
+ final String downloadUrl;
271
+ final int sizeBytes;
272
+
273
+ const _DownloadableFile({
274
+ required this.fileName,
275
+ required this.downloadUrl,
276
+ required this.sizeBytes,
277
+ });
278
+}
lib/features/tts/data/kokoro_tts_model.dart
new
+181
@@ -0,0 +1,181 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// Available Kokoro TTS model variants downloadable from HuggingFace.
4
+enum KokoroTtsModelVariant {
5
+ /// FP16 precision, ~170 MB total. Best quality.
6
+ fp16,
7
+
8
+ /// INT8 quantized, ~82 MB total. Faster, smaller.
9
+ int8;
10
+
11
+ String get displayName {
12
+ switch (this) {
13
+ case KokoroTtsModelVariant.fp16:
14
+ return 'Kokoro TTS (FP16)';
15
+ case KokoroTtsModelVariant.int8:
16
+ return 'Kokoro TTS (Int8)';
17
+ }
18
+ }
19
+
20
+ String get parameterLabel => '82M';
21
+
22
+ /// Approximate total size in bytes.
23
+ int get totalSizeBytes {
24
+ switch (this) {
25
+ case KokoroTtsModelVariant.fp16:
26
+ return 170 * 1024 * 1024;
27
+ case KokoroTtsModelVariant.int8:
28
+ return 82 * 1024 * 1024;
29
+ }
30
+ }
31
+
32
+ String get description {
33
+ switch (this) {
34
+ case KokoroTtsModelVariant.fp16:
35
+ return 'Highest quality with 22 voices. Requires ~250 MB RAM.';
36
+ case KokoroTtsModelVariant.int8:
37
+ return 'Smaller, faster model with excellent quality. Recommended.';
38
+ }
39
+ }
40
+
41
+ bool get isRecommended => this == KokoroTtsModelVariant.int8;
42
+
43
+ String get huggingFaceRepoId {
44
+ switch (this) {
45
+ case KokoroTtsModelVariant.fp16:
46
+ return 'onnx-community/Kokoro-82M-ONNX';
47
+ case KokoroTtsModelVariant.int8:
48
+ return 'palshub/kokoro-82m-v1.0-int8';
49
+ }
50
+ }
51
+
52
+ /// Storage directory name for this variant.
53
+ String get dirName => 'kokoro_$name';
54
+}
55
+
56
+/// Voice metadata for a single Kokoro voice file.
57
+@immutable
58
+class KokoroVoiceFile {
59
+ final String voiceId;
60
+ final String fileName;
61
+ final String downloadUrl;
62
+ final int sizeBytes;
63
+
64
+ const KokoroVoiceFile({
65
+ required this.voiceId,
66
+ required this.fileName,
67
+ required this.downloadUrl,
68
+ required this.sizeBytes,
69
+ });
70
+}
71
+
72
+/// Metadata for the main Kokoro model file.
73
+@immutable
74
+class KokoroModelFile {
75
+ final String fileName;
76
+ final String downloadUrl;
77
+ final int sizeBytes;
78
+
79
+ const KokoroModelFile({
80
+ required this.fileName,
81
+ required this.downloadUrl,
82
+ required this.sizeBytes,
83
+ });
84
+}
85
+
86
+/// A fully-specified Kokoro TTS model with variant, model file, and voices.
87
+@immutable
88
+class KokoroTtsModel {
89
+ final KokoroTtsModelVariant variant;
90
+ final KokoroModelFile modelFile;
91
+ final List<KokoroVoiceFile> voiceFiles;
92
+ final int totalSizeBytes;
93
+
94
+ const KokoroTtsModel({
95
+ required this.variant,
96
+ required this.modelFile,
97
+ required this.voiceFiles,
98
+ required this.totalSizeBytes,
99
+ });
100
+
101
+ static const List<String> _voiceIds = [
102
+ 'af_heart',
103
+ 'af_bella',
104
+ 'af_nicole',
105
+ 'af_aoihana',
106
+ 'af_sarah',
107
+ 'af_sky',
108
+ 'am_adam',
109
+ 'am_michael',
110
+ 'bf_isabelle',
111
+ 'bf_alice',
112
+ 'bm_george',
113
+ 'bm_lewis',
114
+ ];
115
+
116
+ static KokoroTtsModel forVariant(KokoroTtsModelVariant variant) {
117
+ final repo = variant.huggingFaceRepoId;
118
+ final modelPath =
119
+ variant == KokoroTtsModelVariant.fp16 ? 'onnx/model.onnx' : 'kokoro.onnx';
120
+
121
+ final modelFile = KokoroModelFile(
122
+ fileName: 'kokoro.onnx',
123
+ downloadUrl:
124
+ 'https://huggingface.co/$repo/resolve/main/$modelPath',
125
+ sizeBytes: variant == KokoroTtsModelVariant.fp16
126
+ ? 166 * 1024 * 1024
127
+ : 78 * 1024 * 1024,
128
+ );
129
+
130
+ final voiceFiles = _voiceIds.map((id) {
131
+ final voicePath =
132
+ variant == KokoroTtsModelVariant.fp16 ? 'voices/$id.bin' : '$id.bin';
133
+ return KokoroVoiceFile(
134
+ voiceId: id,
135
+ fileName: '$id.bin',
136
+ downloadUrl:
137
+ 'https://huggingface.co/$repo/resolve/main/$voicePath',
138
+ sizeBytes: 175 * 1024,
139
+ );
140
+ }).toList();
141
+
142
+ return KokoroTtsModel(
143
+ variant: variant,
144
+ modelFile: modelFile,
145
+ voiceFiles: voiceFiles,
146
+ totalSizeBytes: variant.totalSizeBytes,
147
+ );
148
+ }
149
+
150
+ static final List<KokoroTtsModel> allModels =
151
+ KokoroTtsModelVariant.values.map(forVariant).toList();
152
+
153
+ String get displayName => variant.displayName;
154
+
155
+ String get parameterLabel => variant.parameterLabel;
156
+
157
+ String get description => variant.description;
158
+
159
+ bool get isRecommended => variant.isRecommended;
160
+}
161
+
162
+/// Progress for downloading a single Kokoro file (model or voice).
163
+@immutable
164
+class KokoroTtsFileProgress {
165
+ final String fileName;
166
+ final KokoroTtsModelVariant variant;
167
+ final int receivedBytes;
168
+ final int totalBytes;
169
+ final bool isComplete;
170
+
171
+ const KokoroTtsFileProgress({
172
+ required this.fileName,
173
+ required this.variant,
174
+ this.receivedBytes = 0,
175
+ this.totalBytes = 0,
176
+ this.isComplete = false,
177
+ });
178
+
179
+ double get fraction =>
180
+ totalBytes > 0 ? (receivedBytes / totalBytes).clamp(0.0, 1.0) : 0.0;
181
+}
lib/features/tts/data/model_downloader.dart
new
+24
@@ -0,0 +1,24 @@
1
+import 'dart:io';
2
+
3
+import 'package:path_provider/path_provider.dart';
4
+
5
+import '../../../core/models/enums.dart';
6
+
7
+class ModelDownloader {
8
+ Future<Directory> getTtsDir() async {
9
+ final supportDir = await getApplicationSupportDirectory();
10
+ final dir = Directory('${supportDir.path}/tts_models');
11
+ if (!await dir.exists()) {
12
+ await dir.create(recursive: true);
13
+ }
14
+ return dir;
15
+ }
16
+
17
+ Future<bool> isEngineDownloaded(EngineId engine) async {
18
+ final ttsDir = await getTtsDir();
19
+ final engineDir = Directory('${ttsDir.path}/${engine.name}');
20
+ if (!await engineDir.exists()) return false;
21
+ final entities = await engineDir.list().toList();
22
+ return entities.isNotEmpty;
23
+ }
24
+}
lib/features/tts/data/phonemizer_service.dart
deleted
-516
@@ -1,516 +0,0 @@
1
-/// A rule-based English G2P converter tuned for KittenTTS.
2
-///
3
-/// KittenTTS internally uses:
4
-/// phonemizer.backend.EspeakBackend(language="en-us", preserve_punctuation=True, with_stress=True)
5
-///
6
-/// This service approximates that pipeline on the Flutter/Dart side so that
7
-/// pre-phonemized IPA can be sent directly to a backend using generate_from_ipa().
8
-///
9
-/// ── TextCleaner vocabulary (from kittentts/onnx_model.py) ────────────────
10
-/// Only characters in this set will be accepted by the server-side tokenizer.
11
-/// Any character outside it is silently dropped (matching the KeyError handler
12
-/// in TextCleaner.__call__). Emitting unknown chars wastes tokens and breaks
13
-/// word shapes, so this service validates its own output via [sanitize].
14
-///
15
-/// Pad: $
16
-/// Punctuation: ; : , . ! ? ¡ ¿ — … " « » " " (space)
17
-/// Letters: A-Z a-z
18
-/// IPA: ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘ
19
-/// ɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞
20
-/// ↓↑→↗↘ ᵻ (and stress/diacritic combining marks)
21
-///
22
-/// ── AmE vs BrE ───────────────────────────────────────────────────────────
23
-/// The model is trained on en-us eSpeak output. Differences from BrE that
24
-/// matter most:
25
-/// • Short 'o' (hot, lot) → /ɑ/ not /ɒ/
26
-/// • Stressed er/ir/ur → /ɝ/ (r-coloured) not /ɜː/
27
-/// • Unstressed final er → /ɚ/ (r-coloured schwa) not /ə/
28
-/// • 'r' is always rhotic → include /ɹ/ after vowels
29
-///
30
-/// ── Limitations ──────────────────────────────────────────────────────────
31
-/// • No stress-mark prediction (ˈ ˌ). The model still works without them.
32
-/// • Numbers, currencies, and abbreviations are NOT expanded. Use the server's
33
-/// clean_text=True flag (TextPreprocessor) or expand them before calling.
34
-/// • This is a heuristic approximation — eSpeak or a neural G2P will always
35
-/// be more accurate for production use.
36
-class PhonemizerService {
37
- // ─────────────────────────── Vocabulary guard ────────────────────────────
38
-
39
- /// Every IPA character emitted by this service must be in this set.
40
- /// Matches the _letters_ipa + _punctuation + _letters strings in TextCleaner.
41
- static final Set<String> _validChars = {
42
- ';', ':', ',', '.', '!', '?', '¡', '¿', '—', '…',
43
- '"', '«', '»', '\u201C', '\u201D', ' ',
44
- for (int c = 65; c <= 90; c++) String.fromCharCode(c),
45
- for (int c = 97; c <= 122; c++) String.fromCharCode(c),
46
- 'ɑ',
47
- 'ɐ',
48
- 'ɒ',
49
- 'æ',
50
- 'ɓ',
51
- 'ʙ',
52
- 'β',
53
- 'ɔ',
54
- 'ɕ',
55
- 'ç',
56
- 'ɗ',
57
- 'ɖ',
58
- 'ð',
59
- 'ʤ',
60
- 'ə',
61
- 'ɘ',
62
- 'ɚ',
63
- 'ɛ',
64
- 'ɜ',
65
- 'ɝ',
66
- 'ɞ',
67
- 'ɟ',
68
- 'ʄ',
69
- 'ɡ',
70
- 'ɠ',
71
- 'ɢ',
72
- 'ʛ',
73
- 'ɦ',
74
- 'ɧ',
75
- 'ħ',
76
- 'ɥ',
77
- 'ʜ',
78
- 'ɨ',
79
- 'ɪ',
80
- 'ʝ',
81
- 'ɭ',
82
- 'ɬ',
83
- 'ɫ',
84
- 'ɮ',
85
- 'ʟ',
86
- 'ɱ',
87
- 'ɯ',
88
- 'ɰ',
89
- 'ŋ',
90
- 'ɳ',
91
- 'ɲ',
92
- 'ɴ',
93
- 'ø',
94
- 'ɵ',
95
- 'ɸ',
96
- 'θ',
97
- 'œ',
98
- 'ɶ',
99
- 'ʘ',
100
- 'ɹ',
101
- 'ɺ',
102
- 'ɾ',
103
- 'ɻ',
104
- 'ʀ',
105
- 'ʁ',
106
- 'ɽ',
107
- 'ʂ',
108
- 'ʃ',
109
- 'ʈ',
110
- 'ʧ',
111
- 'ʉ',
112
- 'ʊ',
113
- 'ʋ',
114
- 'ⱱ', 'ʌ', 'ɣ', 'ɤ', 'ʍ', 'χ', 'ʎ', 'ʏ', 'ʑ', 'ʐ', 'ʒ', 'ʔ', 'ʡ', 'ʕ', 'ʢ',
115
- 'ǀ', 'ǁ', 'ǂ', 'ǃ',
116
- 'ˈ', 'ˌ', 'ː', 'ˑ', 'ʼ', 'ʴ', 'ʰ', 'ʱ', 'ʲ', 'ʷ', 'ˠ', 'ˤ', '˞',
117
- '↓', '↑', '→', '↗', '↘', 'ᵻ',
118
- '\u0329',
119
- };
120
-
121
- // ────────────────────── Silent initial clusters ───────────────────────────
122
-
123
- static const Map<String, String> _silentInitialClusters = {
124
- 'kn': 'n',
125
- 'wr': 'ɹ',
126
- 'gn': 'n',
127
- 'ps': 's',
128
- 'pn': 'n',
129
- 'mn': 'm',
130
- };
131
-
132
- // ──────────────────── Consonant multigraphs ───────────────────────────────
133
-
134
- static const Map<String, String> _consonantMultigraphs = {
135
- 'tch': 'ʧ',
136
- 'dge': 'ʤ',
137
- 'ch': 'ʧ',
138
- 'sh': 'ʃ',
139
- 'zh': 'ʒ',
140
- 'th': 'θ',
141
- 'gh': '',
142
- 'ph': 'f',
143
- 'wh': 'w',
144
- 'ng': 'ŋ',
145
- 'nk': 'ŋk',
146
- 'qu': 'kw',
147
- 'ck': 'k',
148
- };
149
-
150
- static const Map<String, String> _singleConsonants = {
151
- 'b': 'b',
152
- 'c': 'k',
153
- 'd': 'd',
154
- 'f': 'f',
155
- 'g': 'ɡ',
156
- 'h': 'h',
157
- 'j': 'ʤ',
158
- 'k': 'k',
159
- 'l': 'l',
160
- 'm': 'm',
161
- 'n': 'n',
162
- 'p': 'p',
163
- 'q': 'k',
164
- 'r': 'ɹ',
165
- 's': 's',
166
- 't': 't',
167
- 'v': 'v',
168
- 'w': 'w',
169
- 'x': 'ks',
170
- 'y': 'j',
171
- 'z': 'z',
172
- };
173
-
174
- // ─────────────────────── Short vowels (en-us) ─────────────────────────────
175
-
176
- static const Map<String, String> _shortVowels = {
177
- 'a': 'æ',
178
- 'e': 'ɛ',
179
- 'i': 'ɪ',
180
- 'o': 'ɑ',
181
- 'u': 'ʌ',
182
- };
183
-
184
- // ──────────────────── Long vowels (magic-e targets) ───────────────────────
185
-
186
- static const Map<String, String> _longVowels = {
187
- 'a': 'eɪ',
188
- 'e': 'iː',
189
- 'i': 'aɪ',
190
- 'o': 'oʊ',
191
- 'u': 'juː',
192
- };
193
-
194
- // ──────────────────── Vowel multigraphs ───────────────────────────────────
195
-
196
- static const Map<String, String> _vowelMultigraphs = {
197
- 'igh': 'aɪ',
198
- 'ure': 'jʊɹ',
199
- 'ear': 'ɪɹ',
200
- 'air': 'ɛɹ',
201
- 'oor': 'ʊɹ',
202
- 'ar': 'ɑːɹ',
203
- 'er': 'ɝ',
204
- 'ir': 'ɝ',
205
- 'or': 'ɔːɹ',
206
- 'ur': 'ɝ',
207
- 'ay': 'eɪ',
208
- 'ai': 'eɪ',
209
- 'ei': 'eɪ',
210
- 'ee': 'iː',
211
- 'ea': 'iː',
212
- 'ie': 'iː',
213
- 'oa': 'oʊ',
214
- 'ow': 'oʊ',
215
- 'oo': 'uː',
216
- 'ue': 'uː',
217
- 'ew': 'juː',
218
- 'eu': 'juː',
219
- 'ou': 'aʊ',
220
- 'oi': 'ɔɪ',
221
- 'oy': 'ɔɪ',
222
- 'au': 'ɔː',
223
- 'aw': 'ɔː',
224
- };
225
-
226
- // ──────────────── Suffixes — longest-first for greedy stripping ───────────
227
-
228
- static const List<MapEntry<String, String>> _suffixes = [
229
- MapEntry('tion', 'ʃən'),
230
- MapEntry('sion', 'ʒən'),
231
- MapEntry('ture', 'ʧɚ'),
232
- MapEntry('ness', 'nɪs'),
233
- MapEntry('ment', 'mənt'),
234
- MapEntry('able', 'əbəl'),
235
- MapEntry('ible', 'ɪbəl'),
236
- MapEntry('ical', 'ɪkəl'),
237
- MapEntry('ious', 'iəs'),
238
- MapEntry('ance', 'əns'),
239
- MapEntry('ence', 'əns'),
240
- MapEntry('ism', 'ɪzəm'),
241
- MapEntry('ity', 'ɪti'),
242
- MapEntry('ise', 'aɪz'),
243
- MapEntry('ize', 'aɪz'),
244
- MapEntry('ive', 'ɪv'),
245
- MapEntry('ous', 'əs'),
246
- MapEntry('ful', 'fəl'),
247
- MapEntry('ing', 'ɪŋ'),
248
- MapEntry('est', 'ɪst'),
249
- MapEntry('ist', 'ɪst'),
250
- MapEntry('ant', 'ənt'),
251
- MapEntry('ent', 'ənt'),
252
- MapEntry('age', 'ɪʤ'),
253
- MapEntry('less', 'lɪs'),
254
- MapEntry('ify', 'ɪfaɪ'),
255
- MapEntry('fy', 'faɪ'),
256
- MapEntry('ly', 'liː'),
257
- MapEntry('er', 'ɚ'),
258
- MapEntry('ed', 'd'),
259
- MapEntry('s', 'z'),
260
- ];
261
-
262
- // ─────────────────────────── Public API ──────────────────────────────────
263
-
264
- /// Convert English [text] to IPA compatible with KittenTTS's TextCleaner.
265
- String phonemize(String text) {
266
- String textToProcess = text
267
- .replaceAll('0', ' zero ')
268
- .replaceAll('1', ' one ')
269
- .replaceAll('2', ' two ')
270
- .replaceAll('3', ' three ')
271
- .replaceAll('4', ' four ')
272
- .replaceAll('5', ' five ')
273
- .replaceAll('6', ' six ')
274
- .replaceAll('7', ' seven ')
275
- .replaceAll('8', ' eight ')
276
- .replaceAll('9', ' nine ');
277
-
278
- final cleaned = textToProcess
279
- .replaceAll(RegExp(r"[^\w\s'.,!?;:]"), ' ')
280
- .replaceAll(RegExp(r'\s+'), ' ')
281
- .trim()
282
- .toLowerCase();
283
-
284
- final output = StringBuffer();
285
- for (int i = 0; i < cleaned.length; i++) {
286
- final char = cleaned[i];
287
- if (_isPreservablePunctuation(char)) {
288
- output.write(char);
289
- } else if (char == ' ') {
290
- output.write(' ');
291
- } else {
292
- final start = i;
293
- while (i < cleaned.length &&
294
- cleaned[i] != ' ' &&
295
- !_isPreservablePunctuation(cleaned[i])) {
296
- i++;
297
- }
298
- i--;
299
- final word = cleaned.substring(start, i + 1);
300
- if (word.isNotEmpty) output.write(_phonemizeWord(word));
301
- }
302
- }
303
-
304
- return sanitize(output.toString());
305
- }
306
-
307
- /// Remove any characters that KittenTTS's TextCleaner would silently drop.
308
- String sanitize(String ipa) =>
309
- ipa.split('').where((c) => _validChars.contains(c)).join().replaceAll(RegExp(r'\s+'), ' ').trim();
310
-
311
- // ────────────────────────── Word-level logic ──────────────────────────────
312
-
313
- String _phonemizeWord(String word) {
314
- if (_commonWords.containsKey(word)) return _commonWords[word]!;
315
-
316
- for (final entry in _suffixes) {
317
- final suffix = entry.key;
318
- if (word.length > suffix.length + 1 && word.endsWith(suffix)) {
319
- final stem = word.substring(0, word.length - suffix.length);
320
- return _phonemizeCore(stem) + entry.value;
321
- }
322
- }
323
-
324
- return _phonemizeCore(word);
325
- }
326
-
327
- // ─────────────────────────── Core engine ─────────────────────────────────
328
-
329
- String _phonemizeCore(String word) {
330
- if (word.isEmpty) return '';
331
-
332
- final buffer = StringBuffer();
333
- int i = 0;
334
-
335
- if (word.length >= 2) {
336
- final pair = word.substring(0, 2);
337
- if (_silentInitialClusters.containsKey(pair)) {
338
- buffer.write(_silentInitialClusters[pair]);
339
- i = 2;
340
- }
341
- }
342
-
343
- while (i < word.length) {
344
- final char = word[i];
345
- final isLast = i == word.length - 1;
346
- final next = isLast ? null : word[i + 1];
347
-
348
- if (char == 'e' && isLast && word.length > 2) {
349
- i++;
350
- continue;
351
- }
352
-
353
- if (char == 'm' && next == 'b' && i == word.length - 2) {
354
- buffer.write('m');
355
- i += 2;
356
- continue;
357
- }
358
-
359
- if (_isVowel(char)) {
360
- if (i + 2 < word.length) {
361
- final tri = word.substring(i, i + 3);
362
- if (_vowelMultigraphs.containsKey(tri)) {
363
- buffer.write(_vowelMultigraphs[tri]);
364
- i += 3;
365
- continue;
366
- }
367
- }
368
- if (next != null) {
369
- final di = word.substring(i, i + 2);
370
- if (di == 'er' && i == word.length - 2) {
371
- buffer.write('ɚ');
372
- i += 2;
373
- continue;
374
- }
375
- if (_vowelMultigraphs.containsKey(di)) {
376
- buffer.write(_vowelMultigraphs[di]);
377
- i += 2;
378
- continue;
379
- }
380
- }
381
- if (_hasMagicE(word, i)) {
382
- buffer.write(_longVowels[char] ?? char);
383
- i++;
384
- continue;
385
- }
386
- buffer.write(_shortVowels[char] ?? char);
387
- i++;
388
- continue;
389
- }
390
-
391
- if (char == 'c' && next != null && 'eiy'.contains(next)) {
392
- buffer.write('s');
393
- i++;
394
- continue;
395
- }
396
-
397
- if (char == 'g' && next != null && 'eiy'.contains(next)) {
398
- buffer.write('dʒ');
399
- i++;
400
- continue;
401
- }
402
-
403
- if (char == 'y' && isLast) {
404
- buffer.write(word.length <= 3 ? 'aɪ' : 'iː');
405
- i++;
406
- continue;
407
- }
408
-
409
- if (i + 2 < word.length) {
410
- final tri = word.substring(i, i + 3);
411
- if (_consonantMultigraphs.containsKey(tri)) {
412
- buffer.write(_consonantMultigraphs[tri]!);
413
- i += 3;
414
- continue;
415
- }
416
- }
417
-
418
- if (next != null) {
419
- final di = word.substring(i, i + 2);
420
- if (_consonantMultigraphs.containsKey(di)) {
421
- buffer.write(_consonantMultigraphs[di]!);
422
- i += 2;
423
- continue;
424
- }
425
- }
426
-
427
- buffer.write(_singleConsonants[char] ?? '');
428
- i++;
429
- }
430
-
431
- return buffer.toString();
432
- }
433
-
434
- // ───────────────────────────── Helpers ───────────────────────────────────
435
-
436
- bool _isVowel(String char) => 'aeiou'.contains(char);
437
-
438
- bool _isPreservablePunctuation(String char) =>
439
- ','.contains(char) || '.!?;:'.contains(char);
440
-
441
- bool _hasMagicE(String word, int index) {
442
- if (word.isEmpty || word[word.length - 1] != 'e' || word.length < 3) {
443
- return false;
444
- }
445
- int j = index + 1;
446
- int consonantCount = 0;
447
- while (j < word.length - 1) {
448
- if (_isVowel(word[j])) return false;
449
- consonantCount++;
450
- j++;
451
- }
452
- return consonantCount >= 1;
453
- }
454
-
455
- static const Map<String, String> _commonWords = {
456
- 'the': 'ðə', 'a': 'ə', 'an': 'æn',
457
- 'and': 'ænd', 'or': 'ɔːɹ', 'but': 'bʌt', 'if': 'ɪf',
458
- 'of': 'ʌv', 'to': 'tuː', 'in': 'ɪn', 'on': 'ɑn',
459
- 'at': 'æt', 'by': 'baɪ', 'as': 'æz',
460
- 'for': 'fɔːɹ', 'with': 'wɪð', 'from': 'fɹʌm', 'into': 'ɪntuː',
461
- 'up': 'ʌp', 'out': 'aʊt', 'over': 'oʊvɚ', 'than': 'ðæn',
462
- 'then': 'ðɛn', 'so': 'soʊ', 'yet': 'jɛt',
463
- 'i': 'aɪ', 'you': 'juː', 'he': 'hiː', 'she': 'ʃiː',
464
- 'it': 'ɪt', 'we': 'wiː', 'they': 'ðeɪ',
465
- 'me': 'miː', 'him': 'hɪm', 'her': 'hɜːɹ', 'us': 'ʌs',
466
- 'them': 'ðɛm', 'my': 'maɪ', 'your': 'jɔːɹ', 'his': 'hɪz',
467
- 'its': 'ɪts', 'our': 'aʊɚ', 'their': 'ðɛɹ',
468
- 'this': 'ðɪs', 'that': 'ðæt', 'these': 'ðiːz', 'those': 'ðoʊz',
469
- 'who': 'huː', 'which': 'wɪtʃ', 'what': 'wʌt', 'where': 'wɛɹ',
470
- 'when': 'wɛn', 'how': 'haʊ', 'why': 'waɪ',
471
- 'is': 'ɪz', 'are': 'ɑːɹ', 'was': 'wɑz', 'were': 'wɜːɹ',
472
- 'be': 'biː', 'been': 'biːn', 'being': 'biːɪŋ',
473
- 'have': 'hæv', 'has': 'hæz', 'had': 'hæd',
474
- 'do': 'duː', 'does': 'dʌz', 'did': 'dɪd', 'done': 'dʌn',
475
- 'will': 'wɪl', 'would': 'wʊd', 'can': 'kæn', 'could': 'kʊd',
476
- 'shall': 'ʃæl', 'should': 'ʃʊd', 'may': 'meɪ', 'might': 'maɪt',
477
- 'must': 'mʌst', 'need': 'niːd',
478
- 'get': 'ɡɛt', 'got': 'ɡɑt', 'go': 'ɡoʊ', 'went': 'wɛnt',
479
- 'come': 'kʌm', 'came': 'keɪm',
480
- 'make': 'meɪk', 'made': 'meɪd',
481
- 'say': 'seɪ', 'said': 'sɛd',
482
- 'know': 'noʊ', 'think': 'θɪŋk', 'see': 'siː',
483
- 'look': 'lʊk', 'find': 'faɪnd', 'give': 'ɡɪv', 'use': 'juːz',
484
- 'tell': 'tɛl', 'call': 'kɔːl', 'keep': 'kiːp', 'let': 'lɛt',
485
- 'seem': 'siːm', 'feel': 'fiːl', 'try': 'tɹaɪ', 'leave': 'liːv',
486
- 'put': 'pʊt', 'mean': 'miːn', 'show': 'ʃoʊ',
487
- 'time': 'taɪm', 'year': 'jɪɹ', 'day': 'deɪ', 'way': 'weɪ',
488
- 'man': 'mæn', 'men': 'mɛn', 'word': 'wɜːɹd',
489
- 'world': 'wɜːɹld', 'life': 'laɪf', 'hand': 'hænd',
490
- 'place': 'pleɪs', 'case': 'keɪs', 'thing': 'θɪŋ', 'home': 'hoʊm',
491
- 'water': 'wɔːtɚ', 'room': 'ɹuːm', 'book': 'bʊk',
492
- 'eye': 'aɪ', 'door': 'dɔːɹ', 'face': 'feɪs', 'name': 'neɪm',
493
- 'people': 'piːpəl', 'child': 'tʃaɪld', 'children': 'tʃɪldɹən',
494
- 'one': 'wʌn', 'two': 'tuː', 'three': 'θɹiː',
495
- 'not': 'nɑt', 'all': 'ɔːl', 'some': 'sʌm', 'more': 'mɔːɹ',
496
- 'very': 'vɛɹiː', 'just': 'dʒʌst', 'also': 'ɔːlsoʊ',
497
- 'even': 'iːvən', 'well': 'wɛl', 'such': 'sʌtʃ', 'only': 'oʊnliː',
498
- 'any': 'ɛniː', 'many': 'mɛniː', 'each': 'iːtʃ', 'long': 'lɔːŋ',
499
- 'down': 'daʊn', 'first': 'fɜːɹst', 'other': 'ʌðɚ', 'about': 'əbaʊt',
500
- 'hello': 'hɛloʊ', 'hi': 'haɪ', 'hey': 'heɪ',
501
- 'bye': 'baɪ', 'goodbye': 'ɡʊdbaɪ',
502
- 'yes': 'jɛs', 'no': 'noʊ', 'okay': 'oʊkeɪ', 'ok': 'oʊkeɪ',
503
- 'please': 'pliːz', 'thanks': 'θæŋks', 'thank': 'θæŋk',
504
- 'sorry': 'sɑɹiː', 'am': 'æm',
505
- "i'm": "aɪm", "you're": "jʊɹ", "he's": "hiːz", "she's": "ʃiːz", "it's": "ɪts",
506
- "we're": "wɪɹ", "they're": "ðɛɹ", "i've": "aɪv", "you've": "juːv", "we've": "wiːv",
507
- "they've": "ðeɪv", "i'll": "aɪl", "you'll": "juːl", "he'll": "hiːl", "she'll": "ʃiːl",
508
- "we'll": "wiːl", "they'll": "ðeɪl", "i'd": "aɪd", "you'd": "juːd", "he'd": "hiːd",
509
- "she'd": "ʃiːd", "we'd": "wiːd", "they'd": "ðeɪd", "isn't": "ɪzənt", "aren't": "ɑːɹnt",
510
- "wasn't": "wʌzənt", "weren't": "wɜːɹnt", "haven't": "hævənt", "hasn't": "hæzənt",
511
- "hadn't": "hædənt", "won't": "woʊnt", "wouldn't": "wʊdənt", "don't": "doʊnt",
512
- "doesn't": "dʌzənt", "didn't": "dɪdənt", "can't": "kænt", "couldn't": "kʊdənt",
513
- "shouldn't": "ʃʊdənt", "mightn't": "maɪtənt", "mustn't": "mʌstənt",
514
- "audio": "ɔːdiːoʊ", "broken": "bɹoʊkən", "skips": "skɪpz",
515
- };
516
-}
lib/features/tts/providers/tts_model_providers.dart
+95
-46
@@ -2,15 +2,22 @@ import 'dart:async';
2
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
5
-import 'package:neural_tts/neural_tts.dart';
5
+import '../data/model_downloader.dart';
6
import '../data/kitten_tts_model.dart';
7
import '../data/kitten_tts_downloader.dart';
8
+import '../data/kokoro_tts_model.dart';
9
+import '../data/kokoro_tts_downloader.dart';
10
11
/// Provider for the KittenTTS downloader instance.
12
final kittenTtsDownloaderProvider = Provider<KittenTtsDownloader>((ref) {
13
return KittenTtsDownloader();
14
});
15
16
+/// Provider for the Kokoro TTS downloader instance.
17
+final kokoroTtsDownloaderProvider = Provider<KokoroTtsDownloader>((ref) {
18
+ return KokoroTtsDownloader();
19
+});
20
+
21
final modelDownloaderProvider = Provider<ModelDownloader>((ref) {
22
return ModelDownloader();
23
});
@@ -20,13 +27,27 @@ final kittenTtsModelsProvider = Provider<List<KittenTtsModel>>((ref) {
27
return KittenTtsModel.allModels;
28
});
29
23
-/// Async provider for the set of downloaded variants.
30
+/// Static list of all available Kokoro TTS models.
31
+final kokoroTtsModelsProvider = Provider<List<KokoroTtsModel>>((ref) {
32
+ return KokoroTtsModel.allModels;
33
+});
34
+
35
+/// Async provider for the set of downloaded KittenTTS variants.
36
final downloadedKittenTtsVariantsProvider =
37
FutureProvider<Set<KittenTtsModelVariant>>((ref) async {
38
final downloader = ref.read(kittenTtsDownloaderProvider);
39
return downloader.getDownloadedVariants();
40
});
41
42
+/// Async provider for the set of downloaded Kokoro variants.
43
+final downloadedKokoroTtsVariantsProvider =
44
+ FutureProvider<Set<KokoroTtsModelVariant>>((ref) async {
45
+ final downloader = ref.read(kokoroTtsDownloaderProvider);
46
+ return downloader.getDownloadedVariants();
47
+ });
48
+
49
+// ── KittenTTS Download Progress ──
50
+
51
/// Notifier that tracks per-file download progress for KittenTTS models.
52
final ttsDownloadProgressProvider =
53
NotifierProvider<
@@ -81,7 +102,7 @@ class TtsDownloadNotifier
102
ref.invalidate(downloadedKittenTtsVariantsProvider);
103
},
104
onError: (e) {
84
- Log.error(
105
+ _ModelLog.error(
106
'KittenTTS download error for ${variant.displayName}: $e',
107
);
108
_subscriptions.remove(variant);
@@ -136,19 +157,25 @@ class TtsDownloadNotifier
157
}
158
}
159
139
-/// Notifier that tracks per-file download progress for any TTS engine.
140
-final engineDownloadProgressProvider =
160
+// ── Kokoro TTS Download Progress ──
161
+
162
+/// Notifier that tracks per-file download progress for Kokoro TTS models.
163
+final kokoroTtsDownloadProgressProvider =
164
NotifierProvider<
142
- EngineDownloadNotifier,
143
- Map<EngineId, Map<String, FileProgress>>
144
- >(() => EngineDownloadNotifier());
165
+ KokoroTtsDownloadNotifier,
166
+ Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>
167
+ >(() => KokoroTtsDownloadNotifier());
168
146
-class EngineDownloadNotifier
147
- extends Notifier<Map<EngineId, Map<String, FileProgress>>> {
148
- final Map<EngineId, StreamSubscription<FileProgress>> _subscriptions = {};
169
+class KokoroTtsDownloadNotifier
170
+ extends
171
+ Notifier<
172
+ Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>
173
+ > {
174
+ final Map<KokoroTtsModelVariant, StreamSubscription<KokoroTtsFileProgress>>
175
+ _subscriptions = {};
176
177
@override
151
- Map<EngineId, Map<String, FileProgress>> build() {
178
+ Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>> build() {
179
ref.onDispose(() {
180
for (final sub in _subscriptions.values) {
181
sub.cancel();
@@ -158,62 +185,84 @@ class EngineDownloadNotifier
185
return {};
186
}
187
161
- Future<void> startDownload(EngineId engine) async {
162
- final currentProgress = Map<String, FileProgress>.from(state[engine] ?? {});
163
- state = {...state, engine: currentProgress};
188
+ Future<void> startDownload(KokoroTtsModelVariant variant) async {
189
+ final currentVariantProgress = Map<String, KokoroTtsFileProgress>.from(
190
+ state[variant] ?? {},
191
+ );
192
+
193
+ state = {...state, variant: currentVariantProgress};
194
+
195
+ final downloader = ref.read(kokoroTtsDownloaderProvider);
196
165
- final downloader = ref.read(modelDownloaderProvider);
166
- _subscriptions[engine]?.cancel();
167
- _subscriptions[engine] = downloader
168
- .downloadEngineFiles(engine)
197
+ _subscriptions[variant]?.cancel();
198
+ _subscriptions[variant] = downloader
199
+ .downloadModel(variant)
200
.listen(
201
(progress) {
171
- final engineMap = Map<String, FileProgress>.from(
172
- state[engine] ?? {},
202
+ final variantMap = Map<String, KokoroTtsFileProgress>.from(
203
+ state[variant] ?? {},
204
);
174
- engineMap[progress.fileName] = progress;
175
- state = {...state, engine: engineMap};
205
+ variantMap[progress.fileName] = progress;
206
+ state = {...state, variant: variantMap};
207
},
208
onDone: () {
178
- _subscriptions.remove(engine);
179
- // Refresh whatever is needed
209
+ _subscriptions.remove(variant);
210
+ ref.invalidate(downloadedKokoroTtsVariantsProvider);
211
},
212
onError: (e) {
182
- Log.error('Engine $engine download error: $e');
183
- _subscriptions.remove(engine);
213
+ _ModelLog.error(
214
+ 'Kokoro TTS download error for ${variant.displayName}: $e',
215
+ );
216
+ _subscriptions.remove(variant);
217
},
218
);
219
}
220
188
- void cancelDownload(EngineId engine) {
189
- ref.read(modelDownloaderProvider).cancelDownload(engine);
190
- _subscriptions[engine]?.cancel();
191
- _subscriptions.remove(engine);
192
- final newState = Map<EngineId, Map<String, FileProgress>>.from(state);
193
- newState.remove(engine);
221
+ void cancelDownload(KokoroTtsModelVariant variant) {
222
+ ref.read(kokoroTtsDownloaderProvider).cancelDownload(variant);
223
+ _subscriptions[variant]?.cancel();
224
+ _subscriptions.remove(variant);
225
+
226
+ final newState =
227
+ Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>.from(
228
+ state,
229
+ );
230
+ newState.remove(variant);
231
state = newState;
232
}
233
197
- bool isDownloading(EngineId engine) {
198
- final progress = state[engine];
199
- if (progress == null || progress.isEmpty) return false;
200
- return !progress.values.every((f) => f.isComplete);
234
+ Future<void> deleteVariant(KokoroTtsModelVariant variant) async {
235
+ cancelDownload(variant);
236
+ await ref.read(kokoroTtsDownloaderProvider).deleteVariant(variant);
237
+
238
+ final newState =
239
+ Map<KokoroTtsModelVariant, Map<String, KokoroTtsFileProgress>>.from(
240
+ state,
241
+ );
242
+ newState.remove(variant);
243
+ state = newState;
244
+
245
+ ref.invalidate(downloadedKokoroTtsVariantsProvider);
246
+ }
247
+
248
+ bool isDownloading(KokoroTtsModelVariant variant) {
249
+ final variantProgress = state[variant];
250
+ if (variantProgress == null || variantProgress.isEmpty) return false;
251
+ return !variantProgress.values.every((f) => f.isComplete);
252
}
253
203
- double getOverallFraction(EngineId engine) {
204
- final progress = state[engine];
205
- if (progress == null || progress.isEmpty) return 0.0;
206
- final total = progress.values
254
+ double getOverallFraction(KokoroTtsModelVariant variant) {
255
+ final variantProgress = state[variant];
256
+ if (variantProgress == null || variantProgress.isEmpty) return 0.0;
257
+ final total = variantProgress.values
258
.map((f) => f.fraction)
259
.fold<double>(0.0, (a, b) => a + b);
209
- return total / progress.length;
260
+ return total / variantProgress.length;
261
}
262
}
263
213
-/// Simple log helper within this file.
214
-class Log {
264
+class _ModelLog {
265
static void error(String message) {
216
- // ignore: avoid_print
217
- print('[TtsDownloadNotifier] ERROR: $message');
266
+ print('[TtsModel] ERROR: $message');
267
}
268
}
lib/features/tts/providers/tts_providers.dart
+252
-96
@@ -1,18 +1,18 @@
1
-import 'dart:async';
1
import 'dart:io';
2
+import 'dart:typed_data';
3
4
+import 'package:archive/archive.dart';
5
+import 'package:flutter/services.dart';
6
+import 'package:audioplayers/audioplayers.dart';
7
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
-import 'package:localmind/features/tts/providers/tts_model_providers.dart'
6
- hide Log;
7
-import 'package:neural_tts/neural_tts.dart';
8
+import 'package:flutter_tts/flutter_tts.dart';
9
+import 'package:meomeo/meomeo.dart';
10
+import 'package:path_provider/path_provider.dart';
11
12
import '../../../core/logger/app_logger.dart';
13
import '../../../core/models/enums.dart';
14
import '../../../core/providers/app_providers.dart';
12
-
13
-final ttsRuntimeProvider = Provider<TTSRuntime>((ref) {
14
- return TTSRuntime.instance;
15
-});
15
+import 'tts_model_providers.dart';
16
17
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
18
return TtsNotifier();
@@ -23,12 +23,14 @@ class TtsState {
23
final bool isInitializing;
24
final String? error;
25
final EngineId? activeEngine;
26
+ final String? playingContent;
27
28
const TtsState({
29
this.isSpeaking = false,
30
this.isInitializing = false,
31
this.error,
32
this.activeEngine,
33
+ this.playingContent,
34
});
35
36
TtsState copyWith({
@@ -36,156 +38,310 @@ class TtsState {
38
bool? isInitializing,
39
String? error,
40
EngineId? activeEngine,
41
+ String? playingContent,
42
}) {
43
return TtsState(
44
isSpeaking: isSpeaking ?? this.isSpeaking,
45
isInitializing: isInitializing ?? this.isInitializing,
46
error: error,
47
activeEngine: activeEngine ?? this.activeEngine,
48
+ playingContent: playingContent,
49
);
50
}
51
}
52
53
class TtsNotifier extends Notifier<TtsState> {
50
- Engine? _currentEngine;
51
- final Phonemizer _phonemizer = Phonemizer();
52
- EspeakPhonemizer? _espeakPhonemizer;
54
+ MeoKitten? _kitten;
55
+ MeoKokoro? _kokoro;
56
+ MeoPiper? _piper;
57
+ FlutterTts? _flutterTts;
58
+ final AudioPlayer _player = AudioPlayer();
59
+ bool _isPlayerDisposed = false;
60
61
@override
62
TtsState build() {
63
+ ref.keepAlive();
64
final settings = ref.watch(settingsProvider);
65
+ _player.onPlayerComplete.listen((_) {
66
+ _onPlaybackFinished();
67
+ });
68
ref.onDispose(() {
58
- _currentEngine?.release();
69
+ _kitten?.dispose();
70
+ _kokoro?.dispose();
71
+ _piper?.dispose();
72
+ if (!_isPlayerDisposed) {
73
+ _isPlayerDisposed = true;
74
+ try {
75
+ _player.dispose();
76
+ } catch (_) {}
77
+ }
78
});
79
61
- // Initialize espeak data if pre-bundled
62
- _initEspeak();
63
-
80
return TtsState(activeEngine: settings.ttsEngine);
81
}
82
67
- Future<void> _initEspeak() async {
68
- final downloader = ref.read(modelDownloaderProvider);
69
- final ttsDir = await downloader.getTtsDir();
70
- final espeakDir = Directory('${ttsDir.path}/espeak-ng-data');
83
+ void _onPlaybackFinished() {
84
+ if (_isPlayerDisposed || !state.isSpeaking) return;
85
+ state = state.copyWith(
86
+ isSpeaking: false,
87
+ isInitializing: false,
88
+ playingContent: null,
89
+ );
90
+ }
91
72
- if (await espeakDir.exists()) {
73
- try {
74
- _espeakPhonemizer = EspeakPhonemizer(
75
- dataPath: ttsDir.path,
76
- language: 'en-us',
77
- );
78
- } catch (e) {
79
- Log.error('Failed to init EspeakPhonemizer: $e');
92
+ Future<void> _ensureEspeakData(String targetPath) async {
93
+ final dir = Directory(targetPath);
94
+ if (await dir.exists()) {
95
+ // Basic check: if it has espeak-ng-data, assume it's valid
96
+ if (await Directory('$targetPath/espeak-ng-data').exists()) {
97
+ return;
98
}
99
}
82
- }
100
84
- Voice? get _currentVoice {
85
- final settings = ref.read(settingsProvider);
86
- return voiceFromSettings(settings.ttsVoiceId, settings.ttsEngine);
101
+ Log.info('Extracting espeak-data.zip to $targetPath...');
102
+ try {
103
+ final data = await rootBundle.load('assets/espeak-data.zip');
104
+ final bytes = data.buffer.asUint8List();
105
+ final archive = ZipDecoder().decodeBytes(bytes);
106
+
107
+ for (final file in archive) {
108
+ final filename = file.name;
109
+ if (file.isFile) {
110
+ final data = file.content as List<int>;
111
+ final f = File('$targetPath/$filename');
112
+ await f.create(recursive: true);
113
+ await f.writeAsBytes(data);
114
+ } else {
115
+ await Directory('$targetPath/$filename').create(recursive: true);
116
+ }
117
+ }
118
+ Log.info('espeak-data extraction complete.');
119
+ } catch (e) {
120
+ Log.error('Failed to extract espeak-data: $e');
121
+ rethrow;
122
+ }
123
}
124
89
- Future<Engine> _resolveEngine() async {
90
- if (_currentEngine != null) return _currentEngine!;
91
- final engine = createEngine(ref.read(settingsProvider).ttsEngine);
92
- _currentEngine = engine;
93
- return engine;
125
+ Future<void> _initEngine(EngineId engineId) async {
126
+ final supportDir = await getApplicationSupportDirectory();
127
+ final resolvedEspeakPath = '${supportDir.path}/espeak-data';
128
+
129
+ // Ensure espeak-data is available for neural engines
130
+ if (engineId != EngineId.system) {
131
+ await _ensureEspeakData(resolvedEspeakPath);
132
+ }
133
+
134
+ switch (engineId) {
135
+ case EngineId.kitten:
136
+ if (_kitten != null) return;
137
+ final kittenDl = ref.read(kittenTtsDownloaderProvider);
138
+ final variant = ref.read(settingsProvider).kittenTtsModelVariant;
139
+ final modelPath = await kittenDl.getFilePath(
140
+ variant,
141
+ variant.modelFileName,
142
+ );
143
+ final voicesPath = await kittenDl.getFilePath(variant, 'voices.npz');
144
+ if (modelPath == null || voicesPath == null) {
145
+ throw StateError(
146
+ 'Kitten model files not found. Please download them in the Model Manager.',
147
+ );
148
+ }
149
+ _kitten = MeoKitten(
150
+ model: modelPath,
151
+ voices: voicesPath,
152
+ espeakData: resolvedEspeakPath,
153
+ );
154
+ break;
155
+ case EngineId.kokoro:
156
+ if (_kokoro != null) return;
157
+ final kokoroDl = ref.read(kokoroTtsDownloaderProvider);
158
+ final variant =
159
+ ref.read(settingsProvider).kokoroTtsModelVariant;
160
+ final modelPath = await kokoroDl.getModelPath(variant);
161
+ final voicesDir = await kokoroDl.getVoicesDir(variant);
162
+ if (modelPath == null) {
163
+ throw StateError(
164
+ 'Kokoro model file not found. Please download it in the Model Manager.',
165
+ );
166
+ }
167
+ _kokoro = MeoKokoro(
168
+ model: modelPath,
169
+ voices: voicesDir.path,
170
+ espeakData: resolvedEspeakPath,
171
+ );
172
+ break;
173
+ case EngineId.piper:
174
+ if (_piper != null) return;
175
+ final downloader = ref.read(modelDownloaderProvider);
176
+ final ttsDir = await downloader.getTtsDir();
177
+ final modelPath = '${ttsDir.path}/piper/model.onnx';
178
+ if (!await File(modelPath).exists()) {
179
+ throw StateError('Piper model file not found.');
180
+ }
181
+ _piper = MeoPiper(model: modelPath, espeakData: resolvedEspeakPath);
182
+ break;
183
+ default:
184
+ break;
185
+ }
186
}
187
188
Future<void> speak(String text) async {
189
if (text.trim().isEmpty) return;
190
191
+ if (state.isSpeaking) {
192
+ await stop();
193
+ }
194
+
195
final settings = ref.read(settingsProvider);
196
final engineId = settings.ttsEngine;
197
198
+ if (engineId == EngineId.system) {
199
+ _flutterTts ??= FlutterTts();
200
+ state = state.copyWith(
201
+ isSpeaking: true,
202
+ isInitializing: false,
203
+ error: null,
204
+ activeEngine: EngineId.system,
205
+ playingContent: text,
206
+ );
207
+ try {
208
+ await _flutterTts!.setLanguage('en-US');
209
+ await _flutterTts!.setSpeechRate(
210
+ (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
211
+ );
212
+ await _flutterTts!.awaitSpeakCompletion(true);
213
+ await _flutterTts!.speak(text);
214
+ } catch (e) {
215
+ state = state.copyWith(
216
+ isSpeaking: false,
217
+ error: 'System TTS unavailable',
218
+ playingContent: null,
219
+ );
220
+ rethrow;
221
+ } finally {
222
+ state = state.copyWith(
223
+ isSpeaking: false,
224
+ playingContent: null,
225
+ );
226
+ }
227
+ return;
228
+ }
229
+
230
state = state.copyWith(
231
isSpeaking: true,
104
- isInitializing: engineId != EngineId.system,
232
+ isInitializing: true,
233
error: null,
234
activeEngine: engineId,
235
+ playingContent: text,
236
);
237
238
try {
110
- final voice = _currentVoice;
111
- final engine = await _resolveEngine();
112
- final runtime = TTSRuntime.instance;
113
- runtime.resetStop();
114
- await runtime.acquire(engine, () async {
115
- final resultVoice = voice ?? (await engine.getVoices()).firstOrNull;
116
- if (resultVoice == null) {
117
- throw StateError('No voice available for engine ${engine.id}');
118
- }
239
+ await _initEngine(engineId);
240
+ state = state.copyWith(isInitializing: false);
241
120
- // Set phonemizer based on settings
121
- if (settings.useEspeak && _espeakPhonemizer != null) {
122
- engine.setPhonemizer(_espeakPhonemizer!);
123
- } else {
124
- engine.setPhonemizer(_phonemizer);
125
- }
242
+ final voiceId = settings.ttsVoiceId ?? 'Luna';
243
+ final speaker = Speaker(voice: voiceId, speed: settings.ttsSpeed);
244
127
- await engine.play(
128
- text,
129
- resultVoice,
130
- language: 'en',
131
- inferenceSteps: settings.supertonicSteps,
132
- phonemize: settings.usePhonemizer,
133
- );
134
- });
245
+ Float32List? pcm;
246
+ int sampleRate = 22050;
247
+
248
+ if (engineId == EngineId.kitten) {
249
+ pcm = await _kitten?.speak(text, speaker: speaker);
250
+ sampleRate = 24000; // Kitten default
251
+ } else if (engineId == EngineId.kokoro) {
252
+ pcm = await _kokoro?.speak(text, speaker: speaker);
253
+ sampleRate = 24000; // Kokoro default
254
+ } else if (engineId == EngineId.piper) {
255
+ pcm = await _piper?.speak(text, speaker: speaker);
256
+ sampleRate = 22050; // Piper default often 22050
257
+ }
258
+
259
+ if (pcm != null) {
260
+ final wavBytes = _createWav(pcm, sampleRate);
261
+ try {
262
+ await _player.play(BytesSource(wavBytes));
263
+ } catch (e) {
264
+ Log.error('AudioPlayer play error: $e');
265
+ }
266
+ }
267
} catch (e, st) {
268
+ if (_isPlayerDisposed) return;
269
Log.error('TTS speak error: $e\n$st');
270
state = state.copyWith(
271
isSpeaking: false,
272
isInitializing: false,
273
error: e.toString(),
274
+ playingContent: null,
275
);
142
- rethrow;
143
- } finally {
144
- state = state.copyWith(isSpeaking: false, isInitializing: false);
276
}
277
}
278
279
Future<void> stop() async {
149
- await TTSRuntime.instance.stop();
150
- _currentEngine?.stop();
151
- state = state.copyWith(isSpeaking: false, isInitializing: false);
280
+ if (_isPlayerDisposed) return;
281
+ try {
282
+ await _player.stop();
283
+ } catch (_) {}
284
+ await _flutterTts?.stop();
285
+ state = state.copyWith(
286
+ isSpeaking: false,
287
+ isInitializing: false,
288
+ playingContent: null,
289
+ );
290
}
291
154
- Future<void> release() async {
155
- await TTSRuntime.instance.release();
156
- if (_currentEngine != null) {
157
- await _currentEngine!.release();
158
- _currentEngine = null;
292
+ Uint8List _createWav(Float32List samples, int sampleRate) {
293
+ // Simple WAV header creation
294
+ final int byteCount = samples.length * 2;
295
+ final int totalSize = 36 + byteCount;
296
+ final Uint8List wav = Uint8List(44 + byteCount);
297
+ final ByteData data = ByteData.view(wav.buffer);
298
+
299
+ data.setUint8(0, 0x52); // R
300
+ data.setUint8(1, 0x49); // I
301
+ data.setUint8(2, 0x46); // F
302
+ data.setUint8(3, 0x46); // F
303
+ data.setUint32(4, totalSize, Endian.little);
304
+ data.setUint8(8, 0x57); // W
305
+ data.setUint8(9, 0x41); // A
306
+ data.setUint8(10, 0x56); // V
307
+ data.setUint8(11, 0x45); // E
308
+ data.setUint8(12, 0x66); // f
309
+ data.setUint8(13, 0x6d); // m
310
+ data.setUint8(14, 0x74); // t
311
+ data.setUint8(15, 0x20); // space
312
+ data.setUint32(16, 16, Endian.little);
313
+ data.setUint16(20, 1, Endian.little); // PCM
314
+ data.setUint16(22, 1, Endian.little); // Mono
315
+ data.setUint32(24, sampleRate, Endian.little);
316
+ data.setUint32(28, sampleRate * 2, Endian.little);
317
+ data.setUint16(32, 2, Endian.little);
318
+ data.setUint16(34, 16, Endian.little); // 16-bit
319
+ data.setUint8(36, 0x64); // d
320
+ data.setUint8(37, 0x61); // a
321
+ data.setUint8(38, 0x74); // t
322
+ data.setUint8(39, 0x61); // a
323
+ data.setUint32(40, byteCount, Endian.little);
324
+
325
+ for (int i = 0; i < samples.length; i++) {
326
+ final int sample = (samples[i] * 32767).clamp(-32768, 32767).toInt();
327
+ data.setInt16(44 + (i * 2), sample, Endian.little);
328
}
160
- state = state.copyWith(isSpeaking: false);
329
+
330
+ return wav;
331
}
332
333
Future<void> previewVoice(Voice voice) async {
164
- state = state.copyWith(isSpeaking: true, isInitializing: true, error: null);
334
final settings = ref.read(settingsProvider);
166
- try {
167
- final engine = createEngine(voice.engine);
168
- final runtime = TTSRuntime.instance;
169
- runtime.resetStop();
170
- await runtime.acquire(engine, () async {
171
- // Set phonemizer based on settings
172
- if (settings.useEspeak && _espeakPhonemizer != null) {
173
- engine.setPhonemizer(_espeakPhonemizer!);
174
- } else {
175
- engine.setPhonemizer(_phonemizer);
176
- }
177
-
178
- await engine.play(
179
- ttsPreviewSample,
180
- voice,
181
- language: 'en',
182
- phonemize: settings.usePhonemizer,
183
- );
184
- });
185
- } catch (e) {
186
- state = state.copyWith(error: e.toString());
187
- } finally {
188
- state = state.copyWith(isSpeaking: false, isInitializing: false);
335
+ final previousEngine = settings.ttsEngine;
336
+ final previousVoiceId = settings.ttsVoiceId;
337
+ ref.read(settingsProvider.notifier).setTtsEngine(voice.engine);
338
+ ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
339
+ await speak(ttsPreviewSample);
340
+ ref.read(settingsProvider.notifier).setTtsEngine(previousEngine);
341
+ if (previousVoiceId != null) {
342
+ ref.read(settingsProvider.notifier).setTtsVoiceId(previousVoiceId);
343
}
344
}
345
}
346
+
347
+const ttsPreviewSample = "Hello, this is a preview of the selected voice.";
lib/features/tts/views/tts_model_manager_screen.dart
+178
-251
@@ -1,28 +1,35 @@
1
import 'dart:async';
2
3
-import 'package:audioplayers/audioplayers.dart';
3
import 'package:flutter/material.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
-import 'package:neural_tts/neural_tts.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
7
+import '../../../core/models/enums.dart';
8
import '../../../core/providers/app_providers.dart';
9
+import '../../settings/data/models/app_settings.dart';
10
import '../../sidebar/sidebar_widget.dart';
11
+import '../data/kitten_tts_model.dart';
12
+import '../data/kokoro_tts_model.dart';
13
import '../providers/tts_model_providers.dart';
14
import '../providers/tts_providers.dart' as tts;
15
16
/// Async provider for the set of installed engine IDs.
17
final installedEnginesProvider = FutureProvider<Set<EngineId>>((ref) async {
18
final downloader = ref.read(modelDownloaderProvider);
19
+ final kittenDownloader = ref.read(kittenTtsDownloaderProvider);
20
+ final kokoroDownloader = ref.read(kokoroTtsDownloaderProvider);
21
final result = <EngineId>{};
18
- for (final id in EngineId.values) {
19
- if (id == EngineId.system) {
20
- result.add(id);
21
- continue;
22
- }
23
- if (await downloader.isEngineDownloaded(id)) {
24
- result.add(id);
25
- }
22
+ result.add(EngineId.system);
23
+ final downloadedVariants = await kittenDownloader.getDownloadedVariants();
24
+ if (downloadedVariants.isNotEmpty) {
25
+ result.add(EngineId.kitten);
26
+ }
27
+ final downloadedKokoroVariants = await kokoroDownloader.getDownloadedVariants();
28
+ if (downloadedKokoroVariants.isNotEmpty) {
29
+ result.add(EngineId.kokoro);
30
+ }
31
+ if (await downloader.isEngineDownloaded(EngineId.piper)) {
32
+ result.add(EngineId.piper);
33
}
34
return result;
35
});
@@ -40,19 +47,13 @@ class TtsModelManagerScreen extends ConsumerWidget {
47
body: ListView(
48
padding: const EdgeInsets.all(16),
49
children: [
43
- _PhonemizationSettingsCard(),
44
- const SizedBox(height: 16),
50
_SystemEngineCard(isSelected: settings.ttsEngine == EngineId.system),
51
const SizedBox(height: 16),
47
- _KittenEngineCard(
48
- isSelected: settings.ttsEngine == EngineId.kitten,
49
- ),
52
+ _KittenEngineCard(isSelected: settings.ttsEngine == EngineId.kitten),
53
const SizedBox(height: 16),
54
_KokoroEngineCard(isSelected: settings.ttsEngine == EngineId.kokoro),
55
const SizedBox(height: 16),
53
- _SupertonicEngineCard(
54
- isSelected: settings.ttsEngine == EngineId.supertonic,
55
- ),
56
+ _PiperEngineCard(isSelected: settings.ttsEngine == EngineId.piper),
57
const SizedBox(height: 24),
58
],
59
),
@@ -101,7 +102,8 @@ class _SystemVoicesList extends ConsumerWidget {
102
return Padding(
103
padding: const EdgeInsets.only(top: 12),
104
child: Text(
104
- 'Uses your device\'s built-in text-to-speech engine.\nNo downloads required.',
105
+ 'Uses your device\'s built-in text-to-speech engine.\n'
106
+ 'No downloads required. Voice selection uses your device\'s system settings.',
107
style: Theme.of(context).textTheme.bodySmall?.copyWith(
108
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
109
),
@@ -117,20 +119,26 @@ class _KittenEngineCard extends ConsumerWidget {
119
120
const _KittenEngineCard({required this.isSelected});
121
122
+ KittenTtsModel _selectedModel(AppSettings settings) {
123
+ final variant = settings.kittenTtsModelVariant;
124
+ return KittenTtsModel.allModels.firstWhere((m) => m.variant == variant);
125
+ }
126
+
127
@override
128
Widget build(BuildContext context, WidgetRef ref) {
129
+ final settings = ref.watch(settingsProvider);
130
final meta = EngineMeta.kitten;
123
- final installedAsync = ref.watch(installedEnginesProvider);
124
- final downloadProgress = ref.watch(engineDownloadProgressProvider);
125
- final isInstalled = installedAsync.when(
126
- data: (set) => set.contains(EngineId.kitten),
131
+ final downloadedAsync = ref.watch(downloadedKittenTtsVariantsProvider);
132
+ final downloadProgress = ref.watch(ttsDownloadProgressProvider);
133
+ final model = _selectedModel(settings);
134
+ final variantProgress = downloadProgress[model.variant];
135
+ final isInstalled = downloadedAsync.when(
136
+ data: (set) => set.contains(model.variant),
137
loading: () => false,
138
error: (_, _) => false,
139
);
130
- final progress = downloadProgress[EngineId.kitten];
131
- final isDownloading = ref
132
- .read(engineDownloadProgressProvider.notifier)
133
- .isDownloading(EngineId.kitten);
140
+ final notifier = ref.read(ttsDownloadProgressProvider.notifier);
141
+ final isDownloading = notifier.isDownloading(model.variant);
142
143
return _EngineCard(
144
isSelected: isSelected,
@@ -142,7 +150,7 @@ class _KittenEngineCard extends ConsumerWidget {
150
: isDownloading
151
? 'Downloading...'
152
: 'Not installed',
145
- trailing: _buildAction(ref, isInstalled, isDownloading, EngineId.kitten),
153
+ trailing: _buildAction(ref, isInstalled, isDownloading, model),
154
child: Column(
155
crossAxisAlignment: CrossAxisAlignment.start,
156
children: [
@@ -150,7 +158,7 @@ class _KittenEngineCard extends ConsumerWidget {
158
padding: const EdgeInsets.only(top: 12),
159
child: Text(
160
'Lightning-fast neural TTS with 8 expressive voices.\n'
153
- 'Requires ~57 MB download.',
161
+ 'Requires ${_formatSize(model.totalSizeBytes)} download.',
162
style: Theme.of(context).textTheme.bodySmall?.copyWith(
163
color: Theme.of(
164
context,
@@ -160,7 +168,7 @@ class _KittenEngineCard extends ConsumerWidget {
168
),
169
if (isDownloading) ...[
170
const SizedBox(height: 12),
163
- _buildDownloadProgress(context, ref, EngineId.kitten, progress),
171
+ _buildVariantDownloadProgress(context, ref, model, variantProgress),
172
],
173
if (isInstalled) ...[
174
_VoiceChips(voices: kittenVoices, engine: EngineId.kitten),
@@ -174,14 +182,13 @@ class _KittenEngineCard extends ConsumerWidget {
182
WidgetRef ref,
183
bool isInstalled,
184
bool isDownloading,
177
- EngineId engine,
185
+ KittenTtsModel model,
186
) {
187
+ final notifier = ref.read(ttsDownloadProgressProvider.notifier);
188
if (isDownloading) {
189
return ShadButton.outline(
190
size: ShadButtonSize.sm,
182
- onPressed: () => ref
183
- .read(engineDownloadProgressProvider.notifier)
184
- .cancelDownload(engine),
191
+ onPressed: () => notifier.cancelDownload(model.variant),
192
child: const Text('Cancel'),
193
);
194
}
@@ -190,28 +197,31 @@ class _KittenEngineCard extends ConsumerWidget {
197
return ShadButton.outline(
198
size: ShadButtonSize.sm,
199
onPressed: () =>
193
- ref.read(settingsProvider.notifier).setTtsEngine(engine),
200
+ ref.read(settingsProvider.notifier).setTtsEngine(EngineId.kitten),
201
child: const Text('Select'),
202
);
203
}
204
return ShadButton.outline(
205
size: ShadButtonSize.sm,
199
- onPressed: () => ref
200
- .read(engineDownloadProgressProvider.notifier)
201
- .startDownload(engine),
206
+ onPressed: () => notifier.startDownload(model),
207
child: const Text('Install'),
208
);
209
}
210
206
- Widget _buildDownloadProgress(
211
+ String _formatSize(int bytes) {
212
+ final mb = bytes / (1024 * 1024);
213
+ return '${mb.toStringAsFixed(0)} MB';
214
+ }
215
+
216
+ Widget _buildVariantDownloadProgress(
217
BuildContext context,
218
WidgetRef ref,
209
- EngineId engine,
210
- Map<String, FileProgress>? progress,
219
+ KittenTtsModel model,
220
+ Map<String, KittenTtsFileProgress>? progress,
221
) {
222
final fraction = ref
213
- .read(engineDownloadProgressProvider.notifier)
214
- .getOverallFraction(engine);
223
+ .read(ttsDownloadProgressProvider.notifier)
224
+ .getOverallFraction(model.variant);
225
return Column(
226
children: [
227
LinearProgressIndicator(value: fraction),
@@ -248,20 +258,26 @@ class _KokoroEngineCard extends ConsumerWidget {
258
259
const _KokoroEngineCard({required this.isSelected});
260
261
+ KokoroTtsModel _selectedModel(AppSettings settings) {
262
+ final variant = settings.kokoroTtsModelVariant;
263
+ return KokoroTtsModel.forVariant(variant);
264
+ }
265
+
266
@override
267
Widget build(BuildContext context, WidgetRef ref) {
268
+ final settings = ref.watch(settingsProvider);
269
final meta = EngineMeta.kokoro;
254
- final installedAsync = ref.watch(installedEnginesProvider);
255
- final downloadProgress = ref.watch(engineDownloadProgressProvider);
256
- final isInstalled = installedAsync.when(
257
- data: (set) => set.contains(EngineId.kokoro),
270
+ final downloadedAsync = ref.watch(downloadedKokoroTtsVariantsProvider);
271
+ final downloadProgress = ref.watch(kokoroTtsDownloadProgressProvider);
272
+ final model = _selectedModel(settings);
273
+ final variantProgress = downloadProgress[model.variant];
274
+ final isInstalled = downloadedAsync.when(
275
+ data: (set) => set.contains(model.variant),
276
loading: () => false,
277
error: (_, _) => false,
278
);
261
- final progress = downloadProgress[EngineId.kokoro];
262
- final isDownloading = ref
263
- .read(engineDownloadProgressProvider.notifier)
264
- .isDownloading(EngineId.kokoro);
279
+ final notifier = ref.read(kokoroTtsDownloadProgressProvider.notifier);
280
+ final isDownloading = notifier.isDownloading(model.variant);
281
282
return _EngineCard(
283
isSelected: isSelected,
@@ -273,7 +289,7 @@ class _KokoroEngineCard extends ConsumerWidget {
289
: isDownloading
290
? 'Downloading...'
291
: 'Not installed',
276
- trailing: _buildAction(ref, isInstalled, isDownloading, EngineId.kokoro),
292
+ trailing: _buildAction(ref, isInstalled, isDownloading, model),
293
child: Column(
294
crossAxisAlignment: CrossAxisAlignment.start,
295
children: [
@@ -281,17 +297,23 @@ class _KokoroEngineCard extends ConsumerWidget {
297
padding: const EdgeInsets.only(top: 12),
298
child: Text(
299
'Kokoro 82M parameter model with 22 expressive voices.\n'
284
- 'Requires ~170 MB download.',
300
+ 'Requires ${_formatSize(model.totalSizeBytes)} download.',
301
style: Theme.of(context).textTheme.bodySmall?.copyWith(
286
- color: Theme.of(
287
- context,
288
- ).colorScheme.onSurface.withValues(alpha: 0.6),
289
- ),
302
+ color: Theme.of(context)
303
+ .colorScheme
304
+ .onSurface
305
+ .withValues(alpha: 0.6),
306
+ ),
307
),
308
),
309
if (isDownloading) ...[
310
const SizedBox(height: 12),
294
- _buildDownloadProgress(context, ref, EngineId.kokoro, progress),
311
+ _buildVariantDownloadProgress(
312
+ context,
313
+ ref,
314
+ model,
315
+ variantProgress,
316
+ ),
317
],
318
if (isInstalled) ...[
319
_VoiceChips(voices: kokoroVoices, engine: EngineId.kokoro),
@@ -305,14 +327,13 @@ class _KokoroEngineCard extends ConsumerWidget {
327
WidgetRef ref,
328
bool isInstalled,
329
bool isDownloading,
308
- EngineId engine,
330
+ KokoroTtsModel model,
331
) {
332
+ final notifier = ref.read(kokoroTtsDownloadProgressProvider.notifier);
333
if (isDownloading) {
334
return ShadButton.outline(
335
size: ShadButtonSize.sm,
313
- onPressed: () => ref
314
- .read(engineDownloadProgressProvider.notifier)
315
- .cancelDownload(engine),
336
+ onPressed: () => notifier.cancelDownload(model.variant),
337
child: const Text('Cancel'),
338
);
339
}
@@ -321,28 +342,31 @@ class _KokoroEngineCard extends ConsumerWidget {
342
return ShadButton.outline(
343
size: ShadButtonSize.sm,
344
onPressed: () =>
324
- ref.read(settingsProvider.notifier).setTtsEngine(engine),
345
+ ref.read(settingsProvider.notifier).setTtsEngine(EngineId.kokoro),
346
child: const Text('Select'),
347
);
348
}
349
return ShadButton.outline(
350
size: ShadButtonSize.sm,
330
- onPressed: () => ref
331
- .read(engineDownloadProgressProvider.notifier)
332
- .startDownload(engine),
351
+ onPressed: () => notifier.startDownload(model.variant),
352
child: const Text('Install'),
353
);
354
}
355
337
- Widget _buildDownloadProgress(
356
+ String _formatSize(int bytes) {
357
+ final mb = bytes / (1024 * 1024);
358
+ return '${mb.toStringAsFixed(0)} MB';
359
+ }
360
+
361
+ Widget _buildVariantDownloadProgress(
362
BuildContext context,
363
WidgetRef ref,
340
- EngineId engine,
341
- Map<String, FileProgress>? progress,
364
+ KokoroTtsModel model,
365
+ Map<String, KokoroTtsFileProgress>? progress,
366
) {
367
final fraction = ref
344
- .read(engineDownloadProgressProvider.notifier)
345
- .getOverallFraction(engine);
368
+ .read(kokoroTtsDownloadProgressProvider.notifier)
369
+ .getOverallFraction(model.variant);
370
return Column(
371
children: [
372
LinearProgressIndicator(value: fraction),
@@ -372,140 +396,60 @@ class _KokoroEngineCard extends ConsumerWidget {
396
}
397
}
398
375
-// ── Supertonic Engine ──
399
+// ── Piper Engine ──
400
377
-class _SupertonicEngineCard extends ConsumerWidget {
401
+class _PiperEngineCard extends ConsumerWidget {
402
final bool isSelected;
403
380
- const _SupertonicEngineCard({required this.isSelected});
404
+ const _PiperEngineCard({required this.isSelected});
405
406
@override
407
Widget build(BuildContext context, WidgetRef ref) {
384
- final meta = EngineMeta.supertonic;
408
+ final meta = EngineMeta(
409
+ name: 'Piper TTS',
410
+ tagline: 'Open-source neural voices',
411
+ sizeMb: 50,
412
+ ramMb: 100,
413
+ voiceCount: 100,
414
+ accentColor: 0xFF4CAF50,
415
+ );
416
final installedAsync = ref.watch(installedEnginesProvider);
386
- final downloadProgress = ref.watch(engineDownloadProgressProvider);
417
final isInstalled = installedAsync.when(
388
- data: (set) => set.contains(EngineId.supertonic),
418
+ data: (set) => set.contains(EngineId.piper),
419
loading: () => false,
420
error: (_, _) => false,
421
);
392
- final progress = downloadProgress[EngineId.supertonic];
393
- final isDownloading = ref
394
- .read(engineDownloadProgressProvider.notifier)
395
- .isDownloading(EngineId.supertonic);
422
423
return _EngineCard(
424
isSelected: isSelected,
425
meta: meta,
400
- engine: EngineId.supertonic,
426
+ engine: EngineId.piper,
427
installed: isInstalled,
402
- statusText: isInstalled
403
- ? 'Installed'
404
- : isDownloading
405
- ? 'Downloading...'
406
- : 'Not installed',
407
- trailing: _buildAction(
408
- ref,
409
- isInstalled,
410
- isDownloading,
411
- EngineId.supertonic,
412
- ),
413
- child: Column(
414
- crossAxisAlignment: CrossAxisAlignment.start,
415
- children: [
416
- Padding(
417
- padding: const EdgeInsets.only(top: 12),
418
- child: Text(
419
- 'Studio-quality multilingual TTS with 10 voices.\n'
420
- 'Supports EN, KO, ES, PT, FR. Requires ~265 MB download.',
421
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
422
- color: Theme.of(
423
- context,
424
- ).colorScheme.onSurface.withValues(alpha: 0.6),
428
+ statusText: isInstalled ? 'Installed' : 'Not installed',
429
+ trailing: isInstalled && !isSelected
430
+ ? ShadButton.outline(
431
+ size: ShadButtonSize.sm,
432
+ onPressed: () => ref
433
+ .read(settingsProvider.notifier)
434
+ .setTtsEngine(EngineId.piper),
435
+ child: const Text('Select'),
436
+ )
437
+ : null,
438
+ child: Padding(
439
+ padding: const EdgeInsets.only(top: 12),
440
+ child: Text(
441
+ 'One ONNX model per voice. 30+ languages, hundreds of voices.\n'
442
+ 'Place model files in the tts_models/piper directory.',
443
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
444
+ color: Theme.of(context)
445
+ .colorScheme
446
+ .onSurface
447
+ .withValues(alpha: 0.6),
448
),
426
- ),
427
- ),
428
- if (isDownloading) ...[
429
- const SizedBox(height: 12),
430
- _buildDownloadProgress(context, ref, EngineId.supertonic, progress),
431
- ],
432
- if (isInstalled) ...[
433
- _VoiceChips(voices: supertonicVoices, engine: EngineId.supertonic),
434
- ],
435
- ],
449
+ ),
450
),
451
);
452
}
439
-
440
- Widget _buildAction(
441
- WidgetRef ref,
442
- bool isInstalled,
443
- bool isDownloading,
444
- EngineId engine,
445
- ) {
446
- if (isDownloading) {
447
- return ShadButton.outline(
448
- size: ShadButtonSize.sm,
449
- onPressed: () => ref
450
- .read(engineDownloadProgressProvider.notifier)
451
- .cancelDownload(engine),
452
- child: const Text('Cancel'),
453
- );
454
- }
455
- if (isInstalled) {
456
- if (isSelected) return const SizedBox.shrink();
457
- return ShadButton.outline(
458
- size: ShadButtonSize.sm,
459
- onPressed: () =>
460
- ref.read(settingsProvider.notifier).setTtsEngine(engine),
461
- child: const Text('Select'),
462
- );
463
- }
464
- return ShadButton.outline(
465
- size: ShadButtonSize.sm,
466
- onPressed: () => ref
467
- .read(engineDownloadProgressProvider.notifier)
468
- .startDownload(engine),
469
- child: const Text('Install'),
470
- );
471
- }
472
-
473
- Widget _buildDownloadProgress(
474
- BuildContext context,
475
- WidgetRef ref,
476
- EngineId engine,
477
- Map<String, FileProgress>? progress,
478
- ) {
479
- final fraction = ref
480
- .read(engineDownloadProgressProvider.notifier)
481
- .getOverallFraction(engine);
482
- return Column(
483
- children: [
484
- LinearProgressIndicator(value: fraction),
485
- const SizedBox(height: 4),
486
- if (progress != null)
487
- ...progress.entries.map(
488
- (e) => Padding(
489
- padding: const EdgeInsets.only(bottom: 2),
490
- child: Row(
491
- children: [
492
- Expanded(
493
- child: Text(
494
- e.key,
495
- style: Theme.of(context).textTheme.labelSmall,
496
- ),
497
- ),
498
- Text(
499
- '${(e.value.fraction * 100).toStringAsFixed(0)}%',
500
- style: Theme.of(context).textTheme.labelSmall,
501
- ),
502
- ],
503
- ),
504
- ),
505
- ),
506
- ],
507
- );
508
- }
453
}
454
455
// ── Shared Widgets ──
@@ -637,10 +581,10 @@ class _EngineCard extends StatelessWidget {
581
),
582
),
583
const Spacer(),
640
- if (trailing != null) trailing!,
584
+ ?trailing,
585
],
586
),
643
- if (child != null) child!,
587
+ ?child,
588
],
589
),
590
);
@@ -658,18 +602,15 @@ class _VoiceChips extends ConsumerStatefulWidget {
602
}
603
604
class _VoiceChipsState extends ConsumerState<_VoiceChips> {
661
- final AudioPlayer _player = AudioPlayer();
605
Voice? _playingVoice;
606
664
- @override
665
- void dispose() {
666
- _player.dispose();
667
- super.dispose();
668
- }
669
-
607
@override
608
Widget build(BuildContext context) {
609
final theme = Theme.of(context);
610
+ final settings = ref.watch(settingsProvider);
611
+ final selectedVoiceId = widget.engine == settings.ttsEngine
612
+ ? settings.ttsVoiceId
613
+ : null;
614
final females = widget.voices.where((v) => v.gender == 'f').toList();
615
final males = widget.voices.where((v) => v.gender == 'm').toList();
616
final accent = Color(EngineMeta.forEngine(widget.engine).accentColor);
@@ -691,7 +632,7 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
632
spacing: 6,
633
runSpacing: 6,
634
children: females
694
- .map((v) => _voiceChip(v, theme, accent))
635
+ .map((v) => _voiceChip(v, theme, accent, selectedVoiceId))
636
.toList(),
637
),
638
const SizedBox(height: 8),
@@ -707,7 +648,9 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
648
Wrap(
649
spacing: 6,
650
runSpacing: 6,
710
- children: males.map((v) => _voiceChip(v, theme, accent)).toList(),
651
+ children: males
652
+ .map((v) => _voiceChip(v, theme, accent, selectedVoiceId))
653
+ .toList(),
654
),
655
],
656
],
@@ -715,42 +658,69 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
658
);
659
}
660
718
- Widget _voiceChip(Voice voice, ThemeData theme, Color accent) {
661
+ Widget _voiceChip(
662
+ Voice voice,
663
+ ThemeData theme,
664
+ Color accent,
665
+ String? selectedVoiceId,
666
+ ) {
667
final isPlaying = _playingVoice == voice;
668
+ final isSelected = voice.id == selectedVoiceId && !isPlaying;
669
+
670
return ActionChip(
671
avatar: Icon(
722
- isPlaying ? Icons.stop : Icons.play_arrow,
672
+ isSelected
673
+ ? Icons.check_circle
674
+ : isPlaying
675
+ ? Icons.stop
676
+ : Icons.play_arrow,
677
size: 16,
724
- color: isPlaying
678
+ color: isSelected
679
+ ? Colors.green
680
+ : isPlaying
681
? accent
682
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
683
),
684
label: Text(voice.name),
685
labelStyle: theme.textTheme.labelSmall?.copyWith(
730
- color: isPlaying ? accent : theme.colorScheme.onSurface,
731
- fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal,
686
+ color: isSelected
687
+ ? Colors.green
688
+ : isPlaying
689
+ ? accent
690
+ : theme.colorScheme.onSurface,
691
+ fontWeight: isSelected || isPlaying
692
+ ? FontWeight.bold
693
+ : FontWeight.normal,
694
),
733
- backgroundColor: isPlaying
695
+ backgroundColor: isSelected
696
+ ? Colors.green.withValues(alpha: 0.1)
697
+ : isPlaying
698
? accent.withValues(alpha: 0.1)
699
: Colors.transparent,
700
side: BorderSide(
737
- color: isPlaying
701
+ color: isSelected
702
+ ? Colors.green.withValues(alpha: 0.5)
703
+ : isPlaying
704
? accent.withValues(alpha: 0.3)
705
: theme.colorScheme.outline.withValues(alpha: 0.2),
706
),
741
- onPressed: () => _togglePreview(voice),
707
+ onPressed: () => _selectAndPreview(voice),
708
);
709
}
710
745
- Future<void> _togglePreview(Voice voice) async {
711
+ Future<void> _selectAndPreview(Voice voice) async {
712
+ // Set as default voice for this engine
713
+ ref.read(settingsProvider.notifier).setTtsVoiceId(voice.id);
714
+
715
+ // Preview the voice
716
if (_playingVoice == voice) {
747
- await _player.stop();
748
- setState(() => _playingVoice = null);
717
+ await ref.read(tts.ttsProvider.notifier).stop();
718
+ if (mounted) setState(() => _playingVoice = null);
719
return;
720
}
721
try {
722
await ref.read(tts.ttsProvider.notifier).previewVoice(voice);
753
- setState(() => _playingVoice = voice);
723
+ if (mounted) setState(() => _playingVoice = voice);
724
Future.delayed(const Duration(seconds: 2), () {
725
if (mounted) setState(() => _playingVoice = null);
726
});
@@ -763,46 +733,3 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
733
}
734
}
735
}
766
-
767
-class _PhonemizationSettingsCard extends ConsumerWidget {
768
- @override
769
- Widget build(BuildContext context, WidgetRef ref) {
770
- final settings = ref.watch(settingsProvider);
771
-
772
- return ShadCard(
773
- title: const Text('Phonemization Settings'),
774
- description: const Text('Improve pronunciation using IPA phonemes.'),
775
- child: Column(
776
- children: [
777
- ShadSwitch(
778
- value: settings.usePhonemizer && settings.useEspeak,
779
- onChanged: (v) {
780
- if (v) {
781
- ref.read(settingsProvider.notifier).setUsePhonemizer(true);
782
- ref.read(settingsProvider.notifier).setUseEspeak(true);
783
- } else {
784
- ref.read(settingsProvider.notifier).setUsePhonemizer(false);
785
- }
786
- },
787
- label: const Text('Use Espeak-NG (Recommended)'),
788
- sublabel: const Text('High-fidelity IPA phonemization'),
789
- ),
790
- const SizedBox(height: 12),
791
- ShadSwitch(
792
- value: settings.usePhonemizer && !settings.useEspeak,
793
- onChanged: (v) {
794
- if (v) {
795
- ref.read(settingsProvider.notifier).setUsePhonemizer(true);
796
- ref.read(settingsProvider.notifier).setUseEspeak(false);
797
- } else {
798
- ref.read(settingsProvider.notifier).setUsePhonemizer(false);
799
- }
800
- },
801
- label: const Text('Use Rule-based (Legacy)'),
802
- sublabel: const Text('Lightweight but less accurate'),
803
- ),
804
- ],
805
- ),
806
- );
807
- }
808
-}
pubspec.lock
+31
-39
@@ -26,7 +26,7 @@ packages:
26
source: hosted
27
version: "2.1.2"
28
archive:
29
- dependency: transitive
29
+ dependency: "direct main"
30
description:
31
name: archive
32
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
@@ -329,8 +329,16 @@ packages:
329
url: "https://pub.dev"
330
source: hosted
331
version: "2.1.2"
332
- espeak:
332
+ dort:
333
dependency: transitive
334
+ description:
335
+ name: dort
336
+ sha256: "6fd4d405ef4f6a53b27761a5e5236081a251c0a0340b6a4f15a5926bea3cf307"
337
+ url: "https://pub.dev"
338
+ source: hosted
339
+ version: "0.1.1"
340
+ espeak:
341
+ dependency: "direct dev"
342
description:
343
name: espeak
344
sha256: b57d9da4a53e32b8db9a143463075f4510b44b4b5ea34c9ebd1d17a7ea36308a
@@ -467,14 +475,6 @@ packages:
475
url: "https://pub.dev"
476
source: hosted
477
version: "1.0.7"
470
- flutter_onnxruntime:
471
- dependency: transitive
472
- description:
473
- name: flutter_onnxruntime
474
- sha256: "72fad60739e01f2278c266f4ec386b392c5ecdfc33d44b4a155c609366f6ece5"
475
- url: "https://pub.dev"
476
- source: hosted
477
- version: "1.7.0"
478
flutter_plugin_android_lifecycle:
479
dependency: transitive
480
description:
@@ -781,6 +781,14 @@ packages:
781
url: "https://pub.dev"
782
source: hosted
783
version: "0.13.0"
784
+ meomeo:
785
+ dependency: "direct main"
786
+ description:
787
+ name: meomeo
788
+ sha256: "0b5bc652d52328348b794b9a2f6b4309a38e514b99bd39271f94430fa7dcb17b"
789
+ url: "https://pub.dev"
790
+ source: hosted
791
+ version: "0.3.2"
792
meta:
793
dependency: transitive
794
description:
@@ -805,14 +813,14 @@ packages:
813
url: "https://pub.dev"
814
source: hosted
815
version: "0.17.6"
808
- neural_tts:
809
- dependency: "direct main"
816
+ native_toolchain_rust:
817
+ dependency: transitive
818
description:
811
- name: neural_tts
812
- sha256: ad55a617d30b6f76e4f97e35c7e16c12fcdc8b64cc87b7ed9caa8e877ce5f6c7
819
+ name: native_toolchain_rust
820
+ sha256: "26d4dcae954328af4ebfa8fc56cca0bf74b9ae8b4a6132de5da2071ac3d237b3"
821
url: "https://pub.dev"
822
source: hosted
815
- version: "0.4.0"
823
+ version: "1.0.4"
824
node_preamble:
825
dependency: transitive
826
description:
@@ -821,14 +829,6 @@ packages:
829
url: "https://pub.dev"
830
source: hosted
831
version: "2.0.2"
824
- number_to_words_english:
825
- dependency: transitive
826
- description:
827
- name: number_to_words_english
828
- sha256: "21cd549603a37165caa21c01ab41150421b6378fdb9a533f725055fd2de6ed52"
829
- url: "https://pub.dev"
830
- source: hosted
831
- version: "3.0.4"
832
objectbox:
833
dependency: "direct main"
834
description:
@@ -957,22 +957,6 @@ packages:
957
url: "https://pub.dev"
958
source: hosted
959
version: "7.0.2"
960
- phonemize:
961
- dependency: transitive
962
- description:
963
- name: phonemize
964
- sha256: "793ceb10952d82919002f8599bd6e72781c983cec421c2155283325e7eba93ef"
965
- url: "https://pub.dev"
966
- source: hosted
967
- version: "1.0.0"
968
- pinyin:
969
- dependency: transitive
970
- description:
971
- name: pinyin
972
- sha256: "240f271a3c71af20c8d2757756b5ee8e9d79a955d37abad4b3568fd406b22411"
973
- url: "https://pub.dev"
974
- source: hosted
975
- version: "3.3.0"
960
platform:
961
dependency: transitive
962
description:
@@ -1299,6 +1283,14 @@ packages:
1283
url: "https://pub.dev"
1284
source: hosted
1285
version: "0.10.1"
1286
+ toml:
1287
+ dependency: transitive
1288
+ description:
1289
+ name: toml
1290
+ sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
1291
+ url: "https://pub.dev"
1292
+ source: hosted
1293
+ version: "0.18.0"
1294
two_dimensional_scrollables:
1295
dependency: transitive
1296
description:
pubspec.yaml
+4
-2
@@ -61,7 +61,8 @@ dependencies:
61
flutter_local_notifications: ^18.0.1
62
audioplayers: ^6.4.0
63
device_info_plus: ^12.4.0
64
- neural_tts: 0.4.0
64
+ meomeo: ^0.3.2
65
+ archive: ^4.0.2
66
67
dev_dependencies:
68
flutter_test:
@@ -74,6 +75,7 @@ dev_dependencies:
75
# rules and activating additional ones.
76
flutter_lints: ^6.0.0
77
build_runner: ^2.13.1
78
+ espeak: ^0.1.3
79
change_app_package_name: ^1.4.0
80
objectbox_generator: any
81
@@ -91,7 +93,7 @@ flutter:
93
assets:
94
- assets/google_fonts/
95
- assets/images/
94
- - assets/espeak-ng-data.zip
96
+ - assets/espeak-data.zip
97
# An image asset can refer to one or more resolution-specific "variants", see
98
# https://flutter.dev/to/resolution-aware-images
99