21 files changed
+1577
-300
assets/tts/config.json
deleted
-29
@@ -1,29 +0,0 @@
1
-{
2
- "name": "Kitten TTS Nano",
3
- "version": "0.8",
4
- "type": "ONNX2",
5
- "model": "kitten-tts-nano-0.8",
6
- "voices" : "voices.npz",
7
- "model_file": "kitten_tts_nano_v0_8.onnx",
8
- "speed_priors": {
9
- "expr-voice-2-f": 0.8,
10
- "expr-voice-2-m": 0.8,
11
- "expr-voice-3-m": 0.8,
12
- "expr-voice-3-f": 0.8,
13
- "expr-voice-4-m": 0.9,
14
- "expr-voice-4-f": 0.8,
15
- "expr-voice-5-m": 0.8,
16
- "expr-voice-5-f": 0.8
17
- },
18
- "voice_aliases": {
19
- "Bella": "expr-voice-2-f",
20
- "Jasper": "expr-voice-2-m",
21
- "Luna": "expr-voice-3-f",
22
- "Bruno": "expr-voice-3-m",
23
- "Rosie": "expr-voice-4-f",
24
- "Hugo": "expr-voice-4-m",
25
- "Kiki": "expr-voice-5-f",
26
- "Leo": "expr-voice-5-m"
27
- }
28
-
29
-}
assets/tts/kitten_tts_nano_v0_8.onnx
Binary files a/assets/tts/kitten_tts_nano_v0_8.onnx and /dev/null differ
assets/tts/voices.npz
Binary files a/assets/tts/voices.npz and /dev/null differ
lib/app.dart
+8
-2
@@ -21,7 +21,8 @@ import 'features/servers/data/models/server.dart';
21
import 'features/servers/providers/server_providers.dart';
22
import 'features/servers/views/add_server_screen.dart';
23
import 'features/servers/views/server_list_screen.dart';
24
-import 'features/settings/screens/settings_screen.dart';
24
+import 'features/tts/views/tts_model_manager_screen.dart';
25
+import 'features/settings/views/settings_screen.dart';
26
import 'features/sidebar/sidebar_drawer.dart';
27
import 'features/sidebar/sidebar_widget.dart';
28
@@ -122,7 +123,7 @@ final routerProvider = Provider<GoRouter>((ref) {
123
GoRoute(
124
path: AppRoutes.settings,
125
pageBuilder: (context, state) =>
125
- const NoTransitionPage(child: SettingsScreen()),
126
+ const NoTransitionPage(child: SettingsViews()),
127
),
128
GoRoute(
129
path: AppRoutes.chatHistory,
@@ -134,6 +135,11 @@ final routerProvider = Provider<GoRouter>((ref) {
135
pageBuilder: (context, state) =>
136
const NoTransitionPage(child: OnDeviceModelManagerScreen()),
137
),
138
+ GoRoute(
139
+ path: AppRoutes.ttsModels,
140
+ pageBuilder: (context, state) =>
141
+ const NoTransitionPage(child: TtsModelManagerScreen()),
142
+ ),
143
],
144
),
145
],
lib/core/providers/app_providers.dart
+3
@@ -1,6 +1,7 @@
1
import 'package:flutter_riverpod/flutter_riverpod.dart';
2
import '../../features/settings/data/models/app_settings.dart';
3
import '../models/enums.dart';
4
+import '../../features/tts/data/kitten_tts_model.dart';
5
import 'storage_providers.dart';
6
import '../theme/app_theme.dart';
7
@@ -91,6 +92,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
92
_update(state.copyWith(kittenTtsVoice: value));
93
void setKittenTtsSpeed(double value) =>
94
_update(state.copyWith(kittenTtsSpeed: value));
95
+ void setKittenTtsModelVariant(KittenTtsModelVariant value) =>
96
+ _update(state.copyWith(kittenTtsModelVariant: value));
97
98
Future<void> _update(AppSettings updated) async {
99
state = updated;
lib/core/routes/app_routes.dart
+1
@@ -13,4 +13,5 @@ class AppRoutes {
13
static const String models = '/models';
14
static const String chatHistory = '/history';
15
static const String onDeviceModels = '/on-device-models';
16
+ static const String ttsModels = '/tts-models';
17
}
lib/features/chat/views/components/message_action_bar.dart
+1
-1
@@ -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 '../../providers/tts_providers.dart';
5
+import '../../../tts/providers/tts_providers.dart';
6
7
class MessageActionBar extends ConsumerStatefulWidget {
8
const MessageActionBar({
lib/features/settings/data/models/app_settings.dart
+16
@@ -3,6 +3,7 @@ import 'dart:convert';
3
import 'package:flutter/material.dart';
4
5
import '../../../../core/models/enums.dart';
6
+import '../../../../features/tts/data/kitten_tts_model.dart';
7
8
enum SyntaxThemeName {
9
light,
@@ -34,6 +35,7 @@ class AppSettings {
35
final TtsEngine ttsEngine;
36
final KittenTtsVoice kittenTtsVoice;
37
final double kittenTtsSpeed;
38
+ final KittenTtsModelVariant kittenTtsModelVariant;
39
40
AppSettings({
41
this.temperature = 0.7,
@@ -60,6 +62,7 @@ class AppSettings {
62
this.ttsEngine = TtsEngine.system,
63
this.kittenTtsVoice = KittenTtsVoice.bella,
64
this.kittenTtsSpeed = 1.0,
65
+ this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
66
});
67
68
AppSettings copyWith({
@@ -87,6 +90,7 @@ class AppSettings {
90
TtsEngine? ttsEngine,
91
KittenTtsVoice? kittenTtsVoice,
92
double? kittenTtsSpeed,
93
+ KittenTtsModelVariant? kittenTtsModelVariant,
94
}) {
95
return AppSettings(
96
temperature: temperature ?? this.temperature,
@@ -116,6 +120,7 @@ class AppSettings {
120
ttsEngine: ttsEngine ?? this.ttsEngine,
121
kittenTtsVoice: kittenTtsVoice ?? this.kittenTtsVoice,
122
kittenTtsSpeed: kittenTtsSpeed ?? this.kittenTtsSpeed,
123
+ kittenTtsModelVariant: kittenTtsModelVariant ?? this.kittenTtsModelVariant,
124
);
125
}
126
@@ -145,6 +150,7 @@ class AppSettings {
150
'ttsEngine': ttsEngine.index,
151
'kittenTtsVoice': kittenTtsVoice.index,
152
'kittenTtsSpeed': kittenTtsSpeed,
153
+ 'kittenTtsModelVariant': kittenTtsModelVariant.name,
154
};
155
}
156
@@ -174,9 +180,19 @@ class AppSettings {
180
ttsEngine: TtsEngine.values[map['ttsEngine'] ?? 0],
181
kittenTtsVoice: KittenTtsVoice.values[map['kittenTtsVoice'] ?? 0],
182
kittenTtsSpeed: map['kittenTtsSpeed']?.toDouble() ?? 1.0,
183
+ kittenTtsModelVariant: _parseVariant(map['kittenTtsModelVariant']),
184
);
185
}
186
187
+ static KittenTtsModelVariant _parseVariant(dynamic value) {
188
+ if (value is String) {
189
+ try {
190
+ return KittenTtsModelVariant.values.byName(value);
191
+ } catch (_) {}
192
+ }
193
+ return KittenTtsModelVariant.nanoInt8;
194
+ }
195
+
196
String toJson() => json.encode(toMap());
197
198
factory AppSettings.fromJson(String source) =>
lib/features/settings/views/settings_screen.dart
renamed
+16
-2
@@ -12,8 +12,8 @@ import '../../servers/providers/server_providers.dart';
12
import '../data/models/app_settings.dart';
13
import '../../personas/providers/personas_providers.dart';
14
15
-class SettingsScreen extends ConsumerWidget {
16
- const SettingsScreen({super.key});
15
+class SettingsViews extends ConsumerWidget {
16
+ const SettingsViews({super.key});
17
18
@override
19
Widget build(BuildContext context, WidgetRef ref) {
@@ -63,6 +63,20 @@ class SettingsScreen extends ConsumerWidget {
63
ref: ref,
64
isDark: isDark,
65
),
66
+ Padding(
67
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
68
+ child: OutlinedButton.icon(
69
+ onPressed: () => context.push(AppRoutes.ttsModels),
70
+ icon: const Icon(Icons.record_voice_over, size: 18),
71
+ label: const Text('Manage TTS Models'),
72
+ style: OutlinedButton.styleFrom(
73
+ minimumSize: const Size(double.infinity, 44),
74
+ shape: RoundedRectangleBorder(
75
+ borderRadius: BorderRadius.circular(8),
76
+ ),
77
+ ),
78
+ ),
79
+ ),
80
if (settings.ttsEngine == TtsEngine.kitten) ...[
81
const SizedBox(height: 8),
82
_KittenVoiceDropdown(
lib/features/sidebar/sidebar_widget.dart
+69
@@ -27,6 +27,7 @@ class SidebarWidget extends ConsumerWidget {
27
final isPersonas = location.startsWith(AppRoutes.personas);
28
final themeMode = ref.watch(themeModeProvider);
29
final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
30
+ final isTtsModels = location.startsWith(AppRoutes.ttsModels);
31
final isSettings = location.startsWith(AppRoutes.settings);
32
33
return Container(
@@ -100,6 +101,17 @@ class SidebarWidget extends ConsumerWidget {
101
context.go(AppRoutes.onDeviceModels);
102
},
103
),
104
+ _SidebarNavItem(
105
+ icon: const Icon(Icons.record_voice_over, size: 20),
106
+ label: 'TTS Models',
107
+ isSelected: isTtsModels,
108
+ onTap: () {
109
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
110
+ Navigator.pop(context);
111
+ }
112
+ context.go(AppRoutes.ttsModels);
113
+ },
114
+ ),
115
DrawerNavItem(
116
iconData: HugeIcons.strokeRoundedCompass01,
117
label: 'Personas',
@@ -180,3 +192,60 @@ class SidebarWidget extends ConsumerWidget {
192
}
193
}
194
}
195
+
196
+/// A sidebar navigation item that accepts any Widget as an icon.
197
+/// Used when HugeIcons doesn't have a suitable icon.
198
+class _SidebarNavItem extends StatelessWidget {
199
+ const _SidebarNavItem({
200
+ required this.icon,
201
+ required this.label,
202
+ required this.isSelected,
203
+ required this.onTap,
204
+ });
205
+
206
+ final Widget icon;
207
+ final String label;
208
+ final bool isSelected;
209
+ final VoidCallback onTap;
210
+
211
+ @override
212
+ Widget build(BuildContext context) {
213
+ final theme = Theme.of(context);
214
+ final isDark = theme.brightness == Brightness.dark;
215
+
216
+ return Padding(
217
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
218
+ child: InkWell(
219
+ onTap: onTap,
220
+ borderRadius: BorderRadius.circular(12),
221
+ child: Container(
222
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
223
+ decoration: BoxDecoration(
224
+ color: isSelected
225
+ ? (isDark
226
+ ? theme.colorScheme.primary.withAlpha(30)
227
+ : theme.colorScheme.primary.withAlpha(20))
228
+ : Colors.transparent,
229
+ borderRadius: BorderRadius.circular(12),
230
+ ),
231
+ child: Row(
232
+ children: [
233
+ icon,
234
+ const SizedBox(width: 16),
235
+ Text(
236
+ label,
237
+ style: TextStyle(
238
+ fontSize: 14,
239
+ fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
240
+ color: isSelected
241
+ ? (isDark ? Colors.white : theme.colorScheme.primary)
242
+ : (isDark ? Colors.white70 : Colors.black87),
243
+ ),
244
+ ),
245
+ ],
246
+ ),
247
+ ),
248
+ ),
249
+ );
250
+ }
251
+}
lib/features/tts/data/kitten_tts_downloader.dart
new
+263
@@ -0,0 +1,263 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+
4
+import 'package:dio/dio.dart';
5
+import 'package:path_provider/path_provider.dart';
6
+import 'package:wakelock_plus/wakelock_plus.dart';
7
+
8
+import '../../../core/logger/app_logger.dart';
9
+import 'kitten_tts_model.dart';
10
+
11
+/// Downloads KittenTTS model files (config.json, model.onnx, voices.npz)
12
+/// from HuggingFace with resume support and per-file progress reporting.
13
+class KittenTtsDownloader {
14
+ final Dio _dio = Dio();
15
+ final Map<String, CancelToken> _activeDownloads = {};
16
+
17
+ /// Returns the storage directory for a given variant.
18
+ Future<Directory> _getVariantDir(KittenTtsModelVariant variant) async {
19
+ final supportDir = await getApplicationSupportDirectory();
20
+ final dir = Directory('${supportDir.path}/kitten_tts/${variant.dirName}');
21
+ if (!await dir.exists()) {
22
+ await dir.create(recursive: true);
23
+ }
24
+ return dir;
25
+ }
26
+
27
+ /// Check whether all files for a variant have been downloaded.
28
+ Future<bool> isVariantDownloaded(KittenTtsModelVariant variant) async {
29
+ final dir = await _getVariantDir(variant);
30
+ for (final file in variant.files) {
31
+ final path = '${dir.path}/${file.fileName}';
32
+ if (!await File(path).exists()) {
33
+ return false;
34
+ }
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /// Get total downloaded bytes for a variant.
40
+ Future<int> getDownloadedBytes(KittenTtsModelVariant variant) async {
41
+ final dir = await _getVariantDir(variant);
42
+ int total = 0;
43
+ for (final file in variant.files) {
44
+ final path = '${dir.path}/${file.fileName}';
45
+ final f = File(path);
46
+ if (await f.exists()) {
47
+ total += await f.length();
48
+ }
49
+ }
50
+ return total;
51
+ }
52
+
53
+ /// Download all files for [model], emitting per-file progress.
54
+ Stream<KittenTtsFileProgress> downloadModel(KittenTtsModel model) async* {
55
+ final variantId = model.variant.name;
56
+ final dir = await _getVariantDir(model.variant);
57
+
58
+ await WakelockPlus.enable();
59
+
60
+ try {
61
+ for (final file in model.files) {
62
+ final cancelToken = CancelToken();
63
+ _activeDownloads[variantId] = cancelToken;
64
+
65
+ final filePath = '${dir.path}/${file.fileName}';
66
+ final partialPath = '$filePath.part';
67
+ final partialFile = File(partialPath);
68
+
69
+ int receivedBytes = 0;
70
+
71
+ if (await partialFile.exists()) {
72
+ receivedBytes = await partialFile.length();
73
+ Log.info(
74
+ 'Resuming KittenTTS ${model.variant.displayName} ${file.fileName} from $receivedBytes bytes',
75
+ );
76
+ }
77
+
78
+ DateTime lastProgressUpdate = DateTime.now();
79
+
80
+ try {
81
+ final response = await _resolveWithRedirects(
82
+ url: file.downloadUrl,
83
+ startByte: receivedBytes,
84
+ cancelToken: cancelToken,
85
+ );
86
+
87
+ final totalBytes = (response.headers.value(Headers.contentLengthHeader) != null)
88
+ ? int.parse(response.headers.value(Headers.contentLengthHeader)!) + receivedBytes
89
+ : file.sizeBytes;
90
+
91
+ final IOSink sink = partialFile.openWrite(mode: FileMode.append);
92
+ final stream = response.data?.stream;
93
+
94
+ if (stream == null) {
95
+ await sink.close();
96
+ throw DioException(
97
+ requestOptions: response.requestOptions,
98
+ error: 'Response data stream is null',
99
+ );
100
+ }
101
+
102
+ await for (final List<int> chunk in stream) {
103
+ if (cancelToken.isCancelled) break;
104
+ sink.add(chunk);
105
+ receivedBytes += chunk.length;
106
+
107
+ final now = DateTime.now();
108
+ final elapsedSinceLastUpdate = now.difference(lastProgressUpdate);
109
+
110
+ if (elapsedSinceLastUpdate.inMilliseconds >= 500) {
111
+ yield KittenTtsFileProgress(
112
+ fileName: file.fileName,
113
+ variant: model.variant,
114
+ receivedBytes: receivedBytes,
115
+ totalBytes: totalBytes,
116
+ );
117
+
118
+ lastProgressUpdate = now;
119
+ }
120
+ }
121
+
122
+ await sink.flush();
123
+ await sink.close();
124
+
125
+ await partialFile.rename(filePath);
126
+ Log.info(
127
+ 'KittenTTS ${model.variant.displayName} ${file.fileName} downloaded',
128
+ );
129
+
130
+ yield KittenTtsFileProgress(
131
+ fileName: file.fileName,
132
+ variant: model.variant,
133
+ receivedBytes: receivedBytes,
134
+ totalBytes: receivedBytes,
135
+ isComplete: true,
136
+ );
137
+ } catch (e) {
138
+ if (e is DioException && CancelToken.isCancel(e)) {
139
+ Log.info('KittenTTS download cancelled for $variantId');
140
+ return;
141
+ }
142
+ rethrow;
143
+ }
144
+ }
145
+ } finally {
146
+ _activeDownloads.remove(variantId);
147
+ if (_activeDownloads.isEmpty) {
148
+ await WakelockPlus.disable();
149
+ }
150
+ }
151
+ }
152
+
153
+ /// Cancel an in-flight download for [variantId].
154
+ void cancelDownload(KittenTtsModelVariant variant) {
155
+ _activeDownloads[variant.name]?.cancel();
156
+ }
157
+
158
+ /// Delete all files for a variant from disk.
159
+ Future<void> deleteVariant(KittenTtsModelVariant variant) async {
160
+ final dir = await _getVariantDir(variant);
161
+ for (final file in variant.files) {
162
+ final path = '${dir.path}/${file.fileName}';
163
+ final f = File(path);
164
+ if (await f.exists()) {
165
+ await f.delete();
166
+ }
167
+ final partFile = File('$path.part');
168
+ if (await partFile.exists()) {
169
+ await partFile.delete();
170
+ }
171
+ }
172
+ if (await dir.exists()) {
173
+ final entities = await dir.list().toList();
174
+ if (entities.isEmpty) {
175
+ await dir.delete();
176
+ }
177
+ }
178
+ Log.info('KittenTTS variant ${variant.displayName} deleted');
179
+ }
180
+
181
+ /// Get list of downloaded variants.
182
+ Future<Set<KittenTtsModelVariant>> getDownloadedVariants() async {
183
+ final result = <KittenTtsModelVariant>{};
184
+ for (final variant in KittenTtsModelVariant.values) {
185
+ if (await isVariantDownloaded(variant)) {
186
+ result.add(variant);
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+
192
+ /// Get the absolute file path for a variant's file.
193
+ Future<String?> getFilePath(
194
+ KittenTtsModelVariant variant,
195
+ String fileName,
196
+ ) async {
197
+ final dir = await _getVariantDir(variant);
198
+ final file = File('${dir.path}/$fileName');
199
+ if (await file.exists()) {
200
+ return file.path;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /// Manually follows redirects to ensure Authorization header safety.
206
+ Future<Response<ResponseBody>> _resolveWithRedirects({
207
+ required String url,
208
+ required int startByte,
209
+ required CancelToken cancelToken,
210
+ }) async {
211
+ String currentUrl = url;
212
+ final Uri originalUri = Uri.parse(url);
213
+
214
+ for (int hop = 0; hop < 5; hop++) {
215
+ final options = Options(
216
+ responseType: ResponseType.stream,
217
+ followRedirects: false,
218
+ validateStatus: (status) => status != null && status < 500,
219
+ headers: {
220
+ if (startByte > 0) 'Range': 'bytes=$startByte-',
221
+ },
222
+ );
223
+
224
+ final currentUri = Uri.parse(currentUrl);
225
+ if (currentUri.host != originalUri.host) {
226
+ options.headers?.remove('Authorization');
227
+ }
228
+
229
+ final response = await _dio.get<ResponseBody>(
230
+ currentUrl,
231
+ options: options,
232
+ cancelToken: cancelToken,
233
+ );
234
+
235
+ if (response.statusCode == 200 || response.statusCode == 206) {
236
+ return response;
237
+ }
238
+
239
+ if (response.statusCode! >= 300 && response.statusCode! < 400) {
240
+ final location = response.headers.value('location');
241
+ if (location == null) {
242
+ throw DioException(
243
+ requestOptions: response.requestOptions,
244
+ error: 'Redirect without location header',
245
+ );
246
+ }
247
+ currentUrl = Uri.parse(currentUrl).resolve(location).toString();
248
+ continue;
249
+ }
250
+
251
+ throw DioException(
252
+ requestOptions: response.requestOptions,
253
+ response: response,
254
+ error: 'Server returned ${response.statusCode}',
255
+ );
256
+ }
257
+
258
+ throw DioException(
259
+ requestOptions: RequestOptions(path: url),
260
+ error: 'Too many redirects',
261
+ );
262
+ }
263
+}
lib/features/tts/data/kitten_tts_model.dart
new
+247
@@ -0,0 +1,247 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// Available KittenTTS model variants downloadable from HuggingFace.
4
+///
5
+/// Each variant has a different parameter count and quality trade-off.
6
+enum KittenTtsModelVariant {
7
+ /// 15M parameters, ~56 MB total, high quality.
8
+ nano,
9
+
10
+ /// 15M parameters, ~25 MB total, int8 quantized. Best for most devices.
11
+ nanoInt8,
12
+
13
+ /// 40M parameters, ~41 MB total, higher quality.
14
+ micro,
15
+
16
+ /// 80M parameters, ~80 MB total, best quality.
17
+ mini;
18
+
19
+ String get displayName {
20
+ switch (this) {
21
+ case KittenTtsModelVariant.nano:
22
+ return 'Kitten TTS Nano';
23
+ case KittenTtsModelVariant.nanoInt8:
24
+ return 'Kitten TTS Nano (Int8)';
25
+ case KittenTtsModelVariant.micro:
26
+ return 'Kitten TTS Micro';
27
+ case KittenTtsModelVariant.mini:
28
+ return 'Kitten TTS Mini';
29
+ }
30
+ }
31
+
32
+ /// Short label shown in badges (e.g. "15M", "80M").
33
+ String get parameterLabel {
34
+ switch (this) {
35
+ case KittenTtsModelVariant.nano:
36
+ return '15M';
37
+ case KittenTtsModelVariant.nanoInt8:
38
+ return '15M';
39
+ case KittenTtsModelVariant.micro:
40
+ return '40M';
41
+ case KittenTtsModelVariant.mini:
42
+ return '80M';
43
+ }
44
+ }
45
+
46
+ /// Approximate total size in bytes for all 3 files combined.
47
+ int get totalSizeBytes {
48
+ switch (this) {
49
+ case KittenTtsModelVariant.nano:
50
+ return 56 * 1024 * 1024;
51
+ case KittenTtsModelVariant.nanoInt8:
52
+ return 25 * 1024 * 1024;
53
+ case KittenTtsModelVariant.micro:
54
+ return 41 * 1024 * 1024;
55
+ case KittenTtsModelVariant.mini:
56
+ return 80 * 1024 * 1024;
57
+ }
58
+ }
59
+
60
+ String get description {
61
+ switch (this) {
62
+ case KittenTtsModelVariant.nano:
63
+ return 'Balanced quality and size. Good for most devices.';
64
+ case KittenTtsModelVariant.nanoInt8:
65
+ return 'Smallest model with excellent quality. Recommended for most devices.';
66
+ case KittenTtsModelVariant.micro:
67
+ return 'Higher quality with more parameters. Requires more RAM.';
68
+ case KittenTtsModelVariant.mini:
69
+ return 'Highest quality, largest size. Best for high-end devices.';
70
+ }
71
+ }
72
+
73
+ /// Whether this variant is recommended as the default.
74
+ bool get isRecommended => this == KittenTtsModelVariant.nanoInt8;
75
+
76
+ /// HuggingFace repository ID for this variant.
77
+ String get huggingFaceRepoId {
78
+ switch (this) {
79
+ case KittenTtsModelVariant.nano:
80
+ return 'KittenML/kitten-tts-nano-0.8-fp32';
81
+ case KittenTtsModelVariant.nanoInt8:
82
+ return 'KittenML/kitten-tts-nano-0.8-int8';
83
+ case KittenTtsModelVariant.micro:
84
+ return 'KittenML/kitten-tts-micro-0.8';
85
+ case KittenTtsModelVariant.mini:
86
+ return 'KittenML/kitten-tts-mini-0.8';
87
+ }
88
+ }
89
+
90
+ /// The ONNX model file name inside the HF repo.
91
+ String get modelFileName {
92
+ switch (this) {
93
+ case KittenTtsModelVariant.nano:
94
+ return 'kitten_tts_nano_v0_8.onnx';
95
+ case KittenTtsModelVariant.nanoInt8:
96
+ return 'kitten_tts_nano_v0_8.onnx';
97
+ case KittenTtsModelVariant.micro:
98
+ return 'kitten_tts_micro_v0_8.onnx';
99
+ case KittenTtsModelVariant.mini:
100
+ return 'kitten_tts_mini_v0_8.onnx';
101
+ }
102
+ }
103
+
104
+ /// The 3 files required for this variant.
105
+ List<KittenTtsModelFile> get files {
106
+ return [
107
+ KittenTtsModelFile(
108
+ fileName: 'config.json',
109
+ downloadUrl:
110
+ 'https://huggingface.co/$huggingFaceRepoId/resolve/main/config.json',
111
+ sizeBytes: 2 * 1024,
112
+ ),
113
+ KittenTtsModelFile(
114
+ fileName: modelFileName,
115
+ downloadUrl:
116
+ 'https://huggingface.co/$huggingFaceRepoId/resolve/main/$modelFileName',
117
+ sizeBytes: _modelFileSizeBytes,
118
+ ),
119
+ KittenTtsModelFile(
120
+ fileName: 'voices.npz',
121
+ downloadUrl:
122
+ 'https://huggingface.co/$huggingFaceRepoId/resolve/main/voices.npz',
123
+ sizeBytes: 3 * 1024 * 1024,
124
+ ),
125
+ ];
126
+ }
127
+
128
+ int get _modelFileSizeBytes {
129
+ switch (this) {
130
+ case KittenTtsModelVariant.nano:
131
+ return 52 * 1024 * 1024;
132
+ case KittenTtsModelVariant.nanoInt8:
133
+ return 21 * 1024 * 1024;
134
+ case KittenTtsModelVariant.micro:
135
+ return 37 * 1024 * 1024;
136
+ case KittenTtsModelVariant.mini:
137
+ return 76 * 1024 * 1024;
138
+ }
139
+ }
140
+
141
+ /// Storage directory name for this variant.
142
+ String get dirName => name;
143
+}
144
+
145
+/// Metadata for a single file within a KittenTTS model variant.
146
+@immutable
147
+class KittenTtsModelFile {
148
+ final String fileName;
149
+ final String downloadUrl;
150
+ final int sizeBytes;
151
+
152
+ const KittenTtsModelFile({
153
+ required this.fileName,
154
+ required this.downloadUrl,
155
+ required this.sizeBytes,
156
+ });
157
+}
158
+
159
+/// A fully-specified KittenTTS model with its variant and files.
160
+@immutable
161
+class KittenTtsModel {
162
+ final KittenTtsModelVariant variant;
163
+ final List<KittenTtsModelFile> files;
164
+ final int totalSizeBytes;
165
+
166
+ const KittenTtsModel({
167
+ required this.variant,
168
+ required this.files,
169
+ required this.totalSizeBytes,
170
+ });
171
+
172
+ static final List<KittenTtsModel> allModels = [
173
+ KittenTtsModel(
174
+ variant: KittenTtsModelVariant.nano,
175
+ files: KittenTtsModelVariant.nano.files,
176
+ totalSizeBytes: KittenTtsModelVariant.nano.totalSizeBytes,
177
+ ),
178
+ KittenTtsModel(
179
+ variant: KittenTtsModelVariant.nanoInt8,
180
+ files: KittenTtsModelVariant.nanoInt8.files,
181
+ totalSizeBytes: KittenTtsModelVariant.nanoInt8.totalSizeBytes,
182
+ ),
183
+ KittenTtsModel(
184
+ variant: KittenTtsModelVariant.micro,
185
+ files: KittenTtsModelVariant.micro.files,
186
+ totalSizeBytes: KittenTtsModelVariant.micro.totalSizeBytes,
187
+ ),
188
+ KittenTtsModel(
189
+ variant: KittenTtsModelVariant.mini,
190
+ files: KittenTtsModelVariant.mini.files,
191
+ totalSizeBytes: KittenTtsModelVariant.mini.totalSizeBytes,
192
+ ),
193
+ ];
194
+
195
+ String get displayName => variant.displayName;
196
+
197
+ String get parameterLabel => variant.parameterLabel;
198
+
199
+ String get description => variant.description;
200
+
201
+ bool get isRecommended => variant.isRecommended;
202
+}
203
+
204
+/// Progress for downloading a single file of a model variant.
205
+@immutable
206
+class KittenTtsFileProgress {
207
+ final String fileName;
208
+ final KittenTtsModelVariant variant;
209
+ final int receivedBytes;
210
+ final int totalBytes;
211
+ final bool isComplete;
212
+
213
+ const KittenTtsFileProgress({
214
+ required this.fileName,
215
+ required this.variant,
216
+ this.receivedBytes = 0,
217
+ this.totalBytes = 0,
218
+ this.isComplete = false,
219
+ });
220
+
221
+ double get fraction =>
222
+ totalBytes > 0 ? (receivedBytes / totalBytes).clamp(0.0, 1.0) : 0.0;
223
+}
224
+
225
+/// Overall download progress for a variant (all 3 files).
226
+@immutable
227
+class KittenTtsVariantProgress {
228
+ final KittenTtsModelVariant variant;
229
+ final Map<String, KittenTtsFileProgress> fileProgress;
230
+
231
+ const KittenTtsVariantProgress({
232
+ required this.variant,
233
+ this.fileProgress = const {},
234
+ });
235
+
236
+ double get overallFraction {
237
+ if (fileProgress.isEmpty) return 0.0;
238
+ final total = fileProgress.values
239
+ .map((f) => f.fraction)
240
+ .fold<double>(0.0, (a, b) => a + b);
241
+ return total / fileProgress.length;
242
+ }
243
+
244
+ bool get isComplete =>
245
+ fileProgress.isNotEmpty &&
246
+ fileProgress.values.every((f) => f.isComplete);
247
+}
lib/features/tts/data/kitten_tts_service.dart
renamed
+132
-98
@@ -1,24 +1,25 @@
1
import 'dart:async';
2
import 'dart:io';
3
+import 'dart:typed_data';
4
5
import 'package:audioplayers/audioplayers.dart';
5
-import 'package:flutter/services.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
///
15
-/// On first initialization, copies the required model files from app assets
16
-/// to the filesystem (since the ONNX runtime requires absolute file paths).
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();
21
-/// await service.initialize();
22
+/// await service.initialize(variant: KittenTtsModelVariant.nanoInt8);
23
/// await service.speak('Hello world', voice: KittenTtsVoice.bella);
24
/// await service.dispose();
25
/// ```
@@ -30,63 +31,85 @@ class KittenTtsService {
31
bool _isInitialized = false;
32
bool _isSpeaking = false;
33
Future<void>? _initializeFuture;
33
-
34
- // Asset paths
35
- static const String _assetConfig = 'assets/tts/config.json';
36
- static const String _assetModel = 'assets/tts/kitten_tts_nano_v0_8.onnx';
37
- static const String _assetVoices = 'assets/tts/voices.npz';
38
-
39
- // File names in the app support directory
40
- static const String _fileConfig = 'config.json';
41
- static const String _fileModel = 'kitten_tts_nano_v0_8.onnx';
42
- static const String _fileVoices = 'voices.npz';
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
47
- /// Initialize the KittenTTS engine.
75
+ /// Initialize (or re-initialize) the KittenTTS engine for [variant].
76
///
49
- /// Copies model files from assets to the filesystem on first run,
50
- /// then loads the ONNX session.
51
- Future<void> initialize() async {
52
- if (_isInitialized) return;
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
55
- _initializeFuture = _doInitialize();
82
+ _initializeFuture = _doInitialize(variant);
83
return _initializeFuture;
84
}
85
59
- Future<void> _doInitialize() async {
86
+ Future<void> _doInitialize(KittenTtsModelVariant variant) async {
87
try {
61
- final supportDir = await getApplicationSupportDirectory();
62
- final ttsDir = Directory('${supportDir.path}/kitten_tts');
63
- if (!await ttsDir.exists()) {
64
- await ttsDir.create(recursive: true);
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
67
- // Copy assets to filesystem if not already present
68
- final configPath = await _ensureAssetFile(
69
- ttsDir,
70
- _fileConfig,
71
- _assetConfig,
72
- );
73
- final modelPath = await _ensureAssetFile(ttsDir, _fileModel, _assetModel);
74
- final voicesPath = await _ensureAssetFile(
75
- ttsDir,
76
- _fileVoices,
77
- _assetVoices,
78
- );
79
-
96
Log.info(
81
- 'KittenTTS assets ready: config=$configPath, model=$modelPath, voices=$voicesPath',
97
+ 'KittenTTS initializing: variant=${variant.displayName}, '
98
+ 'config=${paths.config}, model=${paths.model}, voices=${paths.voices}',
99
);
100
84
- // Initialize KittenTTS engine
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(
87
- configPath: configPath,
88
- modelPath: modelPath,
89
- voicesPath: voicesPath,
110
+ configPath: paths.config,
111
+ modelPath: paths.model,
112
+ voicesPath: paths.voices,
113
);
114
115
_audioPlayer ??= AudioPlayer();
@@ -94,8 +117,9 @@ class KittenTtsService {
117
_isSpeaking = false;
118
});
119
120
+ _currentVariant = variant;
121
_isInitialized = true;
98
- Log.info('KittenTTS initialized successfully');
122
+ Log.info('KittenTTS initialized successfully (${variant.displayName})');
123
} catch (e, st) {
124
Log.error('Failed to initialize KittenTTS: $e\n$st');
125
rethrow;
@@ -105,20 +129,20 @@ class KittenTtsService {
129
}
130
131
/// Generate WAV bytes and play them for the given text.
108
- ///
109
- /// [text] should be plain English text. It will be phonemized internally.
110
- /// [voice] selects one of the built-in voices.
111
- /// [speed] controls the playback rate (0.5 to 2.0).
132
Future<void> speak(
133
String text, {
134
KittenTtsVoice voice = KittenTtsVoice.bella,
135
double speed = 1.0,
136
}) async {
117
- await initialize();
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
121
- // If already speaking, stop first
146
if (_isSpeaking) {
147
await stop();
148
}
@@ -126,8 +150,6 @@ class KittenTtsService {
150
_isSpeaking = true;
151
152
try {
129
- // Split text into smaller chunks (sentences) to avoid ONNX sequence length limits
130
- // The "Nano" model used by KittenTTS often crashes on inputs > ~200-300 chars.
153
final chunks = _splitIntoChunks(trimmedText);
154
Log.debug('Split text into ${chunks.length} chunks for TTS');
155
@@ -135,40 +157,19 @@ class KittenTtsService {
157
if (!_isSpeaking) break;
158
159
final phonemized =
138
- "${_phonemizer.phonemize(chunk)} "; // Add trailing silence
160
+ "${_phonemizer.phonemize(chunk)} ";
161
Log.debug('Speaking chunk: "$chunk"');
162
Log.debug('Phonemized: "$phonemized" (speed: $speed)');
163
142
- Uint8List wavBytes;
143
- try {
144
- wavBytes = await _tts!.generateWavBytes(
145
- phonemizedText: phonemized,
146
- language: 'en',
147
- voice: voice.displayName,
148
- speed: speed,
149
- );
150
- } catch (e) {
151
- // If session is not initialized, try one re-initialization
152
- if (e.toString().contains('Session not initialized')) {
153
- Log.warning(
154
- 'KittenTTS session not initialized, attempting recovery...',
155
- );
156
- _isInitialized = false;
157
- await initialize();
158
- wavBytes = await _tts!.generateWavBytes(
159
- phonemizedText: phonemized,
160
- language: 'en',
161
- voice: voice.displayName,
162
- speed: speed,
163
- );
164
- } else {
165
- rethrow;
166
- }
167
- }
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
171
- // Play the WAV bytes and wait for completion
173
final completer = Completer<void>();
174
StreamSubscription? completeSub;
175
@@ -178,7 +179,6 @@ class KittenTtsService {
179
180
await _audioPlayer!.play(BytesSource(wavBytes));
181
181
- // Wait until the chunk finishes playing or _isSpeaking becomes false (stopped manually)
182
while (_isSpeaking && !completer.isCompleted) {
183
await Future.delayed(const Duration(milliseconds: 50));
184
}
@@ -217,24 +217,61 @@ class KittenTtsService {
217
_audioPlayer = null;
218
_isInitialized = false;
219
_isSpeaking = false;
220
+ _currentVariant = null;
221
}
222
222
- /// Ensures an asset file exists on the filesystem.
223
- /// If missing, copies it from app assets.
224
- Future<String> _ensureAssetFile(
225
- Directory targetDir,
226
- String fileName,
227
- String assetPath,
228
- ) async {
229
- final file = File('${targetDir.path}/$fileName');
230
- if (await file.exists()) {
231
- return file.path;
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
234
- Log.info('Copying asset $assetPath to ${file.path}');
235
- final byteData = await rootBundle.load(assetPath);
236
- await file.writeAsBytes(byteData.buffer.asUint8List());
237
- return file.path;
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.
@@ -244,14 +281,12 @@ class KittenTtsService {
281
String currentChunk = "";
282
283
for (final sentence in sentences) {
247
- // If adding this sentence exceeds maxChars, push currentChunk and start new
284
if (currentChunk.isNotEmpty &&
285
(currentChunk.length + sentence.length + 1) > maxChars) {
286
chunks.add(currentChunk.trim());
287
currentChunk = "";
288
}
289
254
- // If the sentence itself is longer than maxChars, split it by words
290
if (sentence.length > maxChars) {
291
if (currentChunk.isNotEmpty) {
292
chunks.add(currentChunk.trim());
@@ -280,7 +315,6 @@ class KittenTtsService {
315
316
/// Splits text into sentences based on punctuation.
317
List<String> _splitIntoSentences(String text) {
283
- // Split by . ! ? followed by space or end of string
318
final regex = RegExp(r'(?<=[.!?])\s+');
319
return text.split(regex).where((s) => s.trim().isNotEmpty).toList();
320
}
lib/features/tts/data/phonemizer_service.dart
renamed
+114
-149
@@ -39,13 +39,10 @@ class PhonemizerService {
39
/// Every IPA character emitted by this service must be in this set.
40
/// Matches the _letters_ipa + _punctuation + _letters strings in TextCleaner.
41
static final Set<String> _validChars = {
42
- // Punctuation kept by preserve_punctuation=True
42
';', ':', ',', '.', '!', '?', '¡', '¿', '—', '…',
43
'"', '«', '»', '\u201C', '\u201D', ' ',
45
- // ASCII letters (eSpeak sometimes passes through proper nouns etc.)
44
for (int c = 65; c <= 90; c++) String.fromCharCode(c),
45
for (int c = 97; c <= 122; c++) String.fromCharCode(c),
48
- // IPA characters from TextCleaner._letters_ipa
46
'ɑ',
47
'ɐ',
48
'ɒ',
@@ -118,38 +115,36 @@ class PhonemizerService {
115
'ǀ', 'ǁ', 'ǂ', 'ǃ',
116
'ˈ', 'ˌ', 'ː', 'ˑ', 'ʼ', 'ʴ', 'ʰ', 'ʱ', 'ʲ', 'ʷ', 'ˠ', 'ˤ', '˞',
117
'↓', '↑', '→', '↗', '↘', 'ᵻ',
121
- // Combining characters used by eSpeak
122
- '\u0329', // combining vertical line below (̩)
118
+ '\u0329',
119
};
120
121
// ────────────────────── Silent initial clusters ───────────────────────────
122
123
static const Map<String, String> _silentInitialClusters = {
128
- 'kn': 'n', // knife, know
129
- 'wr': 'ɹ', // write, wrong
130
- 'gn': 'n', // gnome, gnat
131
- 'ps': 's', // psychology
132
- 'pn': 'n', // pneumonia
133
- 'mn': 'm', // mnemonic
124
+ 'kn': 'n',
125
+ 'wr': 'ɹ',
126
+ 'gn': 'n',
127
+ 'ps': 's',
128
+ 'pn': 'n',
129
+ 'mn': 'm',
130
};
131
132
// ──────────────────── Consonant multigraphs ───────────────────────────────
137
- // Longer entries are always checked before shorter ones at the call site.
133
134
static const Map<String, String> _consonantMultigraphs = {
140
- 'tch': 'tʃ', // catch, watch
141
- 'dge': 'dʒ', // bridge, lodge
142
- 'ch': 'tʃ', // chin, church
143
- 'sh': 'ʃ', // ship, fish
144
- 'zh': 'ʒ', // measure (rare spelling)
145
- 'th': 'θ', // default unvoiced; voiced ð covered in lexicon
146
- 'gh': '', // night, though (silent)
147
- 'ph': 'f', // phone, graph
148
- 'wh': 'w', // where, what
149
- 'ng': 'ŋ', // sing, ring
150
- 'nk': 'ŋk', // think, bank
151
- 'qu': 'kw', // queen, quick
152
- 'ck': 'k', // back, rock
135
+ 'tch': 'ʧ',
136
+ 'dge': 'ʤ',
137
+ 'ch': 'ʧ',
138
+ 'sh': 'ʃ',
139
+ 'zh': 'ʒ',
140
+ 'th': 'θ',
141
+ 'gh': '',
142
+ 'ph': 'f',
143
+ 'wh': 'w',
144
+ 'ng': 'ŋ',
145
+ 'nk': 'ŋk',
146
+ 'qu': 'kw',
147
+ 'ck': 'k',
148
};
149
150
static const Map<String, String> _singleConsonants = {
@@ -159,7 +154,7 @@ class PhonemizerService {
154
'f': 'f',
155
'g': 'ɡ',
156
'h': 'h',
162
- 'j': 'dʒ',
157
+ 'j': 'ʤ',
158
'k': 'k',
159
'l': 'l',
160
'm': 'm',
@@ -179,106 +174,108 @@ class PhonemizerService {
174
// ─────────────────────── Short vowels (en-us) ─────────────────────────────
175
176
static const Map<String, String> _shortVowels = {
182
- 'a': 'æ', // cat, bad
183
- 'e': 'ɛ', // bed, red
184
- 'i': 'ɪ', // sit, big
185
- 'o': 'ɑ', // hot, lot ← AmE /ɑ/ not BrE /ɒ/
186
- 'u': 'ʌ', // cup, but
177
+ 'a': 'æ',
178
+ 'e': 'ɛ',
179
+ 'i': 'ɪ',
180
+ 'o': 'ɑ',
181
+ 'u': 'ʌ',
182
};
183
184
// ──────────────────── Long vowels (magic-e targets) ───────────────────────
185
186
static const Map<String, String> _longVowels = {
192
- 'a': 'eɪ', // make, cake
193
- 'e': 'iː', // here (rare)
194
- 'i': 'aɪ', // bike, time
195
- 'o': 'oʊ', // hope, home
196
- 'u': 'juː', // cube, mute
187
+ 'a': 'eɪ',
188
+ 'e': 'iː',
189
+ 'i': 'aɪ',
190
+ 'o': 'oʊ',
191
+ 'u': 'juː',
192
};
193
194
// ──────────────────── Vowel multigraphs ───────────────────────────────────
200
- // Checked longest-first: trigraphs before digraphs before singles.
195
196
static const Map<String, String> _vowelMultigraphs = {
203
- // Trigraphs
204
- 'igh': 'aɪ', // night, light
205
- 'ure': 'jʊɹ', // pure, cure — rhotic AmE
206
- 'ear': 'ɪɹ', // ear, fear — rhotic AmE
207
- 'air': 'ɛɹ', // air, care — rhotic AmE
208
- 'oor': 'ʊɹ', // poor, floor — rhotic AmE
209
- // r-coloured vowel digraphs (AmE rhotic) — checked before plain digraphs
210
- 'ar': 'ɑːɹ', // car, far
211
- 'er': 'ɝ', // her, fern — stressed; see _phonemizeCore for unstressed
212
- 'ir': 'ɝ', // bird, girl
213
- 'or': 'ɔːɹ', // for, born
214
- 'ur': 'ɝ', // burn, hurt
215
- // Pure vowel digraphs
216
- 'ay': 'eɪ', // day, say
217
- 'ai': 'eɪ', // rain, wait
218
- 'ei': 'eɪ', // eight, vein
219
- 'ee': 'iː', // see, feet
220
- 'ea': 'iː', // eat, read (most common allophone)
221
- 'ie': 'iː', // field, brief
222
- 'oa': 'oʊ', // boat, road
223
- 'ow': 'oʊ', // low, snow (the /aʊ/ sense is covered in lexicon)
224
- 'oo': 'uː', // food, moon
225
- 'ue': 'uː', // blue, true
226
- 'ew': 'juː', // new, few
227
- 'eu': 'juː', // feud
228
- 'ou': 'aʊ', // out, found
229
- 'oi': 'ɔɪ', // oil, coin
230
- 'oy': 'ɔɪ', // boy, toy
231
- 'au': 'ɔː', // cause, pause
232
- 'aw': 'ɔː', // saw, law
197
+ 'igh': 'aɪ',
198
+ 'ure': 'jʊɹ',
199
+ 'ear': 'ɪɹ',
200
+ 'air': 'ɛɹ',
201
+ 'oor': 'ʊɹ',
202
+ 'ar': 'ɑːɹ',
203
+ 'er': 'ɝ',
204
+ 'ir': 'ɝ',
205
+ 'or': 'ɔːɹ',
206
+ 'ur': 'ɝ',
207
+ 'ay': 'eɪ',
208
+ 'ai': 'eɪ',
209
+ 'ei': 'eɪ',
210
+ 'ee': 'iː',
211
+ 'ea': 'iː',
212
+ 'ie': 'iː',
213
+ 'oa': 'oʊ',
214
+ 'ow': 'oʊ',
215
+ 'oo': 'uː',
216
+ 'ue': 'uː',
217
+ 'ew': 'juː',
218
+ 'eu': 'juː',
219
+ 'ou': 'aʊ',
220
+ 'oi': 'ɔɪ',
221
+ 'oy': 'ɔɪ',
222
+ 'au': 'ɔː',
223
+ 'aw': 'ɔː',
224
};
225
226
// ──────────────── Suffixes — longest-first for greedy stripping ───────────
227
228
static const List<MapEntry<String, String>> _suffixes = [
238
- MapEntry('tion', 'ʃən'), // nation, station
239
- MapEntry('sion', 'ʒən'), // vision, decision
240
- MapEntry('ture', 'tʃɚ'), // nature, picture — AmE rhotic schwa
241
- MapEntry('ness', 'nɪs'), // kindness
242
- MapEntry('ment', 'mənt'), // moment
243
- MapEntry('able', 'əbəl'), // capable
244
- MapEntry('ible', 'ɪbəl'), // possible
245
- MapEntry('ical', 'ɪkəl'), // logical
246
- MapEntry('ious', 'iəs'), // previous
247
- MapEntry('ance', 'əns'), //rance → balance
248
- MapEntry('ence', 'əns'), //idence → evidence
249
- MapEntry('ism', 'ɪzəm'), // criticism
250
- MapEntry('ity', 'ɪti'), // clarity
251
- MapEntry('ise', 'aɪz'), // realise
252
- MapEntry('ize', 'aɪz'), // realize
253
- MapEntry('ive', 'ɪv'), // active
254
- MapEntry('ous', 'əs'), // famous
255
- MapEntry('ful', 'fəl'), // careful
256
- MapEntry('ing', 'ɪŋ'), // running
257
- MapEntry('est', 'ɪst'), // fastest
258
- MapEntry('ist', 'ɪst'), // artist
259
- MapEntry('ant', 'ənt'), // important
260
- MapEntry('ent', 'ənt'), // student
261
- MapEntry('age', 'ɪdʒ'), // village
262
- MapEntry('less', 'lɪs'), // careless
263
- MapEntry('ify', 'ɪfaɪ'), // clarify
264
- MapEntry('fy', 'faɪ'), // simplify
265
- MapEntry('ly', 'liː'), // quickly
266
- MapEntry('er', 'ɚ'), // runner — unstressed, AmE rhotic schwa
267
- MapEntry('ed', 'd'), // walked (simplified; ignores /t/ and /ɪd/)
268
- MapEntry('s', 'z'), // cats (simplified; ignores /s/)
229
+ MapEntry('tion', 'ʃən'),
230
+ MapEntry('sion', 'ʒən'),
231
+ MapEntry('ture', 'ʧɚ'),
232
+ MapEntry('ness', 'nɪs'),
233
+ MapEntry('ment', 'mənt'),
234
+ MapEntry('able', 'əbəl'),
235
+ MapEntry('ible', 'ɪbəl'),
236
+ MapEntry('ical', 'ɪkəl'),
237
+ MapEntry('ious', 'iəs'),
238
+ MapEntry('ance', 'əns'),
239
+ MapEntry('ence', 'əns'),
240
+ MapEntry('ism', 'ɪzəm'),
241
+ MapEntry('ity', 'ɪti'),
242
+ MapEntry('ise', 'aɪz'),
243
+ MapEntry('ize', 'aɪz'),
244
+ MapEntry('ive', 'ɪv'),
245
+ MapEntry('ous', 'əs'),
246
+ MapEntry('ful', 'fəl'),
247
+ MapEntry('ing', 'ɪŋ'),
248
+ MapEntry('est', 'ɪst'),
249
+ MapEntry('ist', 'ɪst'),
250
+ MapEntry('ant', 'ənt'),
251
+ MapEntry('ent', 'ənt'),
252
+ MapEntry('age', 'ɪʤ'),
253
+ MapEntry('less', 'lɪs'),
254
+ MapEntry('ify', 'ɪfaɪ'),
255
+ MapEntry('fy', 'faɪ'),
256
+ MapEntry('ly', 'liː'),
257
+ MapEntry('er', 'ɚ'),
258
+ MapEntry('ed', 'd'),
259
+ MapEntry('s', 'z'),
260
];
261
262
// ─────────────────────────── Public API ──────────────────────────────────
263
264
/// Convert English [text] to IPA compatible with KittenTTS's TextCleaner.
274
- ///
275
- /// Punctuation (`.`, `,`, `!`, `?`, `;`, `:`) is preserved in-place,
276
- /// matching the behaviour of EspeakBackend with preserve_punctuation=True.
277
- /// The output is space-separated IPA words, ready for the server's
278
- /// basic_english_tokenize → TextCleaner pipeline (or generate_from_ipa).
265
String phonemize(String text) {
280
- // Preserve sentence-level punctuation but strip everything else.
281
- final cleaned = text
266
+ String textToProcess = text
267
+ .replaceAll('0', ' zero ')
268
+ .replaceAll('1', ' one ')
269
+ .replaceAll('2', ' two ')
270
+ .replaceAll('3', ' three ')
271
+ .replaceAll('4', ' four ')
272
+ .replaceAll('5', ' five ')
273
+ .replaceAll('6', ' six ')
274
+ .replaceAll('7', ' seven ')
275
+ .replaceAll('8', ' eight ')
276
+ .replaceAll('9', ' nine ');
277
+
278
+ final cleaned = textToProcess
279
.replaceAll(RegExp(r"[^\w\s'.,!?;:]"), ' ')
280
.replaceAll(RegExp(r'\s+'), ' ')
281
.trim()
@@ -288,20 +285,17 @@ class PhonemizerService {
285
for (int i = 0; i < cleaned.length; i++) {
286
final char = cleaned[i];
287
if (_isPreservablePunctuation(char)) {
291
- // Keep punctuation with no surrounding space so it attaches to the
292
- // preceding word token, mirroring eSpeak preserve_punctuation output.
288
output.write(char);
289
} else if (char == ' ') {
290
output.write(' ');
291
} else {
297
- // Collect the whole word then phonemize it.
292
final start = i;
293
while (i < cleaned.length &&
294
cleaned[i] != ' ' &&
295
!_isPreservablePunctuation(cleaned[i])) {
296
i++;
297
}
304
- i--; // loop will increment
298
+ i--;
299
final word = cleaned.substring(start, i + 1);
300
if (word.isNotEmpty) output.write(_phonemizeWord(word));
301
}
@@ -311,18 +305,14 @@ class PhonemizerService {
305
}
306
307
/// Remove any characters that KittenTTS's TextCleaner would silently drop.
314
- ///
315
- /// Call this on any IPA string before sending to the backend to ensure
316
- /// every character maps to a valid token ID.
308
String sanitize(String ipa) =>
318
- ipa.split('').where((c) => _validChars.contains(c)).join();
309
+ ipa.split('').where((c) => _validChars.contains(c)).join().replaceAll(RegExp(r'\s+'), ' ').trim();
310
311
// ────────────────────────── Word-level logic ──────────────────────────────
312
313
String _phonemizeWord(String word) {
314
if (_commonWords.containsKey(word)) return _commonWords[word]!;
315
325
- // Greedy longest-suffix stripping.
316
for (final entry in _suffixes) {
317
final suffix = entry.key;
318
if (word.length > suffix.length + 1 && word.endsWith(suffix)) {
@@ -342,7 +332,6 @@ class PhonemizerService {
332
final buffer = StringBuffer();
333
int i = 0;
334
345
- // ── Silent initial cluster (kn-, wr-, gn-, ps-, pn-) ──────────────────
335
if (word.length >= 2) {
336
final pair = word.substring(0, 2);
337
if (_silentInitialClusters.containsKey(pair)) {
@@ -356,22 +345,18 @@ class PhonemizerService {
345
final isLast = i == word.length - 1;
346
final next = isLast ? null : word[i + 1];
347
359
- // ── Silent final -e ────────────────────────────────────────────────
348
if (char == 'e' && isLast && word.length > 2) {
349
i++;
350
continue;
351
}
352
365
- // ── Silent final -mb (lamb, comb, thumb) ──────────────────────────
353
if (char == 'm' && next == 'b' && i == word.length - 2) {
354
buffer.write('m');
355
i += 2;
356
continue;
357
}
358
372
- // ── Vowels ──────────────────────────────────────────────────────────
359
if (_isVowel(char)) {
374
- // Trigraph vowel
360
if (i + 2 < word.length) {
361
final tri = word.substring(i, i + 3);
362
if (_vowelMultigraphs.containsKey(tri)) {
@@ -380,11 +365,9 @@ class PhonemizerService {
365
continue;
366
}
367
}
383
- // Digraph vowel — special case: unstressed final 'er' → /ɚ/ (schwa-r)
368
if (next != null) {
369
final di = word.substring(i, i + 2);
370
if (di == 'er' && i == word.length - 2) {
387
- // Word-final 'er': unstressed rhotic schwa (butter, water)
371
buffer.write('ɚ');
372
i += 2;
373
continue;
@@ -395,45 +378,34 @@ class PhonemizerService {
378
continue;
379
}
380
}
398
- // Magic-e: V + C(s) + final-e → long vowel
381
if (_hasMagicE(word, i)) {
382
buffer.write(_longVowels[char] ?? char);
383
i++;
384
continue;
385
}
404
- // Default short vowel
386
buffer.write(_shortVowels[char] ?? char);
387
i++;
388
continue;
389
}
390
410
- // ── Consonants ───────────────────────────────────────────────────────
411
-
412
- // Soft-c before e / i / y → /s/
391
if (char == 'c' && next != null && 'eiy'.contains(next)) {
392
buffer.write('s');
393
i++;
394
continue;
395
}
396
419
- // Soft-g before e / i / y → /dʒ/
397
if (char == 'g' && next != null && 'eiy'.contains(next)) {
398
buffer.write('dʒ');
399
i++;
400
continue;
401
}
402
426
- // y as vowel at end of word
403
if (char == 'y' && isLast) {
404
buffer.write(word.length <= 3 ? 'aɪ' : 'iː');
405
i++;
406
continue;
407
}
408
433
- // Silent final -mb
434
- // (already handled above, but guard for mid-word mb before final e)
435
-
436
- // Consonant trigraph (tch, dge) — checked before digraph
409
if (i + 2 < word.length) {
410
final tri = word.substring(i, i + 3);
411
if (_consonantMultigraphs.containsKey(tri)) {
@@ -443,7 +415,6 @@ class PhonemizerService {
415
}
416
}
417
446
- // Consonant digraph
418
if (next != null) {
419
final di = word.substring(i, i + 2);
420
if (_consonantMultigraphs.containsKey(di)) {
@@ -453,7 +424,6 @@ class PhonemizerService {
424
}
425
}
426
456
- // Single consonant
427
buffer.write(_singleConsonants[char] ?? '');
428
i++;
429
}
@@ -468,8 +438,6 @@ class PhonemizerService {
438
bool _isPreservablePunctuation(String char) =>
439
','.contains(char) || '.!?;:'.contains(char);
440
471
- /// True when the vowel at [index] is lengthened by a trailing silent-e.
472
- /// Pattern: vowel + one-or-more consonants + final-e (VCe).
441
bool _hasMagicE(String word, int index) {
442
if (word.isEmpty || word[word.length - 1] != 'e' || word.length < 3) {
443
return false;
@@ -484,21 +452,14 @@ class PhonemizerService {
452
return consonantCount >= 1;
453
}
454
487
- // ───────────────────────────── Lexicon ───────────────────────────────────
488
- // Common words are listed here verbatim as eSpeak en-us would produce them.
489
- // Voiced-th (ð) words must live here — rule-based cannot predict th voicing.
490
-
455
static const Map<String, String> _commonWords = {
492
- // Articles / determiners
456
'the': 'ðə', 'a': 'ə', 'an': 'æn',
494
- // Conjunctions / prepositions
457
'and': 'ænd', 'or': 'ɔːɹ', 'but': 'bʌt', 'if': 'ɪf',
458
'of': 'ʌv', 'to': 'tuː', 'in': 'ɪn', 'on': 'ɑn',
459
'at': 'æt', 'by': 'baɪ', 'as': 'æz',
460
'for': 'fɔːɹ', 'with': 'wɪð', 'from': 'fɹʌm', 'into': 'ɪntuː',
461
'up': 'ʌp', 'out': 'aʊt', 'over': 'oʊvɚ', 'than': 'ðæn',
462
'then': 'ðɛn', 'so': 'soʊ', 'yet': 'jɛt',
501
- // Pronouns
463
'i': 'aɪ', 'you': 'juː', 'he': 'hiː', 'she': 'ʃiː',
464
'it': 'ɪt', 'we': 'wiː', 'they': 'ðeɪ',
465
'me': 'miː', 'him': 'hɪm', 'her': 'hɜːɹ', 'us': 'ʌs',
@@ -507,7 +468,6 @@ class PhonemizerService {
468
'this': 'ðɪs', 'that': 'ðæt', 'these': 'ðiːz', 'those': 'ðoʊz',
469
'who': 'huː', 'which': 'wɪtʃ', 'what': 'wʌt', 'where': 'wɛɹ',
470
'when': 'wɛn', 'how': 'haʊ', 'why': 'waɪ',
510
- // Auxiliary / modal verbs
471
'is': 'ɪz', 'are': 'ɑːɹ', 'was': 'wɑz', 'were': 'wɜːɹ',
472
'be': 'biː', 'been': 'biːn', 'being': 'biːɪŋ',
473
'have': 'hæv', 'has': 'hæz', 'had': 'hæd',
@@ -515,7 +475,6 @@ class PhonemizerService {
475
'will': 'wɪl', 'would': 'wʊd', 'can': 'kæn', 'could': 'kʊd',
476
'shall': 'ʃæl', 'should': 'ʃʊd', 'may': 'meɪ', 'might': 'maɪt',
477
'must': 'mʌst', 'need': 'niːd',
518
- // Common verbs
478
'get': 'ɡɛt', 'got': 'ɡɑt', 'go': 'ɡoʊ', 'went': 'wɛnt',
479
'come': 'kʌm', 'came': 'keɪm',
480
'make': 'meɪk', 'made': 'meɪd',
@@ -525,7 +484,6 @@ class PhonemizerService {
484
'tell': 'tɛl', 'call': 'kɔːl', 'keep': 'kiːp', 'let': 'lɛt',
485
'seem': 'siːm', 'feel': 'fiːl', 'try': 'tɹaɪ', 'leave': 'liːv',
486
'put': 'pʊt', 'mean': 'miːn', 'show': 'ʃoʊ',
528
- // Common nouns
487
'time': 'taɪm', 'year': 'jɪɹ', 'day': 'deɪ', 'way': 'weɪ',
488
'man': 'mæn', 'men': 'mɛn', 'word': 'wɜːɹd',
489
'world': 'wɜːɹld', 'life': 'laɪf', 'hand': 'hænd',
@@ -533,19 +491,26 @@ class PhonemizerService {
491
'water': 'wɔːtɚ', 'room': 'ɹuːm', 'book': 'bʊk',
492
'eye': 'aɪ', 'door': 'dɔːɹ', 'face': 'feɪs', 'name': 'neɪm',
493
'people': 'piːpəl', 'child': 'tʃaɪld', 'children': 'tʃɪldɹən',
536
- // Numbers (eSpeak expands these — caller should use clean_text=True)
494
'one': 'wʌn', 'two': 'tuː', 'three': 'θɹiː',
538
- // Adjectives / adverbs
495
'not': 'nɑt', 'all': 'ɔːl', 'some': 'sʌm', 'more': 'mɔːɹ',
496
'very': 'vɛɹiː', 'just': 'dʒʌst', 'also': 'ɔːlsoʊ',
497
'even': 'iːvən', 'well': 'wɛl', 'such': 'sʌtʃ', 'only': 'oʊnliː',
498
'any': 'ɛniː', 'many': 'mɛniː', 'each': 'iːtʃ', 'long': 'lɔːŋ',
499
'down': 'daʊn', 'first': 'fɜːɹst', 'other': 'ʌðɚ', 'about': 'əbaʊt',
544
- // Greetings / discourse
500
'hello': 'hɛloʊ', 'hi': 'haɪ', 'hey': 'heɪ',
501
'bye': 'baɪ', 'goodbye': 'ɡʊdbaɪ',
502
'yes': 'jɛs', 'no': 'noʊ', 'okay': 'oʊkeɪ', 'ok': 'oʊkeɪ',
503
'please': 'pliːz', 'thanks': 'θæŋks', 'thank': 'θæŋk',
504
'sorry': 'sɑɹiː', 'am': 'æm',
505
+ "i'm": "aɪm", "you're": "jʊɹ", "he's": "hiːz", "she's": "ʃiːz", "it's": "ɪts",
506
+ "we're": "wɪɹ", "they're": "ðɛɹ", "i've": "aɪv", "you've": "juːv", "we've": "wiːv",
507
+ "they've": "ðeɪv", "i'll": "aɪl", "you'll": "juːl", "he'll": "hiːl", "she'll": "ʃiːl",
508
+ "we'll": "wiːl", "they'll": "ðeɪl", "i'd": "aɪd", "you'd": "juːd", "he'd": "hiːd",
509
+ "she'd": "ʃiːd", "we'd": "wiːd", "they'd": "ðeɪd", "isn't": "ɪzənt", "aren't": "ɑːɹnt",
510
+ "wasn't": "wʌzənt", "weren't": "wɜːɹnt", "haven't": "hævənt", "hasn't": "hæzənt",
511
+ "hadn't": "hædənt", "won't": "woʊnt", "wouldn't": "wʊdənt", "don't": "doʊnt",
512
+ "doesn't": "dʌzənt", "didn't": "dɪdənt", "can't": "kænt", "couldn't": "kʊdənt",
513
+ "shouldn't": "ʃʊdənt", "mightn't": "maɪtənt", "mustn't": "mʌstənt",
514
+ "audio": "ɔːdiːoʊ", "broken": "bɹoʊkən", "skips": "skɪpz",
515
};
516
}
lib/features/tts/data/tts_service.dart
renamed
lib/features/tts/providers/tts_model_providers.dart
new
+133
@@ -0,0 +1,133 @@
1
+import 'dart:async';
2
+
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+
5
+import '../data/kitten_tts_model.dart';
6
+import '../data/kitten_tts_downloader.dart';
7
+
8
+/// Provider for the KittenTTS downloader instance.
9
+final kittenTtsDownloaderProvider = Provider<KittenTtsDownloader>((ref) {
10
+ return KittenTtsDownloader();
11
+});
12
+
13
+/// Static list of all available KittenTTS models.
14
+final kittenTtsModelsProvider = Provider<List<KittenTtsModel>>((ref) {
15
+ return KittenTtsModel.allModels;
16
+});
17
+
18
+/// Async provider for the set of downloaded variants.
19
+final downloadedKittenTtsVariantsProvider =
20
+ FutureProvider<Set<KittenTtsModelVariant>>((ref) async {
21
+ final downloader = ref.read(kittenTtsDownloaderProvider);
22
+ return downloader.getDownloadedVariants();
23
+});
24
+
25
+/// Notifier that tracks per-file download progress for KittenTTS models.
26
+final ttsDownloadProgressProvider =
27
+ NotifierProvider<TtsDownloadNotifier, Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>>(
28
+ () => TtsDownloadNotifier(),
29
+);
30
+
31
+class TtsDownloadNotifier
32
+ extends Notifier<Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>> {
33
+ final Map<KittenTtsModelVariant, StreamSubscription<KittenTtsFileProgress>>
34
+ _subscriptions = {};
35
+
36
+ @override
37
+ Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>> build() {
38
+ ref.onDispose(() {
39
+ for (final sub in _subscriptions.values) {
40
+ sub.cancel();
41
+ }
42
+ _subscriptions.clear();
43
+ });
44
+ return {};
45
+ }
46
+
47
+ /// Start downloading a model variant.
48
+ Future<void> startDownload(KittenTtsModel model) async {
49
+ final variant = model.variant;
50
+
51
+ final currentVariantProgress = Map<String, KittenTtsFileProgress>.from(
52
+ state[variant] ?? {},
53
+ );
54
+
55
+ state = {
56
+ ...state,
57
+ variant: currentVariantProgress,
58
+ };
59
+
60
+ final downloader = ref.read(kittenTtsDownloaderProvider);
61
+
62
+ _subscriptions[variant]?.cancel();
63
+ _subscriptions[variant] = downloader
64
+ .downloadModel(model)
65
+ .listen(
66
+ (progress) {
67
+ final variantMap = Map<String, KittenTtsFileProgress>.from(
68
+ state[variant] ?? {},
69
+ );
70
+ variantMap[progress.fileName] = progress;
71
+ state = {...state, variant: variantMap};
72
+ },
73
+ onDone: () {
74
+ _subscriptions.remove(variant);
75
+ ref.invalidate(downloadedKittenTtsVariantsProvider);
76
+ },
77
+ onError: (e) {
78
+ Log.error('KittenTTS download error for ${variant.displayName}: $e');
79
+ _subscriptions.remove(variant);
80
+ },
81
+ );
82
+ }
83
+
84
+ /// Cancel an in-flight download.
85
+ void cancelDownload(KittenTtsModelVariant variant) {
86
+ ref.read(kittenTtsDownloaderProvider).cancelDownload(variant);
87
+ _subscriptions[variant]?.cancel();
88
+ _subscriptions.remove(variant);
89
+
90
+ final newState = Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>
91
+ .from(state);
92
+ newState.remove(variant);
93
+ state = newState;
94
+ }
95
+
96
+ /// Delete a downloaded variant.
97
+ Future<void> deleteVariant(KittenTtsModelVariant variant) async {
98
+ cancelDownload(variant);
99
+ await ref.read(kittenTtsDownloaderProvider).deleteVariant(variant);
100
+
101
+ final newState = Map<KittenTtsModelVariant, Map<String, KittenTtsFileProgress>>
102
+ .from(state);
103
+ newState.remove(variant);
104
+ state = newState;
105
+
106
+ ref.invalidate(downloadedKittenTtsVariantsProvider);
107
+ }
108
+
109
+ /// Check if a variant is currently being downloaded.
110
+ bool isDownloading(KittenTtsModelVariant variant) {
111
+ final variantProgress = state[variant];
112
+ if (variantProgress == null || variantProgress.isEmpty) return false;
113
+ return !variantProgress.values.every((f) => f.isComplete);
114
+ }
115
+
116
+ /// Get overall progress fraction for a variant (0.0–1.0).
117
+ double getOverallFraction(KittenTtsModelVariant variant) {
118
+ final variantProgress = state[variant];
119
+ if (variantProgress == null || variantProgress.isEmpty) return 0.0;
120
+ final total = variantProgress.values
121
+ .map((f) => f.fraction)
122
+ .fold<double>(0.0, (a, b) => a + b);
123
+ return total / variantProgress.length;
124
+ }
125
+}
126
+
127
+/// Simple log helper within this file.
128
+class Log {
129
+ static void error(String message) {
130
+ // ignore: avoid_print
131
+ print('[TtsDownloadNotifier] ERROR: $message');
132
+ }
133
+}
lib/features/tts/providers/tts_providers.dart
renamed
+2
@@ -90,6 +90,8 @@ class UnifiedTtsNotifier extends Notifier<UnifiedTtsState> {
90
91
try {
92
if (engine == TtsEngine.kitten) {
93
+ final variant = settings.kittenTtsModelVariant;
94
+ await _kittenTtsInstance.initialize(variant: variant);
95
await _kittenTtsInstance.speak(
96
text,
97
voice: settings.kittenTtsVoice,
lib/features/tts/views/tts_model_manager_screen.dart
new
+563
@@ -0,0 +1,563 @@
1
+import 'dart:async';
2
+
3
+import 'package:audioplayers/audioplayers.dart';
4
+import 'package:flutter/material.dart';
5
+import 'package:flutter_riverpod/flutter_riverpod.dart';
6
+import 'package:shadcn_ui/shadcn_ui.dart';
7
+
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';
13
+import '../providers/tts_model_providers.dart';
14
+
15
+class TtsModelManagerScreen extends ConsumerWidget {
16
+ const TtsModelManagerScreen({super.key});
17
+
18
+ @override
19
+ 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);
24
+ final settings = ref.watch(settingsProvider);
25
+
26
+ return Scaffold(
27
+ drawer: const SidebarWidget(),
28
+ appBar: AppBar(title: const Text('Kitten TTS Models')),
29
+ body: ListView(
30
+ padding: const EdgeInsets.all(16),
31
+ 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
+ ),
45
+ ),
46
+ 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
+ ),
58
+ ),
59
+ const SizedBox(height: 24),
60
+ ],
61
+ ),
62
+ );
63
+ }
64
+}
65
+
66
+class _ModelVariantCard extends ConsumerStatefulWidget {
67
+ const _ModelVariantCard({
68
+ required this.model,
69
+ required this.isSelected,
70
+ required this.isDownloaded,
71
+ this.progress,
72
+ });
73
+
74
+ final KittenTtsModel model;
75
+ final bool isSelected;
76
+ final bool isDownloaded;
77
+ final Map<String, KittenTtsFileProgress>? progress;
78
+
79
+ @override
80
+ ConsumerState<_ModelVariantCard> createState() => _ModelVariantCardState();
81
+}
82
+
83
+class _ModelVariantCardState extends ConsumerState<_ModelVariantCard> {
84
+ bool _showPreview = false;
85
+
86
+ @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
+
94
+ 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
+ ),
109
+ ),
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),
148
+ ),
149
+ ),
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
+ ),
188
+ 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
+ ],
202
+ ],
203
+ ),
204
+ ),
205
+ );
206
+ }
207
+
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,
224
+ ),
225
+ ),
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'),
236
+ ),
237
+ if (!widget.isSelected) const SizedBox(width: 8),
238
+ ShadButton.outline(
239
+ size: ShadButtonSize.sm,
240
+ onPressed: () => _deleteModel(),
241
+ child: const Text('Delete'),
242
+ ),
243
+ ],
244
+ );
245
+ }
246
+
247
+ if (isDownloading) {
248
+ final overallFraction = ref
249
+ .read(ttsDownloadProgressProvider.notifier)
250
+ .getOverallFraction(widget.model.variant);
251
+
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,
268
+ ),
269
+ ),
270
+ ],
271
+ ),
272
+ ),
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'),
282
+ ),
283
+ ],
284
+ );
285
+ }
286
+
287
+ return Row(
288
+ children: [
289
+ const Spacer(),
290
+ ShadButton.outline(
291
+ size: ShadButtonSize.sm,
292
+ onPressed: () => _startDownload(),
293
+ child: const Text('Download'),
294
+ ),
295
+ ],
296
+ );
297
+ }
298
+
299
+ Widget _buildFileProgress() {
300
+ if (widget.progress == null || widget.progress!.isEmpty) {
301
+ return const SizedBox.shrink();
302
+ }
303
+
304
+ 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(
313
+ children: [
314
+ SizedBox(
315
+ width: 100,
316
+ child: Text(
317
+ fileName,
318
+ style: theme.textTheme.labelSmall,
319
+ overflow: TextOverflow.ellipsis,
320
+ ),
321
+ ),
322
+ const SizedBox(width: 8),
323
+ Expanded(
324
+ child: LinearProgressIndicator(
325
+ value: progress.fraction,
326
+ backgroundColor:
327
+ theme.colorScheme.primary.withValues(alpha: 0.1),
328
+ minHeight: 4,
329
+ ),
330
+ ),
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,
340
+ ),
341
+ textAlign: TextAlign.right,
342
+ ),
343
+ ),
344
+ ],
345
+ ),
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'),
401
+ ),
402
+ ],
403
+ ),
404
+ );
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';
426
+ }
427
+}
428
+
429
+/// Section that lists all voices with play buttons for the downloaded variant.
430
+class _VoicePreviewSection extends ConsumerStatefulWidget {
431
+ const _VoicePreviewSection({required this.variant});
432
+
433
+ final KittenTtsModelVariant variant;
434
+
435
+ @override
436
+ ConsumerState<_VoicePreviewSection> createState() =>
437
+ _VoicePreviewSectionState();
438
+}
439
+
440
+class _VoicePreviewSectionState extends ConsumerState<_VoicePreviewSection> {
441
+ KittenTtsService? _previewService;
442
+ AudioPlayer? _audioPlayer;
443
+ KittenTtsVoice? _playingVoice;
444
+ bool _isLoading = false;
445
+
446
+ @override
447
+ void dispose() {
448
+ _audioPlayer?.dispose();
449
+ _previewService?.dispose();
450
+ super.dispose();
451
+ }
452
+
453
+ @override
454
+ Widget build(BuildContext context) {
455
+ final theme = Theme.of(context);
456
+
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
+ ),
463
+ child: Column(
464
+ crossAxisAlignment: CrossAxisAlignment.start,
465
+ 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),
470
+ ),
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
+ ),
507
+ ],
508
+ ),
509
+ );
510
+ }
511
+
512
+ Future<void> _togglePreview(KittenTtsVoice voice) async {
513
+ if (_playingVoice == voice) {
514
+ await _audioPlayer?.stop();
515
+ setState(() => _playingVoice = null);
516
+ return;
517
+ }
518
+
519
+ setState(() {
520
+ _isLoading = true;
521
+ _playingVoice = null;
522
+ });
523
+
524
+ 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
+
543
+ setState(() => _playingVoice = voice);
544
+
545
+ await _audioPlayer!.play(BytesSource(wavBytes));
546
+ _audioPlayer!.onPlayerComplete.first.then((_) {
547
+ if (mounted) {
548
+ setState(() => _playingVoice = null);
549
+ }
550
+ });
551
+ } catch (e) {
552
+ 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);
560
+ }
561
+ }
562
+ }
563
+}
lib/main.dart
-7
@@ -6,7 +6,6 @@ import 'app.dart';
6
import 'core/providers/highlighter_provider.dart';
7
import 'core/providers/storage_providers.dart';
8
import 'core/storage/objectbox_store.dart';
9
-import 'features/chat/providers/tts_providers.dart';
9
import 'features/on_device/providers/on_device_providers.dart';
10
11
Future<void> main() async {
@@ -26,12 +25,6 @@ Future<void> main() async {
25
26
// Initialize services
27
await container.read(downloadNotificationServiceProvider).init();
29
-
30
- // Pre-initialize KittenTTS (neural engine)
31
- // Don't await it to avoid blocking app startup, but start the process
32
- container.read(kittenTtsServiceProvider).initialize().catchError((e) {
33
- debugPrint('Failed to pre-initialize KittenTTS: $e');
34
- });
28
29
runApp(
30
UncontrolledProviderScope(
pubspec.lock
+8
-8
@@ -769,10 +769,10 @@ packages:
769
dependency: transitive
770
description:
771
name: matcher
772
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
772
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
773
url: "https://pub.dev"
774
source: hosted
775
- version: "0.12.19"
775
+ version: "0.12.18"
776
material_color_utilities:
777
dependency: transitive
778
description:
@@ -1231,26 +1231,26 @@ packages:
1231
dependency: transitive
1232
description:
1233
name: test
1234
- sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
1234
+ sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
1235
url: "https://pub.dev"
1236
source: hosted
1237
- version: "1.30.0"
1237
+ version: "1.29.0"
1238
test_api:
1239
dependency: transitive
1240
description:
1241
name: test_api
1242
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1242
+ sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
1243
url: "https://pub.dev"
1244
source: hosted
1245
- version: "0.7.10"
1245
+ version: "0.7.9"
1246
test_core:
1247
dependency: transitive
1248
description:
1249
name: test_core
1250
- sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
1250
+ sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
1251
url: "https://pub.dev"
1252
source: hosted
1253
- version: "0.6.16"
1253
+ version: "0.6.15"
1254
theme_extensions_builder_annotation:
1255
dependency: transitive
1256
description:
pubspec.yaml
+1
-4
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.2.0+18
19
+version: 1.2.1+20
20
21
environment:
22
sdk: ^3.11.0
@@ -91,9 +91,6 @@ flutter:
91
assets:
92
- assets/google_fonts/
93
- assets/images/
94
- - assets/tts/config.json
95
- - assets/tts/kitten_tts_nano_v0_8.onnx
96
- - assets/tts/voices.npz
94
95
# An image asset can refer to one or more resolution-specific "variants", see
96
# https://flutter.dev/to/resolution-aware-images