@tduncan / Cortana / commits / 6e89eb0

refactor: migrate TTS architecture to support multi-variant model downloads, replacing Supertonic with Piper and adding Kokoro support.

abdulmominsakib committed May 10, 2026 at 16:18 UTC 6e89eb049a85bc95b08e32588b8078eca291876c
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