@tduncan / Cortana / commits / 0fe39f9

refactor: modularize TTS architecture by moving services and providers into a dedicated feature directory and adding a tts model manager screen for cloud download

abdulmominsakib committed Apr 28, 2026 at 15:49 UTC 0fe39f9da3a65527f6752f9bfca2708fc0298990
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