@tduncan / Cortana / commits / b8f1585

refactor: remove redundant KittenTtsService and migrate TTS model repositories to palshub

abdulmominsakib committed May 9, 2026 at 16:31 UTC b8f1585e01a82ec914886cdcf7160370997c5494
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