19 files changed
+1943
-1158
assets/espeak-ng-data.zip
Binary files /dev/null and b/assets/espeak-ng-data.zip differ
lib/core/models/enums.dart
+47
-3
@@ -1,3 +1,5 @@
1
+import 'package:neural_tts/neural_tts.dart';
2
+
3
enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
4
5
enum ConnectionStatus { connected, disconnected, checking, error }
@@ -8,12 +10,10 @@ enum MessageStatus { sending, streaming, complete, error }
10
11
enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
12
11
-enum EngineStatus { notLoaded, loading, loaded, error }
13
+enum OnDeviceEngineStatus { notLoaded, loading, loaded, error }
14
15
enum LiteLmBackendType { cpu, gpu, npu }
16
15
-enum TtsEngine { system, kitten }
16
-
17
enum KittenTtsVoice {
18
bella,
19
jasper,
@@ -44,6 +44,50 @@ enum KittenTtsVoice {
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
+
70
+EngineId engineIdFromString(String value) {
71
+ switch (value) {
72
+ case 'kitten':
73
+ return EngineId.kitten;
74
+ case 'kokoro':
75
+ return EngineId.kokoro;
76
+ case 'supertonic':
77
+ return EngineId.supertonic;
78
+ default:
79
+ return EngineId.system;
80
+ }
81
+}
82
+
83
+Voice? voiceFromSettings(String? voiceId, EngineId engine) {
84
+ if (voiceId == null || voiceId.isEmpty) return null;
85
+ final voices = voicesForEngine(engine);
86
+ try {
87
+ return voices.firstWhere((v) => v.id == voiceId);
88
+ } catch (_) {
89
+ return null;
90
+ }
91
}
92
93
enum OnDeviceModelState {
lib/core/providers/app_providers.dart
+14
-6
@@ -1,4 +1,6 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:neural_tts/neural_tts.dart';
3
+
4
import '../../features/settings/data/models/app_settings.dart';
5
import '../models/enums.dart';
6
import '../../features/tts/data/kitten_tts_model.dart';
@@ -86,14 +88,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
88
_update(state.copyWith(codeThemeLight: value));
89
void setPreferredBackend(LiteLmBackendType value) =>
90
_update(state.copyWith(preferredBackend: value));
89
- void setTtsEngine(TtsEngine value) =>
90
- _update(state.copyWith(ttsEngine: value));
91
- void setKittenTtsVoice(KittenTtsVoice value) =>
92
- _update(state.copyWith(kittenTtsVoice: value));
93
- void setKittenTtsSpeed(double value) =>
94
- _update(state.copyWith(kittenTtsSpeed: value));
91
+ void setTtsEngine(EngineId value) =>
92
+ _update(state.copyWith(ttsEngine: value, ttsVoiceId: null));
93
+ void setTtsVoiceId(String? value) =>
94
+ _update(state.copyWith(ttsVoiceId: value));
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));
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));
105
106
Future<void> _update(AppSettings updated) async {
107
state = updated;
lib/features/chat/views/components/message_action_bar.dart
+5
-5
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
2
import 'package:flutter/services.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:shadcn_ui/shadcn_ui.dart';
5
-import '../../../tts/providers/tts_providers.dart';
5
+import '../../../tts/providers/tts_providers.dart' as tts;
6
7
class MessageActionBar extends ConsumerStatefulWidget {
8
const MessageActionBar({
@@ -31,8 +31,8 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
31
bool _isInitializing = false;
32
33
void _toggleTts() async {
34
- final ttsNotifier = ref.read(unifiedTtsProvider.notifier);
35
- final ttsState = ref.read(unifiedTtsProvider);
34
+ final ttsNotifier = ref.read(tts.ttsProvider.notifier);
35
+ final ttsState = ref.read(tts.ttsProvider);
36
37
if (_isSpeaking || ttsState.isSpeaking) {
38
await ttsNotifier.stop();
@@ -57,7 +57,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
57
58
@override
59
Widget build(BuildContext context) {
60
- final ttsState = ref.watch(unifiedTtsProvider);
60
+ final ttsState = ref.watch(tts.ttsProvider);
61
final isTtsActive = _isSpeaking || ttsState.isSpeaking;
62
63
return Row(
@@ -152,7 +152,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
152
}
153
154
void _showMoreOptions(BuildContext context) {
155
- final ttsState = ref.read(unifiedTtsProvider);
155
+ final ttsState = ref.read(tts.ttsProvider);
156
final isTtsActive = _isSpeaking || ttsState.isSpeaking;
157
158
showShadSheet(
lib/features/models/screens/model_picker_sheet.dart
+1
-1
@@ -712,7 +712,7 @@ class _OnDeviceModelList extends ConsumerWidget {
712
final isDownloaded = downloadedIds.contains(model.id);
713
final isLoaded = engineState.loadedModelId == model.id;
714
final isCurrentlyLoading =
715
- engineState.status == EngineStatus.loading &&
715
+ engineState.status == OnDeviceEngineStatus.loading &&
716
engineState.loadedModelId == model.id;
717
final isSelected = selectedModelId == model.id;
718
lib/features/on_device/data/models/on_device_model.dart
+3
-3
@@ -119,7 +119,7 @@ class OnDeviceModelStateInfo {
119
final double downloadProgress;
120
final String? error;
121
final LiteLmBackendType backend;
122
- final EngineStatus engineStatus;
122
+ final OnDeviceEngineStatus engineStatus;
123
124
const OnDeviceModelStateInfo({
125
required this.modelId,
@@ -127,7 +127,7 @@ class OnDeviceModelStateInfo {
127
this.downloadProgress = 0.0,
128
this.error,
129
this.backend = LiteLmBackendType.cpu,
130
- this.engineStatus = EngineStatus.notLoaded,
130
+ this.engineStatus = OnDeviceEngineStatus.notLoaded,
131
});
132
133
OnDeviceModelStateInfo copyWith({
@@ -136,7 +136,7 @@ class OnDeviceModelStateInfo {
136
double? downloadProgress,
137
String? error,
138
LiteLmBackendType? backend,
139
- EngineStatus? engineStatus,
139
+ OnDeviceEngineStatus? engineStatus,
140
}) {
141
return OnDeviceModelStateInfo(
142
modelId: modelId ?? this.modelId,
lib/features/on_device/providers/on_device_providers.dart
+8
-8
@@ -61,14 +61,14 @@ final onDeviceModelStateProvider =
61
});
62
63
class OnDeviceEngineState {
64
- final EngineStatus status;
64
+ final OnDeviceEngineStatus status;
65
final String? loadedModelId;
66
final String? loadedModelPath;
67
final LiteLmBackendType? backend;
68
final String? error;
69
70
const OnDeviceEngineState({
71
- this.status = EngineStatus.notLoaded,
71
+ this.status = OnDeviceEngineStatus.notLoaded,
72
this.loadedModelId,
73
this.loadedModelPath,
74
this.backend,
@@ -76,7 +76,7 @@ class OnDeviceEngineState {
76
});
77
78
OnDeviceEngineState copyWith({
79
- EngineStatus? status,
79
+ OnDeviceEngineStatus? status,
80
String? loadedModelId,
81
String? loadedModelPath,
82
LiteLmBackendType? backend,
@@ -106,13 +106,13 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
106
}
107
108
Future<void> loadModel(String modelId, LiteLmBackendType backend) async {
109
- state = state.copyWith(status: EngineStatus.loading, error: null);
109
+ state = state.copyWith(status: OnDeviceEngineStatus.loading, error: null);
110
111
try {
112
final modelPath = await OnDeviceEngineService.getModelPath(modelId);
113
if (modelPath == null) {
114
state = state.copyWith(
115
- status: EngineStatus.error,
115
+ status: OnDeviceEngineStatus.error,
116
error: 'Model not found. Please download it first.',
117
);
118
return;
@@ -121,7 +121,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
121
await engineService.createEngine(modelPath, backend);
122
123
state = state.copyWith(
124
- status: EngineStatus.loaded,
124
+ status: OnDeviceEngineStatus.loaded,
125
loadedModelId: modelId,
126
loadedModelPath: modelPath,
127
backend: backend,
@@ -131,7 +131,7 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
131
} catch (e) {
132
Log.error('Failed to load model $modelId: $e');
133
state = state.copyWith(
134
- status: EngineStatus.error,
134
+ status: OnDeviceEngineStatus.error,
135
error: 'Failed to load model: ${e.toString()}',
136
);
137
}
@@ -163,7 +163,7 @@ class OnDeviceModelStateNotifier
163
double? downloadProgress,
164
String? error,
165
LiteLmBackendType? backend,
166
- EngineStatus? engineStatus,
166
+ OnDeviceEngineStatus? engineStatus,
167
}) {
168
final current =
169
state[modelId] ?? OnDeviceModelStateInfo(modelId: modelId);
lib/features/on_device/views/model_manager_screen.dart
+4
-4
@@ -57,7 +57,7 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
57
),
58
),
59
_buildMemoryInfo(context, ref, deviceMemoryAsync),
60
- if (engineState.status == EngineStatus.loaded)
60
+ if (engineState.status == OnDeviceEngineStatus.loaded)
61
Container(
62
padding: const EdgeInsets.all(12),
63
margin: const EdgeInsets.only(bottom: 16),
@@ -79,12 +79,12 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
79
],
80
),
81
)
82
- else if (engineState.status == EngineStatus.loading)
82
+ else if (engineState.status == OnDeviceEngineStatus.loading)
83
const Padding(
84
padding: EdgeInsets.only(bottom: 16),
85
child: LinearProgressIndicator(),
86
)
87
- else if (engineState.status == EngineStatus.error)
87
+ else if (engineState.status == OnDeviceEngineStatus.error)
88
Container(
89
padding: const EdgeInsets.all(12),
90
margin: const EdgeInsets.only(bottom: 16),
@@ -422,7 +422,7 @@ class _ModelCard extends ConsumerWidget {
422
);
423
424
final isLoading =
425
- engineState.status == EngineStatus.loading &&
425
+ engineState.status == OnDeviceEngineStatus.loading &&
426
engineState.loadedModelId == model.id;
427
428
final isDownloading =
lib/features/settings/data/models/app_settings.dart
+55
-22
@@ -1,14 +1,12 @@
1
import 'dart:convert';
2
3
import 'package:flutter/material.dart';
4
+import 'package:neural_tts/neural_tts.dart';
5
6
import '../../../../core/models/enums.dart';
7
import '../../../../features/tts/data/kitten_tts_model.dart';
8
8
-enum SyntaxThemeName {
9
- light,
10
- dark,
11
-}
9
+enum SyntaxThemeName { light, dark }
10
11
class AppSettings {
12
final double temperature;
@@ -32,10 +30,14 @@ class AppSettings {
30
final SyntaxThemeName codeThemeDark;
31
final SyntaxThemeName codeThemeLight;
32
final LiteLmBackendType preferredBackend;
35
- final TtsEngine ttsEngine;
36
- final KittenTtsVoice kittenTtsVoice;
37
- final double kittenTtsSpeed;
33
+ final EngineId ttsEngine;
34
+ final String? ttsVoiceId;
35
+ final double ttsSpeed;
36
final KittenTtsModelVariant kittenTtsModelVariant;
37
+ final int supertonicSteps;
38
+ final bool autoSpeakEnabled;
39
+ final bool usePhonemizer;
40
+ final bool useEspeak;
41
42
AppSettings({
43
this.temperature = 0.7,
@@ -59,10 +61,14 @@ class AppSettings {
61
this.codeThemeDark = SyntaxThemeName.dark,
62
this.codeThemeLight = SyntaxThemeName.light,
63
this.preferredBackend = LiteLmBackendType.cpu,
62
- this.ttsEngine = TtsEngine.system,
63
- this.kittenTtsVoice = KittenTtsVoice.bella,
64
- this.kittenTtsSpeed = 1.0,
64
+ this.ttsEngine = EngineId.system,
65
+ this.ttsVoiceId,
66
+ this.ttsSpeed = 1.0,
67
this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
68
+ this.supertonicSteps = 5,
69
+ this.autoSpeakEnabled = false,
70
+ this.usePhonemizer = true,
71
+ this.useEspeak = true,
72
});
73
74
AppSettings copyWith({
@@ -87,10 +93,14 @@ class AppSettings {
93
SyntaxThemeName? codeThemeDark,
94
SyntaxThemeName? codeThemeLight,
95
LiteLmBackendType? preferredBackend,
90
- TtsEngine? ttsEngine,
91
- KittenTtsVoice? kittenTtsVoice,
92
- double? kittenTtsSpeed,
96
+ EngineId? ttsEngine,
97
+ String? ttsVoiceId,
98
+ double? ttsSpeed,
99
KittenTtsModelVariant? kittenTtsModelVariant,
100
+ int? supertonicSteps,
101
+ bool? autoSpeakEnabled,
102
+ bool? usePhonemizer,
103
+ bool? useEspeak,
104
}) {
105
return AppSettings(
106
temperature: temperature ?? this.temperature,
@@ -118,9 +128,14 @@ class AppSettings {
128
codeThemeLight: codeThemeLight ?? this.codeThemeLight,
129
preferredBackend: preferredBackend ?? this.preferredBackend,
130
ttsEngine: ttsEngine ?? this.ttsEngine,
121
- kittenTtsVoice: kittenTtsVoice ?? this.kittenTtsVoice,
122
- kittenTtsSpeed: kittenTtsSpeed ?? this.kittenTtsSpeed,
123
- kittenTtsModelVariant: kittenTtsModelVariant ?? this.kittenTtsModelVariant,
131
+ ttsVoiceId: ttsVoiceId ?? this.ttsVoiceId,
132
+ ttsSpeed: ttsSpeed ?? this.ttsSpeed,
133
+ kittenTtsModelVariant:
134
+ kittenTtsModelVariant ?? this.kittenTtsModelVariant,
135
+ supertonicSteps: supertonicSteps ?? this.supertonicSteps,
136
+ autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
137
+ usePhonemizer: usePhonemizer ?? this.usePhonemizer,
138
+ useEspeak: useEspeak ?? this.useEspeak,
139
);
140
}
141
@@ -147,10 +162,14 @@ class AppSettings {
162
'codeThemeDark': codeThemeDark.index,
163
'codeThemeLight': codeThemeLight.index,
164
'preferredBackend': preferredBackend.index,
150
- 'ttsEngine': ttsEngine.index,
151
- 'kittenTtsVoice': kittenTtsVoice.index,
152
- 'kittenTtsSpeed': kittenTtsSpeed,
165
+ 'ttsEngine': ttsEngine.name,
166
+ 'ttsVoiceId': ttsVoiceId,
167
+ 'ttsSpeed': ttsSpeed,
168
'kittenTtsModelVariant': kittenTtsModelVariant.name,
169
+ 'supertonicSteps': supertonicSteps,
170
+ 'autoSpeakEnabled': autoSpeakEnabled,
171
+ 'usePhonemizer': usePhonemizer,
172
+ 'useEspeak': useEspeak,
173
};
174
}
175
@@ -177,13 +196,27 @@ class AppSettings {
196
codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
197
codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
198
preferredBackend: LiteLmBackendType.values[map['preferredBackend'] ?? 0],
180
- ttsEngine: TtsEngine.values[map['ttsEngine'] ?? 0],
181
- kittenTtsVoice: KittenTtsVoice.values[map['kittenTtsVoice'] ?? 0],
182
- kittenTtsSpeed: map['kittenTtsSpeed']?.toDouble() ?? 1.0,
199
+ ttsEngine: _parseEngine(map['ttsEngine']),
200
+ ttsVoiceId: map['ttsVoiceId'],
201
+ ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
202
kittenTtsModelVariant: _parseVariant(map['kittenTtsModelVariant']),
203
+ supertonicSteps: map['supertonicSteps'] ?? 5,
204
+ autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
205
+ usePhonemizer: map['usePhonemizer'] ?? true,
206
+ useEspeak: map['useEspeak'] ?? true,
207
);
208
}
209
210
+ static EngineId _parseEngine(dynamic value) {
211
+ if (value is String) {
212
+ return engineIdFromString(value);
213
+ }
214
+ if (value is int) {
215
+ return EngineId.values[value];
216
+ }
217
+ return EngineId.system;
218
+ }
219
+
220
static KittenTtsModelVariant _parseVariant(dynamic value) {
221
if (value is String) {
222
try {
lib/features/settings/views/settings_screen.dart
+248
-182
@@ -2,6 +2,7 @@ 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';
6
import '../../../core/providers/app_providers.dart';
7
import '../../../core/models/enums.dart';
8
import '../../../core/routes/app_routes.dart';
@@ -58,9 +59,10 @@ class SettingsViews extends ConsumerWidget {
59
),
60
const Divider(height: 32),
61
_SectionHeader(title: 'Text-to-Speech'),
61
- _TtsEngineToggle(
62
+ _EngineDropdown(
63
current: settings.ttsEngine,
63
- ref: ref,
64
+ onChanged: (v) =>
65
+ ref.read(settingsProvider.notifier).setTtsEngine(v),
66
isDark: isDark,
67
),
68
Padding(
@@ -77,26 +79,42 @@ class SettingsViews extends ConsumerWidget {
79
),
80
),
81
),
80
- if (settings.ttsEngine == TtsEngine.kitten) ...[
82
+ if (settings.ttsEngine != EngineId.system) ...[
83
const SizedBox(height: 8),
82
- _KittenVoiceDropdown(
83
- current: settings.kittenTtsVoice,
84
+ _VoiceSelector(
85
+ engine: settings.ttsEngine,
86
+ currentVoiceId: settings.ttsVoiceId,
87
onChanged: (v) =>
85
- ref.read(settingsProvider.notifier).setKittenTtsVoice(v),
88
+ ref.read(settingsProvider.notifier).setTtsVoiceId(v?.id),
89
isDark: isDark,
90
),
91
_SliderSetting(
89
- label: 'Kitten TTS Speed',
90
- value: settings.kittenTtsSpeed,
92
+ label: 'TTS Speed',
93
+ value: settings.ttsSpeed,
94
min: 0.5,
95
max: 2.0,
96
divisions: 15,
94
- description: 'Adjust the playback rate of neural TTS.',
97
+ description: 'Adjust the playback rate.',
98
onChanged: (v) =>
96
- ref.read(settingsProvider.notifier).setKittenTtsSpeed(v),
99
+ ref.read(settingsProvider.notifier).setTtsSpeed(v),
100
isDark: isDark,
101
valueFormat: (v) => '${v.toStringAsFixed(2)}x',
102
),
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
+ ),
118
],
119
const Divider(height: 32),
120
_SectionHeader(title: 'Behavior'),
@@ -166,7 +184,7 @@ class SettingsViews extends ConsumerWidget {
184
),
185
),
186
),
169
- const _OnDeviceEngineStatus(),
187
+ const _OnDeviceOnDeviceEngineStatus(),
188
const Divider(height: 32),
189
_SectionHeader(title: 'Default Server'),
190
_DropdownSetting(
@@ -898,21 +916,19 @@ class _DangerousAction extends StatelessWidget {
916
}
917
}
918
901
-class _TtsEngineToggle extends StatelessWidget {
902
- const _TtsEngineToggle({
919
+class _EngineDropdown extends StatelessWidget {
920
+ const _EngineDropdown({
921
required this.current,
904
- required this.ref,
922
+ required this.onChanged,
923
required this.isDark,
924
});
925
908
- final TtsEngine current;
909
- final WidgetRef ref;
926
+ final EngineId current;
927
+ final ValueChanged<EngineId> onChanged;
928
final bool isDark;
929
930
@override
931
Widget build(BuildContext context) {
914
- final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
915
-
932
return Padding(
933
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
934
child: Column(
@@ -927,125 +943,73 @@ class _TtsEngineToggle extends StatelessWidget {
943
),
944
),
945
const SizedBox(height: 8),
930
- Row(
931
- children: [
932
- Expanded(
933
- child: GestureDetector(
934
- onTap: () => ref
935
- .read(settingsProvider.notifier)
936
- .setTtsEngine(TtsEngine.system),
937
- child: Container(
938
- padding: const EdgeInsets.symmetric(vertical: 10),
939
- decoration: BoxDecoration(
940
- color: current == TtsEngine.system
941
- ? accent.withValues(alpha: 0.15)
942
- : (isDark
943
- ? const Color(0xFF1F1F1F)
944
- : const Color(0xFFF5F5F5)),
945
- borderRadius: BorderRadius.circular(8),
946
- border: Border.all(
947
- color: current == TtsEngine.system
948
- ? accent
949
- : (isDark
950
- ? const Color(0xFF3A3A3A)
951
- : const Color(0xFFE5E5E5)),
952
- ),
953
- ),
954
- child: Column(
955
- children: [
956
- Icon(
957
- Icons.record_voice_over,
958
- size: 20,
959
- color: current == TtsEngine.system
960
- ? accent
961
- : (isDark
962
- ? const Color(0xFF888888)
963
- : const Color(0xFF999999)),
964
- ),
965
- const SizedBox(height: 4),
966
- Text(
967
- 'System TTS',
968
- style: TextStyle(
969
- fontSize: 11,
970
- fontWeight: current == TtsEngine.system
971
- ? FontWeight.w600
972
- : FontWeight.normal,
973
- color: current == TtsEngine.system
974
- ? accent
975
- : (isDark
976
- ? const Color(0xFF888888)
977
- : const Color(0xFF999999)),
978
- ),
979
- ),
980
- ],
981
- ),
982
- ),
983
- ),
946
+ Container(
947
+ decoration: BoxDecoration(
948
+ color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
949
+ borderRadius: BorderRadius.circular(8),
950
+ border: Border.all(
951
+ color: isDark
952
+ ? const Color(0xFF3A3A3A)
953
+ : const Color(0xFFE5E5E5),
954
),
985
- const SizedBox(width: 8),
986
- Expanded(
987
- child: GestureDetector(
988
- onTap: () => ref
989
- .read(settingsProvider.notifier)
990
- .setTtsEngine(TtsEngine.kitten),
991
- child: Container(
992
- padding: const EdgeInsets.symmetric(vertical: 10),
993
- decoration: BoxDecoration(
994
- color: current == TtsEngine.kitten
995
- ? accent.withValues(alpha: 0.15)
996
- : (isDark
997
- ? const Color(0xFF1F1F1F)
998
- : const Color(0xFFF5F5F5)),
999
- borderRadius: BorderRadius.circular(8),
1000
- border: Border.all(
1001
- color: current == TtsEngine.kitten
1002
- ? accent
1003
- : (isDark
1004
- ? const Color(0xFF3A3A3A)
1005
- : const Color(0xFFE5E5E5)),
1006
- ),
1007
- ),
1008
- child: Column(
1009
- children: [
1010
- Icon(
1011
- Icons.auto_awesome,
1012
- size: 20,
1013
- color: current == TtsEngine.kitten
1014
- ? accent
1015
- : (isDark
1016
- ? const Color(0xFF888888)
1017
- : const Color(0xFF999999)),
1018
- ),
1019
- const SizedBox(height: 4),
1020
- Text(
1021
- 'Kitten TTS',
1022
- style: TextStyle(
1023
- fontSize: 11,
1024
- fontWeight: current == TtsEngine.kitten
1025
- ? FontWeight.w600
1026
- : FontWeight.normal,
1027
- color: current == TtsEngine.kitten
1028
- ? accent
1029
- : (isDark
1030
- ? const Color(0xFF888888)
1031
- : const Color(0xFF999999)),
1032
- ),
1033
- ),
1034
- ],
1035
- ),
955
+ ),
956
+ child: DropdownButtonHideUnderline(
957
+ child: DropdownButton<EngineId>(
958
+ value: current,
959
+ isExpanded: true,
960
+ padding: const EdgeInsets.symmetric(horizontal: 12),
961
+ borderRadius: BorderRadius.circular(8),
962
+ dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
963
+ items: [
964
+ _engineItem(
965
+ EngineId.system,
966
+ 'System TTS',
967
+ Icons.record_voice_over,
968
),
1037
- ),
969
+ _engineItem(
970
+ EngineId.kitten,
971
+ 'Kitten TTS',
972
+ Icons.auto_awesome,
973
+ ),
974
+ _engineItem(
975
+ EngineId.kokoro,
976
+ 'Kokoro TTS',
977
+ Icons.auto_awesome,
978
+ ),
979
+ _engineItem(
980
+ EngineId.supertonic,
981
+ 'Supertonic TTS',
982
+ Icons.auto_awesome,
983
+ ),
984
+ ],
985
+ onChanged: (v) {
986
+ if (v != null) onChanged(v);
987
+ },
988
),
1039
- ],
989
+ ),
990
),
1041
- const SizedBox(height: 4),
991
+ ],
992
+ ),
993
+ );
994
+ }
995
+
996
+ DropdownMenuItem<EngineId> _engineItem(
997
+ EngineId id,
998
+ String label,
999
+ IconData icon,
1000
+ ) {
1001
+ final meta = EngineMeta.forEngine(id);
1002
+ return DropdownMenuItem(
1003
+ value: id,
1004
+ child: Row(
1005
+ children: [
1006
+ Icon(icon, size: 16, color: Color(meta.accentColor)),
1007
+ const SizedBox(width: 8),
1008
Text(
1043
- current == TtsEngine.system
1044
- ? 'Uses your device\'s built-in text-to-speech engine.'
1045
- : 'Uses the Kitten neural TTS model running locally on your device.',
1009
+ label,
1010
style: TextStyle(
1047
- fontSize: 12,
1048
- color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
1011
+ fontSize: 14,
1012
+ color: isDark ? Colors.white : Colors.black,
1013
),
1014
),
1015
],
@@ -1054,26 +1018,35 @@ class _TtsEngineToggle extends StatelessWidget {
1018
}
1019
}
1020
1057
-class _KittenVoiceDropdown extends StatelessWidget {
1058
- const _KittenVoiceDropdown({
1059
- required this.current,
1021
+class _VoiceSelector extends StatelessWidget {
1022
+ const _VoiceSelector({
1023
+ required this.engine,
1024
+ required this.currentVoiceId,
1025
required this.onChanged,
1026
required this.isDark,
1027
});
1028
1064
- final KittenTtsVoice current;
1065
- final ValueChanged<KittenTtsVoice> onChanged;
1029
+ final EngineId engine;
1030
+ final String? currentVoiceId;
1031
+ final ValueChanged<Voice?> onChanged;
1032
final bool isDark;
1033
1034
@override
1035
Widget build(BuildContext context) {
1036
+ final voices = voicesForEngine(engine);
1037
+
1038
+ if (voices.isEmpty) return const SizedBox.shrink();
1039
+
1040
+ final femaleVoices = voices.where((v) => v.gender == 'f').toList();
1041
+ final maleVoices = voices.where((v) => v.gender == 'm').toList();
1042
+
1043
return Padding(
1044
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1045
child: Column(
1046
crossAxisAlignment: CrossAxisAlignment.start,
1047
children: [
1048
Text(
1076
- 'Neural Voice',
1049
+ 'Voice',
1050
style: TextStyle(
1051
fontSize: 14,
1052
fontWeight: FontWeight.w500,
@@ -1081,56 +1054,149 @@ class _KittenVoiceDropdown extends StatelessWidget {
1054
),
1055
),
1056
const SizedBox(height: 8),
1084
- Container(
1085
- decoration: BoxDecoration(
1086
- color: isDark ? const Color(0xFF1F1F1F) : const Color(0xFFF5F5F5),
1087
- borderRadius: BorderRadius.circular(8),
1088
- border: Border.all(
1057
+ if (femaleVoices.isNotEmpty) ...[
1058
+ Text(
1059
+ 'Female',
1060
+ style: TextStyle(
1061
+ fontSize: 11,
1062
color: isDark
1090
- ? const Color(0xFF3A3A3A)
1091
- : const Color(0xFFE5E5E5),
1063
+ ? const Color(0xFF888888)
1064
+ : const Color(0xFF999999),
1065
),
1066
),
1094
- child: DropdownButtonHideUnderline(
1095
- child: DropdownButton<KittenTtsVoice>(
1096
- value: current,
1097
- isExpanded: true,
1098
- padding: const EdgeInsets.symmetric(horizontal: 12),
1099
- borderRadius: BorderRadius.circular(8),
1100
- dropdownColor: isDark ? const Color(0xFF1F1F1F) : Colors.white,
1101
- items: KittenTtsVoice.values.map((voice) {
1102
- return DropdownMenuItem(
1103
- value: voice,
1104
- child: Text(
1105
- voice.displayName,
1106
- style: TextStyle(
1107
- fontSize: 14,
1108
- color: isDark ? Colors.white : Colors.black,
1109
- ),
1110
- ),
1111
- );
1112
- }).toList(),
1113
- onChanged: (v) {
1114
- if (v != null) onChanged(v);
1115
- },
1067
+ const SizedBox(height: 4),
1068
+ ...femaleVoices.map(
1069
+ (v) => _VoiceTile(
1070
+ voice: v,
1071
+ selected: v.id == currentVoiceId,
1072
+ onTap: () => onChanged(v),
1073
+ isDark: isDark,
1074
),
1075
),
1118
- ),
1076
+ const SizedBox(height: 8),
1077
+ ],
1078
+ if (maleVoices.isNotEmpty) ...[
1079
+ Text(
1080
+ 'Male',
1081
+ style: TextStyle(
1082
+ fontSize: 11,
1083
+ color: isDark
1084
+ ? const Color(0xFF888888)
1085
+ : const Color(0xFF999999),
1086
+ ),
1087
+ ),
1088
+ const SizedBox(height: 4),
1089
+ ...maleVoices.map(
1090
+ (v) => _VoiceTile(
1091
+ voice: v,
1092
+ selected: v.id == currentVoiceId,
1093
+ onTap: () => onChanged(v),
1094
+ isDark: isDark,
1095
+ ),
1096
+ ),
1097
+ ],
1098
],
1099
),
1100
);
1101
}
1102
}
1103
1125
-class _OnDeviceEngineStatus extends ConsumerWidget {
1126
- const _OnDeviceEngineStatus();
1104
+class _VoiceTile extends StatelessWidget {
1105
+ const _VoiceTile({
1106
+ required this.voice,
1107
+ required this.selected,
1108
+ required this.onTap,
1109
+ required this.isDark,
1110
+ });
1111
+
1112
+ final Voice voice;
1113
+ final bool selected;
1114
+ final VoidCallback onTap;
1115
+ final bool isDark;
1116
+
1117
+ @override
1118
+ Widget build(BuildContext context) {
1119
+ final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
1120
+ return Padding(
1121
+ padding: const EdgeInsets.only(bottom: 4),
1122
+ child: GestureDetector(
1123
+ onTap: onTap,
1124
+ child: Container(
1125
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1126
+ decoration: BoxDecoration(
1127
+ color: selected
1128
+ ? accent.withValues(alpha: 0.1)
1129
+ : (isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA)),
1130
+ borderRadius: BorderRadius.circular(6),
1131
+ border: Border.all(
1132
+ color: selected
1133
+ ? accent
1134
+ : (isDark
1135
+ ? const Color(0xFF3A3A3A)
1136
+ : const Color(0xFFE5E5E5)),
1137
+ ),
1138
+ ),
1139
+ child: Row(
1140
+ children: [
1141
+ Icon(
1142
+ selected ? Icons.radio_button_checked : Icons.radio_button_off,
1143
+ size: 16,
1144
+ color: selected
1145
+ ? accent
1146
+ : (isDark
1147
+ ? const Color(0xFF666666)
1148
+ : const Color(0xFF999999)),
1149
+ ),
1150
+ const SizedBox(width: 8),
1151
+ Text(
1152
+ voice.name,
1153
+ style: TextStyle(
1154
+ fontSize: 13,
1155
+ color: isDark ? Colors.white : Colors.black,
1156
+ fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
1157
+ ),
1158
+ ),
1159
+ if (voice.language != null) ...[
1160
+ const SizedBox(width: 6),
1161
+ Container(
1162
+ padding: const EdgeInsets.symmetric(
1163
+ horizontal: 4,
1164
+ vertical: 1,
1165
+ ),
1166
+ decoration: BoxDecoration(
1167
+ color: isDark
1168
+ ? const Color(0xFF2A2A2A)
1169
+ : const Color(0xFFEEEEEE),
1170
+ borderRadius: BorderRadius.circular(3),
1171
+ ),
1172
+ child: Text(
1173
+ voice.language!,
1174
+ style: TextStyle(
1175
+ fontSize: 9,
1176
+ color: isDark
1177
+ ? const Color(0xFF888888)
1178
+ : const Color(0xFF666666),
1179
+ ),
1180
+ ),
1181
+ ),
1182
+ ],
1183
+ ],
1184
+ ),
1185
+ ),
1186
+ ),
1187
+ );
1188
+ }
1189
+}
1190
+
1191
+class _OnDeviceOnDeviceEngineStatus extends ConsumerWidget {
1192
+ const _OnDeviceOnDeviceEngineStatus();
1193
1194
@override
1195
Widget build(BuildContext context, WidgetRef ref) {
1196
final engineState = ref.watch(onDeviceEngineProvider);
1197
final isDark = Theme.of(context).brightness == Brightness.dark;
1198
1133
- if (engineState.status == EngineStatus.notLoaded) {
1199
+ if (engineState.status == OnDeviceEngineStatus.notLoaded) {
1200
return Padding(
1201
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
1202
child: Text(
@@ -1148,16 +1214,16 @@ class _OnDeviceEngineStatus extends ConsumerWidget {
1214
child: Container(
1215
padding: const EdgeInsets.all(12),
1216
decoration: BoxDecoration(
1151
- color: engineState.status == EngineStatus.loaded
1217
+ color: engineState.status == OnDeviceEngineStatus.loaded
1218
? Colors.green.withValues(alpha: 0.1)
1153
- : engineState.status == EngineStatus.error
1219
+ : engineState.status == OnDeviceEngineStatus.error
1220
? Colors.red.withValues(alpha: 0.1)
1221
: Colors.blue.withValues(alpha: 0.1),
1222
borderRadius: BorderRadius.circular(8),
1223
border: Border.all(
1158
- color: engineState.status == EngineStatus.loaded
1224
+ color: engineState.status == OnDeviceEngineStatus.loaded
1225
? Colors.green
1160
- : engineState.status == EngineStatus.error
1226
+ : engineState.status == OnDeviceEngineStatus.error
1227
? Colors.red
1228
: Colors.blue,
1229
),
@@ -1165,31 +1231,31 @@ class _OnDeviceEngineStatus extends ConsumerWidget {
1231
child: Row(
1232
children: [
1233
Icon(
1168
- engineState.status == EngineStatus.loaded
1234
+ engineState.status == OnDeviceEngineStatus.loaded
1235
? Icons.check_circle
1170
- : engineState.status == EngineStatus.error
1236
+ : engineState.status == OnDeviceEngineStatus.error
1237
? Icons.error
1238
: Icons.hourglass_top,
1239
size: 18,
1174
- color: engineState.status == EngineStatus.loaded
1240
+ color: engineState.status == OnDeviceEngineStatus.loaded
1241
? Colors.green
1176
- : engineState.status == EngineStatus.error
1242
+ : engineState.status == OnDeviceEngineStatus.error
1243
? Colors.red
1244
: Colors.blue,
1245
),
1246
const SizedBox(width: 8),
1247
Expanded(
1248
child: Text(
1183
- engineState.status == EngineStatus.loaded
1249
+ engineState.status == OnDeviceEngineStatus.loaded
1250
? 'Loaded: ${engineState.loadedModelId ?? "unknown"} (${engineState.backend?.name ?? "CPU"})'
1185
- : engineState.status == EngineStatus.loading
1251
+ : engineState.status == OnDeviceEngineStatus.loading
1252
? 'Loading model...'
1253
: 'Error: ${engineState.error ?? "Unknown error"}',
1254
style: TextStyle(
1255
fontSize: 13,
1190
- color: engineState.status == EngineStatus.loaded
1256
+ color: engineState.status == OnDeviceEngineStatus.loaded
1257
? Colors.green
1192
- : engineState.status == EngineStatus.error
1258
+ : engineState.status == OnDeviceEngineStatus.error
1259
? Colors.red
1260
: Colors.blue,
1261
),
lib/features/tts/data/kitten_tts_model.dart
+4
-4
@@ -77,13 +77,13 @@ enum KittenTtsModelVariant {
77
String get huggingFaceRepoId {
78
switch (this) {
79
case KittenTtsModelVariant.nano:
80
- return 'KittenML/kitten-tts-nano-0.8-fp32';
80
+ return 'palshub/kitten-tts-nano-0.8-fp32';
81
case KittenTtsModelVariant.nanoInt8:
82
- return 'KittenML/kitten-tts-nano-0.8-int8';
82
+ return 'palshub/kitten-tts-nano-0.8-int8';
83
case KittenTtsModelVariant.micro:
84
- return 'KittenML/kitten-tts-micro-0.8';
84
+ return 'palshub/kitten-tts-micro-0.8';
85
case KittenTtsModelVariant.mini:
86
- return 'KittenML/kitten-tts-mini-0.8';
86
+ return 'palshub/kitten-tts-mini-0.8';
87
}
88
}
89
lib/features/tts/data/kitten_tts_service.dart
deleted
-321
@@ -1,321 +0,0 @@
1
-import 'dart:async';
2
-import 'dart:io';
3
-import 'dart:typed_data';
4
-
5
-import 'package:audioplayers/audioplayers.dart';
6
-import 'package:kitten_tts_flutter/kitten_tts_flutter.dart';
7
-import 'package:path_provider/path_provider.dart';
8
-
9
-import '../../../core/logger/app_logger.dart';
10
-import '../../../core/models/enums.dart';
11
-import 'kitten_tts_model.dart';
12
-import 'phonemizer_service.dart';
13
-
14
-/// Service that wraps the KittenTTS Flutter package for neural text-to-speech.
15
-///
16
-/// Model files are downloaded on-demand from HuggingFace and stored in the
17
-/// app support directory. Call [initialize] with a variant before use.
18
-///
19
-/// Usage:
20
-/// ```dart
21
-/// final service = KittenTtsService();
22
-/// await service.initialize(variant: KittenTtsModelVariant.nanoInt8);
23
-/// await service.speak('Hello world', voice: KittenTtsVoice.bella);
24
-/// await service.dispose();
25
-/// ```
26
-class KittenTtsService {
27
- KittenTtsFlutter? _tts;
28
- AudioPlayer? _audioPlayer;
29
- final PhonemizerService _phonemizer = PhonemizerService();
30
-
31
- bool _isInitialized = false;
32
- bool _isSpeaking = false;
33
- Future<void>? _initializeFuture;
34
- KittenTtsModelVariant? _currentVariant;
35
-
36
- bool get isInitialized => _isInitialized;
37
- bool get isSpeaking => _isSpeaking;
38
- KittenTtsModelVariant? get currentVariant => _currentVariant;
39
-
40
- /// Check whether all files for a variant exist on disk.
41
- static Future<bool> isVariantDownloaded(KittenTtsModelVariant variant) async {
42
- final supportDir = await getApplicationSupportDirectory();
43
- final ttsDir = Directory('${supportDir.path}/kitten_tts/${variant.dirName}');
44
- for (final file in variant.files) {
45
- if (!await File('${ttsDir.path}/${file.fileName}').exists()) {
46
- return false;
47
- }
48
- }
49
- return true;
50
- }
51
-
52
- /// Get the file paths for a variant, or null if not downloaded.
53
- static Future<({String config, String model, String voices})?> getVariantPaths(
54
- KittenTtsModelVariant variant,
55
- ) async {
56
- final supportDir = await getApplicationSupportDirectory();
57
- final ttsDir = Directory('${supportDir.path}/kitten_tts/${variant.dirName}');
58
- final configFile = File('${ttsDir.path}/config.json');
59
- final modelFile = File('${ttsDir.path}/${variant.modelFileName}');
60
- final voicesFile = File('${ttsDir.path}/voices.npz');
61
-
62
- if (!await configFile.exists() ||
63
- !await modelFile.exists() ||
64
- !await voicesFile.exists()) {
65
- return null;
66
- }
67
-
68
- return (
69
- config: configFile.path,
70
- model: modelFile.path,
71
- voices: voicesFile.path,
72
- );
73
- }
74
-
75
- /// Initialize (or re-initialize) the KittenTTS engine for [variant].
76
- ///
77
- /// Throws if the model files are not downloaded.
78
- Future<void> initialize({required KittenTtsModelVariant variant}) async {
79
- if (_isInitialized && _currentVariant == variant) return;
80
- if (_initializeFuture != null) return _initializeFuture;
81
-
82
- _initializeFuture = _doInitialize(variant);
83
- return _initializeFuture;
84
- }
85
-
86
- Future<void> _doInitialize(KittenTtsModelVariant variant) async {
87
- try {
88
- final paths = await getVariantPaths(variant);
89
- if (paths == null) {
90
- throw StateError(
91
- 'KittenTTS model "${variant.displayName}" is not downloaded. '
92
- 'Please download it from the TTS Models screen.',
93
- );
94
- }
95
-
96
- Log.info(
97
- 'KittenTTS initializing: variant=${variant.displayName}, '
98
- 'config=${paths.config}, model=${paths.model}, voices=${paths.voices}',
99
- );
100
-
101
- if (_tts != null && _currentVariant != null && _currentVariant != variant) {
102
- try {
103
- _tts?.release();
104
- } catch (_) {}
105
- _tts = null;
106
- }
107
-
108
- _tts ??= KittenTtsFlutter();
109
- await _tts!.init(
110
- configPath: paths.config,
111
- modelPath: paths.model,
112
- voicesPath: paths.voices,
113
- );
114
-
115
- _audioPlayer ??= AudioPlayer();
116
- _audioPlayer!.onPlayerComplete.listen((_) {
117
- _isSpeaking = false;
118
- });
119
-
120
- _currentVariant = variant;
121
- _isInitialized = true;
122
- Log.info('KittenTTS initialized successfully (${variant.displayName})');
123
- } catch (e, st) {
124
- Log.error('Failed to initialize KittenTTS: $e\n$st');
125
- rethrow;
126
- } finally {
127
- _initializeFuture = null;
128
- }
129
- }
130
-
131
- /// Generate WAV bytes and play them for the given text.
132
- Future<void> speak(
133
- String text, {
134
- KittenTtsVoice voice = KittenTtsVoice.bella,
135
- double speed = 1.0,
136
- }) async {
137
- if (!_isInitialized) {
138
- throw StateError(
139
- 'KittenTTS not initialized. Call initialize() first.',
140
- );
141
- }
142
-
143
- final trimmedText = text.trim();
144
- if (trimmedText.isEmpty) return;
145
-
146
- if (_isSpeaking) {
147
- await stop();
148
- }
149
-
150
- _isSpeaking = true;
151
-
152
- try {
153
- final chunks = _splitIntoChunks(trimmedText);
154
- Log.debug('Split text into ${chunks.length} chunks for TTS');
155
-
156
- for (final chunk in chunks) {
157
- if (!_isSpeaking) break;
158
-
159
- final phonemized =
160
- "${_phonemizer.phonemize(chunk)} ";
161
- Log.debug('Speaking chunk: "$chunk"');
162
- Log.debug('Phonemized: "$phonemized" (speed: $speed)');
163
-
164
- final wavBytes = await _generateWavWithRetry(
165
- phonemizedText: phonemized,
166
- language: 'en',
167
- voice: voice.displayName,
168
- speed: speed,
169
- );
170
-
171
- if (!_isSpeaking) break;
172
-
173
- final completer = Completer<void>();
174
- StreamSubscription? completeSub;
175
-
176
- completeSub = _audioPlayer!.onPlayerComplete.listen((_) {
177
- if (!completer.isCompleted) completer.complete();
178
- });
179
-
180
- await _audioPlayer!.play(BytesSource(wavBytes));
181
-
182
- while (_isSpeaking && !completer.isCompleted) {
183
- await Future.delayed(const Duration(milliseconds: 50));
184
- }
185
-
186
- await completeSub.cancel();
187
- }
188
- } catch (e, st) {
189
- Log.error('KittenTTS speak error: $e\n$st');
190
- rethrow;
191
- } finally {
192
- _isSpeaking = false;
193
- }
194
- }
195
-
196
- /// Stop any ongoing audio playback.
197
- Future<void> stop() async {
198
- await _audioPlayer?.stop();
199
- _isSpeaking = false;
200
- }
201
-
202
- /// Pause audio playback.
203
- Future<void> pause() async {
204
- await _audioPlayer?.pause();
205
- _isSpeaking = false;
206
- }
207
-
208
- /// Release the ONNX session and audio player.
209
- Future<void> dispose() async {
210
- try {
211
- _tts?.release();
212
- } catch (_) {}
213
- try {
214
- await _audioPlayer?.dispose();
215
- } catch (_) {}
216
- _tts = null;
217
- _audioPlayer = null;
218
- _isInitialized = false;
219
- _isSpeaking = false;
220
- _currentVariant = null;
221
- }
222
-
223
- /// Generate WAV bytes for a sample preview (used by TTS Model Manager).
224
- Future<Uint8List> generatePreviewWav(
225
- String text, {
226
- KittenTtsVoice voice = KittenTtsVoice.bella,
227
- double speed = 1.0,
228
- }) async {
229
- if (!_isInitialized) {
230
- throw StateError('KittenTTS not initialized');
231
- }
232
- final phonemized = "${_phonemizer.phonemize(text)} ";
233
- return _generateWavWithRetry(
234
- phonemizedText: phonemized,
235
- language: 'en',
236
- voice: voice.displayName,
237
- speed: speed,
238
- );
239
- }
240
-
241
- /// Core helper to generate WAV bytes with retry for "Session not initialized".
242
- Future<Uint8List> _generateWavWithRetry({
243
- required String phonemizedText,
244
- required String language,
245
- required String voice,
246
- required double speed,
247
- }) async {
248
- const maxRetries = 3;
249
- for (var attempt = 0; attempt < maxRetries; attempt++) {
250
- try {
251
- return await _tts!.generateWavBytes(
252
- phonemizedText: phonemizedText,
253
- language: language,
254
- voice: voice,
255
- speed: speed,
256
- );
257
- } catch (e) {
258
- if (e.toString().contains('Session not initialized') &&
259
- attempt < maxRetries - 1) {
260
- Log.warning(
261
- 'KittenTTS session not initialized (attempt ${attempt + 1}/$maxRetries), '
262
- 're-initializing...',
263
- );
264
- _isInitialized = false;
265
- if (_currentVariant != null) {
266
- await initialize(variant: _currentVariant!);
267
- }
268
- await Future.delayed(const Duration(milliseconds: 150));
269
- } else {
270
- rethrow;
271
- }
272
- }
273
- }
274
- throw StateError('KittenTTS session failed to initialize after $maxRetries attempts');
275
- }
276
-
277
- /// Splits text into smaller chunks (ideally sentences) to avoid TTS engine limits.
278
- List<String> _splitIntoChunks(String text, {int maxChars = 200}) {
279
- final sentences = _splitIntoSentences(text);
280
- final chunks = <String>[];
281
- String currentChunk = "";
282
-
283
- for (final sentence in sentences) {
284
- if (currentChunk.isNotEmpty &&
285
- (currentChunk.length + sentence.length + 1) > maxChars) {
286
- chunks.add(currentChunk.trim());
287
- currentChunk = "";
288
- }
289
-
290
- if (sentence.length > maxChars) {
291
- if (currentChunk.isNotEmpty) {
292
- chunks.add(currentChunk.trim());
293
- currentChunk = "";
294
- }
295
-
296
- final words = sentence.split(' ');
297
- String subChunk = "";
298
- for (final word in words) {
299
- if (subChunk.isNotEmpty &&
300
- (subChunk.length + word.length + 1) > maxChars) {
301
- chunks.add(subChunk.trim());
302
- subChunk = "";
303
- }
304
- subChunk += (subChunk.isEmpty ? "" : " ") + word;
305
- }
306
- if (subChunk.isNotEmpty) currentChunk = subChunk;
307
- } else {
308
- currentChunk += (currentChunk.isEmpty ? "" : " ") + sentence;
309
- }
310
- }
311
-
312
- if (currentChunk.isNotEmpty) chunks.add(currentChunk.trim());
313
- return chunks;
314
- }
315
-
316
- /// Splits text into sentences based on punctuation.
317
- List<String> _splitIntoSentences(String text) {
318
- final regex = RegExp(r'(?<=[.!?])\s+');
319
- return text.split(regex).where((s) => s.trim().isNotEmpty).toList();
320
- }
321
-}
lib/features/tts/data/tts_service.dart
deleted
-50
@@ -1,50 +0,0 @@
1
-import 'package:flutter_tts/flutter_tts.dart';
2
-
3
-class TtsService {
4
- final FlutterTts _tts = FlutterTts();
5
- bool _isInitialized = false;
6
- bool _isSpeaking = false;
7
-
8
- Future<void> init() async {
9
- if (_isInitialized) return;
10
- await _tts.setVolume(1.0);
11
- await _tts.setPitch(1.0);
12
- await _tts.setSpeechRate(0.5);
13
- _tts.setCompletionHandler(() {
14
- _isSpeaking = false;
15
- });
16
- _isInitialized = true;
17
- }
18
-
19
- bool get isSpeaking => _isSpeaking;
20
-
21
- Future<void> speak(String text) async {
22
- await init();
23
- if (text.trim().isEmpty) return;
24
- _isSpeaking = true;
25
- await _tts.speak(text);
26
- }
27
-
28
- Future<void> stop() async {
29
- await _tts.stop();
30
- _isSpeaking = false;
31
- }
32
-
33
- Future<void> pause() async {
34
- await _tts.pause();
35
- _isSpeaking = false;
36
- }
37
-
38
- Future<List<dynamic>> getVoices() async {
39
- await init();
40
- return await _tts.getVoices;
41
- }
42
-
43
- Future<void> setVoice(String name, String locale) async {
44
- await _tts.setVoice({'name': name, 'locale': locale});
45
- }
46
-
47
- Future<void> dispose() async {
48
- await _tts.stop();
49
- }
50
-}
lib/features/tts/providers/tts_model_providers.dart
+103
-17
@@ -2,6 +2,7 @@ import 'dart:async';
2
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
5
+import 'package:neural_tts/neural_tts.dart';
6
import '../data/kitten_tts_model.dart';
7
import '../data/kitten_tts_downloader.dart';
8
@@ -10,6 +11,10 @@ final kittenTtsDownloaderProvider = Provider<KittenTtsDownloader>((ref) {
11
return KittenTtsDownloader();
12
});
13
14
+final modelDownloaderProvider = Provider<ModelDownloader>((ref) {
15
+ return ModelDownloader();
16
+});
17
+
18
/// Static list of all available KittenTTS models.
19
final kittenTtsModelsProvider = Provider<List<KittenTtsModel>>((ref) {
20
return KittenTtsModel.allModels;
@@ -18,20 +23,24 @@ final kittenTtsModelsProvider = Provider<List<KittenTtsModel>>((ref) {
23
/// Async provider for the set of downloaded variants.
24
final downloadedKittenTtsVariantsProvider =
25
FutureProvider<Set<KittenTtsModelVariant>>((ref) async {
21
- final downloader = ref.read(kittenTtsDownloaderProvider);
22
- return downloader.getDownloadedVariants();
23
-});
26
+ final downloader = ref.read(kittenTtsDownloaderProvider);
27
+ return downloader.getDownloadedVariants();
28
+ });
29
30
/// Notifier that tracks per-file download progress for KittenTTS models.
31
final ttsDownloadProgressProvider =
27
- NotifierProvider<TtsDownloadNotifier, Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>>(
28
- () => TtsDownloadNotifier(),
29
-);
32
+ NotifierProvider<
33
+ TtsDownloadNotifier,
34
+ Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>
35
+ >(() => TtsDownloadNotifier());
36
37
class TtsDownloadNotifier
32
- extends Notifier<Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>> {
38
+ extends
39
+ Notifier<
40
+ Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>
41
+ > {
42
final Map<KittenTtsModelVariant, StreamSubscription<KittenTtsFileProgress>>
34
- _subscriptions = {};
43
+ _subscriptions = {};
44
45
@override
46
Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>> build() {
@@ -52,10 +61,7 @@ class TtsDownloadNotifier
61
state[variant] ?? {},
62
);
63
55
- state = {
56
- ...state,
57
- variant: currentVariantProgress,
58
- };
64
+ state = {...state, variant: currentVariantProgress};
65
66
final downloader = ref.read(kittenTtsDownloaderProvider);
67
@@ -75,7 +81,9 @@ class TtsDownloadNotifier
81
ref.invalidate(downloadedKittenTtsVariantsProvider);
82
},
83
onError: (e) {
78
- Log.error('KittenTTS download error for ${variant.displayName}: $e');
84
+ Log.error(
85
+ 'KittenTTS download error for ${variant.displayName}: $e',
86
+ );
87
_subscriptions.remove(variant);
88
},
89
);
@@ -87,8 +95,10 @@ class TtsDownloadNotifier
95
_subscriptions[variant]?.cancel();
96
_subscriptions.remove(variant);
97
90
- final newState = Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>
91
- .from(state);
98
+ final newState =
99
+ Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>.from(
100
+ state,
101
+ );
102
newState.remove(variant);
103
state = newState;
104
}
@@ -98,8 +108,10 @@ class TtsDownloadNotifier
108
cancelDownload(variant);
109
await ref.read(kittenTtsDownloaderProvider).deleteVariant(variant);
110
101
- final newState = Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>
102
- .from(state);
111
+ final newState =
112
+ Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>.from(
113
+ state,
114
+ );
115
newState.remove(variant);
116
state = newState;
117
@@ -124,6 +136,80 @@ class TtsDownloadNotifier
136
}
137
}
138
139
+/// Notifier that tracks per-file download progress for any TTS engine.
140
+final engineDownloadProgressProvider =
141
+ NotifierProvider<
142
+ EngineDownloadNotifier,
143
+ Map<EngineId, Map<String, FileProgress>>
144
+ >(() => EngineDownloadNotifier());
145
+
146
+class EngineDownloadNotifier
147
+ extends Notifier<Map<EngineId, Map<String, FileProgress>>> {
148
+ final Map<EngineId, StreamSubscription<FileProgress>> _subscriptions = {};
149
+
150
+ @override
151
+ Map<EngineId, Map<String, FileProgress>> build() {
152
+ ref.onDispose(() {
153
+ for (final sub in _subscriptions.values) {
154
+ sub.cancel();
155
+ }
156
+ _subscriptions.clear();
157
+ });
158
+ return {};
159
+ }
160
+
161
+ Future<void> startDownload(EngineId engine) async {
162
+ final currentProgress = Map<String, FileProgress>.from(state[engine] ?? {});
163
+ state = {...state, engine: currentProgress};
164
+
165
+ final downloader = ref.read(modelDownloaderProvider);
166
+ _subscriptions[engine]?.cancel();
167
+ _subscriptions[engine] = downloader
168
+ .downloadEngineFiles(engine)
169
+ .listen(
170
+ (progress) {
171
+ final engineMap = Map<String, FileProgress>.from(
172
+ state[engine] ?? {},
173
+ );
174
+ engineMap[progress.fileName] = progress;
175
+ state = {...state, engine: engineMap};
176
+ },
177
+ onDone: () {
178
+ _subscriptions.remove(engine);
179
+ // Refresh whatever is needed
180
+ },
181
+ onError: (e) {
182
+ Log.error('Engine $engine download error: $e');
183
+ _subscriptions.remove(engine);
184
+ },
185
+ );
186
+ }
187
+
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);
194
+ state = newState;
195
+ }
196
+
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);
201
+ }
202
+
203
+ double getOverallFraction(EngineId engine) {
204
+ final progress = state[engine];
205
+ if (progress == null || progress.isEmpty) return 0.0;
206
+ final total = progress.values
207
+ .map((f) => f.fraction)
208
+ .fold<double>(0.0, (a, b) => a + b);
209
+ return total / progress.length;
210
+ }
211
+}
212
+
213
/// Simple log helper within this file.
214
class Log {
215
static void error(String message) {
lib/features/tts/providers/tts_providers.dart
+120
-86
@@ -1,52 +1,43 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
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
9
+import '../../../core/logger/app_logger.dart';
10
import '../../../core/models/enums.dart';
11
import '../../../core/providers/app_providers.dart';
5
-import '../data/kitten_tts_service.dart';
6
-import '../data/tts_service.dart';
12
8
-/// Provider for the system TTS service (flutter_tts).
9
-final systemTtsProvider = Provider<TtsService>((ref) {
10
- return TtsService();
13
+final ttsRuntimeProvider = Provider<TTSRuntime>((ref) {
14
+ return TTSRuntime.instance;
15
});
16
13
-/// Provider for the KittenTTS neural TTS service.
14
-///
15
-/// This is a lazy-initialized provider. The service copies model files
16
-/// from assets to the filesystem on first use.
17
-final kittenTtsServiceProvider = Provider<KittenTtsService>((ref) {
18
- return KittenTtsService();
17
+final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
18
+ return TtsNotifier();
19
});
20
21
-/// Notifier that manages the currently active TTS engine state.
22
-///
23
-/// Delegates to either [TtsService] (system) or [KittenTtsService]
24
-/// based on the user's settings preference.
25
-final unifiedTtsProvider =
26
- NotifierProvider<UnifiedTtsNotifier, UnifiedTtsState>(() {
27
- return UnifiedTtsNotifier();
28
- });
29
-
30
-class UnifiedTtsState {
21
+class TtsState {
22
final bool isSpeaking;
23
final bool isInitializing;
24
final String? error;
34
- final TtsEngine activeEngine;
25
+ final EngineId? activeEngine;
26
36
- const UnifiedTtsState({
27
+ const TtsState({
28
this.isSpeaking = false,
29
this.isInitializing = false,
30
this.error,
40
- this.activeEngine = TtsEngine.system,
31
+ this.activeEngine,
32
});
33
43
- UnifiedTtsState copyWith({
34
+ TtsState copyWith({
35
bool? isSpeaking,
36
bool? isInitializing,
37
String? error,
47
- TtsEngine? activeEngine,
38
+ EngineId? activeEngine,
39
}) {
49
- return UnifiedTtsState(
40
+ return TtsState(
41
isSpeaking: isSpeaking ?? this.isSpeaking,
42
isInitializing: isInitializing ?? this.isInitializing,
43
error: error,
@@ -55,103 +46,146 @@ class UnifiedTtsState {
46
}
47
}
48
58
-class UnifiedTtsNotifier extends Notifier<UnifiedTtsState> {
59
- TtsService? _systemTts;
60
- KittenTtsService? _kittenTts;
49
+class TtsNotifier extends Notifier<TtsState> {
50
+ Engine? _currentEngine;
51
+ final Phonemizer _phonemizer = Phonemizer();
52
+ EspeakPhonemizer? _espeakPhonemizer;
53
54
@override
63
- UnifiedTtsState build() {
55
+ TtsState build() {
56
final settings = ref.watch(settingsProvider);
65
- return UnifiedTtsState(activeEngine: settings.ttsEngine);
57
+ ref.onDispose(() {
58
+ _currentEngine?.release();
59
+ });
60
+
61
+ // Initialize espeak data if pre-bundled
62
+ _initEspeak();
63
+
64
+ return TtsState(activeEngine: settings.ttsEngine);
65
+ }
66
+
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');
71
+
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');
80
+ }
81
+ }
82
}
83
68
- TtsService get _systemTtsInstance {
69
- _systemTts ??= ref.read(systemTtsProvider);
70
- return _systemTts!;
84
+ Voice? get _currentVoice {
85
+ final settings = ref.read(settingsProvider);
86
+ return voiceFromSettings(settings.ttsVoiceId, settings.ttsEngine);
87
}
88
73
- KittenTtsService get _kittenTtsInstance {
74
- _kittenTts ??= ref.read(kittenTtsServiceProvider);
75
- return _kittenTts!;
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;
94
}
95
78
- /// Speak the given text using the currently selected TTS engine.
96
Future<void> speak(String text) async {
97
if (text.trim().isEmpty) return;
98
99
final settings = ref.read(settingsProvider);
83
- final engine = settings.ttsEngine;
100
+ final engineId = settings.ttsEngine;
101
102
state = state.copyWith(
103
isSpeaking: true,
87
- isInitializing: engine == TtsEngine.kitten,
104
+ isInitializing: engineId != EngineId.system,
105
error: null,
106
+ activeEngine: engineId,
107
);
108
109
try {
92
- if (engine == TtsEngine.kitten) {
93
- final variant = settings.kittenTtsModelVariant;
94
- await _kittenTtsInstance.initialize(variant: variant);
95
- await _kittenTtsInstance.speak(
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
+ }
119
+
120
+ // Set phonemizer based on settings
121
+ if (settings.useEspeak && _espeakPhonemizer != null) {
122
+ engine.setPhonemizer(_espeakPhonemizer!);
123
+ } else {
124
+ engine.setPhonemizer(_phonemizer);
125
+ }
126
+
127
+ await engine.play(
128
text,
97
- voice: settings.kittenTtsVoice,
98
- speed: settings.kittenTtsSpeed,
129
+ resultVoice,
130
+ language: 'en',
131
+ inferenceSteps: settings.supertonicSteps,
132
+ phonemize: settings.usePhonemizer,
133
);
100
- } else {
101
- await _systemTtsInstance.speak(text);
102
- }
103
- } catch (e) {
134
+ });
135
+ } catch (e, st) {
136
+ Log.error('TTS speak error: $e\n$st');
137
state = state.copyWith(
138
isSpeaking: false,
139
isInitializing: false,
107
- error: 'TTS error: ${e.toString()}',
140
+ error: e.toString(),
141
);
142
+ rethrow;
143
} finally {
110
- state = state.copyWith(
111
- isSpeaking: false,
112
- isInitializing: false,
113
- );
144
+ state = state.copyWith(isSpeaking: false, isInitializing: false);
145
}
146
}
147
117
- /// Stop any ongoing speech.
148
Future<void> stop() async {
119
- final settings = ref.read(settingsProvider);
120
- if (settings.ttsEngine == TtsEngine.kitten) {
121
- await _kittenTtsInstance.stop();
122
- } else {
123
- await _systemTtsInstance.stop();
124
- }
125
- state = state.copyWith(isSpeaking: false);
149
+ await TTSRuntime.instance.stop();
150
+ _currentEngine?.stop();
151
+ state = state.copyWith(isSpeaking: false, isInitializing: false);
152
}
153
128
- /// Pause ongoing speech.
129
- Future<void> pause() async {
130
- final settings = ref.read(settingsProvider);
131
- if (settings.ttsEngine == TtsEngine.kitten) {
132
- await _kittenTtsInstance.pause();
133
- } else {
134
- await _systemTtsInstance.pause();
154
+ Future<void> release() async {
155
+ await TTSRuntime.instance.release();
156
+ if (_currentEngine != null) {
157
+ await _currentEngine!.release();
158
+ _currentEngine = null;
159
}
160
state = state.copyWith(isSpeaking: false);
161
}
162
139
- /// Dispose all TTS resources.
140
- Future<void> dispose() async {
141
- try {
142
- await _kittenTtsInstance.dispose();
143
- } catch (_) {}
144
- try {
145
- await _systemTtsInstance.dispose();
146
- } catch (_) {}
147
- }
148
-
149
- /// Check if any TTS engine is currently speaking.
150
- bool get isSpeaking {
163
+ Future<void> previewVoice(Voice voice) async {
164
+ state = state.copyWith(isSpeaking: true, isInitializing: true, error: null);
165
final settings = ref.read(settingsProvider);
152
- if (settings.ttsEngine == TtsEngine.kitten) {
153
- return _kittenTtsInstance.isSpeaking;
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);
189
}
155
- return _systemTtsInstance.isSpeaking;
190
}
191
}
lib/features/tts/views/tts_model_manager_screen.dart
+681
-436
@@ -3,58 +3,55 @@ import 'dart:async';
3
import 'package:audioplayers/audioplayers.dart';
4
import 'package:flutter/material.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+import 'package:neural_tts/neural_tts.dart';
7
import 'package:shadcn_ui/shadcn_ui.dart';
8
8
-import '../../../core/models/enums.dart';
9
import '../../../core/providers/app_providers.dart';
10
import '../../sidebar/sidebar_widget.dart';
11
-import '../data/kitten_tts_model.dart';
12
-import '../data/kitten_tts_service.dart';
11
import '../providers/tts_model_providers.dart';
12
+import '../providers/tts_providers.dart' as tts;
13
+
14
+/// Async provider for the set of installed engine IDs.
15
+final installedEnginesProvider = FutureProvider<Set<EngineId>>((ref) async {
16
+ final downloader = ref.read(modelDownloaderProvider);
17
+ 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
+ }
26
+ }
27
+ return result;
28
+});
29
30
class TtsModelManagerScreen extends ConsumerWidget {
31
const TtsModelManagerScreen({super.key});
32
33
@override
34
Widget build(BuildContext context, WidgetRef ref) {
20
- final theme = Theme.of(context);
21
- final models = ref.watch(kittenTtsModelsProvider);
22
- final downloadedAsync = ref.watch(downloadedKittenTtsVariantsProvider);
23
- final downloadProgress = ref.watch(ttsDownloadProgressProvider);
35
final settings = ref.watch(settingsProvider);
36
37
return Scaffold(
38
drawer: const SidebarWidget(),
28
- appBar: AppBar(title: const Text('Kitten TTS Models')),
39
+ appBar: AppBar(title: const Text('TTS Models')),
40
body: ListView(
41
padding: const EdgeInsets.all(16),
42
children: [
32
- Text(
33
- 'Available Models',
34
- style: theme.textTheme.titleMedium?.copyWith(
35
- fontWeight: FontWeight.bold,
36
- ),
37
- ),
38
- const SizedBox(height: 4),
39
- Text(
40
- 'Download a KittenTTS model to use neural text-to-speech. '
41
- 'Models are stored locally on your device.',
42
- style: theme.textTheme.bodySmall?.copyWith(
43
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
44
- ),
43
+ _PhonemizationSettingsCard(),
44
+ const SizedBox(height: 16),
45
+ _SystemEngineCard(isSelected: settings.ttsEngine == EngineId.system),
46
+ const SizedBox(height: 16),
47
+ _KittenEngineCard(
48
+ isSelected: settings.ttsEngine == EngineId.kitten,
49
),
50
const SizedBox(height: 16),
47
- ...models.map(
48
- (model) => _ModelVariantCard(
49
- model: model,
50
- isSelected: settings.kittenTtsModelVariant == model.variant,
51
- isDownloaded: downloadedAsync.when(
52
- data: (set) => set.contains(model.variant),
53
- loading: () => false,
54
- error: (_, _) => false,
55
- ),
56
- progress: downloadProgress[model.variant],
57
- ),
51
+ _KokoroEngineCard(isSelected: settings.ttsEngine == EngineId.kokoro),
52
+ const SizedBox(height: 16),
53
+ _SupertonicEngineCard(
54
+ isSelected: settings.ttsEngine == EngineId.supertonic,
55
),
56
const SizedBox(height: 24),
57
],
@@ -63,501 +60,749 @@ class TtsModelManagerScreen extends ConsumerWidget {
60
}
61
}
62
66
-class _ModelVariantCard extends ConsumerStatefulWidget {
67
- const _ModelVariantCard({
68
- required this.model,
69
- required this.isSelected,
70
- required this.isDownloaded,
71
- this.progress,
72
- });
63
+// ── System Engine ──
64
74
- final KittenTtsModel model;
65
+class _SystemEngineCard extends ConsumerWidget {
66
final bool isSelected;
76
- final bool isDownloaded;
77
- final Map<String, KittenTtsFileProgress>? progress;
67
+
68
+ const _SystemEngineCard({required this.isSelected});
69
70
@override
80
- ConsumerState<_ModelVariantCard> createState() => _ModelVariantCardState();
71
+ Widget build(BuildContext context, WidgetRef ref) {
72
+ final meta = EngineMeta.system;
73
+
74
+ return _EngineCard(
75
+ isSelected: isSelected,
76
+ meta: meta,
77
+ engine: EngineId.system,
78
+ installed: true,
79
+ statusText: 'Always available',
80
+ trailing: Row(
81
+ mainAxisSize: MainAxisSize.min,
82
+ children: [
83
+ if (!isSelected)
84
+ ShadButton.outline(
85
+ size: ShadButtonSize.sm,
86
+ onPressed: () => ref
87
+ .read(settingsProvider.notifier)
88
+ .setTtsEngine(EngineId.system),
89
+ child: const Text('Select'),
90
+ ),
91
+ ],
92
+ ),
93
+ child: _SystemVoicesList(),
94
+ );
95
+ }
96
}
97
83
-class _ModelVariantCardState extends ConsumerState<_ModelVariantCard> {
84
- bool _showPreview = false;
85
-
98
+class _SystemVoicesList extends ConsumerWidget {
99
@override
87
- Widget build(BuildContext context) {
88
- final theme = Theme.of(context);
89
- final isDownloading =
90
- widget.progress != null &&
91
- widget.progress!.isNotEmpty &&
92
- !widget.progress!.values.every((f) => f.isComplete);
93
-
100
+ Widget build(BuildContext context, WidgetRef ref) {
101
return Padding(
95
- padding: const EdgeInsets.only(bottom: 12),
96
- child: Container(
97
- padding: const EdgeInsets.all(16),
98
- decoration: BoxDecoration(
99
- color: theme.colorScheme.surface,
100
- borderRadius: BorderRadius.circular(12),
101
- border: Border.all(
102
- color: widget.isSelected
103
- ? theme.colorScheme.primary.withValues(alpha: 0.5)
104
- : widget.isDownloaded
105
- ? Colors.green.withValues(alpha: 0.3)
106
- : theme.colorScheme.outline.withValues(alpha: 0.3),
107
- width: widget.isSelected ? 1.5 : 1,
108
- ),
102
+ padding: const EdgeInsets.only(top: 12),
103
+ child: Text(
104
+ 'Uses your device\'s built-in text-to-speech engine.\nNo downloads required.',
105
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
106
+ color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
107
),
110
- child: Column(
111
- crossAxisAlignment: CrossAxisAlignment.start,
112
- children: [
113
- Row(
114
- children: [
115
- Expanded(
116
- child: Text(
117
- widget.model.displayName,
118
- style: theme.textTheme.titleSmall?.copyWith(
119
- fontWeight: FontWeight.bold,
120
- ),
121
- ),
122
- ),
123
- if (widget.model.isRecommended)
124
- Container(
125
- padding: const EdgeInsets.symmetric(
126
- horizontal: 8,
127
- vertical: 2,
128
- ),
129
- decoration: BoxDecoration(
130
- color: theme.colorScheme.primary.withValues(alpha: 0.1),
131
- borderRadius: BorderRadius.circular(4),
132
- ),
133
- child: Text(
134
- 'RECOMMENDED',
135
- style: theme.textTheme.labelSmall?.copyWith(
136
- color: theme.colorScheme.primary,
137
- fontWeight: FontWeight.bold,
138
- ),
139
- ),
140
- ),
141
- ],
142
- ),
143
- const SizedBox(height: 4),
144
- Text(
145
- widget.model.description,
146
- style: theme.textTheme.bodySmall?.copyWith(
147
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
108
+ ),
109
+ );
110
+ }
111
+}
112
+
113
+// ── Kitten Engine ──
114
+
115
+class _KittenEngineCard extends ConsumerWidget {
116
+ final bool isSelected;
117
+
118
+ const _KittenEngineCard({required this.isSelected});
119
+
120
+ @override
121
+ Widget build(BuildContext context, WidgetRef ref) {
122
+ 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),
127
+ loading: () => false,
128
+ error: (_, _) => false,
129
+ );
130
+ final progress = downloadProgress[EngineId.kitten];
131
+ final isDownloading = ref
132
+ .read(engineDownloadProgressProvider.notifier)
133
+ .isDownloading(EngineId.kitten);
134
+
135
+ return _EngineCard(
136
+ isSelected: isSelected,
137
+ meta: meta,
138
+ engine: EngineId.kitten,
139
+ installed: isInstalled,
140
+ statusText: isInstalled
141
+ ? 'Installed'
142
+ : isDownloading
143
+ ? 'Downloading...'
144
+ : 'Not installed',
145
+ trailing: _buildAction(ref, isInstalled, isDownloading, EngineId.kitten),
146
+ child: Column(
147
+ crossAxisAlignment: CrossAxisAlignment.start,
148
+ children: [
149
+ Padding(
150
+ padding: const EdgeInsets.only(top: 12),
151
+ child: Text(
152
+ 'Lightning-fast neural TTS with 8 expressive voices.\n'
153
+ 'Requires ~57 MB download.',
154
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
155
+ color: Theme.of(
156
+ context,
157
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
158
),
159
),
150
- const SizedBox(height: 8),
151
- Row(
152
- children: [
153
- Text(
154
- _formatBytes(widget.model.totalSizeBytes),
155
- style: theme.textTheme.labelMedium,
156
- ),
157
- const SizedBox(width: 12),
158
- Text(
159
- '${widget.model.parameterLabel} params',
160
- style: theme.textTheme.labelMedium?.copyWith(
161
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
162
- ),
163
- ),
164
- const Spacer(),
165
- if (widget.isSelected)
166
- Container(
167
- padding: const EdgeInsets.symmetric(
168
- horizontal: 8,
169
- vertical: 2,
170
- ),
171
- decoration: BoxDecoration(
172
- color: theme.colorScheme.primary.withValues(alpha: 0.1),
173
- borderRadius: BorderRadius.circular(4),
174
- border: Border.all(
175
- color: theme.colorScheme.primary.withValues(alpha: 0.3),
176
- ),
177
- ),
178
- child: Text(
179
- 'Selected',
180
- style: theme.textTheme.labelSmall?.copyWith(
181
- color: theme.colorScheme.primary,
182
- fontWeight: FontWeight.bold,
183
- ),
184
- ),
185
- ),
186
- ],
187
- ),
160
+ ),
161
+ if (isDownloading) ...[
162
const SizedBox(height: 12),
189
- _buildActionRow(isDownloading),
190
- if (isDownloading) ...[
191
- const SizedBox(height: 12),
192
- _buildFileProgress(),
193
- ],
194
- if (widget.isDownloaded) ...[
195
- const SizedBox(height: 12),
196
- _buildPreviewToggle(),
197
- if (_showPreview) ...[
198
- const SizedBox(height: 8),
199
- _VoicePreviewSection(variant: widget.model.variant),
200
- ],
201
- ],
163
+ _buildDownloadProgress(context, ref, EngineId.kitten, progress),
164
],
203
- ),
165
+ if (isInstalled) ...[
166
+ _VoiceChips(voices: kittenVoices, engine: EngineId.kitten),
167
+ ],
168
+ ],
169
),
170
);
171
}
172
208
- Widget _buildActionRow(bool isDownloading) {
209
- if (widget.isDownloaded) {
210
- return Row(
211
- children: [
212
- Icon(
213
- Icons.check_circle,
214
- color: Colors.green,
215
- size: 18,
216
- ),
217
- const SizedBox(width: 4),
218
- Text(
219
- 'Downloaded',
220
- style: TextStyle(
221
- fontSize: 13,
222
- color: Colors.green,
223
- fontWeight: FontWeight.w600,
173
+ Widget _buildAction(
174
+ WidgetRef ref,
175
+ bool isInstalled,
176
+ bool isDownloading,
177
+ EngineId engine,
178
+ ) {
179
+ if (isDownloading) {
180
+ return ShadButton.outline(
181
+ size: ShadButtonSize.sm,
182
+ onPressed: () => ref
183
+ .read(engineDownloadProgressProvider.notifier)
184
+ .cancelDownload(engine),
185
+ child: const Text('Cancel'),
186
+ );
187
+ }
188
+ if (isInstalled) {
189
+ if (isSelected) return const SizedBox.shrink();
190
+ return ShadButton.outline(
191
+ size: ShadButtonSize.sm,
192
+ onPressed: () =>
193
+ ref.read(settingsProvider.notifier).setTtsEngine(engine),
194
+ child: const Text('Select'),
195
+ );
196
+ }
197
+ return ShadButton.outline(
198
+ size: ShadButtonSize.sm,
199
+ onPressed: () => ref
200
+ .read(engineDownloadProgressProvider.notifier)
201
+ .startDownload(engine),
202
+ child: const Text('Install'),
203
+ );
204
+ }
205
+
206
+ Widget _buildDownloadProgress(
207
+ BuildContext context,
208
+ WidgetRef ref,
209
+ EngineId engine,
210
+ Map<String, FileProgress>? progress,
211
+ ) {
212
+ final fraction = ref
213
+ .read(engineDownloadProgressProvider.notifier)
214
+ .getOverallFraction(engine);
215
+ return Column(
216
+ children: [
217
+ LinearProgressIndicator(value: fraction),
218
+ const SizedBox(height: 4),
219
+ if (progress != null)
220
+ ...progress.entries.map(
221
+ (e) => Padding(
222
+ padding: const EdgeInsets.only(bottom: 2),
223
+ child: Row(
224
+ children: [
225
+ Expanded(
226
+ child: Text(
227
+ e.key,
228
+ style: Theme.of(context).textTheme.labelSmall,
229
+ ),
230
+ ),
231
+ Text(
232
+ '${(e.value.fraction * 100).toStringAsFixed(0)}%',
233
+ style: Theme.of(context).textTheme.labelSmall,
234
+ ),
235
+ ],
236
+ ),
237
),
238
),
226
- const Spacer(),
227
- if (!widget.isSelected)
228
- ShadButton.outline(
229
- size: ShadButtonSize.sm,
230
- onPressed: () {
231
- ref
232
- .read(settingsProvider.notifier)
233
- .setKittenTtsModelVariant(widget.model.variant);
234
- },
235
- child: const Text('Select'),
239
+ ],
240
+ );
241
+ }
242
+}
243
+
244
+// ── Kokoro Engine ──
245
+
246
+class _KokoroEngineCard extends ConsumerWidget {
247
+ final bool isSelected;
248
+
249
+ const _KokoroEngineCard({required this.isSelected});
250
+
251
+ @override
252
+ Widget build(BuildContext context, WidgetRef ref) {
253
+ 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),
258
+ loading: () => false,
259
+ error: (_, _) => false,
260
+ );
261
+ final progress = downloadProgress[EngineId.kokoro];
262
+ final isDownloading = ref
263
+ .read(engineDownloadProgressProvider.notifier)
264
+ .isDownloading(EngineId.kokoro);
265
+
266
+ return _EngineCard(
267
+ isSelected: isSelected,
268
+ meta: meta,
269
+ engine: EngineId.kokoro,
270
+ installed: isInstalled,
271
+ statusText: isInstalled
272
+ ? 'Installed'
273
+ : isDownloading
274
+ ? 'Downloading...'
275
+ : 'Not installed',
276
+ trailing: _buildAction(ref, isInstalled, isDownloading, EngineId.kokoro),
277
+ child: Column(
278
+ crossAxisAlignment: CrossAxisAlignment.start,
279
+ children: [
280
+ Padding(
281
+ padding: const EdgeInsets.only(top: 12),
282
+ child: Text(
283
+ 'Kokoro 82M parameter model with 22 expressive voices.\n'
284
+ 'Requires ~170 MB download.',
285
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
286
+ color: Theme.of(
287
+ context,
288
+ ).colorScheme.onSurface.withValues(alpha: 0.6),
289
+ ),
290
),
237
- if (!widget.isSelected) const SizedBox(width: 8),
238
- ShadButton.outline(
239
- size: ShadButtonSize.sm,
240
- onPressed: () => _deleteModel(),
241
- child: const Text('Delete'),
291
),
292
+ if (isDownloading) ...[
293
+ const SizedBox(height: 12),
294
+ _buildDownloadProgress(context, ref, EngineId.kokoro, progress),
295
+ ],
296
+ if (isInstalled) ...[
297
+ _VoiceChips(voices: kokoroVoices, engine: EngineId.kokoro),
298
+ ],
299
],
244
- );
245
- }
300
+ ),
301
+ );
302
+ }
303
304
+ Widget _buildAction(
305
+ WidgetRef ref,
306
+ bool isInstalled,
307
+ bool isDownloading,
308
+ EngineId engine,
309
+ ) {
310
if (isDownloading) {
248
- final overallFraction = ref
249
- .read(ttsDownloadProgressProvider.notifier)
250
- .getOverallFraction(widget.model.variant);
311
+ return ShadButton.outline(
312
+ size: ShadButtonSize.sm,
313
+ onPressed: () => ref
314
+ .read(engineDownloadProgressProvider.notifier)
315
+ .cancelDownload(engine),
316
+ child: const Text('Cancel'),
317
+ );
318
+ }
319
+ if (isInstalled) {
320
+ if (isSelected) return const SizedBox.shrink();
321
+ return ShadButton.outline(
322
+ size: ShadButtonSize.sm,
323
+ onPressed: () =>
324
+ ref.read(settingsProvider.notifier).setTtsEngine(engine),
325
+ child: const Text('Select'),
326
+ );
327
+ }
328
+ return ShadButton.outline(
329
+ size: ShadButtonSize.sm,
330
+ onPressed: () => ref
331
+ .read(engineDownloadProgressProvider.notifier)
332
+ .startDownload(engine),
333
+ child: const Text('Install'),
334
+ );
335
+ }
336
252
- return Row(
253
- children: [
254
- Expanded(
255
- child: Column(
256
- crossAxisAlignment: CrossAxisAlignment.start,
257
- children: [
258
- LinearProgressIndicator(
259
- value: overallFraction,
260
- backgroundColor:
261
- Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
262
- ),
263
- const SizedBox(height: 6),
264
- Text(
265
- '${(overallFraction * 100).toStringAsFixed(0)}%',
266
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
267
- fontWeight: FontWeight.w600,
337
+ Widget _buildDownloadProgress(
338
+ BuildContext context,
339
+ WidgetRef ref,
340
+ EngineId engine,
341
+ Map<String, FileProgress>? progress,
342
+ ) {
343
+ final fraction = ref
344
+ .read(engineDownloadProgressProvider.notifier)
345
+ .getOverallFraction(engine);
346
+ return Column(
347
+ children: [
348
+ LinearProgressIndicator(value: fraction),
349
+ const SizedBox(height: 4),
350
+ if (progress != null)
351
+ ...progress.entries.map(
352
+ (e) => Padding(
353
+ padding: const EdgeInsets.only(bottom: 2),
354
+ child: Row(
355
+ children: [
356
+ Expanded(
357
+ child: Text(
358
+ e.key,
359
+ style: Theme.of(context).textTheme.labelSmall,
360
+ ),
361
),
269
- ),
270
- ],
362
+ Text(
363
+ '${(e.value.fraction * 100).toStringAsFixed(0)}%',
364
+ style: Theme.of(context).textTheme.labelSmall,
365
+ ),
366
+ ],
367
+ ),
368
),
369
),
273
- const SizedBox(width: 12),
274
- ShadButton.outline(
275
- size: ShadButtonSize.sm,
276
- onPressed: () {
277
- ref
278
- .read(ttsDownloadProgressProvider.notifier)
279
- .cancelDownload(widget.model.variant);
280
- },
281
- child: const Text('Cancel'),
370
+ ],
371
+ );
372
+ }
373
+}
374
+
375
+// ── Supertonic Engine ──
376
+
377
+class _SupertonicEngineCard extends ConsumerWidget {
378
+ final bool isSelected;
379
+
380
+ const _SupertonicEngineCard({required this.isSelected});
381
+
382
+ @override
383
+ Widget build(BuildContext context, WidgetRef ref) {
384
+ final meta = EngineMeta.supertonic;
385
+ final installedAsync = ref.watch(installedEnginesProvider);
386
+ final downloadProgress = ref.watch(engineDownloadProgressProvider);
387
+ final isInstalled = installedAsync.when(
388
+ data: (set) => set.contains(EngineId.supertonic),
389
+ loading: () => false,
390
+ error: (_, _) => false,
391
+ );
392
+ final progress = downloadProgress[EngineId.supertonic];
393
+ final isDownloading = ref
394
+ .read(engineDownloadProgressProvider.notifier)
395
+ .isDownloading(EngineId.supertonic);
396
+
397
+ return _EngineCard(
398
+ isSelected: isSelected,
399
+ meta: meta,
400
+ engine: EngineId.supertonic,
401
+ 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),
425
+ ),
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
],
436
+ ),
437
+ );
438
+ }
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
287
- return Row(
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: [
289
- const Spacer(),
290
- ShadButton.outline(
291
- size: ShadButtonSize.sm,
292
- onPressed: () => _startDownload(),
293
- child: const Text('Download'),
294
- ),
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
}
509
+}
510
299
- Widget _buildFileProgress() {
300
- if (widget.progress == null || widget.progress!.isEmpty) {
301
- return const SizedBox.shrink();
302
- }
511
+// ── Shared Widgets ──
512
513
+class _EngineCard extends StatelessWidget {
514
+ final bool isSelected;
515
+ final EngineMeta meta;
516
+ final EngineId engine;
517
+ final bool installed;
518
+ final String statusText;
519
+ final Widget? trailing;
520
+ final Widget? child;
521
+
522
+ const _EngineCard({
523
+ required this.isSelected,
524
+ required this.meta,
525
+ required this.engine,
526
+ required this.installed,
527
+ required this.statusText,
528
+ this.trailing,
529
+ this.child,
530
+ });
531
+
532
+ @override
533
+ Widget build(BuildContext context) {
534
final theme = Theme.of(context);
305
- return Column(
306
- crossAxisAlignment: CrossAxisAlignment.start,
307
- children: widget.progress!.entries.map((entry) {
308
- final fileName = entry.key;
309
- final progress = entry.value;
310
- return Padding(
311
- padding: const EdgeInsets.only(bottom: 6),
312
- child: Row(
535
+ final accentColor = Color(meta.accentColor);
536
+
537
+ return Container(
538
+ padding: const EdgeInsets.all(16),
539
+ decoration: BoxDecoration(
540
+ color: theme.colorScheme.surface,
541
+ borderRadius: BorderRadius.circular(12),
542
+ border: Border.all(
543
+ color: isSelected
544
+ ? accentColor.withValues(alpha: 0.6)
545
+ : installed
546
+ ? Colors.green.withValues(alpha: 0.3)
547
+ : theme.colorScheme.outline.withValues(alpha: 0.3),
548
+ width: isSelected ? 1.5 : 1,
549
+ ),
550
+ ),
551
+ child: Column(
552
+ crossAxisAlignment: CrossAxisAlignment.start,
553
+ children: [
554
+ Row(
555
children: [
314
- SizedBox(
315
- width: 100,
316
- child: Text(
317
- fileName,
318
- style: theme.textTheme.labelSmall,
319
- overflow: TextOverflow.ellipsis,
556
+ Container(
557
+ width: 36,
558
+ height: 36,
559
+ decoration: BoxDecoration(
560
+ color: accentColor.withValues(alpha: 0.15),
561
+ borderRadius: BorderRadius.circular(8),
562
+ ),
563
+ child: Icon(
564
+ Icons.record_voice_over,
565
+ size: 18,
566
+ color: accentColor,
567
),
568
),
322
- const SizedBox(width: 8),
569
+ const SizedBox(width: 12),
570
Expanded(
324
- child: LinearProgressIndicator(
325
- value: progress.fraction,
326
- backgroundColor:
327
- theme.colorScheme.primary.withValues(alpha: 0.1),
328
- minHeight: 4,
571
+ child: Column(
572
+ crossAxisAlignment: CrossAxisAlignment.start,
573
+ children: [
574
+ Text(
575
+ meta.name,
576
+ style: theme.textTheme.titleSmall?.copyWith(
577
+ fontWeight: FontWeight.bold,
578
+ ),
579
+ ),
580
+ Text(
581
+ meta.tagline,
582
+ style: theme.textTheme.bodySmall?.copyWith(
583
+ color: theme.colorScheme.onSurface.withValues(
584
+ alpha: 0.6,
585
+ ),
586
+ ),
587
+ ),
588
+ ],
589
),
590
),
331
- const SizedBox(width: 8),
332
- SizedBox(
333
- width: 50,
334
- child: Text(
335
- progress.isComplete
336
- ? 'Done'
337
- : '${(progress.fraction * 100).toStringAsFixed(0)}%',
338
- style: theme.textTheme.labelSmall?.copyWith(
339
- fontWeight: FontWeight.w600,
591
+ Column(
592
+ crossAxisAlignment: CrossAxisAlignment.end,
593
+ children: [
594
+ Text(
595
+ statusText,
596
+ style: theme.textTheme.labelSmall?.copyWith(
597
+ color: installed
598
+ ? Colors.green
599
+ : theme.colorScheme.onSurface.withValues(alpha: 0.5),
600
+ fontWeight: FontWeight.w600,
601
+ ),
602
),
341
- textAlign: TextAlign.right,
342
- ),
603
+ const SizedBox(height: 2),
604
+ Text(
605
+ '${meta.sizeMb} MB · ${meta.ramMb} MB RAM · ${meta.voiceCount} voices',
606
+ style: theme.textTheme.labelSmall?.copyWith(
607
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
608
+ fontSize: 10,
609
+ ),
610
+ ),
611
+ ],
612
),
613
],
614
),
346
- );
347
- }).toList(),
348
- );
349
- }
350
-
351
- Widget _buildPreviewToggle() {
352
- return InkWell(
353
- onTap: () => setState(() => _showPreview = !_showPreview),
354
- borderRadius: BorderRadius.circular(8),
355
- child: Padding(
356
- padding: const EdgeInsets.symmetric(vertical: 4),
357
- child: Row(
358
- children: [
359
- Icon(
360
- _showPreview ? Icons.expand_less : Icons.expand_more,
361
- size: 18,
362
- color: Theme.of(context).colorScheme.primary,
363
- ),
364
- const SizedBox(width: 4),
365
- Text(
366
- 'Voice Preview',
367
- style: TextStyle(
368
- fontSize: 13,
369
- fontWeight: FontWeight.w600,
370
- color: Theme.of(context).colorScheme.primary,
371
- ),
372
- ),
373
- ],
374
- ),
375
- ),
376
- );
377
- }
378
-
379
- Future<void> _startDownload() async {
380
- await ref
381
- .read(ttsDownloadProgressProvider.notifier)
382
- .startDownload(widget.model);
383
- }
384
-
385
- Future<void> _deleteModel() async {
386
- final confirm = await showDialog<bool>(
387
- context: context,
388
- builder: (ctx) => AlertDialog(
389
- title: const Text('Delete Model'),
390
- content: Text(
391
- 'Are you sure you want to delete ${widget.model.displayName}?',
392
- ),
393
- actions: [
394
- TextButton(
395
- onPressed: () => Navigator.pop(ctx, false),
396
- child: const Text('Cancel'),
397
- ),
398
- TextButton(
399
- onPressed: () => Navigator.pop(ctx, true),
400
- child: const Text('Delete'),
615
+ const SizedBox(height: 12),
616
+ Row(
617
+ children: [
618
+ if (isSelected)
619
+ Container(
620
+ padding: const EdgeInsets.symmetric(
621
+ horizontal: 10,
622
+ vertical: 3,
623
+ ),
624
+ decoration: BoxDecoration(
625
+ color: accentColor.withValues(alpha: 0.1),
626
+ borderRadius: BorderRadius.circular(4),
627
+ border: Border.all(
628
+ color: accentColor.withValues(alpha: 0.3),
629
+ ),
630
+ ),
631
+ child: Text(
632
+ 'Active',
633
+ style: theme.textTheme.labelSmall?.copyWith(
634
+ color: accentColor,
635
+ fontWeight: FontWeight.bold,
636
+ ),
637
+ ),
638
+ ),
639
+ const Spacer(),
640
+ if (trailing != null) trailing!,
641
+ ],
642
),
643
+ if (child != null) child!,
644
],
645
),
646
);
405
-
406
- if (confirm == true) {
407
- await ref
408
- .read(ttsDownloadProgressProvider.notifier)
409
- .deleteVariant(widget.model.variant);
410
-
411
- final settings = ref.read(settingsProvider);
412
- if (settings.kittenTtsModelVariant == widget.model.variant) {
413
- ref
414
- .read(settingsProvider.notifier)
415
- .setKittenTtsModelVariant(KittenTtsModelVariant.nanoInt8);
416
- }
417
- }
418
- }
419
-
420
- String _formatBytes(int bytes) {
421
- final mb = bytes / (1024 * 1024);
422
- if (mb >= 1024) {
423
- return '${(mb / 1024).toStringAsFixed(1)} GB';
424
- }
425
- return '${mb.toStringAsFixed(0)} MB';
647
}
648
}
649
429
-/// Section that lists all voices with play buttons for the downloaded variant.
430
-class _VoicePreviewSection extends ConsumerStatefulWidget {
431
- const _VoicePreviewSection({required this.variant});
650
+class _VoiceChips extends ConsumerStatefulWidget {
651
+ final List<Voice> voices;
652
+ final EngineId engine;
653
433
- final KittenTtsModelVariant variant;
654
+ const _VoiceChips({required this.voices, required this.engine});
655
656
@override
436
- ConsumerState<_VoicePreviewSection> createState() =>
437
- _VoicePreviewSectionState();
657
+ ConsumerState<_VoiceChips> createState() => _VoiceChipsState();
658
}
659
440
-class _VoicePreviewSectionState extends ConsumerState<_VoicePreviewSection> {
441
- KittenTtsService? _previewService;
442
- AudioPlayer? _audioPlayer;
443
- KittenTtsVoice? _playingVoice;
444
- bool _isLoading = false;
660
+class _VoiceChipsState extends ConsumerState<_VoiceChips> {
661
+ final AudioPlayer _player = AudioPlayer();
662
+ Voice? _playingVoice;
663
664
@override
665
void dispose() {
448
- _audioPlayer?.dispose();
449
- _previewService?.dispose();
666
+ _player.dispose();
667
super.dispose();
668
}
669
670
@override
671
Widget build(BuildContext context) {
672
final theme = Theme.of(context);
673
+ final females = widget.voices.where((v) => v.gender == 'f').toList();
674
+ final males = widget.voices.where((v) => v.gender == 'm').toList();
675
+ final accent = Color(EngineMeta.forEngine(widget.engine).accentColor);
676
457
- return Container(
458
- padding: const EdgeInsets.all(12),
459
- decoration: BoxDecoration(
460
- color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
461
- borderRadius: BorderRadius.circular(8),
462
- ),
677
+ return Padding(
678
+ padding: const EdgeInsets.only(top: 12),
679
child: Column(
680
crossAxisAlignment: CrossAxisAlignment.start,
681
children: [
466
- Text(
467
- 'Tap a voice to hear a preview',
468
- style: theme.textTheme.labelSmall?.copyWith(
469
- color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
682
+ if (females.isNotEmpty) ...[
683
+ Text(
684
+ 'Female',
685
+ style: theme.textTheme.labelSmall?.copyWith(
686
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
687
+ ),
688
),
471
- ),
472
- const SizedBox(height: 8),
473
- Wrap(
474
- spacing: 8,
475
- runSpacing: 8,
476
- children: KittenTtsVoice.values.map((voice) {
477
- final isPlaying = _playingVoice == voice;
478
- return ActionChip(
479
- avatar: Icon(
480
- isPlaying ? Icons.stop : Icons.play_arrow,
481
- size: 18,
482
- color: isPlaying
483
- ? theme.colorScheme.primary
484
- : theme.colorScheme.onSurface.withValues(alpha: 0.6),
485
- ),
486
- label: Text(voice.displayName),
487
- labelStyle: theme.textTheme.labelMedium?.copyWith(
488
- color: isPlaying
489
- ? theme.colorScheme.primary
490
- : theme.colorScheme.onSurface,
491
- fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal,
492
- ),
493
- backgroundColor: isPlaying
494
- ? theme.colorScheme.primary.withValues(alpha: 0.1)
495
- : theme.colorScheme.surface,
496
- side: BorderSide(
497
- color: isPlaying
498
- ? theme.colorScheme.primary.withValues(alpha: 0.3)
499
- : theme.colorScheme.outline.withValues(alpha: 0.2),
500
- ),
501
- onPressed: _isLoading && !isPlaying
502
- ? null
503
- : () => _togglePreview(voice),
504
- );
505
- }).toList(),
506
- ),
689
+ const SizedBox(height: 6),
690
+ Wrap(
691
+ spacing: 6,
692
+ runSpacing: 6,
693
+ children: females
694
+ .map((v) => _voiceChip(v, theme, accent))
695
+ .toList(),
696
+ ),
697
+ const SizedBox(height: 8),
698
+ ],
699
+ if (males.isNotEmpty) ...[
700
+ Text(
701
+ 'Male',
702
+ style: theme.textTheme.labelSmall?.copyWith(
703
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
704
+ ),
705
+ ),
706
+ const SizedBox(height: 6),
707
+ Wrap(
708
+ spacing: 6,
709
+ runSpacing: 6,
710
+ children: males.map((v) => _voiceChip(v, theme, accent)).toList(),
711
+ ),
712
+ ],
713
],
714
),
715
);
716
}
717
512
- Future<void> _togglePreview(KittenTtsVoice voice) async {
718
+ Widget _voiceChip(Voice voice, ThemeData theme, Color accent) {
719
+ final isPlaying = _playingVoice == voice;
720
+ return ActionChip(
721
+ avatar: Icon(
722
+ isPlaying ? Icons.stop : Icons.play_arrow,
723
+ size: 16,
724
+ color: isPlaying
725
+ ? accent
726
+ : theme.colorScheme.onSurface.withValues(alpha: 0.5),
727
+ ),
728
+ label: Text(voice.name),
729
+ labelStyle: theme.textTheme.labelSmall?.copyWith(
730
+ color: isPlaying ? accent : theme.colorScheme.onSurface,
731
+ fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal,
732
+ ),
733
+ backgroundColor: isPlaying
734
+ ? accent.withValues(alpha: 0.1)
735
+ : Colors.transparent,
736
+ side: BorderSide(
737
+ color: isPlaying
738
+ ? accent.withValues(alpha: 0.3)
739
+ : theme.colorScheme.outline.withValues(alpha: 0.2),
740
+ ),
741
+ onPressed: () => _togglePreview(voice),
742
+ );
743
+ }
744
+
745
+ Future<void> _togglePreview(Voice voice) async {
746
if (_playingVoice == voice) {
514
- await _audioPlayer?.stop();
747
+ await _player.stop();
748
setState(() => _playingVoice = null);
749
return;
750
}
518
-
519
- setState(() {
520
- _isLoading = true;
521
- _playingVoice = null;
522
- });
523
-
751
try {
525
- if (_previewService == null ||
526
- _previewService!.currentVariant != widget.variant) {
527
- _previewService?.dispose();
528
- _previewService = KittenTtsService();
529
- await _previewService!.initialize(variant: widget.variant);
530
- }
531
-
532
- _audioPlayer ??= AudioPlayer();
533
- await _audioPlayer!.stop();
534
-
535
- final sampleText =
536
- "Hello, I am ${voice.displayName}. It's a pleasure to meet you.";
537
- final wavBytes = await _previewService!.generatePreviewWav(
538
- sampleText,
539
- voice: voice,
540
- speed: 1.0,
541
- );
542
-
752
+ await ref.read(tts.ttsProvider.notifier).previewVoice(voice);
753
setState(() => _playingVoice = voice);
544
-
545
- await _audioPlayer!.play(BytesSource(wavBytes));
546
- _audioPlayer!.onPlayerComplete.first.then((_) {
547
- if (mounted) {
548
- setState(() => _playingVoice = null);
549
- }
754
+ Future.delayed(const Duration(seconds: 2), () {
755
+ if (mounted) setState(() => _playingVoice = null);
756
});
757
} catch (e) {
758
if (mounted) {
553
- ScaffoldMessenger.of(context).showSnackBar(
554
- SnackBar(content: Text('Preview failed: $e')),
555
- );
556
- }
557
- } finally {
558
- if (mounted) {
559
- setState(() => _isLoading = false);
759
+ ScaffoldMessenger.of(
760
+ context,
761
+ ).showSnackBar(SnackBar(content: Text('Preview failed: $e')));
762
}
763
}
764
}
765
}
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
+}
plan_tts.md
new
+608
@@ -0,0 +1,608 @@
1
+# Feature Plan: On-Device Neural TTS for Flutter
2
+
3
+## Overview
4
+
5
+Port the PocketPal AI TTS system to a Flutter app. The system uses **on-device neural TTS engines** (no cloud APIs) orchestrated through a custom native module. All model files are downloaded at runtime from HuggingFace and run locally via ONNX Runtime.
6
+
7
+---
8
+
9
+## Architecture
10
+
11
+### High-Level Layers
12
+
13
+```
14
+┌──────────────────────────────────────────────────┐
15
+│ UI Layer (Flutter widgets) │
16
+│ - TTSSetupSheet (engine cards, voice picker) │
17
+│ - PlayButton, VoiceChip │
18
+│ - AutoSpeakRow, HeroRow (quality selector) │
19
+└──────────────────────┬───────────────────────────┘
20
+ │ calls
21
+┌──────────────────────▼───────────────────────────┐
22
+│ TTS Store (State Management) │
23
+│ - orchestrates playback, download lifecycle │
24
+│ - per-engine download state machines │
25
+│ - streaming vs replay paths │
26
+│ - memory gate (4 GiB minimum RAM) │
27
+│ - thinking stripper for LLM <think> tags │
28
+└──────────────────────┬───────────────────────────┘
29
+ │ calls
30
+┌──────────────────────▼───────────────────────────┐
31
+│ TTS Runtime (singleton coordinator) │
32
+│ - serializes engine swap (only 1 engine at a time)│
33
+│ - acquire/release lifecycle │
34
+│ - stop ordering via mutex chain │
35
+└──────────────────────┬───────────────────────────┘
36
+ │ calls
37
+┌──────────────────────▼───────────────────────────┐
38
+│ Engine Implementations │
39
+│ ┌─────────┬──────────┬────────────┬──────────┐ │
40
+│ │ Kitten │ Kokoro │ Supertonic │ System │ │
41
+│ │ (nano) │ (medium) │ (large) │ (OS TTS) │ │
42
+│ └─────────┴──────────┴────────────┴──────────┘ │
43
+└──────────────────────┬───────────────────────────┘
44
+ │ calls
45
+┌──────────────────────▼───────────────────────────┐
46
+│ Flutter Native Module (platform channel) │
47
+│ - ONNX Runtime inference │
48
+│ - Phonemizer (grapheme→IPA conversion) │
49
+│ - Audio playback │
50
+└──────────────────────────────────────────────────┘
51
+```
52
+
53
+### Key Design Decisions
54
+
55
+1. **No cloud APIs** — all TTS runs on-device using neural ONNX models
56
+2. **Single active engine** — only one neural engine loaded at a time (saves RAM)
57
+3. **CPU-only execution** — `executionProviders: 'cpu'` for battery/thermal stability
58
+4. **4 GiB RAM minimum gate** — TTS disabled on devices with <4GB total RAM
59
+5. **Phonemization required** for Kokoro and Kitten engines (grapheme→IPA)
60
+
61
+---
62
+
63
+## TTS Engines
64
+
65
+### Engine Comparison Table
66
+
67
+| Feature | Kitten | Kokoro | Supertonic | System |
68
+|---------|--------|--------|------------|--------|
69
+| Type | StyleTTS2 nano | Kokoro 82M v1.0 | Supertonic v2 | OS native |
70
+| Format | ONNX FP32 | ONNX FP16 | ONNX (5 files) | Platform API |
71
+| Disk | ~57 MB | ~170 MB | ~265 MB | 0 MB |
72
+| RAM (peak) | ~235 MB | ~510 MB | ~428 MB | Minimal |
73
+| Voices | 8 (4F/4M) | 22 (17F/5M) | 10 (5F/5M) | OS-dependent |
74
+| Languages | EN only | EN, EN-GB | EN, KO, ES, PT, FR | OS-dependent |
75
+| Phonemizer? | Yes (JS `phonemize`) | Yes (JS `phonemize`) | No (grapheme direct) | No |
76
+| Install | One-phase (all-or-nothing) | Two-phase (core + voices) | Two-phase (core + voices) | Always available |
77
+| Voice embeddings | Built-in NPZ | Per-voice `.bin` files | Per-voice `.json` style files | OS built-in |
78
+
79
+### Model Sources (HuggingFace)
80
+
81
+- **IPA dictionary (shared):** `https://huggingface.co/datasets/palshub/phonemizer-dicts/resolve/main/en-us.bin` → saved as `en-us.bin`
82
+- **Kitten:** `https://huggingface.co/palshub/kitten-tts-nano-0.8-fp32`
83
+ - Files: `kitten_tts_nano_v0_8.onnx` → `kitten.onnx`, `voices-manifest.json`
84
+- **Kokoro:** `https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX`
85
+ - Core: `onnx/model_fp16.onnx` → `model.onnx`, `tokenizer.json`
86
+ - Voices: per-voice `.bin` files under `voices/` directory
87
+- **Supertonic:** `https://huggingface.co/Supertone/supertonic-2`
88
+ - 5 ONNX files: `duration_predictor.onnx`, `text_encoder.onnx`, `vector_estimator.onnx`, `vocoder.onnx`, `unicode_indexer.json`
89
+ - Voices: per-voice `.json` files under `voice_styles/`
90
+
91
+---
92
+
93
+## Phonemizer
94
+
95
+### Why It's Needed
96
+
97
+Kokoro and Kitten are **phoneme-based** TTS models: they accept IPA phonemes as input, not raw text. The text must be converted to phonemes before synthesis.
98
+
99
+### Implementation
100
+
101
+- **Library:** MIT `phonemize` JS library (v1.1.0)
102
+- **Input:** Grapheme text (e.g., "Hello world")
103
+- **Output:** IPA phoneme sequence (e.g., "həlˈoʊ wˈɜːld")
104
+- **Dictionary:** Pre-built `en-us.bin` IPA dictionary (~XX MB), downloaded from HuggingFace
105
+- **How it flows:**
106
+ 1. User text is received by the engine
107
+ 2. Text is chunked (max 200 chars per batch)
108
+ 3. Each chunk is passed through `phonemize(text_chunk)`
109
+ 4. Phoneme sequence is fed to the ONNX model for synthesis
110
+ 5. ONNX output → audio waveform → playback
111
+
112
+### Flutter Port
113
+
114
+In the React Native version, phonemization happens inside the native `@pocketpalai/react-native-speech` module. For Flutter, you have **two options:**
115
+
116
+**Option A: Dart phonemizer (recommended)**
117
+- Port the MIT `phonemize` JS library to Dart
118
+- Bundle the `en-us.bin` IPA dictionary (download at runtime from HuggingFace)
119
+- Run phonemization in Dart before passing phonemes to native
120
+
121
+**Option B: Native phonemizer**
122
+- Implement phonemization in the platform channel (Kotlin/Swift)
123
+- Use the `phonemize` JS library via a JS engine (requires embedding QuickJS/JSC)
124
+- Or use another native phonemizer (e.g., `espeak-ng` bindings — but this adds ~15 MB)
125
+
126
+**Recommendation:** Option A — pure Dart phonemizer keeps the port clean and avoids native complexity.
127
+
128
+---
129
+
130
+## Directory Structure (App Documents)
131
+
132
+```
133
+<app_documents>/
134
+└── tts/
135
+ ├── en-us.bin # IPA dict (shared)
136
+ ├── kitten/
137
+ │ ├── kitten.onnx
138
+ │ ├── voices-manifest.json
139
+ │ └── en-us.bin
140
+ ├── kokoro/
141
+ │ ├── model.onnx
142
+ │ ├── tokenizer.json
143
+ │ ├── voices-manifest.json
144
+ │ ├── en-us.bin
145
+ │ └── voices/
146
+ │ ├── af_heart.bin
147
+ │ ├── af_bella.bin
148
+ │ └── ...
149
+ └── supertonic/
150
+ ├── duration_predictor.onnx
151
+ ├── text_encoder.onnx
152
+ ├── vector_estimator.onnx
153
+ ├── vocoder.onnx
154
+ ├── unicode_indexer.json
155
+ ├── voices-manifest.json
156
+ ├── F1.json
157
+ ├── F2.json
158
+ └── ...
159
+```
160
+
161
+**iOS:** Root path is `Library/Application Support/tts/` (excluded from iCloud backup via `NSURLIsExcludedFromBackupKey`)
162
+**Android:** Root path is `files/tts/` (excluded from backup via backup rules XML)
163
+
164
+---
165
+
166
+## Voice Catalogs
167
+
168
+### Voice Type Definition
169
+
170
+```dart
171
+enum EngineId { kitten, kokoro, supertonic, system }
172
+
173
+class Voice {
174
+ final String id;
175
+ final String name;
176
+ final EngineId engine;
177
+ final String? language;
178
+ final String? gender; // 'f' or 'm'
179
+}
180
+```
181
+
182
+### Kitten (8 voices)
183
+
184
+| ID | Name | Gender |
185
+|----|------|--------|
186
+| `expr-voice-2-f` | Bella | f |
187
+| `expr-voice-3-f` | Luna | f |
188
+| `expr-voice-4-f` | Rosie | f |
189
+| `expr-voice-5-f` | Kiki | f |
190
+| `expr-voice-2-m` | Jasper | m |
191
+| `expr-voice-3-m` | Bruno | m |
192
+| `expr-voice-4-m` | Hugo | m |
193
+| `expr-voice-5-m` | Leo | m |
194
+
195
+### Kokoro (22 voices)
196
+
197
+American English female: `af_heart` (Heart), `af_bella` (Bella), `af_nicole` (Nicole), `af_sarah` (Sarah), `af_sky` (Sky), `af_aoede` (Aoede), `af_jessica` (Jessica), `af_kore` (Kore), `af_river` (River)
198
+American English male: `am_adam` (Adam), `am_echo` (Echo), `am_eric` (Eric), `am_fenrir` (Fenrir), `am_liam` (Liam), `am_michael` (Michael), `am_onyx` (Onyx), `am_santa` (Santa)
199
+British English female: `bf_alice` (Alice), `bf_emma` (Emma), `bf_lily` (Lily)
200
+British English male: `bm_george` (George), `bm_lewis` (Lewis)
201
+
202
+### Supertonic (10 voices)
203
+
204
+Female: `F1` (Sarah), `F2` (Lily), `F3` (Jessica), `F4` (Olivia), `F5` (Emily)
205
+Male: `M1` (Alex), `M2` (James), `M3` (Robert), `M4` (Sam), `M5` (Daniel)
206
+
207
+---
208
+
209
+## Core Interfaces to Implement
210
+
211
+### Engine Interface
212
+
213
+```dart
214
+abstract class Engine {
215
+ EngineId get id;
216
+ Future<bool> isInstalled();
217
+ Future<List<Voice>> getVoices();
218
+ Future<void> loadInto(); // Initialize native engine
219
+ Future<void> play(String text, Voice voice);
220
+ StreamingHandle playStreaming(Voice voice, [Future<void>? waitFor]);
221
+ Future<void> stop();
222
+}
223
+```
224
+
225
+### StreamingHandle
226
+
227
+```dart
228
+abstract class StreamingHandle {
229
+ void appendText(String chunk);
230
+ Future<void> finalize();
231
+ Future<void> cancel();
232
+}
233
+```
234
+
235
+### TTSRuntime (Singleton)
236
+
237
+- Tracks which engine is currently loaded in native land
238
+- Serializes all engine swaps via a promise chain (FIFO mutex)
239
+- `acquire(engine, fn)` — releases previous engine, loads new one, runs fn
240
+- `release()` — frees native resources
241
+- `stop()` — stops playback AND serializes with subsequent acquires
242
+
243
+### TTSStore (State)
244
+
245
+- **Persisted preferences:** `autoSpeakEnabled`, `currentVoice`, `supertonicSteps`
246
+- **Non-persisted state:** per-engine `NeuralDownloadState` (not_installed / downloading / ready / error), download progress, playback state
247
+- **Streaming hooks:** `onAssistantMessageStart`, `onAssistantMessageChunk`, `onAssistantMessageComplete`
248
+- **Replay path:** `play(messageId, text)`
249
+- **Preview path:** `preview(voice)`
250
+- **Download actions:** `downloadSupertonic()`, `downloadKokoro()`, `downloadKitten()`, plus retry/delete variants
251
+
252
+---
253
+
254
+## Download Lifecycle
255
+
256
+### Kitten (one-phase, all-or-nothing)
257
+
258
+```
259
+1. mkdir tts/kitten/ (with iOS backup exclusion on parent)
260
+2. Download kitten.onnx (progress tracked)
261
+3. Download voices-manifest.json (progress tracked)
262
+4. Download en-us.bin IPA dict (progress tracked)
263
+5. If ANY step fails: delete entire tts/kitten/ directory
264
+6. On success: set download state to 'ready'
265
+```
266
+
267
+### Kokoro (two-phase)
268
+
269
+```
270
+Phase 1 (all-or-nothing, 60% of progress weight):
271
+ 1. mkdir tts/kokoro/voices/
272
+ 2. Download model.onnx
273
+ 3. Download tokenizer.json
274
+ 4. Download en-us.bin
275
+ 5. If ANY fails: delete entire tts/kokoro/
276
+
277
+Phase 2 (best-effort, 40% of progress weight):
278
+ 6. Download each voice's .bin embedding file
279
+ 7. Partial success OK (missing voice files fetched lazily later)
280
+ 8. Write voices-manifest.json pointing at voices/ directory
281
+```
282
+
283
+### Supertonic (two-phase)
284
+
285
+```
286
+Phase 1 (all-or-nothing, 70% of progress weight):
287
+ 1. mkdir tts/supertonic/
288
+ 2. Download duration_predictor.onnx
289
+ 3. Download text_encoder.onnx
290
+ 4. Download vector_estimator.onnx
291
+ 5. Download vocoder.onnx
292
+ 6. Download unicode_indexer.json
293
+ 7. If ANY fails: delete entire tts/supertonic/
294
+
295
+Phase 2 (best-effort, 30% of progress weight):
296
+ 8. Download each voice's .json style file
297
+ 9. Write voices-manifest.json with baseUrl for lazy fallback
298
+```
299
+
300
+---
301
+
302
+## Playback Paths
303
+
304
+### Streaming Path (during LLM response)
305
+
306
+```
307
+1. onAssistantMessageStart(messageId)
308
+ - Stop any prior playback
309
+ - Create ThinkingStripper instance
310
+ - Get engine for currentVoice
311
+ - acquire(engine): release old engine → loadInto() new engine
312
+ - Create SpeechStream (native)
313
+ - Return StreamingHandle
314
+
315
+2. onAssistantMessageChunk(chunk, reasoningDelta?)
316
+ - Feed chunk to ThinkingStripper (strips <think>...</think>)
317
+ - If thinking detected → prepend "Hmm, let me think." placeholder
318
+ - Append cleaned text to StreamingHandle
319
+ - (Native phonemizes and synthesizes incrementally)
320
+
321
+3. onAssistantMessageComplete(text)
322
+ - Flush ThinkingStripper leftovers
323
+ - StreamingHandle.finalize()
324
+```
325
+
326
+### Replay Path (play button on past message)
327
+
328
+```
329
+1. stop() any in-flight playback
330
+2. Strip <think> tags via ThinkingStripper.stripFinal(text)
331
+3. Prepend thinking placeholder if reasoning occurred
332
+4. play() → acquire(engine) → Speech.speak(phonemized_text, voiceId)
333
+5. On completion → set playbackState to idle
334
+```
335
+
336
+---
337
+
338
+## Thinking/Reasoning Stripper
339
+
340
+### Purpose
341
+
342
+LLM responses may contain `<think>...</think>` tags (either from the model or from a separate `reasoning_content` stream). These should be stripped before TTS to avoid speaking internal reasoning aloud.
343
+
344
+### Implementation
345
+
346
+```dart
347
+class ThinkingStripper {
348
+ String _buffer = '';
349
+ bool _inside = false;
350
+ bool _nonEmptyThink = false;
351
+
352
+ // Feed a chunk, return clean text to speak
353
+ String feed(String chunk);
354
+
355
+ // Flush remaining buffer at stream end
356
+ String flush();
357
+
358
+ // Whether non-empty think content was encountered
359
+ bool hadNonEmptyThink();
360
+
361
+ // Record out-of-band reasoning delta
362
+ void noteReasoning(String delta);
363
+
364
+ // Static helper for non-streaming replay
365
+ static StripResult stripFinal(String text, {bool? hadReasoning});
366
+}
367
+```
368
+
369
+**Placeholder phrases** (randomly selected): "Hmm, let me think.", "Hmm.", "Let me think.", "Okay, let me think about that.", "One moment.", "Let me consider this."
370
+
371
+---
372
+
373
+## Voice Selection Flow (UI)
374
+
375
+1. User opens `TTSSetupSheet` bottom sheet
376
+2. Sees engine cards in order: Kitten → Kokoro → Supertonic
377
+3. Each card shows: engine logo, name, tagline, disk size, RAM, voice count, accent color
378
+4. Uninstalled engine: shows "Install · XX MB" button with progress bar during download
379
+5. Installed engine: expandable to show voice radio buttons grouped by gender
380
+6. Tapping a voice: calls `ttsStore.currentVoice = voice` (persisted)
381
+7. Preview button plays `TTS_PREVIEW_SAMPLE` text through selected voice
382
+8. Quality selector (Supertonic only): diffusion steps `1/2/3/5/10/20`
383
+
384
+### Engine Metadata for UI
385
+
386
+```dart
387
+class EngineMeta {
388
+ final int sizeMb;
389
+ final int ramMb;
390
+ final int voices;
391
+ final String accent;
392
+ final String gradientFrom;
393
+ final String gradientTo;
394
+}
395
+```
396
+
397
+| Engine | Size | RAM | Voices | Accent |
398
+|--------|------|-----|--------|--------|
399
+| Kitten | 57 MB | 235 MB | 8 | #F29547 |
400
+| Kokoro | 170 MB | 510 MB | 22 | #6F5CD6 |
401
+| Supertonic | 265 MB | 428 MB | 10 | #1E4DF6 |
402
+
403
+---
404
+
405
+## Native Module Platform Channel API
406
+
407
+The Flutter app needs a native platform channel (MethodChannel) with these methods:
408
+
409
+### Initialization
410
+
411
+```dart
412
+// Kitten
413
+await platform.invokeMethod('tts.initialize', {
414
+ 'engine': 'kitten',
415
+ 'modelPath': '/path/to/kitten.onnx',
416
+ 'voicesPath': '/path/to/voices-manifest.json',
417
+ 'dictPath': '/path/to/en-us.bin',
418
+ 'executionProviders': 'cpu',
419
+ 'maxChunkSize': 200,
420
+ 'silentMode': 'obey',
421
+ 'ducking': true,
422
+});
423
+
424
+// Kokoro
425
+await platform.invokeMethod('tts.initialize', {
426
+ 'engine': 'kokoro',
427
+ 'modelPath': '/path/to/model.onnx',
428
+ 'tokenizerPath': '/path/to/tokenizer.json',
429
+ 'voicesPath': '/path/to/voices-manifest.json',
430
+ 'dictPath': '/path/to/en-us.bin',
431
+ 'executionProviders': 'cpu',
432
+ 'maxChunkSize': 200,
433
+ 'silentMode': 'obey',
434
+ 'ducking': true,
435
+});
436
+
437
+// Supertonic
438
+await platform.invokeMethod('tts.initialize', {
439
+ 'engine': 'supertonic',
440
+ 'durationPredictorPath': '/path/to/duration_predictor.onnx',
441
+ 'textEncoderPath': '/path/to/text_encoder.onnx',
442
+ 'vectorEstimatorPath': '/path/to/vector_estimator.onnx',
443
+ 'vocoderPath': '/path/to/vocoder.onnx',
444
+ 'unicodeIndexerPath': '/path/to/unicode_indexer.json',
445
+ 'voicesPath': '/path/to/voices-manifest.json',
446
+ 'executionProviders': 'cpu',
447
+ 'maxChunkSize': 200,
448
+ 'silentMode': 'obey',
449
+ 'ducking': true,
450
+});
451
+
452
+// System (OS native)
453
+await platform.invokeMethod('tts.initialize', {
454
+ 'engine': 'os_native',
455
+});
456
+```
457
+
458
+### Playback
459
+
460
+```dart
461
+// Non-streaming speak
462
+await platform.invokeMethod('tts.speak', {
463
+ 'text': 'Hello world', // Pre-phonemized text for Kitten/Kokoro
464
+ 'voiceId': 'af_bella',
465
+ 'language': 'en', // Supertonic only
466
+ 'inferenceSteps': 5, // Supertonic only
467
+});
468
+
469
+// Create speech stream
470
+final stream = EventChannel('tts.stream.$voiceId');
471
+// Send chunks via MethodChannel
472
+await platform.invokeMethod('tts.stream.append', {'text': 'chunk'});
473
+await platform.invokeMethod('tts.stream.finalize');
474
+await platform.invokeMethod('tts.stream.cancel');
475
+
476
+// Control
477
+await platform.invokeMethod('tts.stop');
478
+await platform.invokeMethod('tts.release');
479
+
480
+// System voices
481
+final voices = await platform.invokeMethod('tts.getAvailableVoices');
482
+```
483
+
484
+### OS-Specific Notes
485
+
486
+**iOS:**
487
+- Use `AVSpeechSynthesizer` for System engine
488
+- Neural engines: ONNX Runtime with CoreML execution provider (optional, CPU is safer)
489
+- Model storage: `NSLibraryDirectory/Application Support/tts/` with backup exclusion
490
+- Handle audio session interruptions
491
+
492
+**Android:**
493
+- Use `TextToSpeech` API for System engine
494
+- Neural engines: ONNX Runtime with NNAPI (optional, CPU is safer)
495
+- Model storage: `context.filesDir/tts/` with backup rules in `AndroidManifest.xml`
496
+- Handle `TextToSpeech.OnInitListener` for System engine
497
+
498
+---
499
+
500
+## Implementation Order
501
+
502
+### Phase 1: Foundation
503
+1. Create Dart `Engine` interface and `Voice` types
504
+2. Implement `TTSRuntime` singleton with serialized acquire/release
505
+3. Create `ThinkingStripper` in Dart
506
+4. Implement platform channel for System engine (OS native TTS)
507
+5. Build basic `TTSStore` with `play()` replay path
508
+6. Verify System engine works on both platforms
509
+
510
+### Phase 2: Neural Engine Support
511
+7. Implement file download infrastructure (resumable, progress tracking)
512
+8. Build native module boilerplate for ONNX Runtime (Kotlin/Swift)
513
+9. Implement Kitten engine (simplest — single phase, 8 voices)
514
+10. Implement Kokoro engine (two-phase, 22 voices, phonemizer needed)
515
+11. Implement Supertonic engine (5 ONNX files, multilingual)
516
+12. Implement per-engine download state machines in `TTSStore`
517
+
518
+### Phase 3: Streaming
519
+13. Implement `StreamingHandle` in Dart
520
+14. Implement `SpeechStream` in native (Kotlin/Swift)
521
+15. Wire streaming path through `TTSStore` (`onAssistantMessageStart/Chunk/Complete`)
522
+16. Integrate with LLM chat session
523
+
524
+### Phase 4: UI
525
+17. Build `TTSSetupSheet` bottom sheet widget
526
+18. Build `EngineCard` widget with install/delete controls
527
+19. Build `VoicePickerView` with gender-grouped radio buttons
528
+20. Build `PlayButton` for per-message replay
529
+21. Build `VoiceChip` header pill
530
+22. Build `AutoSpeakRow` toggle
531
+23. Build `HeroRow` quality selector (Supertonic steps)
532
+
533
+### Phase 5: Polish
534
+24. Implement RAM gate check (`DeviceInfo.getTotalMemory()` → min 4 GiB)
535
+25. iOS backup exclusion on `tts/` directory
536
+26. Handle app backgrounding (release engine resources)
537
+27. Handle audio interruptions (phone calls, etc.)
538
+28. Disk space check before download
539
+29. Handle model deletion cleanup
540
+30. Error states and retry flows
541
+
542
+---
543
+
544
+## Constants Reference
545
+
546
+```dart
547
+// Preview sample
548
+const ttsPreviewSample = "Oh, hello there! I've been waiting for you to test me. I sound pretty good!";
549
+
550
+// RAM gate
551
+const ttsMinRamBytes = 4 * 1024 * 1024 * 1024; // 4 GiB
552
+
553
+// Directories
554
+const ttsParentSubdir = 'tts';
555
+const kittenModelSubdir = 'tts/kitten';
556
+const kokoroModelSubdir = 'tts/kokoro';
557
+const supertonicModelSubdir = 'tts/supertonic';
558
+
559
+// Model URLs
560
+const ttsDictUrl = 'https://huggingface.co/datasets/palshub/phonemizer-dicts/resolve/main/en-us.bin';
561
+const kittenModelBaseUrl = 'https://huggingface.co/palshub/kitten-tts-nano-0.8-fp32/resolve/main';
562
+const kokoroModelBaseUrl = 'https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX/resolve/main';
563
+const supertonicModelBaseUrl = 'https://huggingface.co/Supertone/supertonic-2/resolve/main';
564
+
565
+// Engine config
566
+const maxChunkSize = 200;
567
+const streamTargetChars = 300;
568
+const supertonicStepsDefault = 5;
569
+```
570
+
571
+---
572
+
573
+## Testing Strategy
574
+
575
+- Unit test `ThinkingStripper` with various `<think>` tag patterns
576
+- Unit test `TTSRuntime` acquire/release/stop ordering
577
+- Unit test `TTSStore` state machine transitions
578
+- Widget test engine cards in various states (not_installed, downloading, ready, error)
579
+- Integration test download → initialize → play flow for each engine
580
+- Integration test streaming path with mock LLM token feed
581
+- Test edge cases: switching engines mid-playback, rapid play/stop cycles, app backgrounding mid-download
582
+- Test on low-RAM devices (verify gate disables TTS)
583
+- Test on iOS and Android with all 4 engines
584
+
585
+---
586
+
587
+## Key Source Files (Reference)
588
+
589
+From the original React Native codebase (`src/services/tts/`):
590
+
591
+| File | Purpose |
592
+|------|---------|
593
+| `types.ts` | Core types: `Engine`, `Voice`, `StreamingHandle`, `EngineId` |
594
+| `constants.ts` | Model URLs, file names, sizes, config constants |
595
+| `runtime.ts` | `TTSRuntime` singleton — serialized engine swap |
596
+| `engineRegistry.ts` | Singleton instances of all 4 engines |
597
+| `streamingHandle.ts` | `createEngineStreamingHandle()` — bridges streaming with runtime |
598
+| `thinkingStripper.ts` | `ThinkingStripper` class for `<think>` tag stripping |
599
+| `engines/kitten/index.ts` | Kitten engine implementation |
600
+| `engines/kitten/voices.ts` | Kitten voice catalog |
601
+| `engines/kokoro/index.ts` | Kokoro engine implementation |
602
+| `engines/kokoro/voices.ts` | Kokoro voice catalog |
603
+| `engines/supertonic/index.ts` | Supertonic engine implementation |
604
+| `engines/supertonic/voices.ts` | Supertonic voice catalog |
605
+| `engines/system/index.ts` | System engine implementation |
606
+| `engines/system/voices.ts` | System voice fetcher |
607
+| `store/TTSStore.ts` | Main TTS store with all orchestration |
608
+| `components/TTSSetupSheet/engineMeta.ts` | Per-engine UI metadata |
pubspec.lock
+40
-8
@@ -329,6 +329,14 @@ packages:
329
url: "https://pub.dev"
330
source: hosted
331
version: "2.1.2"
332
+ espeak:
333
+ dependency: transitive
334
+ description:
335
+ name: espeak
336
+ sha256: b57d9da4a53e32b8db9a143463075f4510b44b4b5ea34c9ebd1d17a7ea36308a
337
+ url: "https://pub.dev"
338
+ source: hosted
339
+ version: "0.1.3"
340
extended_image:
341
dependency: transitive
342
description:
@@ -693,14 +701,6 @@ packages:
701
url: "https://pub.dev"
702
source: hosted
703
version: "4.11.0"
696
- kitten_tts_flutter:
697
- dependency: "direct main"
698
- description:
699
- name: kitten_tts_flutter
700
- sha256: "54896fa5d96c20edaff63973e80add6f4f23fd2abb3c631aa2a976f477df6a8b"
701
- url: "https://pub.dev"
702
- source: hosted
703
- version: "0.0.1"
704
leak_tracker:
705
dependency: transitive
706
description:
@@ -805,6 +805,14 @@ packages:
805
url: "https://pub.dev"
806
source: hosted
807
version: "0.17.6"
808
+ neural_tts:
809
+ dependency: "direct main"
810
+ description:
811
+ name: neural_tts
812
+ sha256: ad55a617d30b6f76e4f97e35c7e16c12fcdc8b64cc87b7ed9caa8e877ce5f6c7
813
+ url: "https://pub.dev"
814
+ source: hosted
815
+ version: "0.4.0"
816
node_preamble:
817
dependency: transitive
818
description:
@@ -813,6 +821,14 @@ packages:
821
url: "https://pub.dev"
822
source: hosted
823
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:
@@ -941,6 +957,22 @@ 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"
976
platform:
977
dependency: transitive
978
description:
pubspec.yaml
+2
-2
@@ -59,9 +59,9 @@ dependencies:
59
flutter_litert_lm: ^0.3.0
60
wakelock_plus: ^1.2.8
61
flutter_local_notifications: ^18.0.1
62
- kitten_tts_flutter: ^0.0.1
62
audioplayers: ^6.4.0
63
device_info_plus: ^12.4.0
64
+ neural_tts: 0.4.0
65
66
dev_dependencies:
67
flutter_test:
@@ -91,7 +91,7 @@ flutter:
91
assets:
92
- assets/google_fonts/
93
- assets/images/
94
-
94
+ - assets/espeak-ng-data.zip
95
# An image asset can refer to one or more resolution-specific "variants", see
96
# https://flutter.dev/to/resolution-aware-images
97